chore: archive inactive projects to apps-archived/

Move inactive projects out of active workspace:
- bauntown (community website)
- maerchenzauber (AI story generation)
- memoro (voice memo app)
- news (news aggregation)
- nutriphi (nutrition tracking)
- reader (reading app)
- uload (URL shortener)
- wisekeep (AI wisdom extraction)

Update CLAUDE.md documentation:
- Add presi to active projects
- Document archived projects section
- Update workspace configuration

Archived apps can be re-activated by moving back to apps/

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Till-JS 2025-11-29 07:03:59 +01:00
parent b97149ac12
commit 61d181fbc2
3148 changed files with 437 additions and 46640 deletions

View file

@ -0,0 +1,189 @@
import React from 'react';
import {
Platform,
ActionSheetIOS,
Modal,
View,
Text,
Pressable,
StyleSheet,
FlatList,
} from 'react-native';
import { Ionicons } from '@expo/vector-icons';
import { useTheme } from '~/hooks/useTheme';
interface ActionMenuOption {
title: string;
systemIcon?: string;
icon?: keyof typeof Ionicons.glyphMap;
destructive?: boolean;
disabled?: boolean;
}
interface ActionMenuProps {
options: ActionMenuOption[];
onSelect: (index: number) => void;
children: React.ReactElement;
title?: string;
message?: string;
}
export function ActionMenu({ options, onSelect, children, title, message }: ActionMenuProps) {
const [visible, setVisible] = React.useState(false);
const { colors } = useTheme();
const iconMap: Record<string, keyof typeof Ionicons.glyphMap> = {
'doc.text': 'document-text-outline',
'play.circle': 'play-circle-outline',
'square.and.arrow.up': 'share-outline',
tag: 'pricetag-outline',
trash: 'trash-outline',
};
const showActionSheet = () => {
if (Platform.OS === 'ios') {
const optionTitles = options.map((opt) => opt.title);
const destructiveButtonIndex = options.findIndex((opt) => opt.destructive);
const disabledButtonIndices = options
.map((opt, idx) => (opt.disabled ? idx : -1))
.filter((idx) => idx !== -1);
ActionSheetIOS.showActionSheetWithOptions(
{
options: [...optionTitles, 'Abbrechen'],
cancelButtonIndex: optionTitles.length,
destructiveButtonIndex: destructiveButtonIndex >= 0 ? destructiveButtonIndex : undefined,
disabledButtonIndices,
title,
message,
},
(buttonIndex) => {
if (buttonIndex !== optionTitles.length) {
onSelect(buttonIndex);
}
}
);
} else {
setVisible(true);
}
};
const handleSelect = (index: number) => {
setVisible(false);
setTimeout(() => onSelect(index), 100);
};
const renderOption = ({ item, index }: { item: ActionMenuOption; index: number }) => {
const iconName = item.icon || (item.systemIcon ? iconMap[item.systemIcon] : undefined);
const isDisabled = item.disabled;
const isDestructive = item.destructive;
return (
<Pressable
onPress={() => !isDisabled && handleSelect(index)}
disabled={isDisabled}
className={`flex-row items-center px-4 py-4`}
style={({ pressed }) => ({
backgroundColor: pressed && !isDisabled ? 'rgba(0, 0, 0, 0.05)' : 'transparent',
opacity: isDisabled ? 0.5 : 1,
})}
>
{iconName && (
<Ionicons
name={iconName}
size={22}
color={
isDestructive ? '#EF4444' : colors.text.includes('white') ? '#FFFFFF' : '#111827'
}
style={{ marginRight: 16 }}
/>
)}
<Text className={`text-lg ${isDestructive ? 'text-red-500' : colors.text}`}>
{item.title}
</Text>
</Pressable>
);
};
return (
<>
{React.cloneElement(children, {
onLongPress: showActionSheet,
delayLongPress: 500,
} as any)}
{Platform.OS !== 'ios' && (
<Modal
visible={visible}
transparent
animationType="slide"
onRequestClose={() => setVisible(false)}
>
<Pressable style={StyleSheet.absoluteFillObject} onPress={() => setVisible(false)}>
<View style={styles.backdrop} />
<View style={styles.container}>
<View className={`rounded-t-2xl ${colors.surface}`} style={styles.menu}>
{(title || message) && (
<View className={`border-b px-4 py-3 ${colors.border}`}>
{title && (
<Text className={`text-center font-semibold ${colors.text}`}>{title}</Text>
)}
{message && (
<Text className={`mt-1 text-center text-sm ${colors.textSecondary}`}>
{message}
</Text>
)}
</View>
)}
<FlatList
data={options}
renderItem={renderOption}
keyExtractor={(_, index) => index.toString()}
scrollEnabled={false}
ItemSeparatorComponent={() => <View className={`h-px ${colors.border}`} />}
/>
<View className={`border-t ${colors.border}`}>
<Pressable
onPress={() => setVisible(false)}
className="py-4"
style={({ pressed }) => ({
backgroundColor: pressed ? 'rgba(0, 0, 0, 0.05)' : 'transparent',
})}
>
<Text className="text-center text-lg font-medium text-blue-600">Abbrechen</Text>
</Pressable>
</View>
</View>
</View>
</Pressable>
</Modal>
)}
</>
);
}
const styles = StyleSheet.create({
backdrop: {
...StyleSheet.absoluteFillObject,
backgroundColor: 'rgba(0, 0, 0, 0.4)',
},
container: {
flex: 1,
justifyContent: 'flex-end',
},
menu: {
maxHeight: '80%',
...Platform.select({
ios: {
// @ts-ignore - React Native Web supports boxShadow
boxShadow: '0px -2px 8px rgba(0, 0, 0, 0.1)',
},
android: {
elevation: 16,
},
}),
},
});

View file

@ -0,0 +1,489 @@
import React, { useState, useEffect, useRef, useMemo } from 'react';
import {
View,
Text,
Pressable,
ActivityIndicator,
Alert,
Animated,
ScrollView,
} from 'react-native';
import { Ionicons } from '@expo/vector-icons';
import { useAudio } from '~/hooks/useAudio';
import { Text as TextType, AudioVersion } from '~/types/database';
import { useStore } from '~/store/store';
import { useTheme } from '~/hooks/useTheme';
import { Dropdown } from '~/components/dropdown';
import {
Voice,
ALL_VOICES,
getVoiceById,
GERMAN_VOICES,
PROVIDER_LABELS,
QUALITY_LABELS,
} from '~/constants/voices';
import { getCurrentAudioVersion, migrateAudioData } from '~/utils/audioMigration';
interface AudioPlayerProps {
text: TextType;
onAudioGenerated?: () => void;
}
export const AudioPlayer: React.FC<AudioPlayerProps> = ({ text, onAudioGenerated }) => {
const [isGenerating, setIsGenerating] = useState(false);
const [showSpeedControl, setShowSpeedControl] = useState(false);
const [selectedVoice, setSelectedVoice] = useState<string>('');
const [showVersions, setShowVersions] = useState(false);
const progressBarRef = useRef<View>(null);
const pulseAnim = useRef(new Animated.Value(1)).current;
const { settings, updateSettings } = useStore();
const { colors } = useTheme();
// Use useMemo to prevent re-migration on every render
const migratedData = useMemo(() => migrateAudioData(text.data), [text.data]);
const audioVersions = migratedData.audioVersions || [];
const currentVersion = useMemo(() => getCurrentAudioVersion(migratedData), [migratedData]);
// Initialize selectedVersionId with current version
const [selectedVersionId, setSelectedVersionId] = useState<string>(currentVersion?.id || '');
// Initialize selected voice
useEffect(() => {
setSelectedVoice(settings.voice);
}, [settings.voice]);
const {
audioState,
generationProgress,
generateAudio,
playAudio,
pauseAudio,
resumeAudio,
stopAudio,
seekTo,
seekForward,
seekBackward,
setPlaybackSpeed,
clearCache,
} = useAudio();
// Pulsating animation for loading state
useEffect(() => {
if (audioState.isLoading) {
Animated.loop(
Animated.sequence([
Animated.timing(pulseAnim, {
toValue: 1.2,
duration: 600,
useNativeDriver: true,
}),
Animated.timing(pulseAnim, {
toValue: 1,
duration: 600,
useNativeDriver: true,
}),
])
).start();
} else {
pulseAnim.setValue(1);
}
}, [audioState.isLoading, pulseAnim]);
const handleGenerateAudio = async () => {
try {
setIsGenerating(true);
await generateAudio(text.id, text.content, selectedVoice, settings.speed, text);
onAudioGenerated?.();
Alert.alert(
'Audio generiert!',
'Das Audio wurde erfolgreich generiert und ist jetzt verfügbar.'
);
} catch (error) {
Alert.alert(
'Fehler',
error instanceof Error ? error.message : 'Fehler beim Generieren des Audios'
);
} finally {
setIsGenerating(false);
}
};
const handleVoiceChange = (newVoice: string) => {
setSelectedVoice(newVoice);
// Update the global settings
updateSettings({ voice: newVoice });
};
const handlePlayPause = async () => {
if (!selectedVersion?.chunks) return;
try {
if (audioState.isPlaying) {
await pauseAudio();
} else if (audioState.sound) {
await resumeAudio();
} else {
// Play directly from Supabase Storage
await playAudio(text.id, selectedVersion.chunks, text.data.tts?.lastPosition || 0);
}
} catch (error) {
Alert.alert(
'Wiedergabe-Fehler',
error instanceof Error ? error.message : 'Fehler beim Abspielen des Audios'
);
}
};
const handleStop = async () => {
await stopAudio();
};
const formatTime = (milliseconds: number): string => {
const totalSeconds = Math.floor(milliseconds / 1000);
const minutes = Math.floor(totalSeconds / 60);
const seconds = totalSeconds % 60;
return `${minutes}:${seconds.toString().padStart(2, '0')}`;
};
const formatSize = (bytes: number): string => {
const mb = bytes / (1024 * 1024);
return `${mb.toFixed(1)} MB`;
};
const speedOptions = [0.5, 0.75, 1, 1.25, 1.5, 2];
const handleSpeedChange = async (speed: number) => {
await setPlaybackSpeed(speed);
setShowSpeedControl(false);
};
// Use duration from audio state if available, otherwise calculate from chunks
const totalDuration =
audioState.duration ||
(selectedVersion?.chunks
? selectedVersion.chunks.reduce((sum, chunk) => sum + chunk.duration, 0) * 1000
: 0);
// Handle progress bar press
const handleProgressPress = async (event: any) => {
if (progressBarRef.current && totalDuration > 0) {
progressBarRef.current.measure(async (x, y, width, height, pageX, pageY) => {
const touchX = event.nativeEvent.pageX - pageX;
const progress = Math.max(0, Math.min(1, touchX / width));
const newPosition = progress * totalDuration;
// If audio hasn't been started yet, start it at the desired position
if (!audioState.sound) {
await playAudio(text.id, text.data.audio!.chunks, newPosition);
} else {
await seekTo(newPosition);
}
});
}
};
// Get the selected audio version
const selectedVersion = audioVersions.find((v) => v.id === selectedVersionId) || currentVersion;
const hasAudio = selectedVersion && selectedVersion.chunks.length > 0;
return (
<View className={`rounded-lg ${colors.surface} p-3 shadow-sm`}>
{/* Voice selection and generate button - always visible */}
<View className="mb-4">
<Text className={`mb-2 text-sm font-medium ${colors.textSecondary}`}>Sprachauswahl</Text>
<Dropdown
options={[]}
value={selectedVoice}
onValueChange={handleVoiceChange}
placeholder="Wähle eine Stimme"
disabled={isGenerating}
title="Stimme auswählen"
groups={Object.entries(
GERMAN_VOICES.reduce(
(groups, voice) => {
const provider = voice.provider;
const quality = voice.quality;
if (!groups[provider]) {
groups[provider] = {};
}
if (!groups[provider][quality]) {
groups[provider][quality] = [];
}
groups[provider][quality].push(voice);
return groups;
},
{} as Record<string, Record<string, typeof GERMAN_VOICES>>
)
).map(([provider, qualityGroups]) => ({
title: PROVIDER_LABELS[provider as keyof typeof PROVIDER_LABELS],
options: Object.entries(qualityGroups).flatMap(([quality, voices]) =>
voices.map((voice) => ({
label: `${QUALITY_LABELS[quality as keyof typeof QUALITY_LABELS]} - ${voice.label}`,
value: voice.value,
}))
),
}))}
/>
<Pressable
onPress={handleGenerateAudio}
disabled={isGenerating}
className={`mt-3 rounded-lg px-4 py-2.5 ${isGenerating ? 'bg-gray-400' : colors.primary}`}
>
{isGenerating ? (
<View className="flex-row items-center justify-center">
<ActivityIndicator size="small" color="white" />
<Text className="ml-2 font-medium text-white">
{generationProgress?.currentChunk || 'Generiere Audio...'}
</Text>
</View>
) : (
<View className="flex-row items-center justify-center">
<Ionicons name="volume-high" size={20} color="white" />
<Text className="ml-2 font-medium text-white">
{hasAudio ? 'Audio neu generieren' : 'Audio generieren'}
</Text>
</View>
)}
</Pressable>
{generationProgress && (
<View className="mt-2">
<View className={`h-1.5 rounded-full ${colors.surfaceSecondary}`}>
<View
className={`h-1.5 rounded-full ${colors.primary}`}
style={{
width: `${(generationProgress.chunksCompleted / generationProgress.totalChunks) * 100}%`,
}}
/>
</View>
<Text className={`mt-1 text-xs ${colors.textSecondary}`}>
{generationProgress.chunksCompleted} / {generationProgress.totalChunks} Chunks
</Text>
</View>
)}
</View>
{/* Audio versions - only shown when audio exists */}
{audioVersions.length > 0 && (
<View className="mt-4">
<Pressable
onPress={() => setShowVersions(!showVersions)}
className="flex-row items-center justify-between"
>
<Text className={`text-sm font-medium ${colors.textSecondary}`}>
Audio-Versionen ({audioVersions.length})
</Text>
<Ionicons
name={showVersions ? 'chevron-up' : 'chevron-down'}
size={16}
color="#71717a"
/>
</Pressable>
{showVersions && (
<ScrollView className="mt-2 max-h-40">
{audioVersions.map((version) => {
const voice = getVoiceById(version.settings.voice);
const isActive = version.id === selectedVersionId;
const date = new Date(version.createdAt);
return (
<Pressable
key={version.id}
onPress={() => setSelectedVersionId(version.id)}
className={`mb-2 rounded-lg p-3 ${
isActive ? 'bg-blue-600' : colors.surfaceSecondary
}`}
>
<View className="flex-row items-center justify-between">
<View className="flex-1">
<Text className={`text-sm ${isActive ? 'text-white' : colors.text}`}>
{date.toLocaleDateString('de-DE', {
day: '2-digit',
month: '2-digit',
hour: '2-digit',
minute: '2-digit',
})}
</Text>
<Text
className={`text-xs ${isActive ? 'text-blue-100' : colors.textSecondary}`}
>
{voice?.label || version.settings.voice} {version.settings.speed}x
</Text>
</View>
<View className="flex-row items-center">
{isActive && <Text className="mr-2 text-xs text-white">Aktiv</Text>}
<Ionicons
name={isActive ? 'radio-button-on' : 'radio-button-off'}
size={20}
color={isActive ? 'white' : '#71717a'}
/>
</View>
</View>
</Pressable>
);
})}
</ScrollView>
)}
</View>
)}
{/* Audio player - only shown when audio exists */}
{hasAudio && (
<View className="mt-4 border-t border-zinc-800 pt-3">
{/* </View> closing tag moved to end */}
{/* Progress bar and time info - full width */}
<View className="mb-3">
{/* Progress Bar with touch gestures */}
<Pressable onPress={handleProgressPress} className="py-2">
<View
ref={progressBarRef}
className={`h-2 rounded-full ${colors.surfaceSecondary} overflow-hidden`}
>
<View
className={`h-2 rounded-full ${colors.primary}`}
style={{
width:
totalDuration > 0
? `${(audioState.currentPosition / totalDuration) * 100}%`
: '0%',
}}
/>
{/* Scrubber indicator */}
{totalDuration > 0 && (
<View
className="absolute top-0 h-2"
style={{
left: `${(audioState.currentPosition / totalDuration) * 100}%`,
}}
>
<View
className={`h-3 w-3 rounded-full ${colors.primary} shadow-lg`}
style={{ marginTop: -2, marginLeft: -6 }}
/>
</View>
)}
</View>
</Pressable>
{/* Time display */}
<View className="mt-1 flex-row justify-between">
<Text className={`text-xs ${colors.textTertiary}`}>
{formatTime(audioState.currentPosition)}
</Text>
<Text className={`text-xs ${colors.textTertiary}`}>{formatTime(totalDuration)}</Text>
</View>
</View>
{/* Controls row */}
<View className="flex-row items-center justify-center">
{/* Stop button */}
<Pressable
onPress={handleStop}
disabled={audioState.isLoading}
className={`rounded-full ${colors.surfaceSecondary} mr-3 p-2`}
>
<Ionicons name="stop" size={18} color="#6b7280" />
</Pressable>
{/* Backward 15s button */}
<Pressable
onPress={() => seekBackward(15)}
disabled={audioState.isLoading || !audioState.sound}
className={`rounded-full ${colors.surfaceSecondary} mr-2 p-2`}
>
<View className="relative" style={{ transform: [{ scaleX: -1 }] }}>
<Ionicons name="reload" size={18} color="#6b7280" />
<View
className="absolute -bottom-1 -left-1"
style={{ transform: [{ scaleX: -1 }] }}
>
<Text style={{ fontSize: 8, color: '#6b7280', fontWeight: 'bold' }}>15</Text>
</View>
</View>
</Pressable>
{/* Play/Pause button */}
<Animated.View
style={{
transform: [{ scale: audioState.isLoading ? pulseAnim : 1 }],
}}
>
<Pressable
onPress={handlePlayPause}
disabled={audioState.isLoading}
className={`rounded-full ${colors.primary} mx-2 p-2.5`}
>
{audioState.isLoading ? (
<ActivityIndicator size="small" color="white" />
) : (
<Ionicons
name={audioState.isPlaying ? 'pause' : 'play'}
size={20}
color="white"
/>
)}
</Pressable>
</Animated.View>
{/* Forward 15s button */}
<Pressable
onPress={() => seekForward(15)}
disabled={audioState.isLoading || !audioState.sound}
className={`rounded-full ${colors.surfaceSecondary} mr-3 p-2`}
>
<View className="relative">
<Ionicons name="reload" size={18} color="#6b7280" />
<View className="absolute -bottom-1 -right-1">
<Text style={{ fontSize: 8, color: '#6b7280', fontWeight: 'bold' }}>15</Text>
</View>
</View>
</Pressable>
{/* Speed control button */}
<Pressable
onPress={() => setShowSpeedControl(!showSpeedControl)}
className={`rounded-full ${colors.surfaceSecondary} px-3 py-1.5`}
>
<Text style={{ fontSize: 14, color: '#6b7280', fontWeight: '600' }}>
{audioState.playbackRate}x
</Text>
</Pressable>
</View>
{/* Speed options dropdown */}
{showSpeedControl && (
<View className="mt-2 flex-row justify-center">
<View className={`rounded-lg ${colors.surfaceSecondary} flex-row p-2`}>
{speedOptions.map((speed) => (
<Pressable
key={speed}
onPress={() => handleSpeedChange(speed)}
className={`mx-1 rounded px-3 py-1 ${
audioState.playbackRate === speed ? colors.primary : ''
}`}
>
<Text
style={{
fontSize: 12,
color: audioState.playbackRate === speed ? '#ffffff' : '#6b7280',
fontWeight: audioState.playbackRate === speed ? 'bold' : 'normal',
}}
>
{speed}x
</Text>
</Pressable>
))}
</View>
</View>
)}
</View>
)}
</View>
);
};

View file

@ -0,0 +1,207 @@
import React from 'react';
import { Pressable, PressableProps, ActivityIndicator, View } from 'react-native';
import { Icon, IconName } from './Icon';
import { Text } from './Text';
export type ButtonVariant =
| 'primary'
| 'secondary'
| 'outline'
| 'ghost'
| 'link'
| 'destructive'
| 'success'
| 'warning';
export type ButtonSize = 'xs' | 'sm' | 'md' | 'lg' | 'xl';
interface ButtonProps extends Omit<PressableProps, 'children'> {
variant?: ButtonVariant;
size?: ButtonSize;
icon?: IconName;
iconPosition?: 'left' | 'right';
loading?: boolean;
disabled?: boolean;
fullWidth?: boolean;
className?: string;
children?: React.ReactNode;
}
export const Button: React.FC<ButtonProps> = ({
variant = 'primary',
size = 'md',
icon,
iconPosition = 'left',
loading = false,
disabled = false,
fullWidth = false,
className,
children,
...props
}) => {
const isDisabled = disabled || loading;
// Get variant styles
const getVariantClasses = () => {
switch (variant) {
case 'primary':
return 'bg-blue-600 active:bg-blue-700';
case 'secondary':
return 'bg-gray-600 active:bg-gray-700';
case 'outline':
return 'border border-gray-300 bg-white active:bg-gray-50';
case 'ghost':
return 'bg-transparent active:bg-gray-100';
case 'link':
return 'bg-transparent';
case 'destructive':
return 'bg-red-600 active:bg-red-700';
case 'success':
return 'bg-green-600 active:bg-green-700';
case 'warning':
return 'bg-yellow-600 active:bg-yellow-700';
default:
return 'bg-blue-600 active:bg-blue-700';
}
};
// Get size styles
const getSizeClasses = () => {
switch (size) {
case 'xs':
return 'px-2 py-1';
case 'sm':
return 'px-3 py-2';
case 'md':
return 'px-4 py-3';
case 'lg':
return 'px-6 py-4';
case 'xl':
return 'px-8 py-5';
default:
return 'px-4 py-3';
}
};
// Get text color
const getTextColor = () => {
if (isDisabled) return 'muted';
switch (variant) {
case 'primary':
case 'secondary':
case 'destructive':
case 'success':
case 'warning':
return 'white';
case 'outline':
case 'ghost':
return 'gray';
case 'link':
return 'primary';
default:
return 'white';
}
};
// Get icon color
const getIconColor = () => {
if (isDisabled) return '#9CA3AF';
switch (variant) {
case 'primary':
case 'secondary':
case 'destructive':
case 'success':
case 'warning':
return '#FFFFFF';
case 'outline':
case 'ghost':
return '#6B7280';
case 'link':
return '#2563EB';
default:
return '#FFFFFF';
}
};
// Get icon size
const getIconSize = () => {
switch (size) {
case 'xs':
return 14;
case 'sm':
return 16;
case 'md':
return 18;
case 'lg':
return 20;
case 'xl':
return 24;
default:
return 18;
}
};
// Get text variant
const getTextVariant = () => {
switch (size) {
case 'xs':
case 'sm':
return 'buttonSmall';
default:
return 'button';
}
};
const renderContent = () => {
if (loading) {
return <ActivityIndicator size="small" color={getIconColor()} />;
}
const iconElement = icon ? (
<Icon name={icon} size={getIconSize()} color={getIconColor()} />
) : null;
const textElement = children ? (
<Text variant={getTextVariant()} color={getTextColor()} align="center">
{children}
</Text>
) : null;
if (!icon && !children) {
return null;
}
if (icon && !children) {
return iconElement;
}
if (!icon && children) {
return textElement;
}
return (
<View className="flex-row items-center gap-2">
{iconPosition === 'left' && iconElement}
{textElement}
{iconPosition === 'right' && iconElement}
</View>
);
};
const buttonClasses = [
'rounded-lg items-center justify-center',
getSizeClasses(),
getVariantClasses(),
fullWidth ? 'w-full' : '',
isDisabled ? 'opacity-50' : '',
className,
]
.filter(Boolean)
.join(' ');
return (
<Pressable className={buttonClasses} disabled={isDisabled} {...props}>
{renderContent()}
</Pressable>
);
};

View file

@ -0,0 +1,159 @@
import React, { useState, useRef } from 'react';
import {
Modal,
View,
Text,
Pressable,
Dimensions,
Platform,
StyleSheet,
FlatList,
} from 'react-native';
import { Ionicons } from '@expo/vector-icons';
import { useTheme } from '~/hooks/useTheme';
interface ContextMenuAction {
title: string;
systemIcon?: string;
icon?: keyof typeof Ionicons.glyphMap;
destructive?: boolean;
disabled?: boolean;
}
interface ContextMenuProps {
actions: ContextMenuAction[];
onPress: (index: number) => void;
children: React.ReactElement;
}
export function ContextMenu({ actions, onPress, children }: ContextMenuProps) {
const [visible, setVisible] = useState(false);
const [menuPosition, setMenuPosition] = useState({ x: 0, y: 0 });
const childRef = useRef<View>(null);
const { colors } = useTheme();
const handleLongPress = () => {
childRef.current?.measure((x, y, width, height, pageX, pageY) => {
const screenHeight = Dimensions.get('window').height;
const menuHeight = actions.length * 50 + 20; // Approximate menu height
// Position menu above or below the pressed item based on available space
const posY = pageY + height + menuHeight > screenHeight ? pageY - menuHeight : pageY + height;
setMenuPosition({ x: pageX, y: posY });
setVisible(true);
});
};
const handleActionPress = (index: number) => {
setVisible(false);
// Small delay to allow modal to close before action
setTimeout(() => onPress(index), 100);
};
const iconMap: Record<string, keyof typeof Ionicons.glyphMap> = {
'doc.text': 'document-text-outline',
'play.circle': 'play-circle-outline',
'square.and.arrow.up': 'share-outline',
tag: 'pricetag-outline',
trash: 'trash-outline',
};
const renderAction = ({ item, index }: { item: ContextMenuAction; index: number }) => {
const iconName = item.icon || (item.systemIcon ? iconMap[item.systemIcon] : undefined);
const isDisabled = item.disabled;
const isDestructive = item.destructive;
return (
<Pressable
onPress={() => !isDisabled && handleActionPress(index)}
disabled={isDisabled}
className={`flex-row items-center px-4 py-3 ${
index < actions.length - 1 ? `border-b ${colors.border}` : ''
}`}
style={({ pressed }) => ({
backgroundColor: pressed && !isDisabled ? 'rgba(0, 0, 0, 0.05)' : 'transparent',
opacity: isDisabled ? 0.5 : 1,
})}
>
{iconName && (
<Ionicons
name={iconName}
size={20}
color={
isDestructive ? '#EF4444' : colors.text.includes('white') ? '#FFFFFF' : '#111827'
}
style={{ marginRight: 12 }}
/>
)}
<Text className={`text-base ${isDestructive ? 'text-red-500' : colors.text}`}>
{item.title}
</Text>
</Pressable>
);
};
return (
<>
<View ref={childRef} collapsable={false}>
{React.cloneElement(children, {
onLongPress: handleLongPress,
delayLongPress: 500,
} as any)}
</View>
<Modal
visible={visible}
transparent
animationType="fade"
onRequestClose={() => setVisible(false)}
>
<Pressable style={StyleSheet.absoluteFillObject} onPress={() => setVisible(false)}>
<View style={[styles.backdrop, { backgroundColor: 'rgba(0, 0, 0, 0.3)' }]} />
<View
style={[
styles.menu,
{
top: menuPosition.y,
left: 20,
right: 20,
maxWidth: 300,
alignSelf: 'center',
backgroundColor: colors.text.includes('white') ? '#1f2937' : '#ffffff',
},
]}
className={`rounded-lg shadow-lg ${colors.surface}`}
>
<FlatList
data={actions}
renderItem={renderAction}
keyExtractor={(_, index) => index.toString()}
scrollEnabled={false}
/>
</View>
</Pressable>
</Modal>
</>
);
}
const styles = StyleSheet.create({
backdrop: {
...StyleSheet.absoluteFillObject,
},
menu: {
position: 'absolute',
borderRadius: 12,
overflow: 'hidden',
...Platform.select({
ios: {
// @ts-ignore - React Native Web supports boxShadow
boxShadow: '0px 2px 10px rgba(0, 0, 0, 0.25)',
},
android: {
elevation: 8,
},
}),
},
});

View file

@ -0,0 +1,29 @@
import { Text, View } from 'react-native';
export const EditScreenInfo = ({ path }: { path: string }) => {
const title = 'Open up the code for this screen:';
const description =
'Change any of the text, save the file, and your app will automatically update.';
return (
<View>
<View className={styles.getStartedContainer}>
<Text className={styles.getStartedText}>{title}</Text>
<View className={styles.codeHighlightContainer + styles.homeScreenFilename}>
<Text>{path}</Text>
</View>
<Text className={styles.getStartedText}>{description}</Text>
</View>
</View>
);
};
const styles = {
codeHighlightContainer: `rounded-md px-1`,
getStartedContainer: `items-center mx-12`,
getStartedText: `text-lg leading-6 text-center`,
helpContainer: `items-center mx-5 mt-4`,
helpLink: `py-4`,
helpLinkText: `text-center`,
homeScreenFilename: `my-2`,
};

View file

@ -0,0 +1,43 @@
import React from 'react';
import { Pressable, Text, ActivityIndicator, ViewStyle } from 'react-native';
import { useTheme } from '~/hooks/useTheme';
interface FloatingActionButtonProps {
onPress: () => void;
icon: string;
label: string;
disabled?: boolean;
loading?: boolean;
style?: ViewStyle;
}
export function FloatingActionButton({
onPress,
icon,
label,
disabled = false,
loading = false,
style,
}: FloatingActionButtonProps) {
const { colors } = useTheme();
return (
<Pressable
onPress={onPress}
disabled={disabled || loading}
style={style}
className={`flex-row items-center rounded-full px-4 py-3 shadow-lg ${
disabled || loading ? 'bg-gray-400' : colors.primary
}`}
>
{loading ? (
<ActivityIndicator size="small" color="white" />
) : (
<>
<Text className="mr-2 text-lg text-white">{icon}</Text>
<Text className="font-medium text-white">{label}</Text>
</>
)}
</Pressable>
);
}

View file

@ -0,0 +1,92 @@
import React from 'react';
import { View, Pressable, Platform, StatusBar } from 'react-native';
import { router } from 'expo-router';
import { Icon } from './Icon';
import { Text } from './Text';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
import { useTheme } from '~/hooks/useTheme';
interface HeaderProps {
title?: string;
showBackButton?: boolean;
rightComponent?: React.ReactNode;
onBackPress?: () => void;
backgroundColor?: string;
textColor?: string;
}
export const Header: React.FC<HeaderProps> = ({
title,
showBackButton = true,
rightComponent,
onBackPress,
backgroundColor,
textColor,
}) => {
const insets = useSafeAreaInsets();
const { isDark, colors } = useTheme();
const handleBackPress = () => {
if (onBackPress) {
onBackPress();
} else {
router.back();
}
};
// Use theme colors if not explicitly provided
const headerBackgroundColor = backgroundColor || (isDark ? colors.tabBarBackground : '#ffffff');
const headerTextColor = textColor || (isDark ? '#ffffff' : '#000000');
const borderColor = isDark ? colors.tabBarBorder : '#e5e7eb';
return (
<View
style={{
backgroundColor: headerBackgroundColor,
paddingTop: insets.top,
paddingBottom: 12,
paddingHorizontal: 16,
borderBottomWidth: 1,
borderBottomColor: borderColor,
}}
>
<StatusBar
barStyle={isDark ? 'light-content' : 'dark-content'}
backgroundColor={headerBackgroundColor}
/>
<View className="min-h-[44px] flex-row items-center justify-between">
{/* Left side - Back button */}
<View className="flex-1 flex-row items-center">
{showBackButton && (
<Pressable
onPress={handleBackPress}
className="-ml-2 mr-3 rounded-full p-2"
hitSlop={{ top: 10, bottom: 10, left: 10, right: 10 }}
>
<Icon name="arrow-back" size={24} color={headerTextColor} />
</Pressable>
)}
</View>
{/* Center - Title */}
<View className="flex-2 items-center">
{title && (
<Text
variant="h4"
color={headerTextColor === '#000000' ? 'black' : 'white'}
className="text-center font-semibold"
numberOfLines={1}
ellipsizeMode="tail"
>
{title}
</Text>
)}
</View>
{/* Right side - Custom component */}
<View className="flex-1 flex-row items-center justify-end">{rightComponent}</View>
</View>
</View>
);
};

View file

@ -0,0 +1,128 @@
import React from 'react';
import { View } from 'react-native';
import { Ionicons } from '@expo/vector-icons';
export type IconName =
| 'add'
| 'delete'
| 'edit'
| 'save'
| 'close'
| 'back'
| 'play'
| 'pause'
| 'stop'
| 'refresh'
| 'settings'
| 'logout'
| 'eye'
| 'eye-off'
| 'heart'
| 'heart-outline'
| 'tag'
| 'filter'
| 'search'
| 'download'
| 'share'
| 'volume-high'
| 'volume-low'
| 'volume-mute'
| 'fast-forward'
| 'rewind'
| 'skip-forward'
| 'skip-backward'
| 'checkmark'
| 'close-circle'
| 'alert-circle'
| 'information-circle'
| 'chevron-down'
| 'chevron-up'
| 'chevron-left'
| 'chevron-right'
| 'arrow-back'
| 'arrow-forward'
| 'home'
| 'library'
| 'person'
| 'menu'
| 'more-horizontal'
| 'more-vertical'
| 'replay-15'
| 'forward-15'
| 'play-circle'
| 'pause-circle'
| 'mic-circle';
interface IconProps {
name: IconName;
size?: number;
color?: string;
className?: string;
}
const iconMapping: Record<IconName, keyof typeof Ionicons.glyphMap> = {
add: 'add',
delete: 'trash',
edit: 'pencil',
save: 'save',
close: 'close',
back: 'arrow-back',
play: 'play',
pause: 'pause',
stop: 'stop',
refresh: 'refresh',
settings: 'settings',
logout: 'log-out',
eye: 'eye',
'eye-off': 'eye-off',
heart: 'heart',
'heart-outline': 'heart-outline',
tag: 'pricetag',
filter: 'filter',
search: 'search',
download: 'download',
share: 'share',
'volume-high': 'volume-high',
'volume-low': 'volume-low',
'volume-mute': 'volume-mute',
'fast-forward': 'play-forward',
rewind: 'play-back',
'skip-forward': 'play-skip-forward',
'skip-backward': 'play-skip-back',
checkmark: 'checkmark',
'close-circle': 'close-circle',
'alert-circle': 'alert-circle',
'information-circle': 'information-circle',
'chevron-down': 'chevron-down',
'chevron-up': 'chevron-up',
'chevron-left': 'chevron-back',
'chevron-right': 'chevron-forward',
'arrow-back': 'arrow-back',
'arrow-forward': 'arrow-forward',
home: 'home',
library: 'library',
person: 'person',
menu: 'menu',
'more-horizontal': 'ellipsis-horizontal',
'more-vertical': 'ellipsis-vertical',
'replay-15': 'refresh-circle',
'forward-15': 'add-circle',
'play-circle': 'play-circle',
'pause-circle': 'pause-circle',
'mic-circle': 'mic-circle',
};
export const Icon: React.FC<IconProps> = ({ name, size = 24, color = '#000000', className }) => {
const ionIconName = iconMapping[name];
if (!ionIconName) {
console.warn(`Icon "${name}" not found in iconMapping`);
return null;
}
return (
<View className={className}>
<Ionicons name={ionIconName} size={size} color={color} />
</View>
);
};

View file

@ -0,0 +1,91 @@
import React, { useState, useEffect } from 'react';
import { View, Pressable, ActivityIndicator } from 'react-native';
import { Icon } from '~/components/Icon';
import { useAudio } from '~/hooks/useAudio';
import { Text as TextType } from '~/types/database';
import { useStore } from '~/store/store';
import { useTheme } from '~/hooks/useTheme';
import { getCurrentAudioVersion, migrateAudioData } from '~/utils/audioMigration';
interface MinimalAudioPlayerProps {
text: TextType;
}
export const MinimalAudioPlayer: React.FC<MinimalAudioPlayerProps> = ({ text }) => {
const [isGenerating, setIsGenerating] = useState(false);
const { currentTextId } = useStore();
const { colors } = useTheme();
const { audioState, generateAudio, playAudio, pauseAudio, resumeAudio, stopAudio } = useAudio();
// Check if this text is currently playing
const isCurrentText = currentTextId === text.id;
const isPlaying = isCurrentText && audioState.isPlaying;
const isLoading = isCurrentText && audioState.isLoading;
// Get audio version
const migratedData = migrateAudioData(text.data);
const currentVersion = getCurrentAudioVersion(migratedData);
const hasAudio = currentVersion && currentVersion.chunks.length > 0;
// Stop audio when component unmounts or text changes
useEffect(() => {
return () => {
if (isCurrentText) {
stopAudio();
}
};
}, [isCurrentText, stopAudio]);
const handlePlayPause = async () => {
if (!hasAudio) {
// Generate audio if not available
try {
setIsGenerating(true);
const { settings } = useStore.getState();
await generateAudio(text.id, text.content, settings.voice, settings.speed, text);
} catch (error) {
console.error('Error generating audio:', error);
} finally {
setIsGenerating(false);
}
return;
}
try {
if (isPlaying) {
await pauseAudio();
} else if (isCurrentText && audioState.sound) {
await resumeAudio();
} else {
// Stop any other playing audio and start this one
if (currentTextId && currentTextId !== text.id) {
await stopAudio();
}
await playAudio(text.id, currentVersion.chunks, 0);
}
} catch (error) {
console.error('Error playing audio:', error);
}
};
return (
<Pressable
onPress={handlePlayPause}
disabled={isLoading || isGenerating}
className={`rounded-full p-2 ${
hasAudio ? colors.surfaceSecondary : colors.surface
} active:opacity-70`}
>
{isLoading || isGenerating ? (
<ActivityIndicator size="small" color={colors.tabBarInactive} />
) : (
<Icon
name={hasAudio ? (isPlaying ? 'pause-circle' : 'play-circle') : 'mic-circle'}
size={28}
color={hasAudio ? colors.tabBarActive : colors.tabBarInactive}
/>
)}
</Pressable>
);
};

View file

@ -0,0 +1,25 @@
import { Text, View } from 'react-native';
import { EditScreenInfo } from './EditScreenInfo';
type ScreenContentProps = {
title: string;
path: string;
children?: React.ReactNode;
};
export const ScreenContent = ({ title, path, children }: ScreenContentProps) => {
return (
<View className={styles.container}>
<Text className={styles.title}>{title}</Text>
<View className={styles.separator} />
<EditScreenInfo path={path} />
{children}
</View>
);
};
const styles = {
container: `items-center flex-1 justify-center`,
separator: `h-[1px] my-7 w-4/5 bg-gray-200`,
title: `text-xl font-bold`,
};

View file

@ -0,0 +1,15 @@
import FontAwesome from '@expo/vector-icons/FontAwesome';
import { StyleSheet } from 'react-native';
export const TabBarIcon = (props: {
name: React.ComponentProps<typeof FontAwesome>['name'];
color: string;
}) => {
return <FontAwesome size={28} style={styles.tabBarIcon} {...props} />;
};
export const styles = StyleSheet.create({
tabBarIcon: {
marginBottom: -3,
},
});

View file

@ -0,0 +1,55 @@
import React from 'react';
import { View, Text, ScrollView, Pressable } from 'react-native';
import { useTexts } from '~/hooks/useTexts';
import { useStore } from '~/store/store';
import { useTheme } from '~/hooks/useTheme';
export const TagFilter: React.FC = () => {
const { getAllTags } = useTexts();
const { selectedTags, toggleTag, clearTags } = useStore();
const { colors } = useTheme();
const allTags = getAllTags();
if (allTags.length === 0) {
return null;
}
return (
<View className={`border-b ${colors.border} ${colors.surface} px-4 py-2`}>
<View className="mb-2 flex-row items-center justify-between">
<Text className={`text-sm font-medium ${colors.textSecondary}`}>Tags filtern:</Text>
{selectedTags.length > 0 && (
<Pressable onPress={clearTags}>
<Text className="text-sm text-blue-600">Alle entfernen</Text>
</Pressable>
)}
</View>
<ScrollView
horizontal
showsHorizontalScrollIndicator={false}
contentContainerStyle={{ paddingRight: 16 }}
>
{allTags.map((tag) => {
const isSelected = selectedTags.includes(tag);
return (
<Pressable
key={tag}
onPress={() => toggleTag(tag)}
className={`mr-2 rounded-full border px-3 py-1 ${
isSelected
? `border-blue-500 ${colors.primaryLight}`
: `${colors.borderSecondary} ${colors.surfaceSecondary}`
}`}
>
<Text className={`text-sm ${isSelected ? 'text-blue-800' : colors.textSecondary}`}>
{tag}
</Text>
</Pressable>
);
})}
</ScrollView>
</View>
);
};

View file

@ -0,0 +1,158 @@
import React from 'react';
import { Text as RNText, TextProps as RNTextProps } from 'react-native';
import { useTheme } from '~/hooks/useTheme';
export type TextVariant =
| 'h1'
| 'h2'
| 'h3'
| 'h4'
| 'h5'
| 'h6'
| 'body'
| 'bodyLarge'
| 'bodySmall'
| 'caption'
| 'label'
| 'labelLarge'
| 'labelSmall'
| 'button'
| 'buttonSmall'
| 'overline'
| 'subtitle1'
| 'subtitle2';
export type TextColor =
| 'primary'
| 'secondary'
| 'tertiary'
| 'accent'
| 'error'
| 'warning'
| 'success'
| 'info'
| 'white'
| 'black'
| 'gray'
| 'muted'
| 'red'
| 'blue'
| 'green'
| 'yellow'
| 'purple'
| 'pink'
| 'indigo'
| 'cyan'
| 'orange'
| 'inherit';
interface TextComponentProps extends RNTextProps {
variant?: TextVariant;
color?: TextColor;
weight?: 'light' | 'normal' | 'medium' | 'semibold' | 'bold';
align?: 'left' | 'center' | 'right' | 'justify';
className?: string;
children: React.ReactNode;
}
const variantStyles: Record<TextVariant, string> = {
h1: 'text-4xl font-bold',
h2: 'text-3xl font-bold',
h3: 'text-2xl font-bold',
h4: 'text-xl font-bold',
h5: 'text-lg font-bold',
h6: 'text-base font-bold',
body: 'text-base',
bodyLarge: 'text-lg',
bodySmall: 'text-sm',
caption: 'text-xs',
label: 'text-sm font-medium',
labelLarge: 'text-base font-medium',
labelSmall: 'text-xs font-medium',
button: 'text-base font-semibold',
buttonSmall: 'text-sm font-semibold',
overline: 'text-xs font-medium uppercase tracking-wide',
subtitle1: 'text-base font-medium',
subtitle2: 'text-sm font-medium',
};
const colorStyles: Record<TextColor, string> = {
primary: 'text-blue-600',
secondary: 'text-gray-600',
accent: 'text-purple-600',
error: 'text-red-600',
warning: 'text-yellow-600',
success: 'text-green-600',
info: 'text-blue-500',
white: 'text-white',
black: 'text-black',
gray: 'text-gray-500',
muted: 'text-gray-400',
red: 'text-red-600',
blue: 'text-blue-600',
green: 'text-green-600',
yellow: 'text-yellow-600',
purple: 'text-purple-600',
pink: 'text-pink-600',
indigo: 'text-indigo-600',
cyan: 'text-cyan-600',
orange: 'text-orange-600',
};
const weightStyles: Record<string, string> = {
light: 'font-light',
normal: 'font-normal',
medium: 'font-medium',
semibold: 'font-semibold',
bold: 'font-bold',
};
const alignStyles: Record<string, string> = {
left: 'text-left',
center: 'text-center',
right: 'text-right',
justify: 'text-justify',
};
export const Text: React.FC<TextComponentProps> = ({
variant = 'body',
color = 'inherit',
weight,
align,
className,
children,
...props
}) => {
const { colors } = useTheme();
// Map semantic colors to theme colors
const getThemeColor = (textColor: TextColor): string => {
switch (textColor) {
case 'inherit':
case 'primary':
return colors.text;
case 'secondary':
return colors.textSecondary;
case 'tertiary':
case 'muted':
return colors.textTertiary;
default:
return colorStyles[textColor] || colors.text;
}
};
const variantClass = variantStyles[variant];
const colorClass = getThemeColor(color);
const weightClass = weight ? weightStyles[weight] : '';
const alignClass = align ? alignStyles[align] : '';
const combinedClassName = [variantClass, colorClass, weightClass, alignClass, className]
.filter(Boolean)
.join(' ');
return (
<RNText className={combinedClassName} {...props}>
{children}
</RNText>
);
};

View file

@ -0,0 +1,95 @@
import React from 'react';
import { View, Text, Pressable } from 'react-native';
import { router } from 'expo-router';
import { ActionMenu } from '~/components/ActionMenu';
import { MinimalAudioPlayer } from '~/components/MinimalAudioPlayer';
import { Text as TextType } from '~/types/database';
import { useTheme } from '~/hooks/useTheme';
interface TextListItemProps {
item: TextType;
onShare: (text: TextType) => void;
onDelete: (textId: string, title: string) => void;
formatDate: (dateString: string) => string;
getAudioDuration: (item: TextType) => string | null;
}
export const TextListItem: React.FC<TextListItemProps> = ({
item,
onShare,
onDelete,
formatDate,
getAudioDuration,
}) => {
const { colors } = useTheme();
const handleMenuSelect = (index: number) => {
switch (index) {
case 0: // Öffnen
router.push(`/text/${item.id}`);
break;
case 1: // Teilen
onShare(item);
break;
case 2: // Tags bearbeiten
router.push(`/text/${item.id}`);
break;
case 3: // Löschen
onDelete(item.id, item.title);
break;
}
};
return (
<ActionMenu
options={[
{ title: 'Öffnen', systemIcon: 'doc.text' },
{ title: 'Teilen', systemIcon: 'square.and.arrow.up' },
{ title: 'Tags bearbeiten', systemIcon: 'tag' },
{ title: 'Löschen', systemIcon: 'trash', destructive: true },
]}
onSelect={handleMenuSelect}
>
<Pressable
onPress={() => router.push(`/text/${item.id}`)}
className={`mb-3 rounded-lg border ${colors.border} ${colors.surface} p-4 shadow-sm`}
>
{/* Header with title and date/duration */}
<View className="mb-2 flex-row items-start justify-between">
<Text className={`mr-2 flex-1 text-lg font-semibold ${colors.text}`} numberOfLines={1}>
{item.title}
</Text>
<View className="flex-row items-center">
<Text className={`text-sm ${colors.textTertiary}`}>{formatDate(item.updated_at)}</Text>
{getAudioDuration(item) && (
<>
<Text className={`mx-1 text-sm ${colors.textTertiary}`}></Text>
<Text className={`text-sm ${colors.textTertiary}`}>{getAudioDuration(item)}</Text>
</>
)}
</View>
</View>
{/* Content preview */}
<Text className={`mb-3 ${colors.textSecondary}`} numberOfLines={2}>
{item.content}
</Text>
{/* Footer with tags and audio player */}
<View className="flex-row items-center justify-between">
<View className="flex-row items-center">
{item.data.tags?.map((tag, index) => (
<View key={index} className={`mr-2 rounded-full ${colors.primaryLight} px-2 py-1`}>
<Text className="text-xs text-blue-800">{tag}</Text>
</View>
))}
</View>
<View className="flex-row items-center">
<MinimalAudioPlayer text={item} />
</View>
</View>
</Pressable>
</ActionMenu>
);
};

View file

@ -0,0 +1,144 @@
import React, { useState } from 'react';
import { View, Text, TouchableOpacity, Modal, ScrollView, Pressable } from 'react-native';
import { Ionicons } from '@expo/vector-icons';
import { useTheme } from '~/hooks/useTheme';
interface DropdownOption {
label: string;
value: string;
}
interface DropdownGroup {
title: string;
options: DropdownOption[];
}
interface DropdownProps {
options: DropdownOption[];
groups?: DropdownGroup[];
value: string;
onValueChange: (value: string) => void;
placeholder?: string;
disabled?: boolean;
title?: string;
}
export function Dropdown({
options,
groups,
value,
onValueChange,
placeholder = 'Select an option',
disabled = false,
title = 'Select Option',
}: DropdownProps) {
const [isOpen, setIsOpen] = useState(false);
const { colors } = useTheme();
// Find selected option from either flat options or groups
const allOptions = groups ? groups.flatMap((g) => g.options) : options;
const selectedOption = allOptions.find((opt) => opt.value === value);
const handleSelect = (optionValue: string) => {
onValueChange(optionValue);
setIsOpen(false);
};
return (
<View>
<TouchableOpacity
onPress={() => !disabled && setIsOpen(true)}
className={`flex-row items-center justify-between rounded-lg border ${colors.border} ${colors.surface} px-4 py-3 ${
disabled ? 'opacity-50' : ''
}`}
disabled={disabled}
>
<Text
className={`flex-1 ${selectedOption ? colors.text : colors.textSecondary}`}
numberOfLines={1}
>
{selectedOption?.label || placeholder}
</Text>
<Ionicons
name={isOpen ? 'chevron-up' : 'chevron-down'}
size={20}
color={colors.textSecondary}
/>
</TouchableOpacity>
<Modal
visible={isOpen}
transparent
animationType="fade"
onRequestClose={() => setIsOpen(false)}
>
<Pressable
style={{ flex: 1, backgroundColor: 'rgba(0, 0, 0, 0.5)' }}
onPress={() => setIsOpen(false)}
>
<View className="flex-1 justify-center px-4">
<Pressable
onPress={(e) => e.stopPropagation()}
className={`max-h-[80%] rounded-xl border ${colors.border} ${colors.surface} shadow-xl`}
>
<View className={`border-b ${colors.border} px-4 py-3`}>
<View className="flex-row items-center justify-between">
<Text className={`text-lg font-semibold ${colors.text}`}>{title}</Text>
<TouchableOpacity onPress={() => setIsOpen(false)}>
<Ionicons name="close-circle" size={24} color={colors.textSecondary} />
</TouchableOpacity>
</View>
</View>
<ScrollView className="px-2 py-2" showsVerticalScrollIndicator={true}>
{groups
? // Render grouped options
groups.map((group, groupIndex) => (
<View key={group.title} className={groupIndex > 0 ? 'mt-4' : ''}>
<Text className={`mx-2 mb-2 text-sm font-bold ${colors.textSecondary}`}>
{group.title}
</Text>
{group.options.map((option) => (
<TouchableOpacity
key={option.value}
onPress={() => handleSelect(option.value)}
className={`mx-2 mb-1 rounded-lg px-4 py-3 ${
option.value === value ? colors.primary : colors.surfaceSecondary
}`}
>
<Text
className={`${
option.value === value ? 'font-medium text-white' : colors.text
}`}
>
{option.label}
</Text>
</TouchableOpacity>
))}
</View>
))
: // Render flat options
options.map((option) => (
<TouchableOpacity
key={option.value}
onPress={() => handleSelect(option.value)}
className={`mx-2 mb-1 rounded-lg px-4 py-3 ${
option.value === value ? colors.primary : colors.surfaceSecondary
}`}
>
<Text
className={`${
option.value === value ? 'font-medium text-white' : colors.text
}`}
>
{option.label}
</Text>
</TouchableOpacity>
))}
</ScrollView>
</Pressable>
</View>
</Pressable>
</Modal>
</View>
);
}