style: auto-format codebase with Prettier

Applied formatting to 1487+ files using pnpm format:write
  - TypeScript/JavaScript files
  - Svelte components
  - Astro pages
  - JSON configs
  - Markdown docs

  13 files still need manual review (Astro JSX comments)
This commit is contained in:
Wuesteon 2025-11-27 18:33:16 +01:00
parent 0241f5554c
commit d36b321d9d
3952 changed files with 661498 additions and 739751 deletions

View file

@ -1,29 +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.';
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>
);
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`,
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

@ -3,76 +3,77 @@ import { View, Text, Pressable, ScrollView } from 'react-native';
import * as Updates from 'expo-updates';
interface Props {
children: ReactNode;
children: ReactNode;
}
interface State {
hasError: boolean;
error: Error | null;
errorInfo: ErrorInfo | null;
hasError: boolean;
error: Error | null;
errorInfo: ErrorInfo | null;
}
export class ErrorBoundary extends Component<Props, State> {
constructor(props: Props) {
super(props);
this.state = { hasError: false, error: null, errorInfo: null };
}
constructor(props: Props) {
super(props);
this.state = { hasError: false, error: null, errorInfo: null };
}
static getDerivedStateFromError(error: Error): State {
return { hasError: true, error, errorInfo: null };
}
static getDerivedStateFromError(error: Error): State {
return { hasError: true, error, errorInfo: null };
}
componentDidCatch(error: Error, errorInfo: ErrorInfo) {
console.error('Uncaught error:', error, errorInfo);
this.setState({ error, errorInfo });
}
componentDidCatch(error: Error, errorInfo: ErrorInfo) {
console.error('Uncaught error:', error, errorInfo);
this.setState({ error, errorInfo });
}
handleRestart = async () => {
if (Updates.isEnabled) {
await Updates.reloadAsync();
} else {
// In development, just reset the error state
this.setState({ hasError: false, error: null, errorInfo: null });
}
};
handleRestart = async () => {
if (Updates.isEnabled) {
await Updates.reloadAsync();
} else {
// In development, just reset the error state
this.setState({ hasError: false, error: null, errorInfo: null });
}
};
render() {
if (this.state.hasError) {
return (
<View className="flex-1 bg-background p-6">
<ScrollView>
<Text className="mb-4 text-2xl font-bold text-destructive">
Ein Fehler ist aufgetreten
</Text>
render() {
if (this.state.hasError) {
return (
<View className="flex-1 bg-background p-6">
<ScrollView>
<Text className="mb-4 text-2xl font-bold text-destructive">
Ein Fehler ist aufgetreten
</Text>
<View className="mb-4 rounded-lg bg-destructive/10 p-4">
<Text className="text-sm font-semibold text-destructive">
{this.state.error?.message || 'Unbekannter Fehler'}
</Text>
</View>
<View className="mb-4 rounded-lg bg-destructive/10 p-4">
<Text className="text-sm font-semibold text-destructive">
{this.state.error?.message || 'Unbekannter Fehler'}
</Text>
</View>
{__DEV__ && this.state.errorInfo && (
<View className="mb-4 rounded-lg bg-muted p-4">
<Text className="mb-2 text-sm font-semibold text-foreground">Stack Trace:</Text>
<Text className="font-mono text-xs text-muted-foreground">
{this.state.errorInfo.componentStack}
</Text>
</View>
)}
{__DEV__ && this.state.errorInfo && (
<View className="mb-4 rounded-lg bg-muted p-4">
<Text className="mb-2 text-sm font-semibold text-foreground">Stack Trace:</Text>
<Text className="font-mono text-xs text-muted-foreground">
{this.state.errorInfo.componentStack}
</Text>
</View>
)}
<Pressable
onPress={this.handleRestart}
className="rounded-lg bg-primary px-4 py-3"
style={({ pressed }) => pressed && { opacity: 0.7 }}>
<Text className="text-center font-semibold text-primary-foreground">
App neu starten
</Text>
</Pressable>
</ScrollView>
</View>
);
}
<Pressable
onPress={this.handleRestart}
className="rounded-lg bg-primary px-4 py-3"
style={({ pressed }) => pressed && { opacity: 0.7 }}
>
<Text className="text-center font-semibold text-primary-foreground">
App neu starten
</Text>
</Pressable>
</ScrollView>
</View>
);
}
return this.props.children;
}
return this.props.children;
}
}

View file

@ -3,31 +3,31 @@ import FontAwesome from '@expo/vector-icons/FontAwesome';
import { Pressable, StyleSheet } from 'react-native';
export const HeaderButton = forwardRef<typeof Pressable, { onPress?: () => void }>(
({ onPress }, ref) => {
return (
<Pressable onPress={onPress}>
{({ pressed }) => (
<FontAwesome
name="info-circle"
size={25}
color="gray"
style={[
styles.headerRight,
{
opacity: pressed ? 0.5 : 1,
},
]}
/>
)}
</Pressable>
);
}
({ onPress }, ref) => {
return (
<Pressable onPress={onPress}>
{({ pressed }) => (
<FontAwesome
name="info-circle"
size={25}
color="gray"
style={[
styles.headerRight,
{
opacity: pressed ? 0.5 : 1,
},
]}
/>
)}
</Pressable>
);
}
);
HeaderButton.displayName = 'HeaderButton';
export const styles = StyleSheet.create({
headerRight: {
marginRight: 15,
},
headerRight: {
marginRight: 15,
},
});

View file

@ -2,190 +2,175 @@ import React from 'react';
import { View, Text, Modal, TouchableOpacity, StyleSheet } from 'react-native';
interface InsufficientCreditsModalProps {
visible: boolean;
requiredCredits: number;
availableCredits: number;
operation?: string;
onClose: () => void;
onPurchase?: () => void;
visible: boolean;
requiredCredits: number;
availableCredits: number;
operation?: string;
onClose: () => void;
onPurchase?: () => void;
}
/**
* Modal shown when user has insufficient mana credits
*/
export function InsufficientCreditsModal({
visible,
requiredCredits,
availableCredits,
operation = 'this operation',
onClose,
onPurchase,
visible,
requiredCredits,
availableCredits,
operation = 'this operation',
onClose,
onPurchase,
}: InsufficientCreditsModalProps) {
const shortfall = requiredCredits - availableCredits;
const shortfall = requiredCredits - availableCredits;
return (
<Modal
visible={visible}
transparent
animationType="fade"
onRequestClose={onClose}
>
<View style={styles.overlay}>
<View style={styles.modalContainer}>
{/* Header */}
<View style={styles.header}>
<Text style={styles.icon}></Text>
<Text style={styles.title}>Insufficient Mana</Text>
</View>
return (
<Modal visible={visible} transparent animationType="fade" onRequestClose={onClose}>
<View style={styles.overlay}>
<View style={styles.modalContainer}>
{/* Header */}
<View style={styles.header}>
<Text style={styles.icon}></Text>
<Text style={styles.title}>Insufficient Mana</Text>
</View>
{/* Content */}
<View style={styles.content}>
<Text style={styles.message}>
You don't have enough mana to {operation}.
</Text>
{/* Content */}
<View style={styles.content}>
<Text style={styles.message}>You don't have enough mana to {operation}.</Text>
<View style={styles.creditsInfo}>
<View style={styles.creditsRow}>
<Text style={styles.label}>Required:</Text>
<Text style={styles.value}>{requiredCredits} mana</Text>
</View>
<View style={styles.creditsRow}>
<Text style={styles.label}>Available:</Text>
<Text style={styles.value}>{availableCredits} mana</Text>
</View>
<View style={[styles.creditsRow, styles.shortfallRow]}>
<Text style={styles.label}>Needed:</Text>
<Text style={[styles.value, styles.shortfall]}>
{shortfall} mana
</Text>
</View>
</View>
</View>
<View style={styles.creditsInfo}>
<View style={styles.creditsRow}>
<Text style={styles.label}>Required:</Text>
<Text style={styles.value}>{requiredCredits} mana</Text>
</View>
<View style={styles.creditsRow}>
<Text style={styles.label}>Available:</Text>
<Text style={styles.value}>{availableCredits} mana</Text>
</View>
<View style={[styles.creditsRow, styles.shortfallRow]}>
<Text style={styles.label}>Needed:</Text>
<Text style={[styles.value, styles.shortfall]}>{shortfall} mana</Text>
</View>
</View>
</View>
{/* Actions */}
<View style={styles.actions}>
{onPurchase && (
<TouchableOpacity
style={[styles.button, styles.primaryButton]}
onPress={onPurchase}
>
<Text style={styles.primaryButtonText}>Get More Mana</Text>
</TouchableOpacity>
)}
{/* Actions */}
<View style={styles.actions}>
{onPurchase && (
<TouchableOpacity style={[styles.button, styles.primaryButton]} onPress={onPurchase}>
<Text style={styles.primaryButtonText}>Get More Mana</Text>
</TouchableOpacity>
)}
<TouchableOpacity
style={[styles.button, styles.secondaryButton]}
onPress={onClose}
>
<Text style={styles.secondaryButtonText}>Cancel</Text>
</TouchableOpacity>
</View>
</View>
</View>
</Modal>
);
<TouchableOpacity style={[styles.button, styles.secondaryButton]} onPress={onClose}>
<Text style={styles.secondaryButtonText}>Cancel</Text>
</TouchableOpacity>
</View>
</View>
</View>
</Modal>
);
}
const styles = StyleSheet.create({
overlay: {
flex: 1,
backgroundColor: 'rgba(0, 0, 0, 0.5)',
justifyContent: 'center',
alignItems: 'center',
padding: 20,
},
modalContainer: {
backgroundColor: 'white',
borderRadius: 16,
width: '100%',
maxWidth: 400,
shadowColor: '#000',
shadowOffset: { width: 0, height: 2 },
shadowOpacity: 0.25,
shadowRadius: 8,
elevation: 5,
},
header: {
alignItems: 'center',
paddingTop: 24,
paddingBottom: 16,
borderBottomWidth: 1,
borderBottomColor: '#f0f0f0',
},
icon: {
fontSize: 48,
marginBottom: 8,
},
title: {
fontSize: 22,
fontWeight: '600',
color: '#1a1a1a',
},
content: {
padding: 24,
},
message: {
fontSize: 16,
color: '#666',
textAlign: 'center',
marginBottom: 20,
lineHeight: 24,
},
creditsInfo: {
backgroundColor: '#f8f9fa',
borderRadius: 12,
padding: 16,
},
creditsRow: {
flexDirection: 'row',
justifyContent: 'space-between',
marginBottom: 12,
},
shortfallRow: {
marginBottom: 0,
paddingTop: 12,
borderTopWidth: 1,
borderTopColor: '#e0e0e0',
},
label: {
fontSize: 15,
color: '#666',
fontWeight: '500',
},
value: {
fontSize: 15,
color: '#1a1a1a',
fontWeight: '600',
},
shortfall: {
color: '#dc2626',
},
actions: {
padding: 16,
gap: 12,
},
button: {
paddingVertical: 14,
paddingHorizontal: 24,
borderRadius: 12,
alignItems: 'center',
},
primaryButton: {
backgroundColor: '#3b82f6',
},
primaryButtonText: {
color: 'white',
fontSize: 16,
fontWeight: '600',
},
secondaryButton: {
backgroundColor: 'transparent',
borderWidth: 1,
borderColor: '#d1d5db',
},
secondaryButtonText: {
color: '#6b7280',
fontSize: 16,
fontWeight: '500',
},
overlay: {
flex: 1,
backgroundColor: 'rgba(0, 0, 0, 0.5)',
justifyContent: 'center',
alignItems: 'center',
padding: 20,
},
modalContainer: {
backgroundColor: 'white',
borderRadius: 16,
width: '100%',
maxWidth: 400,
shadowColor: '#000',
shadowOffset: { width: 0, height: 2 },
shadowOpacity: 0.25,
shadowRadius: 8,
elevation: 5,
},
header: {
alignItems: 'center',
paddingTop: 24,
paddingBottom: 16,
borderBottomWidth: 1,
borderBottomColor: '#f0f0f0',
},
icon: {
fontSize: 48,
marginBottom: 8,
},
title: {
fontSize: 22,
fontWeight: '600',
color: '#1a1a1a',
},
content: {
padding: 24,
},
message: {
fontSize: 16,
color: '#666',
textAlign: 'center',
marginBottom: 20,
lineHeight: 24,
},
creditsInfo: {
backgroundColor: '#f8f9fa',
borderRadius: 12,
padding: 16,
},
creditsRow: {
flexDirection: 'row',
justifyContent: 'space-between',
marginBottom: 12,
},
shortfallRow: {
marginBottom: 0,
paddingTop: 12,
borderTopWidth: 1,
borderTopColor: '#e0e0e0',
},
label: {
fontSize: 15,
color: '#666',
fontWeight: '500',
},
value: {
fontSize: 15,
color: '#1a1a1a',
fontWeight: '600',
},
shortfall: {
color: '#dc2626',
},
actions: {
padding: 16,
gap: 12,
},
button: {
paddingVertical: 14,
paddingHorizontal: 24,
borderRadius: 12,
alignItems: 'center',
},
primaryButton: {
backgroundColor: '#3b82f6',
},
primaryButtonText: {
color: 'white',
fontSize: 16,
fontWeight: '600',
},
secondaryButton: {
backgroundColor: 'transparent',
borderWidth: 1,
borderColor: '#d1d5db',
},
secondaryButtonText: {
color: '#6b7280',
fontSize: 16,
fontWeight: '500',
},
});

View file

@ -3,23 +3,23 @@ import { Text, View } from 'react-native';
import { EditScreenInfo } from './EditScreenInfo';
type ScreenContentProps = {
title: string;
path: string;
children?: React.ReactNode;
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>
);
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`,
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

@ -1,13 +1,13 @@
import { Icon } from './ui/Icon';
export const TabBarIcon = (props: { name: string; color: string }) => {
return (
<Icon
name={props.name}
color={props.color}
size={28}
library="FontAwesome"
className="mb-[-3px]"
/>
);
return (
<Icon
name={props.name}
color={props.color}
size={28}
library="FontAwesome"
className="mb-[-3px]"
/>
);
};

View file

@ -3,25 +3,33 @@ import { ThemeStoreProvider, useThemeStore } from '~/store/themeStore';
import { ThemeContextType } from '~/types/theme';
interface ThemeProviderProps {
children: React.ReactNode;
children: React.ReactNode;
}
// This wrapper component provides the theme store context
export function ThemeProvider({ children }: ThemeProviderProps) {
return <ThemeStoreProvider>{children}</ThemeStoreProvider>;
return <ThemeStoreProvider>{children}</ThemeStoreProvider>;
}
// Export useTheme hook that wraps useThemeStore for backward compatibility
export function useTheme(): ThemeContextType {
const { theme, mode, isDark, currentColors: colors, setTheme, setMode, toggleMode } = useThemeStore();
const {
theme,
mode,
isDark,
currentColors: colors,
setTheme,
setMode,
toggleMode,
} = useThemeStore();
return {
theme,
mode,
isDark,
colors,
setTheme,
setMode,
toggleMode,
};
}
return {
theme,
mode,
isDark,
colors,
setTheme,
setMode,
toggleMode,
};
}

View file

@ -3,45 +3,45 @@ import { View, Platform } from 'react-native';
import { useRootClassName } from '~/store/themeStore';
interface ThemeWrapperProps {
children: React.ReactNode;
children: React.ReactNode;
}
export function ThemeWrapper({ children }: ThemeWrapperProps) {
const rootClassName = useRootClassName();
const [themeKey, setThemeKey] = useState(0);
const rootClassName = useRootClassName();
const [themeKey, setThemeKey] = useState(0);
useEffect(() => {
// For web, apply classes to document element
if (Platform.OS === 'web') {
const html = document.documentElement;
// Clear all existing theme classes
html.classList.remove('theme-default', 'theme-forest', 'theme-sunset', 'dark');
useEffect(() => {
// For web, apply classes to document element
if (Platform.OS === 'web') {
const html = document.documentElement;
// Clear all existing theme classes
html.classList.remove('theme-default', 'theme-forest', 'theme-sunset', 'dark');
// Apply new theme classes
if (rootClassName) {
const classes = rootClassName.split(' ').filter(Boolean);
classes.forEach((cls) => html.classList.add(cls));
}
}
// Apply new theme classes
if (rootClassName) {
const classes = rootClassName.split(' ').filter(Boolean);
classes.forEach((cls) => html.classList.add(cls));
}
}
// Force re-render to apply new CSS variables
setThemeKey((prev) => prev + 1);
// Force re-render to apply new CSS variables
setThemeKey((prev) => prev + 1);
// Force Metro/NativeWind to re-process styles
if (Platform.OS !== 'web') {
// Trigger a small delay to ensure styles are applied
setTimeout(() => {
setThemeKey((prev) => prev + 1);
}, 50);
}
}, [rootClassName]);
// Force Metro/NativeWind to re-process styles
if (Platform.OS !== 'web') {
// Trigger a small delay to ensure styles are applied
setTimeout(() => {
setThemeKey((prev) => prev + 1);
}, 50);
}
}, [rootClassName]);
// For React Native, we need to set the theme class on a wrapper View
// The CSS variables will be applied via the global CSS
// Using key prop to force re-render when theme changes
return (
<View key={themeKey} className={`flex-1 ${rootClassName}`} style={{ flex: 1 }}>
{children}
</View>
);
// For React Native, we need to set the theme class on a wrapper View
// The CSS variables will be applied via the global CSS
// Using key prop to force re-render when theme changes
return (
<View key={themeKey} className={`flex-1 ${rootClassName}`} style={{ flex: 1 }}>
{children}
</View>
);
}

View file

@ -9,186 +9,190 @@ import { useAIStore } from '../../store/aiStore';
import { GeneratedCard } from '../../utils/supabaseAIService';
interface ImageCardCreatorProps {
onCardsGenerated?: (cards: GeneratedCard[]) => void;
onCardsGenerated?: (cards: GeneratedCard[]) => void;
}
export const ImageCardCreator: React.FC<ImageCardCreatorProps> = ({ onCardsGenerated }) => {
const { generateCardsFromImage, isGenerating } = useAIStore();
const [imageUri, setImageUri] = useState<string | null>(null);
const [context, setContext] = useState('');
const [error, setError] = useState<string | null>(null);
const { generateCardsFromImage, isGenerating } = useAIStore();
const [imageUri, setImageUri] = useState<string | null>(null);
const [context, setContext] = useState('');
const [error, setError] = useState<string | null>(null);
const pickImage = async (source: 'camera' | 'library') => {
try {
let result;
const pickImage = async (source: 'camera' | 'library') => {
try {
let result;
if (source === 'camera') {
const { status } = await ImagePicker.requestCameraPermissionsAsync();
if (status !== 'granted') {
setError('Kamerazugriff verweigert');
return;
}
if (source === 'camera') {
const { status } = await ImagePicker.requestCameraPermissionsAsync();
if (status !== 'granted') {
setError('Kamerazugriff verweigert');
return;
}
result = await ImagePicker.launchCameraAsync({
mediaTypes: ImagePicker.MediaTypeOptions.Images,
allowsEditing: true,
quality: 0.8,
base64: false,
});
} else {
const { status } = await ImagePicker.requestMediaLibraryPermissionsAsync();
if (status !== 'granted') {
setError('Galerie-Zugriff verweigert');
return;
}
result = await ImagePicker.launchCameraAsync({
mediaTypes: ImagePicker.MediaTypeOptions.Images,
allowsEditing: true,
quality: 0.8,
base64: false,
});
} else {
const { status } = await ImagePicker.requestMediaLibraryPermissionsAsync();
if (status !== 'granted') {
setError('Galerie-Zugriff verweigert');
return;
}
result = await ImagePicker.launchImageLibraryAsync({
mediaTypes: ImagePicker.MediaTypeOptions.Images,
allowsEditing: true,
quality: 0.8,
base64: false,
});
}
result = await ImagePicker.launchImageLibraryAsync({
mediaTypes: ImagePicker.MediaTypeOptions.Images,
allowsEditing: true,
quality: 0.8,
base64: false,
});
}
if (!result.canceled && result.assets[0]) {
setImageUri(result.assets[0].uri);
setError(null);
}
} catch (error) {
console.error('Error picking image:', error);
setError('Fehler beim Auswählen des Bildes');
}
};
if (!result.canceled && result.assets[0]) {
setImageUri(result.assets[0].uri);
setError(null);
}
} catch (error) {
console.error('Error picking image:', error);
setError('Fehler beim Auswählen des Bildes');
}
};
const handleGenerateCards = async () => {
if (!imageUri) return;
const handleGenerateCards = async () => {
if (!imageUri) return;
try {
setError(null);
const cards = await generateCardsFromImage(imageUri, context);
try {
setError(null);
const cards = await generateCardsFromImage(imageUri, context);
if (onCardsGenerated) {
onCardsGenerated(cards);
}
if (onCardsGenerated) {
onCardsGenerated(cards);
}
// Reset after successful generation
setImageUri(null);
setContext('');
} catch (error: any) {
setError(error.message || 'Fehler beim Generieren der Karten');
}
};
// Reset after successful generation
setImageUri(null);
setContext('');
} catch (error: any) {
setError(error.message || 'Fehler beim Generieren der Karten');
}
};
const removeImage = () => {
setImageUri(null);
setContext('');
setError(null);
};
const removeImage = () => {
setImageUri(null);
setContext('');
setError(null);
};
return (
<Card padding="lg" variant="elevated">
{!imageUri ? (
<>
<Text variant="h4" className="mb-4 text-center font-semibold text-gray-900">
Bild auswählen
</Text>
return (
<Card padding="lg" variant="elevated">
{!imageUri ? (
<>
<Text variant="h4" className="mb-4 text-center font-semibold text-gray-900">
Bild auswählen
</Text>
<View className="flex-row space-x-3">
<Pressable
onPress={() => pickImage('camera')}
className="flex-1 items-center rounded-lg border-2 border-dashed border-gray-300 bg-gray-50 p-6"
style={({ pressed }) => pressed && { opacity: 0.7 }}>
<Icon name="camera" size={32} color="#6B7280" library="Ionicons" />
<Text variant="caption" className="mt-2 text-gray-600">
Foto aufnehmen
</Text>
</Pressable>
<View className="flex-row space-x-3">
<Pressable
onPress={() => pickImage('camera')}
className="flex-1 items-center rounded-lg border-2 border-dashed border-gray-300 bg-gray-50 p-6"
style={({ pressed }) => pressed && { opacity: 0.7 }}
>
<Icon name="camera" size={32} color="#6B7280" library="Ionicons" />
<Text variant="caption" className="mt-2 text-gray-600">
Foto aufnehmen
</Text>
</Pressable>
<Pressable
onPress={() => pickImage('library')}
className="flex-1 items-center rounded-lg border-2 border-dashed border-gray-300 bg-gray-50 p-6"
style={({ pressed }) => pressed && { opacity: 0.7 }}>
<Icon name="images" size={32} color="#6B7280" library="Ionicons" />
<Text variant="caption" className="mt-2 text-gray-600">
Aus Galerie
</Text>
</Pressable>
</View>
<Pressable
onPress={() => pickImage('library')}
className="flex-1 items-center rounded-lg border-2 border-dashed border-gray-300 bg-gray-50 p-6"
style={({ pressed }) => pressed && { opacity: 0.7 }}
>
<Icon name="images" size={32} color="#6B7280" library="Ionicons" />
<Text variant="caption" className="mt-2 text-gray-600">
Aus Galerie
</Text>
</Pressable>
</View>
<Text variant="small" className="mt-4 text-center text-gray-500">
Fotografiere Lehrbücher, Notizen, Whiteboards oder Diagramme
</Text>
</>
) : (
<>
{/* Image Preview */}
<View className="relative mb-4">
<Image
source={{ uri: imageUri }}
className="h-64 w-full rounded-lg"
resizeMode="cover"
/>
<Text variant="small" className="mt-4 text-center text-gray-500">
Fotografiere Lehrbücher, Notizen, Whiteboards oder Diagramme
</Text>
</>
) : (
<>
{/* Image Preview */}
<View className="relative mb-4">
<Image
source={{ uri: imageUri }}
className="h-64 w-full rounded-lg"
resizeMode="cover"
/>
<Pressable
onPress={removeImage}
className="absolute right-2 top-2 rounded-full bg-red-500 p-2"
style={({ pressed }) => pressed && { opacity: 0.7 }}>
<Icon name="close" size={20} color="white" library="Ionicons" />
</Pressable>
</View>
<Pressable
onPress={removeImage}
className="absolute right-2 top-2 rounded-full bg-red-500 p-2"
style={({ pressed }) => pressed && { opacity: 0.7 }}
>
<Icon name="close" size={20} color="white" library="Ionicons" />
</Pressable>
</View>
{/* Context Input */}
<View className="mb-4">
<Text variant="caption" className="mb-2 font-medium text-gray-700">
Kontext (optional)
</Text>
<TextInput
value={context}
onChangeText={setContext}
placeholder="Z.B. 'Mathematik Kapitel 3' oder 'Biologie Zellaufbau'"
className="rounded-lg border border-gray-200 bg-white p-3 text-gray-900"
/>
</View>
{/* Context Input */}
<View className="mb-4">
<Text variant="caption" className="mb-2 font-medium text-gray-700">
Kontext (optional)
</Text>
<TextInput
value={context}
onChangeText={setContext}
placeholder="Z.B. 'Mathematik Kapitel 3' oder 'Biologie Zellaufbau'"
className="rounded-lg border border-gray-200 bg-white p-3 text-gray-900"
/>
</View>
{/* Generate Button */}
<Button
onPress={handleGenerateCards}
variant="primary"
fullWidth
disabled={isGenerating}
leftIcon={
isGenerating ? (
<ActivityIndicator size="small" color="white" />
) : (
<Icon name="sparkles" size={20} color="white" library="Ionicons" />
)
}>
{isGenerating ? 'Analysiere Bild...' : 'Karten aus Bild generieren'}
</Button>
</>
)}
{/* Generate Button */}
<Button
onPress={handleGenerateCards}
variant="primary"
fullWidth
disabled={isGenerating}
leftIcon={
isGenerating ? (
<ActivityIndicator size="small" color="white" />
) : (
<Icon name="sparkles" size={20} color="white" library="Ionicons" />
)
}
>
{isGenerating ? 'Analysiere Bild...' : 'Karten aus Bild generieren'}
</Button>
</>
)}
{/* Error Display */}
{error && (
<View className="mt-4 rounded-lg bg-red-50 p-3">
<Text variant="caption" className="text-red-600">
{error}
</Text>
</View>
)}
{/* Error Display */}
{error && (
<View className="mt-4 rounded-lg bg-red-50 p-3">
<Text variant="caption" className="text-red-600">
{error}
</Text>
</View>
)}
{/* Info Box */}
{imageUri && !isGenerating && (
<View className="mt-4 rounded-lg bg-blue-50 p-3">
<View className="flex-row items-start">
<Icon name="information-circle" size={20} color="#3B82F6" library="Ionicons" />
<Text variant="small" className="ml-2 flex-1 text-blue-700">
Die KI erkennt Text, Diagramme und Konzepte im Bild und erstellt daraus passende
Lernkarten.
</Text>
</View>
</View>
)}
</Card>
);
{/* Info Box */}
{imageUri && !isGenerating && (
<View className="mt-4 rounded-lg bg-blue-50 p-3">
<View className="flex-row items-start">
<Icon name="information-circle" size={20} color="#3B82F6" library="Ionicons" />
<Text variant="small" className="ml-2 flex-1 text-blue-700">
Die KI erkennt Text, Diagramme und Konzepte im Bild und erstellt daraus passende
Lernkarten.
</Text>
</View>
</View>
)}
</Card>
);
};

View file

@ -1,13 +1,13 @@
import React, { useState } from 'react';
import {
View,
Text,
TextInput,
ScrollView,
Pressable,
ActivityIndicator,
KeyboardAvoidingView,
Platform,
View,
Text,
TextInput,
ScrollView,
Pressable,
ActivityIndicator,
KeyboardAvoidingView,
Platform,
} from 'react-native';
import { Icon } from '../ui/Icon';
import { Card } from '../ui/Card';
@ -17,385 +17,400 @@ import { GeneratedCard, GenerationOptions } from '../../utils/supabaseAIService'
import { ImageCardCreator } from './ImageCardCreator';
interface SmartCardCreatorProps {
deckId: string;
onCardsCreated?: (cards: GeneratedCard[]) => void;
deckId: string;
onCardsCreated?: (cards: GeneratedCard[]) => void;
}
export const SmartCardCreator: React.FC<SmartCardCreatorProps> = ({ deckId, onCardsCreated }) => {
const {
isGenerating,
generatedCards,
error,
generateCardsFromText,
clearGeneratedCards,
saveGeneratedCards,
} = useAIStore();
const {
isGenerating,
generatedCards,
error,
generateCardsFromText,
clearGeneratedCards,
saveGeneratedCards,
} = useAIStore();
const [inputMode, setInputMode] = useState<'text' | 'image'>('text');
const [textInput, setTextInput] = useState('');
const [options, setOptions] = useState<GenerationOptions>({
cardTypes: ['flashcard', 'quiz'],
difficulty: 'medium',
count: 5,
language: 'de',
});
const [selectedCards, setSelectedCards] = useState<Set<number>>(new Set());
const [inputMode, setInputMode] = useState<'text' | 'image'>('text');
const [textInput, setTextInput] = useState('');
const [options, setOptions] = useState<GenerationOptions>({
cardTypes: ['flashcard', 'quiz'],
difficulty: 'medium',
count: 5,
language: 'de',
});
const [selectedCards, setSelectedCards] = useState<Set<number>>(new Set());
const handleGenerateFromText = async () => {
if (!textInput.trim()) return;
const handleGenerateFromText = async () => {
if (!textInput.trim()) return;
try {
const cards = await generateCardsFromText(textInput, options);
if (onCardsCreated) {
onCardsCreated(cards);
}
} catch (error) {
console.error('Error generating cards:', error);
}
};
try {
const cards = await generateCardsFromText(textInput, options);
if (onCardsCreated) {
onCardsCreated(cards);
}
} catch (error) {
console.error('Error generating cards:', error);
}
};
const handleSaveSelectedCards = async () => {
const cardsToSave = generatedCards.filter((_, index) => selectedCards.has(index));
if (cardsToSave.length === 0) return;
const handleSaveSelectedCards = async () => {
const cardsToSave = generatedCards.filter((_, index) => selectedCards.has(index));
if (cardsToSave.length === 0) return;
try {
await saveGeneratedCards(deckId, cardsToSave);
clearGeneratedCards();
setSelectedCards(new Set());
if (onCardsCreated) {
onCardsCreated(cardsToSave);
}
} catch (error) {
console.error('Error saving cards:', error);
}
};
try {
await saveGeneratedCards(deckId, cardsToSave);
clearGeneratedCards();
setSelectedCards(new Set());
if (onCardsCreated) {
onCardsCreated(cardsToSave);
}
} catch (error) {
console.error('Error saving cards:', error);
}
};
const toggleCardSelection = (index: number) => {
const newSelection = new Set(selectedCards);
if (newSelection.has(index)) {
newSelection.delete(index);
} else {
newSelection.add(index);
}
setSelectedCards(newSelection);
};
const toggleCardSelection = (index: number) => {
const newSelection = new Set(selectedCards);
if (newSelection.has(index)) {
newSelection.delete(index);
} else {
newSelection.add(index);
}
setSelectedCards(newSelection);
};
const selectAllCards = () => {
if (selectedCards.size === generatedCards.length) {
setSelectedCards(new Set());
} else {
setSelectedCards(new Set(generatedCards.map((_, i) => i)));
}
};
const selectAllCards = () => {
if (selectedCards.size === generatedCards.length) {
setSelectedCards(new Set());
} else {
setSelectedCards(new Set(generatedCards.map((_, i) => i)));
}
};
const renderGeneratedCard = (card: GeneratedCard, index: number) => {
const isSelected = selectedCards.has(index);
const renderGeneratedCard = (card: GeneratedCard, index: number) => {
const isSelected = selectedCards.has(index);
return (
<Pressable
key={index}
onPress={() => toggleCardSelection(index)}
className="mb-3"
style={({ pressed }) => pressed && { opacity: 0.7 }}>
<Card
padding="md"
variant={isSelected ? 'primary' : 'outlined'}
className={isSelected ? 'border-2 border-blue-500' : ''}>
<View className="flex-row items-start justify-between">
<View className="flex-1">
<View className="mb-2 flex-row items-center">
<View
className={`rounded px-2 py-1 ${
card.type === 'flashcard'
? 'bg-blue-100'
: card.type === 'quiz'
? 'bg-purple-100'
: 'bg-gray-100'
}`}>
<Text
className={`text-xs font-medium ${
card.type === 'flashcard'
? 'text-blue-700'
: card.type === 'quiz'
? 'text-purple-700'
: 'text-gray-700'
}`}>
{card.type.toUpperCase()}
</Text>
</View>
<View className="ml-2 flex-row items-center">
<Icon name="speedometer-outline" size={12} color="#6B7280" library="Ionicons" />
<Text className="ml-1 text-xs text-gray-500">
{Math.round(card.metadata.confidence * 100)}% Konfidenz
</Text>
</View>
</View>
return (
<Pressable
key={index}
onPress={() => toggleCardSelection(index)}
className="mb-3"
style={({ pressed }) => pressed && { opacity: 0.7 }}
>
<Card
padding="md"
variant={isSelected ? 'primary' : 'outlined'}
className={isSelected ? 'border-2 border-blue-500' : ''}
>
<View className="flex-row items-start justify-between">
<View className="flex-1">
<View className="mb-2 flex-row items-center">
<View
className={`rounded px-2 py-1 ${
card.type === 'flashcard'
? 'bg-blue-100'
: card.type === 'quiz'
? 'bg-purple-100'
: 'bg-gray-100'
}`}
>
<Text
className={`text-xs font-medium ${
card.type === 'flashcard'
? 'text-blue-700'
: card.type === 'quiz'
? 'text-purple-700'
: 'text-gray-700'
}`}
>
{card.type.toUpperCase()}
</Text>
</View>
<View className="ml-2 flex-row items-center">
<Icon name="speedometer-outline" size={12} color="#6B7280" library="Ionicons" />
<Text className="ml-1 text-xs text-gray-500">
{Math.round(card.metadata.confidence * 100)}% Konfidenz
</Text>
</View>
</View>
{card.type === 'flashcard' && card.content && 'front' in card.content && (
<>
<Text className="font-semibold text-gray-900">{card.content.front}</Text>
<Text className="mt-1 text-sm text-gray-600">{card.content.back}</Text>
</>
)}
{card.type === 'flashcard' && card.content && 'front' in card.content && (
<>
<Text className="font-semibold text-gray-900">{card.content.front}</Text>
<Text className="mt-1 text-sm text-gray-600">{card.content.back}</Text>
</>
)}
{card.type === 'quiz' && card.content && 'question' in card.content && (
<>
<Text className="font-semibold text-gray-900">{card.content.question}</Text>
<View className="mt-1">
{card.content.options.map((option: string, i: number) => (
<Text
key={i}
className={`text-sm ${
i === card.content.correctAnswer
? 'font-medium text-green-600'
: 'text-gray-600'
}`}>
{i + 1}. {option}
</Text>
))}
</View>
</>
)}
{card.type === 'quiz' && card.content && 'question' in card.content && (
<>
<Text className="font-semibold text-gray-900">{card.content.question}</Text>
<View className="mt-1">
{card.content.options.map((option: string, i: number) => (
<Text
key={i}
className={`text-sm ${
i === card.content.correctAnswer
? 'font-medium text-green-600'
: 'text-gray-600'
}`}
>
{i + 1}. {option}
</Text>
))}
</View>
</>
)}
{card.type === 'text' && card.content && 'text' in card.content && (
<Text className="text-gray-900">{card.content.text}</Text>
)}
</View>
{card.type === 'text' && card.content && 'text' in card.content && (
<Text className="text-gray-900">{card.content.text}</Text>
)}
</View>
<View className="ml-2">
<Icon
name={isSelected ? 'checkbox' : 'square-outline'}
size={24}
color={isSelected ? '#3B82F6' : '#9CA3AF'}
library="Ionicons"
/>
</View>
</View>
</Card>
</Pressable>
);
};
<View className="ml-2">
<Icon
name={isSelected ? 'checkbox' : 'square-outline'}
size={24}
color={isSelected ? '#3B82F6' : '#9CA3AF'}
library="Ionicons"
/>
</View>
</View>
</Card>
</Pressable>
);
};
return (
<KeyboardAvoidingView
behavior={Platform.OS === 'ios' ? 'padding' : 'height'}
className="flex-1">
<ScrollView className="flex-1" showsVerticalScrollIndicator={false}>
{/* Input Mode Selector */}
<View className="mb-4 flex-row space-x-2">
{[
{ key: 'text', label: 'Text', icon: 'text' },
{ key: 'image', label: 'Bild', icon: 'image' },
].map((mode) => (
<Pressable
key={mode.key}
onPress={() => setInputMode(mode.key as any)}
className={`flex-1 flex-row items-center justify-center rounded-lg border p-3 ${
inputMode === mode.key ? 'border-blue-500 bg-blue-50' : 'border-gray-200 bg-white'
}`}
style={({ pressed }) => pressed && { opacity: 0.7 }}>
<Icon
name={mode.icon}
size={20}
color={inputMode === mode.key ? '#3B82F6' : '#6B7280'}
library="Ionicons"
/>
<Text
className={`ml-2 font-medium ${
inputMode === mode.key ? 'text-blue-600' : 'text-gray-600'
}`}>
{mode.label}
</Text>
</Pressable>
))}
</View>
return (
<KeyboardAvoidingView
behavior={Platform.OS === 'ios' ? 'padding' : 'height'}
className="flex-1"
>
<ScrollView className="flex-1" showsVerticalScrollIndicator={false}>
{/* Input Mode Selector */}
<View className="mb-4 flex-row space-x-2">
{[
{ key: 'text', label: 'Text', icon: 'text' },
{ key: 'image', label: 'Bild', icon: 'image' },
].map((mode) => (
<Pressable
key={mode.key}
onPress={() => setInputMode(mode.key as any)}
className={`flex-1 flex-row items-center justify-center rounded-lg border p-3 ${
inputMode === mode.key ? 'border-blue-500 bg-blue-50' : 'border-gray-200 bg-white'
}`}
style={({ pressed }) => pressed && { opacity: 0.7 }}
>
<Icon
name={mode.icon}
size={20}
color={inputMode === mode.key ? '#3B82F6' : '#6B7280'}
library="Ionicons"
/>
<Text
className={`ml-2 font-medium ${
inputMode === mode.key ? 'text-blue-600' : 'text-gray-600'
}`}
>
{mode.label}
</Text>
</Pressable>
))}
</View>
{/* Input Area */}
{inputMode === 'text' && (
<Card padding="lg" variant="elevated" className="mb-4">
<Text className="mb-2 text-sm font-medium text-gray-700">
Gib deinen Lerninhalt ein
</Text>
<TextInput
value={textInput}
onChangeText={setTextInput}
placeholder="Füge hier deinen Text ein oder beschreibe, was du lernen möchtest..."
multiline
numberOfLines={6}
className="rounded-lg border border-gray-200 bg-white p-3 text-gray-900"
style={{ textAlignVertical: 'top' }}
/>
</Card>
)}
{/* Input Area */}
{inputMode === 'text' && (
<Card padding="lg" variant="elevated" className="mb-4">
<Text className="mb-2 text-sm font-medium text-gray-700">
Gib deinen Lerninhalt ein
</Text>
<TextInput
value={textInput}
onChangeText={setTextInput}
placeholder="Füge hier deinen Text ein oder beschreibe, was du lernen möchtest..."
multiline
numberOfLines={6}
className="rounded-lg border border-gray-200 bg-white p-3 text-gray-900"
style={{ textAlignVertical: 'top' }}
/>
</Card>
)}
{inputMode === 'image' && (
<View className="mb-4">
<ImageCardCreator
onCardsGenerated={(cards) => {
// Add to generated cards
if (onCardsCreated) {
onCardsCreated(cards);
}
}}
/>
</View>
)}
{inputMode === 'image' && (
<View className="mb-4">
<ImageCardCreator
onCardsGenerated={(cards) => {
// Add to generated cards
if (onCardsCreated) {
onCardsCreated(cards);
}
}}
/>
</View>
)}
{/* Generation Options */}
{inputMode === 'text' && (
<Card padding="lg" variant="elevated" className="mb-4">
<Text className="mb-3 text-sm font-medium text-gray-700">Optionen</Text>
{/* Generation Options */}
{inputMode === 'text' && (
<Card padding="lg" variant="elevated" className="mb-4">
<Text className="mb-3 text-sm font-medium text-gray-700">Optionen</Text>
{/* Card Types */}
<View className="mb-3">
<Text className="mb-2 text-xs text-gray-500">Kartentypen</Text>
<View className="flex-row flex-wrap">
{(['flashcard', 'quiz', 'text'] as const).map((type) => (
<Pressable
key={type}
onPress={() => {
const types = options.cardTypes || [];
if (types.includes(type)) {
setOptions({
...options,
cardTypes: types.filter((t) => t !== type),
});
} else {
setOptions({
...options,
cardTypes: [...types, type],
});
}
}}
className={`mb-2 mr-2 rounded-full border px-3 py-1 ${
options.cardTypes?.includes(type)
? 'border-blue-500 bg-blue-50'
: 'border-gray-300 bg-white'
}`}
style={({ pressed }) => pressed && { opacity: 0.7 }}>
<Text
className={`text-sm ${
options.cardTypes?.includes(type) ? 'text-blue-600' : 'text-gray-600'
}`}>
{type === 'flashcard' ? 'Karteikarte' : type === 'quiz' ? 'Quiz' : 'Text'}
</Text>
</Pressable>
))}
</View>
</View>
{/* Card Types */}
<View className="mb-3">
<Text className="mb-2 text-xs text-gray-500">Kartentypen</Text>
<View className="flex-row flex-wrap">
{(['flashcard', 'quiz', 'text'] as const).map((type) => (
<Pressable
key={type}
onPress={() => {
const types = options.cardTypes || [];
if (types.includes(type)) {
setOptions({
...options,
cardTypes: types.filter((t) => t !== type),
});
} else {
setOptions({
...options,
cardTypes: [...types, type],
});
}
}}
className={`mb-2 mr-2 rounded-full border px-3 py-1 ${
options.cardTypes?.includes(type)
? 'border-blue-500 bg-blue-50'
: 'border-gray-300 bg-white'
}`}
style={({ pressed }) => pressed && { opacity: 0.7 }}
>
<Text
className={`text-sm ${
options.cardTypes?.includes(type) ? 'text-blue-600' : 'text-gray-600'
}`}
>
{type === 'flashcard' ? 'Karteikarte' : type === 'quiz' ? 'Quiz' : 'Text'}
</Text>
</Pressable>
))}
</View>
</View>
{/* Difficulty */}
<View className="mb-3">
<Text className="mb-2 text-xs text-gray-500">Schwierigkeit</Text>
<View className="flex-row space-x-2">
{(['easy', 'medium', 'hard'] as const).map((level) => (
<Pressable
key={level}
onPress={() => setOptions({ ...options, difficulty: level })}
className={`flex-1 rounded-lg border p-2 ${
options.difficulty === level
? 'border-blue-500 bg-blue-50'
: 'border-gray-300 bg-white'
}`}
style={({ pressed }) => pressed && { opacity: 0.7 }}>
<Text
className={`text-center text-sm ${
options.difficulty === level ? 'text-blue-600' : 'text-gray-600'
}`}>
{level === 'easy' ? 'Einfach' : level === 'medium' ? 'Mittel' : 'Schwer'}
</Text>
</Pressable>
))}
</View>
</View>
{/* Difficulty */}
<View className="mb-3">
<Text className="mb-2 text-xs text-gray-500">Schwierigkeit</Text>
<View className="flex-row space-x-2">
{(['easy', 'medium', 'hard'] as const).map((level) => (
<Pressable
key={level}
onPress={() => setOptions({ ...options, difficulty: level })}
className={`flex-1 rounded-lg border p-2 ${
options.difficulty === level
? 'border-blue-500 bg-blue-50'
: 'border-gray-300 bg-white'
}`}
style={({ pressed }) => pressed && { opacity: 0.7 }}
>
<Text
className={`text-center text-sm ${
options.difficulty === level ? 'text-blue-600' : 'text-gray-600'
}`}
>
{level === 'easy' ? 'Einfach' : level === 'medium' ? 'Mittel' : 'Schwer'}
</Text>
</Pressable>
))}
</View>
</View>
{/* Count */}
<View>
<Text className="mb-2 text-xs text-gray-500">Anzahl Karten: {options.count}</Text>
<View className="flex-row items-center">
<Pressable
onPress={() =>
setOptions({ ...options, count: Math.max(1, (options.count || 5) - 1) })
}
className="rounded-lg bg-gray-200 p-2"
style={({ pressed }) => pressed && { opacity: 0.7 }}>
<Icon name="remove" size={20} color="#374151" library="Ionicons" />
</Pressable>
<Text className="mx-4 text-lg font-semibold">{options.count}</Text>
<Pressable
onPress={() =>
setOptions({ ...options, count: Math.min(20, (options.count || 5) + 1) })
}
className="rounded-lg bg-gray-200 p-2"
style={({ pressed }) => pressed && { opacity: 0.7 }}>
<Icon name="add" size={20} color="#374151" library="Ionicons" />
</Pressable>
</View>
</View>
</Card>
)}
{/* Count */}
<View>
<Text className="mb-2 text-xs text-gray-500">Anzahl Karten: {options.count}</Text>
<View className="flex-row items-center">
<Pressable
onPress={() =>
setOptions({ ...options, count: Math.max(1, (options.count || 5) - 1) })
}
className="rounded-lg bg-gray-200 p-2"
style={({ pressed }) => pressed && { opacity: 0.7 }}
>
<Icon name="remove" size={20} color="#374151" library="Ionicons" />
</Pressable>
<Text className="mx-4 text-lg font-semibold">{options.count}</Text>
<Pressable
onPress={() =>
setOptions({ ...options, count: Math.min(20, (options.count || 5) + 1) })
}
className="rounded-lg bg-gray-200 p-2"
style={({ pressed }) => pressed && { opacity: 0.7 }}
>
<Icon name="add" size={20} color="#374151" library="Ionicons" />
</Pressable>
</View>
</View>
</Card>
)}
{/* Generate Button */}
{inputMode === 'text' && textInput.trim() && (
<Button
onPress={handleGenerateFromText}
variant="primary"
fullWidth
disabled={isGenerating}
leftIcon={
isGenerating ? (
<ActivityIndicator size="small" color="white" />
) : (
<Icon name="sparkles" size={20} color="white" library="Ionicons" />
)
}>
{isGenerating ? 'Generiere Karten...' : 'Karten generieren'}
</Button>
)}
{/* Generate Button */}
{inputMode === 'text' && textInput.trim() && (
<Button
onPress={handleGenerateFromText}
variant="primary"
fullWidth
disabled={isGenerating}
leftIcon={
isGenerating ? (
<ActivityIndicator size="small" color="white" />
) : (
<Icon name="sparkles" size={20} color="white" library="Ionicons" />
)
}
>
{isGenerating ? 'Generiere Karten...' : 'Karten generieren'}
</Button>
)}
{/* Error Display */}
{error && (
<Card padding="md" variant="outlined" className="mb-4 border-red-200 bg-red-50">
<Text className="text-sm text-red-600">{error}</Text>
</Card>
)}
{/* Error Display */}
{error && (
<Card padding="md" variant="outlined" className="mb-4 border-red-200 bg-red-50">
<Text className="text-sm text-red-600">{error}</Text>
</Card>
)}
{/* Generated Cards */}
{generatedCards.length > 0 && (
<View className="mt-6">
<View className="mb-3 flex-row items-center justify-between">
<Text className="text-lg font-semibold text-gray-900">
Generierte Karten ({selectedCards.size}/{generatedCards.length})
</Text>
<Pressable
onPress={selectAllCards}
style={({ pressed }) => pressed && { opacity: 0.7 }}>
<Text className="text-sm font-medium text-blue-600">
{selectedCards.size === generatedCards.length
? 'Keine auswählen'
: 'Alle auswählen'}
</Text>
</Pressable>
</View>
{/* Generated Cards */}
{generatedCards.length > 0 && (
<View className="mt-6">
<View className="mb-3 flex-row items-center justify-between">
<Text className="text-lg font-semibold text-gray-900">
Generierte Karten ({selectedCards.size}/{generatedCards.length})
</Text>
<Pressable
onPress={selectAllCards}
style={({ pressed }) => pressed && { opacity: 0.7 }}
>
<Text className="text-sm font-medium text-blue-600">
{selectedCards.size === generatedCards.length
? 'Keine auswählen'
: 'Alle auswählen'}
</Text>
</Pressable>
</View>
{generatedCards.map((card, index) => renderGeneratedCard(card, index))}
{generatedCards.map((card, index) => renderGeneratedCard(card, index))}
{selectedCards.size > 0 && (
<View className="mt-4 flex-row space-x-2">
<Button
onPress={handleSaveSelectedCards}
variant="primary"
fullWidth
leftIcon={
<Icon name="save-outline" size={20} color="white" library="Ionicons" />
}>
{selectedCards.size} Karten speichern
</Button>
</View>
)}
</View>
)}
</ScrollView>
</KeyboardAvoidingView>
);
{selectedCards.size > 0 && (
<View className="mt-4 flex-row space-x-2">
<Button
onPress={handleSaveSelectedCards}
variant="primary"
fullWidth
leftIcon={<Icon name="save-outline" size={20} color="white" library="Ionicons" />}
>
{selectedCards.size} Karten speichern
</Button>
</View>
)}
</View>
)}
</ScrollView>
</KeyboardAvoidingView>
);
};

View file

@ -7,102 +7,103 @@ import { useThemeColors } from '~/utils/themeUtils';
import { Ionicons } from '@expo/vector-icons';
interface AppleSignInButtonProps {
onSignInSuccess?: () => void;
onSignInError?: (error: string) => void;
onSignIn: (identityToken: string) => Promise<void>;
onSignInSuccess?: () => void;
onSignInError?: (error: string) => void;
onSignIn: (identityToken: string) => Promise<void>;
}
export const AppleSignInButton: React.FC<AppleSignInButtonProps> = ({
onSignInSuccess,
onSignInError,
onSignIn,
onSignInSuccess,
onSignInError,
onSignIn,
}) => {
const colors = useThemeColors();
const [isLoading, setIsLoading] = useState(false);
const colors = useThemeColors();
const [isLoading, setIsLoading] = useState(false);
// Apple Sign-In is iOS only
if (Platform.OS !== 'ios') {
return null;
}
// Apple Sign-In is iOS only
if (Platform.OS !== 'ios') {
return null;
}
const handleAppleSignIn = async () => {
setIsLoading(true);
try {
// Trigger Apple Sign-In
const credential = await AppleAuthentication.signInAsync({
requestedScopes: [
AppleAuthentication.AppleAuthenticationScope.FULL_NAME,
AppleAuthentication.AppleAuthenticationScope.EMAIL,
],
});
const handleAppleSignIn = async () => {
setIsLoading(true);
try {
// Trigger Apple Sign-In
const credential = await AppleAuthentication.signInAsync({
requestedScopes: [
AppleAuthentication.AppleAuthenticationScope.FULL_NAME,
AppleAuthentication.AppleAuthenticationScope.EMAIL,
],
});
const identityToken = credential.identityToken;
const identityToken = credential.identityToken;
if (!identityToken) {
Alert.alert('Anmeldung fehlgeschlagen', 'Kein Identity Token von Apple erhalten');
if (onSignInError) {
onSignInError('Kein Identity Token erhalten');
}
return;
}
if (!identityToken) {
Alert.alert('Anmeldung fehlgeschlagen', 'Kein Identity Token von Apple erhalten');
if (onSignInError) {
onSignInError('Kein Identity Token erhalten');
}
return;
}
console.log('Got Apple identity token');
console.log('Got Apple identity token');
// Send to backend for validation
await onSignIn(identityToken);
// Send to backend for validation
await onSignIn(identityToken);
if (onSignInSuccess) {
onSignInSuccess();
}
} catch (error: any) {
if (error.code === 'ERR_REQUEST_CANCELED') {
console.log('User cancelled Apple sign-in');
} else {
console.error('Apple Sign-In Error:', error);
const errorMessage = error.message || 'Anmeldung mit Apple fehlgeschlagen';
Alert.alert('Anmeldefehler', errorMessage);
if (onSignInError) {
onSignInError(errorMessage);
}
}
} finally {
setIsLoading(false);
}
};
if (onSignInSuccess) {
onSignInSuccess();
}
} catch (error: any) {
if (error.code === 'ERR_REQUEST_CANCELED') {
console.log('User cancelled Apple sign-in');
} else {
console.error('Apple Sign-In Error:', error);
const errorMessage = error.message || 'Anmeldung mit Apple fehlgeschlagen';
Alert.alert('Anmeldefehler', errorMessage);
if (onSignInError) {
onSignInError(errorMessage);
}
}
} finally {
setIsLoading(false);
}
};
return (
<TouchableOpacity
style={[styles.button, { backgroundColor: '#000000' }]}
onPress={handleAppleSignIn}
disabled={isLoading}
activeOpacity={0.7}>
{isLoading ? (
<ActivityIndicator size="small" color="#FFFFFF" />
) : (
<>
<Ionicons name="logo-apple" size={20} color="#FFFFFF" style={styles.icon} />
<Text style={styles.buttonText}>Mit Apple anmelden</Text>
</>
)}
</TouchableOpacity>
);
return (
<TouchableOpacity
style={[styles.button, { backgroundColor: '#000000' }]}
onPress={handleAppleSignIn}
disabled={isLoading}
activeOpacity={0.7}
>
{isLoading ? (
<ActivityIndicator size="small" color="#FFFFFF" />
) : (
<>
<Ionicons name="logo-apple" size={20} color="#FFFFFF" style={styles.icon} />
<Text style={styles.buttonText}>Mit Apple anmelden</Text>
</>
)}
</TouchableOpacity>
);
};
const styles = StyleSheet.create({
button: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
padding: 16,
borderRadius: 8,
minHeight: 52,
},
icon: {
marginRight: 12,
},
buttonText: {
fontSize: 16,
fontWeight: '600',
color: '#FFFFFF',
},
button: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
padding: 16,
borderRadius: 8,
minHeight: 52,
},
icon: {
marginRight: 12,
},
buttonText: {
fontSize: 16,
fontWeight: '600',
color: '#FFFFFF',
},
});

View file

@ -7,115 +7,114 @@ import { useThemeColors } from '~/utils/themeUtils';
import { Ionicons } from '@expo/vector-icons';
interface GoogleSignInButtonProps {
onSignInSuccess?: () => void;
onSignInError?: (error: string) => void;
onSignIn: (idToken: string) => Promise<void>;
onSignInSuccess?: () => void;
onSignInError?: (error: string) => void;
onSignIn: (idToken: string) => Promise<void>;
}
export const GoogleSignInButton: React.FC<GoogleSignInButtonProps> = ({
onSignInSuccess,
onSignInError,
onSignIn,
onSignInSuccess,
onSignInError,
onSignIn,
}) => {
const colors = useThemeColors();
const [isLoading, setIsLoading] = useState(false);
const colors = useThemeColors();
const [isLoading, setIsLoading] = useState(false);
useEffect(() => {
// Configure Google Sign-In
GoogleSignin.configure({
webClientId: process.env.EXPO_PUBLIC_GOOGLE_CLIENT_ID,
iosClientId: process.env.EXPO_PUBLIC_GOOGLE_IOS_CLIENT_ID,
scopes: ['profile', 'email'],
offlineAccess: false,
});
}, []);
useEffect(() => {
// Configure Google Sign-In
GoogleSignin.configure({
webClientId: process.env.EXPO_PUBLIC_GOOGLE_CLIENT_ID,
iosClientId: process.env.EXPO_PUBLIC_GOOGLE_IOS_CLIENT_ID,
scopes: ['profile', 'email'],
offlineAccess: false,
});
}, []);
const handleGoogleSignIn = async () => {
setIsLoading(true);
try {
// Check Google Play Services (Android only)
if (Platform.OS === 'android') {
await GoogleSignin.hasPlayServices();
}
const handleGoogleSignIn = async () => {
setIsLoading(true);
try {
// Check Google Play Services (Android only)
if (Platform.OS === 'android') {
await GoogleSignin.hasPlayServices();
}
// Trigger Google Sign-In
await GoogleSignin.signIn();
// Trigger Google Sign-In
await GoogleSignin.signIn();
// Get ID token
const tokens = await GoogleSignin.getTokens();
const idToken = tokens.idToken;
// Get ID token
const tokens = await GoogleSignin.getTokens();
const idToken = tokens.idToken;
console.log('Got Google ID token');
console.log('Got Google ID token');
// Send to backend for validation
await onSignIn(idToken);
// Send to backend for validation
await onSignIn(idToken);
if (onSignInSuccess) {
onSignInSuccess();
}
} catch (error: any) {
console.error('Google Sign-In Error:', error);
if (onSignInSuccess) {
onSignInSuccess();
}
} catch (error: any) {
console.error('Google Sign-In Error:', error);
// Handle specific error codes
if (error.code === statusCodes.SIGN_IN_CANCELLED) {
console.log('User cancelled sign-in');
} else if (error.code === statusCodes.IN_PROGRESS) {
console.log('Sign-in already in progress');
} else if (error.code === statusCodes.PLAY_SERVICES_NOT_AVAILABLE) {
Alert.alert(
'Google Play Services',
'Google Play Services ist nicht verfügbar oder veraltet. Bitte aktualisieren Sie es.'
);
if (onSignInError) {
onSignInError('Google Play Services nicht verfügbar');
}
} else {
const errorMessage = error.message || 'Anmeldung mit Google fehlgeschlagen';
Alert.alert('Anmeldefehler', errorMessage);
if (onSignInError) {
onSignInError(errorMessage);
}
}
} finally {
setIsLoading(false);
}
};
// Handle specific error codes
if (error.code === statusCodes.SIGN_IN_CANCELLED) {
console.log('User cancelled sign-in');
} else if (error.code === statusCodes.IN_PROGRESS) {
console.log('Sign-in already in progress');
} else if (error.code === statusCodes.PLAY_SERVICES_NOT_AVAILABLE) {
Alert.alert(
'Google Play Services',
'Google Play Services ist nicht verfügbar oder veraltet. Bitte aktualisieren Sie es.'
);
if (onSignInError) {
onSignInError('Google Play Services nicht verfügbar');
}
} else {
const errorMessage = error.message || 'Anmeldung mit Google fehlgeschlagen';
Alert.alert('Anmeldefehler', errorMessage);
if (onSignInError) {
onSignInError(errorMessage);
}
}
} finally {
setIsLoading(false);
}
};
return (
<TouchableOpacity
style={[styles.button, { backgroundColor: '#FFFFFF', borderColor: colors.border }]}
onPress={handleGoogleSignIn}
disabled={isLoading}
activeOpacity={0.7}>
{isLoading ? (
<ActivityIndicator size="small" color="#4285F4" />
) : (
<>
<Ionicons name="logo-google" size={20} color="#4285F4" style={styles.icon} />
<Text style={[styles.buttonText, { color: colors.foreground }]}>
Mit Google anmelden
</Text>
</>
)}
</TouchableOpacity>
);
return (
<TouchableOpacity
style={[styles.button, { backgroundColor: '#FFFFFF', borderColor: colors.border }]}
onPress={handleGoogleSignIn}
disabled={isLoading}
activeOpacity={0.7}
>
{isLoading ? (
<ActivityIndicator size="small" color="#4285F4" />
) : (
<>
<Ionicons name="logo-google" size={20} color="#4285F4" style={styles.icon} />
<Text style={[styles.buttonText, { color: colors.foreground }]}>Mit Google anmelden</Text>
</>
)}
</TouchableOpacity>
);
};
const styles = StyleSheet.create({
button: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
padding: 16,
borderRadius: 8,
borderWidth: 1,
minHeight: 52,
},
icon: {
marginRight: 12,
},
buttonText: {
fontSize: 16,
fontWeight: '600',
},
button: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
padding: 16,
borderRadius: 8,
borderWidth: 1,
minHeight: 52,
},
icon: {
marginRight: 12,
},
buttonText: {
fontSize: 16,
fontWeight: '600',
},
});

View file

@ -9,332 +9,384 @@ import { Button } from '../ui/Button';
import { useThemeColors } from '~/utils/themeUtils';
interface CardListProps {
deckId: string;
isCompact?: boolean;
showActions?: boolean;
limit?: number;
onCardPress?: (card: Card) => void;
deckId: string;
isCompact?: boolean;
showActions?: boolean;
limit?: number;
onCardPress?: (card: Card) => void;
}
interface CardItemProps {
card: Card;
isCompact: boolean;
showActions: boolean;
onPress: () => void;
onEdit: () => void;
onDelete: () => void;
onToggleFavorite: () => void;
card: Card;
isCompact: boolean;
showActions: boolean;
onPress: () => void;
onEdit: () => void;
onDelete: () => void;
onToggleFavorite: () => void;
}
const CardItem: React.FC<CardItemProps> = ({
card,
isCompact,
showActions,
onPress,
onEdit,
onDelete,
onToggleFavorite,
card,
isCompact,
showActions,
onPress,
onEdit,
onDelete,
onToggleFavorite,
}) => {
const colors = useThemeColors();
const getCardTypeIcon = (type: Card['card_type']) => {
switch (type) {
case 'text':
return 'document-text-outline';
case 'flashcard':
return 'card-outline';
case 'quiz':
return 'help-circle-outline';
case 'mixed':
return 'grid-outline';
default:
return 'document-outline';
}
};
const colors = useThemeColors();
const getCardTypeColor = (type: Card['card_type']) => {
switch (type) {
case 'text':
return colors.primary;
case 'flashcard':
return colors.accent;
case 'quiz':
return colors.secondary;
case 'mixed':
return colors.destructive;
default:
return colors.mutedForeground;
}
};
const getCardTypeIcon = (type: Card['card_type']) => {
switch (type) {
case 'text':
return 'document-text-outline';
case 'flashcard':
return 'card-outline';
case 'quiz':
return 'help-circle-outline';
case 'mixed':
return 'grid-outline';
default:
return 'document-outline';
}
};
const getContentPreview = (card: Card) => {
switch (card.card_type) {
case 'text':
return (card.content as any).text || '';
case 'flashcard':
return (card.content as any).front || '';
case 'quiz':
return (card.content as any).question || '';
case 'mixed':
const blocks = (card.content as any).blocks || [];
const firstTextBlock = blocks.find((block: any) => block.type === 'text');
return firstTextBlock?.data?.text || '';
default:
return '';
}
};
const getCardTypeColor = (type: Card['card_type']) => {
switch (type) {
case 'text':
return colors.primary;
case 'flashcard':
return colors.accent;
case 'quiz':
return colors.secondary;
case 'mixed':
return colors.destructive;
default:
return colors.mutedForeground;
}
};
if (isCompact) {
return (
<Pressable onPress={onPress}>
{({ pressed }) => (
<View
style={{
marginBottom: 10,
flexDirection: 'row',
alignItems: 'center',
backgroundColor: 'rgba(255, 255, 255, 0.8)',
borderRadius: 14,
padding: 14,
borderWidth: 1,
borderColor: '#E5E7EB',
shadowColor: '#000',
shadowOffset: { width: 0, height: 1 },
shadowOpacity: 0.06,
shadowRadius: 4,
elevation: 2,
opacity: pressed ? 0.7 : 1,
}}>
{/* Icon Container */}
<View
style={{
width: 50,
height: 50,
backgroundColor: getCardTypeColor(card.card_type),
borderRadius: 12,
alignItems: 'center',
justifyContent: 'center',
marginRight: 14,
}}>
<Icon
name={getCardTypeIcon(card.card_type)}
size={26}
color="#FFFFFF"
library="Ionicons"
/>
</View>
const getContentPreview = (card: Card) => {
switch (card.card_type) {
case 'text':
return (card.content as any).text || '';
case 'flashcard':
return (card.content as any).front || '';
case 'quiz':
return (card.content as any).question || '';
case 'mixed':
const blocks = (card.content as any).blocks || [];
const firstTextBlock = blocks.find((block: any) => block.type === 'text');
return firstTextBlock?.data?.text || '';
default:
return '';
}
};
{/* Content */}
<View style={{ flex: 1 }}>
<Text
style={{
fontSize: 16,
fontWeight: '600',
color: '#1F2937',
marginBottom: 4,
}}
numberOfLines={1}>
{card.title || `${card.card_type.charAt(0).toUpperCase() + card.card_type.slice(1)} Karte`}
</Text>
<Text
style={{
fontSize: 14,
color: '#6B7280',
}}
numberOfLines={1}>
{getContentPreview(card)}
</Text>
</View>
if (isCompact) {
return (
<Pressable onPress={onPress}>
{({ pressed }) => (
<View
style={{
marginBottom: 10,
flexDirection: 'row',
alignItems: 'center',
backgroundColor: 'rgba(255, 255, 255, 0.8)',
borderRadius: 14,
padding: 14,
borderWidth: 1,
borderColor: '#E5E7EB',
shadowColor: '#000',
shadowOffset: { width: 0, height: 1 },
shadowOpacity: 0.06,
shadowRadius: 4,
elevation: 2,
opacity: pressed ? 0.7 : 1,
}}
>
{/* Icon Container */}
<View
style={{
width: 50,
height: 50,
backgroundColor: getCardTypeColor(card.card_type),
borderRadius: 12,
alignItems: 'center',
justifyContent: 'center',
marginRight: 14,
}}
>
<Icon
name={getCardTypeIcon(card.card_type)}
size={26}
color="#FFFFFF"
library="Ionicons"
/>
</View>
{/* Chevron */}
<Icon
name="chevron-forward"
size={20}
color="#9CA3AF"
library="Ionicons"
style={{ marginLeft: 8 }}
/>
</View>
)}
</Pressable>
);
}
{/* Content */}
<View style={{ flex: 1 }}>
<Text
style={{
fontSize: 16,
fontWeight: '600',
color: '#1F2937',
marginBottom: 4,
}}
numberOfLines={1}
>
{card.title ||
`${card.card_type.charAt(0).toUpperCase() + card.card_type.slice(1)} Karte`}
</Text>
<Text
style={{
fontSize: 14,
color: '#6B7280',
}}
numberOfLines={1}
>
{getContentPreview(card)}
</Text>
</View>
return (
<UICard onPress={onPress} variant="elevated" padding="lg" style={{ marginBottom: 16 }}>
<View style={{ marginBottom: 8, flexDirection: 'row', alignItems: 'flex-start', justifyContent: 'space-between' }}>
<View style={{ flex: 1 }}>
<View style={{ marginBottom: 8, flexDirection: 'row', alignItems: 'center' }}>
<View
style={{
height: 24,
width: 24,
backgroundColor: getCardTypeColor(card.card_type),
marginRight: 8,
alignItems: 'center',
justifyContent: 'center',
borderRadius: 4,
}}>
<Icon
name={getCardTypeIcon(card.card_type)}
size={12}
color="white"
library="Ionicons"
/>
</View>
<Text style={{ fontSize: 12, fontWeight: '500', textTransform: 'uppercase', letterSpacing: 0.5, color: colors.mutedForeground }}>
{card.card_type}
</Text>
{card.is_favorite && (
<Icon
name="heart"
size={14}
color={colors.destructive}
style={{ marginLeft: 8 }}
library="Ionicons"
/>
)}
</View>
{/* Chevron */}
<Icon
name="chevron-forward"
size={20}
color="#9CA3AF"
library="Ionicons"
style={{ marginLeft: 8 }}
/>
</View>
)}
</Pressable>
);
}
<Text style={{ marginBottom: 4, fontSize: 18, fontWeight: '600', color: colors.foreground }}>
{card.title || `Position ${card.position}`}
</Text>
return (
<UICard onPress={onPress} variant="elevated" padding="lg" style={{ marginBottom: 16 }}>
<View
style={{
marginBottom: 8,
flexDirection: 'row',
alignItems: 'flex-start',
justifyContent: 'space-between',
}}
>
<View style={{ flex: 1 }}>
<View style={{ marginBottom: 8, flexDirection: 'row', alignItems: 'center' }}>
<View
style={{
height: 24,
width: 24,
backgroundColor: getCardTypeColor(card.card_type),
marginRight: 8,
alignItems: 'center',
justifyContent: 'center',
borderRadius: 4,
}}
>
<Icon
name={getCardTypeIcon(card.card_type)}
size={12}
color="white"
library="Ionicons"
/>
</View>
<Text
style={{
fontSize: 12,
fontWeight: '500',
textTransform: 'uppercase',
letterSpacing: 0.5,
color: colors.mutedForeground,
}}
>
{card.card_type}
</Text>
{card.is_favorite && (
<Icon
name="heart"
size={14}
color={colors.destructive}
style={{ marginLeft: 8 }}
library="Ionicons"
/>
)}
</View>
<Text style={{ color: colors.mutedForeground }} numberOfLines={3}>
{getContentPreview(card)}
</Text>
</View>
<Text
style={{ marginBottom: 4, fontSize: 18, fontWeight: '600', color: colors.foreground }}
>
{card.title || `Position ${card.position}`}
</Text>
{showActions && (
<View style={{ marginLeft: 12, flexDirection: 'row' }}>
<Pressable
onPress={onToggleFavorite}
style={({ pressed }) => ({ padding: 8, opacity: pressed ? 0.7 : 1 })}>
<Icon
name={card.is_favorite ? 'heart' : 'heart-outline'}
size={20}
color={card.is_favorite ? colors.destructive : colors.mutedForeground}
library="Ionicons"
/>
</Pressable>
<Pressable
onPress={onEdit}
style={({ pressed }) => ({ padding: 8, opacity: pressed ? 0.7 : 1 })}>
<Icon name="create-outline" size={20} color={colors.mutedForeground} library="Ionicons" />
</Pressable>
<Pressable
onPress={onDelete}
style={({ pressed }) => ({ padding: 8, opacity: pressed ? 0.7 : 1 })}>
<Icon name="trash-outline" size={20} color={colors.destructive} library="Ionicons" />
</Pressable>
</View>
)}
</View>
<Text style={{ color: colors.mutedForeground }} numberOfLines={3}>
{getContentPreview(card)}
</Text>
</View>
<View style={{ marginTop: 12, flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between', borderTopWidth: 1, borderTopColor: colors.border, paddingTop: 12 }}>
<Text style={{ fontSize: 12, color: colors.mutedForeground }}>Position {card.position}</Text>
<Text style={{ fontSize: 12, color: colors.mutedForeground }}>
{new Date(card.updated_at).toLocaleDateString('de-DE')}
</Text>
</View>
</UICard>
);
{showActions && (
<View style={{ marginLeft: 12, flexDirection: 'row' }}>
<Pressable
onPress={onToggleFavorite}
style={({ pressed }) => ({ padding: 8, opacity: pressed ? 0.7 : 1 })}
>
<Icon
name={card.is_favorite ? 'heart' : 'heart-outline'}
size={20}
color={card.is_favorite ? colors.destructive : colors.mutedForeground}
library="Ionicons"
/>
</Pressable>
<Pressable
onPress={onEdit}
style={({ pressed }) => ({ padding: 8, opacity: pressed ? 0.7 : 1 })}
>
<Icon
name="create-outline"
size={20}
color={colors.mutedForeground}
library="Ionicons"
/>
</Pressable>
<Pressable
onPress={onDelete}
style={({ pressed }) => ({ padding: 8, opacity: pressed ? 0.7 : 1 })}
>
<Icon name="trash-outline" size={20} color={colors.destructive} library="Ionicons" />
</Pressable>
</View>
)}
</View>
<View
style={{
marginTop: 12,
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
borderTopWidth: 1,
borderTopColor: colors.border,
paddingTop: 12,
}}
>
<Text style={{ fontSize: 12, color: colors.mutedForeground }}>
Position {card.position}
</Text>
<Text style={{ fontSize: 12, color: colors.mutedForeground }}>
{new Date(card.updated_at).toLocaleDateString('de-DE')}
</Text>
</View>
</UICard>
);
};
export const CardList: React.FC<CardListProps> = ({
deckId,
isCompact = false,
showActions = true,
limit,
onCardPress,
deckId,
isCompact = false,
showActions = true,
limit,
onCardPress,
}) => {
const { cards, fetchCards, deleteCard, toggleFavorite, isLoading } = useCardStore();
const colors = useThemeColors();
const { cards, fetchCards, deleteCard, toggleFavorite, isLoading } = useCardStore();
const colors = useThemeColors();
useEffect(() => {
fetchCards(deckId);
}, [deckId]);
useEffect(() => {
fetchCards(deckId);
}, [deckId]);
const displayCards = limit ? cards.slice(0, limit) : cards;
const displayCards = limit ? cards.slice(0, limit) : cards;
const handleCardPress = (card: Card) => {
if (onCardPress) {
onCardPress(card);
} else {
router.push(`/card/${card.id}`);
}
};
const handleCardPress = (card: Card) => {
if (onCardPress) {
onCardPress(card);
} else {
router.push(`/card/${card.id}`);
}
};
const handleEdit = (card: Card) => {
router.push(`/card/edit/${card.id}`);
};
const handleEdit = (card: Card) => {
router.push(`/card/edit/${card.id}`);
};
const handleDelete = (card: Card) => {
Alert.alert('Karte löschen', 'Möchtest du diese Karte wirklich löschen?', [
{ text: 'Abbrechen', style: 'cancel' },
{
text: 'Löschen',
style: 'destructive',
onPress: () => deleteCard(card.id),
},
]);
};
const handleDelete = (card: Card) => {
Alert.alert('Karte löschen', 'Möchtest du diese Karte wirklich löschen?', [
{ text: 'Abbrechen', style: 'cancel' },
{
text: 'Löschen',
style: 'destructive',
onPress: () => deleteCard(card.id),
},
]);
};
const handleToggleFavorite = (card: Card) => {
toggleFavorite(card.id);
};
const handleToggleFavorite = (card: Card) => {
toggleFavorite(card.id);
};
if (cards.length === 0 && !isLoading) {
return (
<UICard padding="lg" variant="outlined">
<View style={{ alignItems: 'center', paddingVertical: 32 }}>
<Icon name="card-outline" size={48} color={colors.mutedForeground} library="Ionicons" />
<Text style={{ marginTop: 8, color: colors.mutedForeground }}>Noch keine Karten</Text>
<Text style={{ marginTop: 4, textAlign: 'center', fontSize: 14, color: colors.mutedForeground }}>
Füge deine erste Karte hinzu, um zu beginnen
</Text>
<View style={{ marginTop: 16 }}>
<Button
onPress={() => router.push(`/card/create?deckId=${deckId}`)}
variant="primary"
size="sm">
Erste Karte erstellen
</Button>
</View>
</View>
</UICard>
);
}
if (cards.length === 0 && !isLoading) {
return (
<UICard padding="lg" variant="outlined">
<View style={{ alignItems: 'center', paddingVertical: 32 }}>
<Icon name="card-outline" size={48} color={colors.mutedForeground} library="Ionicons" />
<Text style={{ marginTop: 8, color: colors.mutedForeground }}>Noch keine Karten</Text>
<Text
style={{
marginTop: 4,
textAlign: 'center',
fontSize: 14,
color: colors.mutedForeground,
}}
>
Füge deine erste Karte hinzu, um zu beginnen
</Text>
<View style={{ marginTop: 16 }}>
<Button
onPress={() => router.push(`/card/create?deckId=${deckId}`)}
variant="primary"
size="sm"
>
Erste Karte erstellen
</Button>
</View>
</View>
</UICard>
);
}
return (
<View>
<FlatList
data={displayCards}
keyExtractor={(item) => item.id}
renderItem={({ item }) => (
<CardItem
card={item}
isCompact={isCompact}
showActions={showActions}
onPress={() => handleCardPress(item)}
onEdit={() => handleEdit(item)}
onDelete={() => handleDelete(item)}
onToggleFavorite={() => handleToggleFavorite(item)}
/>
)}
scrollEnabled={false}
ItemSeparatorComponent={isCompact ? undefined : () => <View style={{ height: 8 }} />}
/>
return (
<View>
<FlatList
data={displayCards}
keyExtractor={(item) => item.id}
renderItem={({ item }) => (
<CardItem
card={item}
isCompact={isCompact}
showActions={showActions}
onPress={() => handleCardPress(item)}
onEdit={() => handleEdit(item)}
onDelete={() => handleDelete(item)}
onToggleFavorite={() => handleToggleFavorite(item)}
/>
)}
scrollEnabled={false}
ItemSeparatorComponent={isCompact ? undefined : () => <View style={{ height: 8 }} />}
/>
{limit && cards.length > limit && (
<Pressable
onPress={() => router.push(`/deck/${deckId}/cards`)}
style={({ pressed }) => ({ marginTop: 12, opacity: pressed ? 0.7 : 1 })}>
<Text style={{ textAlign: 'center', fontWeight: '500', color: colors.primary }}>
Alle {cards.length} Karten anzeigen
</Text>
</Pressable>
)}
</View>
);
{limit && cards.length > limit && (
<Pressable
onPress={() => router.push(`/deck/${deckId}/cards`)}
style={({ pressed }) => ({ marginTop: 12, opacity: pressed ? 0.7 : 1 })}
>
<Text style={{ textAlign: 'center', fontWeight: '500', color: colors.primary }}>
Alle {cards.length} Karten anzeigen
</Text>
</Pressable>
)}
</View>
);
};

View file

@ -3,165 +3,173 @@ import { View, Text, Pressable } from 'react-native';
import { Icon } from '../ui/Icon';
interface CardType {
id: 'text' | 'flashcard' | 'quiz' | 'mixed';
name: string;
description: string;
icon: string;
color: string;
id: 'text' | 'flashcard' | 'quiz' | 'mixed';
name: string;
description: string;
icon: string;
color: string;
}
const cardTypes: CardType[] = [
{
id: 'text',
name: 'Text',
description: 'Einfache Textkarte mit Formatierung',
icon: 'document-text-outline',
color: 'bg-blue-500',
},
{
id: 'flashcard',
name: 'Flashcard',
description: 'Vorder- und Rückseite zum Lernen',
icon: 'card-outline',
color: 'bg-green-500',
},
{
id: 'quiz',
name: 'Quiz',
description: 'Multiple Choice Frage',
icon: 'help-circle-outline',
color: 'bg-purple-500',
},
{
id: 'mixed',
name: 'Mixed',
description: 'Kombiniert verschiedene Elemente',
icon: 'grid-outline',
color: 'bg-orange-500',
},
{
id: 'text',
name: 'Text',
description: 'Einfache Textkarte mit Formatierung',
icon: 'document-text-outline',
color: 'bg-blue-500',
},
{
id: 'flashcard',
name: 'Flashcard',
description: 'Vorder- und Rückseite zum Lernen',
icon: 'card-outline',
color: 'bg-green-500',
},
{
id: 'quiz',
name: 'Quiz',
description: 'Multiple Choice Frage',
icon: 'help-circle-outline',
color: 'bg-purple-500',
},
{
id: 'mixed',
name: 'Mixed',
description: 'Kombiniert verschiedene Elemente',
icon: 'grid-outline',
color: 'bg-orange-500',
},
];
interface CardTypeSelectorProps {
selectedType: 'text' | 'flashcard' | 'quiz' | 'mixed';
onTypeChange: (type: 'text' | 'flashcard' | 'quiz' | 'mixed') => void;
showDescriptions?: boolean;
layout?: 'grid' | 'list';
compact?: boolean;
selectedType: 'text' | 'flashcard' | 'quiz' | 'mixed';
onTypeChange: (type: 'text' | 'flashcard' | 'quiz' | 'mixed') => void;
showDescriptions?: boolean;
layout?: 'grid' | 'list';
compact?: boolean;
}
export const CardTypeSelector: React.FC<CardTypeSelectorProps> = ({
selectedType,
onTypeChange,
showDescriptions = true,
layout = 'grid',
compact = false,
selectedType,
onTypeChange,
showDescriptions = true,
layout = 'grid',
compact = false,
}) => {
if (compact) {
return (
<View className="flex-row items-center space-x-2">
{cardTypes.map((type) => (
<Pressable
key={type.id}
onPress={() => onTypeChange(type.id)}
className={`
if (compact) {
return (
<View className="flex-row items-center space-x-2">
{cardTypes.map((type) => (
<Pressable
key={type.id}
onPress={() => onTypeChange(type.id)}
className={`
flex-row items-center rounded-lg border px-3 py-2
${selectedType === type.id ? 'border-blue-500 bg-blue-50' : 'border-gray-200 bg-white'}
`}
style={({ pressed }) => pressed && { opacity: 0.7 }}>
<View className={`h-4 w-4 ${type.color} mr-2 items-center justify-center rounded`}>
<Icon name={type.icon} size={10} color="white" library="Ionicons" />
</View>
<Text
className={`text-sm font-medium ${
selectedType === type.id ? 'text-blue-900' : 'text-gray-700'
}`}>
{type.name}
</Text>
</Pressable>
))}
</View>
);
}
if (layout === 'list') {
return (
<View className="space-y-2">
{cardTypes.map((type) => (
<Pressable
key={type.id}
onPress={() => onTypeChange(type.id)}
className={`
style={({ pressed }) => pressed && { opacity: 0.7 }}
>
<View className={`h-4 w-4 ${type.color} mr-2 items-center justify-center rounded`}>
<Icon name={type.icon} size={10} color="white" library="Ionicons" />
</View>
<Text
className={`text-sm font-medium ${
selectedType === type.id ? 'text-blue-900' : 'text-gray-700'
}`}
>
{type.name}
</Text>
</Pressable>
))}
</View>
);
}
if (layout === 'list') {
return (
<View className="space-y-2">
{cardTypes.map((type) => (
<Pressable
key={type.id}
onPress={() => onTypeChange(type.id)}
className={`
flex-row items-center rounded-lg border-2 p-3
${
selectedType === type.id ? 'border-blue-500 bg-blue-50' : 'border-gray-200 bg-white'
}
selectedType === type.id ? 'border-blue-500 bg-blue-50' : 'border-gray-200 bg-white'
}
`}
style={({ pressed }) => pressed && { opacity: 0.7 }}>
<View className={`h-10 w-10 ${type.color} mr-3 items-center justify-center rounded-lg`}>
<Icon name={type.icon} size={20} color="white" library="Ionicons" />
</View>
<View className="flex-1">
<Text
className={`text-base font-medium ${
selectedType === type.id ? 'text-blue-900' : 'text-gray-900'
}`}>
{type.name}
</Text>
{showDescriptions && (
<Text
className={`text-sm ${
selectedType === type.id ? 'text-blue-600' : 'text-gray-500'
}`}>
{type.description}
</Text>
)}
</View>
{selectedType === type.id && (
<Icon name="checkmark-circle" size={24} color="#3B82F6" library="Ionicons" />
)}
</Pressable>
))}
</View>
);
}
style={({ pressed }) => pressed && { opacity: 0.7 }}
>
<View className={`h-10 w-10 ${type.color} mr-3 items-center justify-center rounded-lg`}>
<Icon name={type.icon} size={20} color="white" library="Ionicons" />
</View>
<View className="flex-1">
<Text
className={`text-base font-medium ${
selectedType === type.id ? 'text-blue-900' : 'text-gray-900'
}`}
>
{type.name}
</Text>
{showDescriptions && (
<Text
className={`text-sm ${
selectedType === type.id ? 'text-blue-600' : 'text-gray-500'
}`}
>
{type.description}
</Text>
)}
</View>
{selectedType === type.id && (
<Icon name="checkmark-circle" size={24} color="#3B82F6" library="Ionicons" />
)}
</Pressable>
))}
</View>
);
}
return (
<View className="-mx-2 flex-row flex-wrap">
{cardTypes.map((type) => (
<View key={type.id} className="mb-4 w-1/2 px-2">
<Pressable
onPress={() => onTypeChange(type.id)}
className={`
return (
<View className="-mx-2 flex-row flex-wrap">
{cardTypes.map((type) => (
<View key={type.id} className="mb-4 w-1/2 px-2">
<Pressable
onPress={() => onTypeChange(type.id)}
className={`
items-center rounded-xl border-2 p-4
${
selectedType === type.id ? 'border-blue-500 bg-blue-50' : 'border-gray-200 bg-white'
}
selectedType === type.id ? 'border-blue-500 bg-blue-50' : 'border-gray-200 bg-white'
}
`}
style={({ pressed }) => pressed && { opacity: 0.7 }}>
<View className={`h-12 w-12 ${type.color} mb-2 items-center justify-center rounded-lg`}>
<Icon name={type.icon} size={24} color="white" library="Ionicons" />
</View>
<Text
className={`text-center text-base font-medium ${
selectedType === type.id ? 'text-blue-900' : 'text-gray-900'
}`}>
{type.name}
</Text>
{showDescriptions && (
<Text
className={`mt-1 text-center text-xs ${
selectedType === type.id ? 'text-blue-600' : 'text-gray-500'
}`}>
{type.description}
</Text>
)}
{selectedType === type.id && (
<View className="absolute right-2 top-2">
<Icon name="checkmark-circle" size={20} color="#3B82F6" library="Ionicons" />
</View>
)}
</Pressable>
</View>
))}
</View>
);
style={({ pressed }) => pressed && { opacity: 0.7 }}
>
<View className={`h-12 w-12 ${type.color} mb-2 items-center justify-center rounded-lg`}>
<Icon name={type.icon} size={24} color="white" library="Ionicons" />
</View>
<Text
className={`text-center text-base font-medium ${
selectedType === type.id ? 'text-blue-900' : 'text-gray-900'
}`}
>
{type.name}
</Text>
{showDescriptions && (
<Text
className={`mt-1 text-center text-xs ${
selectedType === type.id ? 'text-blue-600' : 'text-gray-500'
}`}
>
{type.description}
</Text>
)}
{selectedType === type.id && (
<View className="absolute right-2 top-2">
<Icon name="checkmark-circle" size={20} color="#3B82F6" library="Ionicons" />
</View>
)}
</Pressable>
</View>
))}
</View>
);
};

View file

@ -3,285 +3,456 @@ import { View, Pressable, ScrollView } from 'react-native';
import { Icon } from '../ui/Icon';
import { Text } from '../ui/Text';
import {
Card,
TextContent,
FlashcardContent,
QuizContent,
MixedContent,
Card,
TextContent,
FlashcardContent,
QuizContent,
MixedContent,
} from '../../store/cardStore';
import { Button } from '../ui/Button';
import { useThemeColors } from '~/utils/themeUtils';
interface CardViewProps {
card: Card;
mode: 'view' | 'study' | 'edit' | 'preview';
onFlip?: () => void;
onAnswerSelect?: (answerIndex: number) => void;
onEdit?: () => void;
showActions?: boolean;
isFlipped?: boolean;
selectedAnswer?: number;
showFeedback?: boolean;
card: Card;
mode: 'view' | 'study' | 'edit' | 'preview';
onFlip?: () => void;
onAnswerSelect?: (answerIndex: number) => void;
onEdit?: () => void;
showActions?: boolean;
isFlipped?: boolean;
selectedAnswer?: number;
showFeedback?: boolean;
}
export const CardView: React.FC<CardViewProps> = ({
card,
mode,
onFlip,
onAnswerSelect,
onEdit,
showActions = false,
isFlipped = false,
selectedAnswer,
showFeedback = false,
card,
mode,
onFlip,
onAnswerSelect,
onEdit,
showActions = false,
isFlipped = false,
selectedAnswer,
showFeedback = false,
}) => {
const [localFlipped, setLocalFlipped] = useState(false);
const [showHint, setShowHint] = useState(false);
const actuallyFlipped = isFlipped !== undefined ? isFlipped : localFlipped;
const colors = useThemeColors();
const [localFlipped, setLocalFlipped] = useState(false);
const [showHint, setShowHint] = useState(false);
const actuallyFlipped = isFlipped !== undefined ? isFlipped : localFlipped;
const colors = useThemeColors();
const handleFlip = () => {
if (onFlip) {
onFlip();
} else {
setLocalFlipped(!localFlipped);
}
};
const handleFlip = () => {
if (onFlip) {
onFlip();
} else {
setLocalFlipped(!localFlipped);
}
};
const renderTextCard = (content: TextContent) => (
<View style={{ padding: 16 }}>
{card.title && <Text style={{ marginBottom: 12, fontSize: 20, fontWeight: 'bold', color: colors.foreground }}>{card.title}</Text>}
<Text style={{ fontSize: 16, lineHeight: 24, color: colors.foreground }}>{content.text}</Text>
</View>
);
const renderTextCard = (content: TextContent) => (
<View style={{ padding: 16 }}>
{card.title && (
<Text
style={{ marginBottom: 12, fontSize: 20, fontWeight: 'bold', color: colors.foreground }}
>
{card.title}
</Text>
)}
<Text style={{ fontSize: 16, lineHeight: 24, color: colors.foreground }}>{content.text}</Text>
</View>
);
const renderFlashcard = (content: FlashcardContent) => (
<View style={{ height: '100%', flexDirection: 'column' }}>
{/* Main Content Area */}
<View style={{ flex: 1, padding: 24, justifyContent: 'center' }}>
{!actuallyFlipped ? (
// Front side
<View style={{ justifyContent: 'center', alignItems: 'center' }}>
<Text style={{ fontSize: 28, fontWeight: '600', textAlign: 'center', lineHeight: 36, color: colors.foreground }}>
{content.front}
</Text>
const renderFlashcard = (content: FlashcardContent) => (
<View style={{ height: '100%', flexDirection: 'column' }}>
{/* Main Content Area */}
<View style={{ flex: 1, padding: 24, justifyContent: 'center' }}>
{!actuallyFlipped ? (
// Front side
<View style={{ justifyContent: 'center', alignItems: 'center' }}>
<Text
style={{
fontSize: 28,
fontWeight: '600',
textAlign: 'center',
lineHeight: 36,
color: colors.foreground,
}}
>
{content.front}
</Text>
{/* Hint Content - only if shown */}
{content.hint && mode === 'study' && showHint && (
<View style={{ marginTop: 20, width: '100%', borderRadius: 12, backgroundColor: colors.accent, padding: 16 }}>
<Text style={{ fontSize: 15, lineHeight: 22, color: colors.accentForeground }}>
{content.hint}
</Text>
</View>
)}
</View>
) : (
// Back side
<View style={{ justifyContent: 'center', alignItems: 'center' }}>
<Text style={{ fontSize: 28, fontWeight: '600', textAlign: 'center', lineHeight: 36, color: colors.foreground }}>
{content.back}
</Text>
</View>
)}
</View>
{/* Hint Content - only if shown */}
{content.hint && mode === 'study' && showHint && (
<View
style={{
marginTop: 20,
width: '100%',
borderRadius: 12,
backgroundColor: colors.accent,
padding: 16,
}}
>
<Text style={{ fontSize: 15, lineHeight: 22, color: colors.accentForeground }}>
{content.hint}
</Text>
</View>
)}
</View>
) : (
// Back side
<View style={{ justifyContent: 'center', alignItems: 'center' }}>
<Text
style={{
fontSize: 28,
fontWeight: '600',
textAlign: 'center',
lineHeight: 36,
color: colors.foreground,
}}
>
{content.back}
</Text>
</View>
)}
</View>
{/* Fixed Hint Bar at Bottom - only on front side */}
{!actuallyFlipped && content.hint && mode === 'study' && (
<View style={{ borderTopWidth: 1, borderTopColor: colors.border, backgroundColor: colors.card }}>
<Pressable
onPress={() => setShowHint(!showHint)}
style={({ pressed }) => ({
paddingVertical: 18,
paddingHorizontal: 24,
backgroundColor: showHint ? `${colors.accent}20` : 'transparent',
opacity: pressed ? 0.7 : 1,
})}>
<View style={{ flexDirection: 'row', alignItems: 'center', justifyContent: 'center' }}>
<Icon
name={showHint ? 'bulb' : 'bulb-outline'}
library="Ionicons"
size={20}
color={showHint ? colors.primary : colors.mutedForeground}
/>
<Text style={{ fontSize: 15, fontWeight: '500', color: showHint ? colors.primary : colors.mutedForeground, marginLeft: 6 }}>
{showHint ? 'Hinweis ausblenden' : 'Hinweis anzeigen'}
</Text>
</View>
</Pressable>
</View>
)}
</View>
);
{/* Fixed Hint Bar at Bottom - only on front side */}
{!actuallyFlipped && content.hint && mode === 'study' && (
<View
style={{
borderTopWidth: 1,
borderTopColor: colors.border,
backgroundColor: colors.card,
}}
>
<Pressable
onPress={() => setShowHint(!showHint)}
style={({ pressed }) => ({
paddingVertical: 18,
paddingHorizontal: 24,
backgroundColor: showHint ? `${colors.accent}20` : 'transparent',
opacity: pressed ? 0.7 : 1,
})}
>
<View style={{ flexDirection: 'row', alignItems: 'center', justifyContent: 'center' }}>
<Icon
name={showHint ? 'bulb' : 'bulb-outline'}
library="Ionicons"
size={20}
color={showHint ? colors.primary : colors.mutedForeground}
/>
<Text
style={{
fontSize: 15,
fontWeight: '500',
color: showHint ? colors.primary : colors.mutedForeground,
marginLeft: 6,
}}
>
{showHint ? 'Hinweis ausblenden' : 'Hinweis anzeigen'}
</Text>
</View>
</Pressable>
</View>
)}
</View>
);
const renderQuizCard = (content: QuizContent) => (
<View style={{ padding: 16 }}>
<Text style={{ marginBottom: 16, fontSize: 20, fontWeight: 'bold', color: colors.foreground }}>{content.question}</Text>
const renderQuizCard = (content: QuizContent) => (
<View style={{ padding: 16 }}>
<Text
style={{ marginBottom: 16, fontSize: 20, fontWeight: 'bold', color: colors.foreground }}
>
{content.question}
</Text>
<View style={{ gap: 12 }}>
{content.options.map((option, index) => {
const isSelected = selectedAnswer === index;
const isCorrect = index === content.correct_answer;
const showCorrect = showFeedback && isCorrect;
const showIncorrect = showFeedback && isSelected && !isCorrect;
<View style={{ gap: 12 }}>
{content.options.map((option, index) => {
const isSelected = selectedAnswer === index;
const isCorrect = index === content.correct_answer;
const showCorrect = showFeedback && isCorrect;
const showIncorrect = showFeedback && isSelected && !isCorrect;
const getBorderColor = () => {
if (showCorrect) return 'rgb(34, 197, 94)'; // green-500
if (showIncorrect) return colors.destructive;
if (isSelected && !showFeedback) return colors.primary;
return colors.border;
};
const getBorderColor = () => {
if (showCorrect) return 'rgb(34, 197, 94)'; // green-500
if (showIncorrect) return colors.destructive;
if (isSelected && !showFeedback) return colors.primary;
return colors.border;
};
const getBackgroundColor = () => {
if (showCorrect) return 'rgba(34, 197, 94, 0.1)';
if (showIncorrect) return `${colors.destructive}15`;
if (isSelected && !showFeedback) return `${colors.primary}15`;
return colors.surface;
};
const getBackgroundColor = () => {
if (showCorrect) return 'rgba(34, 197, 94, 0.1)';
if (showIncorrect) return `${colors.destructive}15`;
if (isSelected && !showFeedback) return `${colors.primary}15`;
return colors.surface;
};
return (
<Pressable
key={index}
onPress={() => onAnswerSelect?.(index)}
disabled={showFeedback || mode !== 'study'}
style={({ pressed }) => ({
flexDirection: 'row',
alignItems: 'center',
borderRadius: 8,
borderWidth: 2,
padding: 12,
borderColor: getBorderColor(),
backgroundColor: getBackgroundColor(),
opacity: pressed && !showFeedback && mode === 'study' ? 0.7 : 1,
})}>
<View
style={{
marginRight: 12,
height: 24,
width: 24,
alignItems: 'center',
justifyContent: 'center',
borderRadius: 12,
borderWidth: 2,
borderColor: getBorderColor(),
backgroundColor: (isSelected || showCorrect) ? getBorderColor() : 'transparent',
}}>
{(isSelected || showCorrect) && (
<Icon
name={showCorrect ? 'checkmark' : showIncorrect ? 'close' : 'checkmark'}
library="Ionicons"
size={16}
color={colors.background}
/>
)}
</View>
<Text
style={{
flex: 1,
fontSize: 16,
color: showCorrect ? 'rgb(34, 197, 94)' : showIncorrect ? colors.destructive : isSelected && !showFeedback ? colors.primary : colors.foreground,
}}>
{option}
</Text>
</Pressable>
);
})}
</View>
return (
<Pressable
key={index}
onPress={() => onAnswerSelect?.(index)}
disabled={showFeedback || mode !== 'study'}
style={({ pressed }) => ({
flexDirection: 'row',
alignItems: 'center',
borderRadius: 8,
borderWidth: 2,
padding: 12,
borderColor: getBorderColor(),
backgroundColor: getBackgroundColor(),
opacity: pressed && !showFeedback && mode === 'study' ? 0.7 : 1,
})}
>
<View
style={{
marginRight: 12,
height: 24,
width: 24,
alignItems: 'center',
justifyContent: 'center',
borderRadius: 12,
borderWidth: 2,
borderColor: getBorderColor(),
backgroundColor: isSelected || showCorrect ? getBorderColor() : 'transparent',
}}
>
{(isSelected || showCorrect) && (
<Icon
name={showCorrect ? 'checkmark' : showIncorrect ? 'close' : 'checkmark'}
library="Ionicons"
size={16}
color={colors.background}
/>
)}
</View>
<Text
style={{
flex: 1,
fontSize: 16,
color: showCorrect
? 'rgb(34, 197, 94)'
: showIncorrect
? colors.destructive
: isSelected && !showFeedback
? colors.primary
: colors.foreground,
}}
>
{option}
</Text>
</Pressable>
);
})}
</View>
{showFeedback && content.explanation && (
<View style={{ marginTop: 16, borderRadius: 8, backgroundColor: `${colors.primary}15`, padding: 12 }}>
<Text style={{ marginBottom: 4, fontSize: 14, fontWeight: '500', color: colors.primary }}>Erklärung:</Text>
<Text style={{ fontSize: 14, color: colors.primary }}>{content.explanation}</Text>
</View>
)}
</View>
);
{showFeedback && content.explanation && (
<View
style={{
marginTop: 16,
borderRadius: 8,
backgroundColor: `${colors.primary}15`,
padding: 12,
}}
>
<Text style={{ marginBottom: 4, fontSize: 14, fontWeight: '500', color: colors.primary }}>
Erklärung:
</Text>
<Text style={{ fontSize: 14, color: colors.primary }}>{content.explanation}</Text>
</View>
)}
</View>
);
const renderMixedCard = (content: MixedContent) => (
<View style={{ padding: 16 }}>
{card.title && <Text style={{ marginBottom: 16, fontSize: 20, fontWeight: 'bold', color: colors.foreground }}>{card.title}</Text>}
<ScrollView style={{ maxHeight: 384 }}>
{content.blocks.map((block, index) => (
<View key={index} style={{ marginBottom: 16 }}>
{block.type === 'text' && (
<Text style={{ fontSize: 16, lineHeight: 24, color: colors.foreground }}>{block.data.text}</Text>
)}
{block.type === 'image' && (
<View style={{ height: 128, alignItems: 'center', justifyContent: 'center', borderRadius: 8, backgroundColor: colors.muted }}>
<Icon name="image-outline" library="Ionicons" size={32} color={colors.mutedForeground} />
<Text style={{ marginTop: 4, fontSize: 14, color: colors.mutedForeground }}>
Bild: {block.data.caption || 'Ohne Titel'}
</Text>
</View>
)}
{block.type === 'quiz' && (
<View style={{ borderRadius: 8, borderWidth: 1, borderColor: colors.border, padding: 12 }}>
<Text style={{ marginBottom: 8, fontSize: 14, fontWeight: '500', color: colors.mutedForeground }}>Quiz-Block</Text>
{renderQuizCard({ ...block.data } as QuizContent)}
</View>
)}
{block.type === 'flashcard' && (
<View style={{ borderRadius: 8, borderWidth: 1, borderColor: colors.border, padding: 12 }}>
<Text style={{ marginBottom: 8, fontSize: 14, fontWeight: '500', color: colors.mutedForeground }}>Flashcard-Block</Text>
{renderFlashcard({ ...block.data } as FlashcardContent)}
</View>
)}
</View>
))}
</ScrollView>
</View>
);
const renderMixedCard = (content: MixedContent) => (
<View style={{ padding: 16 }}>
{card.title && (
<Text
style={{ marginBottom: 16, fontSize: 20, fontWeight: 'bold', color: colors.foreground }}
>
{card.title}
</Text>
)}
<ScrollView style={{ maxHeight: 384 }}>
{content.blocks.map((block, index) => (
<View key={index} style={{ marginBottom: 16 }}>
{block.type === 'text' && (
<Text style={{ fontSize: 16, lineHeight: 24, color: colors.foreground }}>
{block.data.text}
</Text>
)}
{block.type === 'image' && (
<View
style={{
height: 128,
alignItems: 'center',
justifyContent: 'center',
borderRadius: 8,
backgroundColor: colors.muted,
}}
>
<Icon
name="image-outline"
library="Ionicons"
size={32}
color={colors.mutedForeground}
/>
<Text style={{ marginTop: 4, fontSize: 14, color: colors.mutedForeground }}>
Bild: {block.data.caption || 'Ohne Titel'}
</Text>
</View>
)}
{block.type === 'quiz' && (
<View
style={{
borderRadius: 8,
borderWidth: 1,
borderColor: colors.border,
padding: 12,
}}
>
<Text
style={{
marginBottom: 8,
fontSize: 14,
fontWeight: '500',
color: colors.mutedForeground,
}}
>
Quiz-Block
</Text>
{renderQuizCard({ ...block.data } as QuizContent)}
</View>
)}
{block.type === 'flashcard' && (
<View
style={{
borderRadius: 8,
borderWidth: 1,
borderColor: colors.border,
padding: 12,
}}
>
<Text
style={{
marginBottom: 8,
fontSize: 14,
fontWeight: '500',
color: colors.mutedForeground,
}}
>
Flashcard-Block
</Text>
{renderFlashcard({ ...block.data } as FlashcardContent)}
</View>
)}
</View>
))}
</ScrollView>
</View>
);
const renderCardContent = () => {
switch (card.card_type) {
case 'text':
return renderTextCard(card.content as TextContent);
case 'flashcard':
return renderFlashcard(card.content as FlashcardContent);
case 'quiz':
return renderQuizCard(card.content as QuizContent);
case 'mixed':
return renderMixedCard(card.content as MixedContent);
default:
return (
<View style={{ padding: 16 }}>
<Text style={{ color: colors.mutedForeground }}>Unbekannter Kartentyp</Text>
</View>
);
}
};
const renderCardContent = () => {
switch (card.card_type) {
case 'text':
return renderTextCard(card.content as TextContent);
case 'flashcard':
return renderFlashcard(card.content as FlashcardContent);
case 'quiz':
return renderQuizCard(card.content as QuizContent);
case 'mixed':
return renderMixedCard(card.content as MixedContent);
default:
return (
<View style={{ padding: 16 }}>
<Text style={{ color: colors.mutedForeground }}>Unbekannter Kartentyp</Text>
</View>
);
}
};
return (
<View style={{ height: '100%', width: '100%', borderRadius: 12, borderWidth: 1, borderColor: colors.border, backgroundColor: colors.card, shadowColor: '#000', shadowOffset: { width: 0, height: 1 }, shadowOpacity: 0.05, shadowRadius: 2 }}>
{/* Card Header */}
{showActions && (
<View style={{ flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between', borderBottomWidth: 1, borderBottomColor: colors.border, padding: 12 }}>
<View style={{ flexDirection: 'row', alignItems: 'center' }}>
<View style={{ marginRight: 8, height: 8, width: 8, borderRadius: 4, backgroundColor: colors.primary }} />
<Text style={{ fontSize: 14, fontWeight: '500', textTransform: 'capitalize', color: colors.mutedForeground }}>{card.card_type}</Text>
</View>
<View style={{ flexDirection: 'row', alignItems: 'center', gap: 8 }}>
{card.is_favorite && <Icon name="heart" library="Ionicons" size={16} color={colors.destructive} />}
{onEdit && (
<Pressable onPress={onEdit} style={({ pressed }) => pressed && { opacity: 0.7 }}>
<Icon name="create-outline" library="Ionicons" size={20} color={colors.mutedForeground} />
</Pressable>
)}
</View>
</View>
)}
return (
<View
style={{
height: '100%',
width: '100%',
borderRadius: 12,
borderWidth: 1,
borderColor: colors.border,
backgroundColor: colors.card,
shadowColor: '#000',
shadowOffset: { width: 0, height: 1 },
shadowOpacity: 0.05,
shadowRadius: 2,
}}
>
{/* Card Header */}
{showActions && (
<View
style={{
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
borderBottomWidth: 1,
borderBottomColor: colors.border,
padding: 12,
}}
>
<View style={{ flexDirection: 'row', alignItems: 'center' }}>
<View
style={{
marginRight: 8,
height: 8,
width: 8,
borderRadius: 4,
backgroundColor: colors.primary,
}}
/>
<Text
style={{
fontSize: 14,
fontWeight: '500',
textTransform: 'capitalize',
color: colors.mutedForeground,
}}
>
{card.card_type}
</Text>
</View>
<View style={{ flexDirection: 'row', alignItems: 'center', gap: 8 }}>
{card.is_favorite && (
<Icon name="heart" library="Ionicons" size={16} color={colors.destructive} />
)}
{onEdit && (
<Pressable onPress={onEdit} style={({ pressed }) => pressed && { opacity: 0.7 }}>
<Icon
name="create-outline"
library="Ionicons"
size={20}
color={colors.mutedForeground}
/>
</Pressable>
)}
</View>
</View>
)}
{/* Card Content */}
{renderCardContent()}
{/* Card Content */}
{renderCardContent()}
{/* Card Footer */}
{mode === 'view' && (
<View style={{ paddingHorizontal: 16, paddingBottom: 12 }}>
<View style={{ flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between' }}>
<Text style={{ fontSize: 12, color: colors.mutedForeground }}>Position {card.position}</Text>
<Text style={{ fontSize: 12, color: colors.mutedForeground }}>Version {card.version}</Text>
</View>
</View>
)}
</View>
);
{/* Card Footer */}
{mode === 'view' && (
<View style={{ paddingHorizontal: 16, paddingBottom: 12 }}>
<View
style={{ flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between' }}
>
<Text style={{ fontSize: 12, color: colors.mutedForeground }}>
Position {card.position}
</Text>
<Text style={{ fontSize: 12, color: colors.mutedForeground }}>
Version {card.version}
</Text>
</View>
</View>
)}
</View>
);
};

View file

@ -6,129 +6,198 @@ import { Deck } from '../../store/deckStore';
import { useThemeColors } from '~/utils/themeUtils';
interface DeckCardProps {
deck: Deck;
onPress: () => void;
showProgress?: boolean;
isCompact?: boolean;
deck: Deck;
onPress: () => void;
showProgress?: boolean;
isCompact?: boolean;
}
export const DeckCard: React.FC<DeckCardProps> = ({
deck,
onPress,
showProgress = false,
isCompact = false,
deck,
onPress,
showProgress = false,
isCompact = false,
}) => {
const isFavorite = deck.metadata?.is_favorite || false;
const colors = useThemeColors();
const isFavorite = deck.metadata?.is_favorite || false;
const colors = useThemeColors();
if (isCompact) {
return (
<Pressable onPress={onPress} style={({ pressed }) => ({
backgroundColor: colors.surfaceElevated,
borderRadius: 16,
borderWidth: 1,
borderColor: colors.border,
padding: 16,
marginBottom: 12,
opacity: pressed ? 0.95 : 1,
})}>
<View style={{ flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between' }}>
<View style={{ flex: 1 }}>
<Text style={{ fontWeight: '600', color: colors.foreground, fontSize: 16 }} numberOfLines={1}>
{deck.title}
</Text>
<View style={{ marginTop: 4, flexDirection: 'row', alignItems: 'center' }}>
<Icon name="card-outline" size={14} color={colors.mutedForeground} library="Ionicons" />
<Text style={{ marginLeft: 4, color: colors.mutedForeground, fontSize: 12 }}>
{deck.card_count || 0} Karten
</Text>
{deck.is_public && (
<>
<Text style={{ marginHorizontal: 8, color: colors.mutedForeground }}></Text>
<Icon name="globe-outline" size={14} color={colors.mutedForeground} library="Ionicons" />
<Text style={{ marginLeft: 4, color: colors.mutedForeground, fontSize: 12 }}>
Öffentlich
</Text>
</>
)}
</View>
</View>
<Icon name="chevron-forward" size={20} color={colors.mutedForeground} library="Ionicons" />
</View>
</Pressable>
);
}
if (isCompact) {
return (
<Pressable
onPress={onPress}
style={({ pressed }) => ({
backgroundColor: colors.surfaceElevated,
borderRadius: 16,
borderWidth: 1,
borderColor: colors.border,
padding: 16,
marginBottom: 12,
opacity: pressed ? 0.95 : 1,
})}
>
<View
style={{ flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between' }}
>
<View style={{ flex: 1 }}>
<Text
style={{ fontWeight: '600', color: colors.foreground, fontSize: 16 }}
numberOfLines={1}
>
{deck.title}
</Text>
<View style={{ marginTop: 4, flexDirection: 'row', alignItems: 'center' }}>
<Icon
name="card-outline"
size={14}
color={colors.mutedForeground}
library="Ionicons"
/>
<Text style={{ marginLeft: 4, color: colors.mutedForeground, fontSize: 12 }}>
{deck.card_count || 0} Karten
</Text>
{deck.is_public && (
<>
<Text style={{ marginHorizontal: 8, color: colors.mutedForeground }}></Text>
<Icon
name="globe-outline"
size={14}
color={colors.mutedForeground}
library="Ionicons"
/>
<Text style={{ marginLeft: 4, color: colors.mutedForeground, fontSize: 12 }}>
Öffentlich
</Text>
</>
)}
</View>
</View>
<Icon
name="chevron-forward"
size={20}
color={colors.mutedForeground}
library="Ionicons"
/>
</View>
</Pressable>
);
}
return (
<View style={{
backgroundColor: colors.surfaceElevated,
borderRadius: 16,
borderWidth: 1,
borderColor: colors.border,
shadowColor: '#000',
shadowOffset: { width: 0, height: 4 },
shadowOpacity: 0.15,
shadowRadius: 12,
elevation: 6,
overflow: 'hidden',
}}>
<Pressable onPress={onPress} style={({ pressed }) => ({ opacity: pressed ? 0.95 : 1 })}>
{deck.cover_image_url ? (
<Image source={{ uri: deck.cover_image_url }} style={{ height: 140, width: '100%', borderTopLeftRadius: 16, borderTopRightRadius: 16 }} resizeMode="cover" />
) : (
<View style={{
height: 140,
width: '100%',
alignItems: 'center',
justifyContent: 'center',
backgroundColor: `${colors.primary}15`,
borderTopLeftRadius: 16,
borderTopRightRadius: 16,
}}>
<Icon name="albums-outline" size={40} color={colors.primary} library="Ionicons" />
</View>
)}
return (
<View
style={{
backgroundColor: colors.surfaceElevated,
borderRadius: 16,
borderWidth: 1,
borderColor: colors.border,
shadowColor: '#000',
shadowOffset: { width: 0, height: 4 },
shadowOpacity: 0.15,
shadowRadius: 12,
elevation: 6,
overflow: 'hidden',
}}
>
<Pressable onPress={onPress} style={({ pressed }) => ({ opacity: pressed ? 0.95 : 1 })}>
{deck.cover_image_url ? (
<Image
source={{ uri: deck.cover_image_url }}
style={{
height: 140,
width: '100%',
borderTopLeftRadius: 16,
borderTopRightRadius: 16,
}}
resizeMode="cover"
/>
) : (
<View
style={{
height: 140,
width: '100%',
alignItems: 'center',
justifyContent: 'center',
backgroundColor: `${colors.primary}15`,
borderTopLeftRadius: 16,
borderTopRightRadius: 16,
}}
>
<Icon name="albums-outline" size={40} color={colors.primary} library="Ionicons" />
</View>
)}
<View style={{ padding: 16 }}>
<View style={{ flexDirection: 'row', alignItems: 'flex-start', justifyContent: 'space-between' }}>
<View style={{ flex: 1 }}>
<Text style={{ fontSize: 17, fontWeight: '600', color: colors.foreground, marginBottom: 4 }} numberOfLines={1}>
{deck.title}
</Text>
{deck.description && (
<Text style={{ color: colors.mutedForeground, fontSize: 14, lineHeight: 20 }} numberOfLines={2}>
{deck.description}
</Text>
)}
</View>
{isFavorite && (
<Icon
name="heart"
size={18}
color={colors.destructive}
style={{ marginLeft: 12, marginTop: 2 }}
library="Ionicons"
/>
)}
</View>
<View style={{ padding: 16 }}>
<View
style={{
flexDirection: 'row',
alignItems: 'flex-start',
justifyContent: 'space-between',
}}
>
<View style={{ flex: 1 }}>
<Text
style={{
fontSize: 17,
fontWeight: '600',
color: colors.foreground,
marginBottom: 4,
}}
numberOfLines={1}
>
{deck.title}
</Text>
{deck.description && (
<Text
style={{ color: colors.mutedForeground, fontSize: 14, lineHeight: 20 }}
numberOfLines={2}
>
{deck.description}
</Text>
)}
</View>
{isFavorite && (
<Icon
name="heart"
size={18}
color={colors.destructive}
style={{ marginLeft: 12, marginTop: 2 }}
library="Ionicons"
/>
)}
</View>
{showProgress && (
<View style={{ marginTop: 16 }}>
<View style={{ marginBottom: 6, flexDirection: 'row', justifyContent: 'space-between' }}>
<Text style={{ fontSize: 12, fontWeight: '500', color: colors.mutedForeground }}>
Fortschritt
</Text>
<Text style={{ fontSize: 12, fontWeight: '500', color: colors.mutedForeground }}>
0%
</Text>
</View>
<View style={{ height: 6, overflow: 'hidden', borderRadius: 3, backgroundColor: `${colors.muted}60` }}>
<View style={{ height: '100%', borderRadius: 3, backgroundColor: colors.primary, width: '0%' }} />
</View>
</View>
)}
</View>
</Pressable>
</View>
);
{showProgress && (
<View style={{ marginTop: 16 }}>
<View
style={{ marginBottom: 6, flexDirection: 'row', justifyContent: 'space-between' }}
>
<Text style={{ fontSize: 12, fontWeight: '500', color: colors.mutedForeground }}>
Fortschritt
</Text>
<Text style={{ fontSize: 12, fontWeight: '500', color: colors.mutedForeground }}>
0%
</Text>
</View>
<View
style={{
height: 6,
overflow: 'hidden',
borderRadius: 3,
backgroundColor: `${colors.muted}60`,
}}
>
<View
style={{
height: '100%',
borderRadius: 3,
backgroundColor: colors.primary,
width: '0%',
}}
/>
</View>
</View>
)}
</View>
</Pressable>
</View>
);
};

View file

@ -8,97 +8,139 @@ import { router } from 'expo-router';
import { useThemeColors } from '~/utils/themeUtils';
interface DeckProgressCardProps {
progress: DeckProgress;
progress: DeckProgress;
}
export const DeckProgressCard: React.FC<DeckProgressCardProps> = ({ progress }) => {
const colors = useThemeColors();
const getMasteryColor = (percentage: number) => {
if (percentage >= 80) return '#16a34a'; // green-600
if (percentage >= 60) return '#2563eb'; // blue-600
if (percentage >= 40) return '#ca8a04'; // yellow-600
if (percentage >= 20) return '#ea580c'; // orange-600
return colors.mutedForeground;
};
const colors = useThemeColors();
const getMasteryIcon = (percentage: number) => {
if (percentage >= 80) return 'trophy';
if (percentage >= 60) return 'medal';
if (percentage >= 40) return 'ribbon';
if (percentage >= 20) return 'school';
return 'book';
};
const getMasteryColor = (percentage: number) => {
if (percentage >= 80) return '#16a34a'; // green-600
if (percentage >= 60) return '#2563eb'; // blue-600
if (percentage >= 40) return '#ca8a04'; // yellow-600
if (percentage >= 20) return '#ea580c'; // orange-600
return colors.mutedForeground;
};
return (
<Pressable
onPress={() => router.push(`/deck/${progress.deck_id}`)}
style={({ pressed }) => pressed && { opacity: 0.7 }}>
<Card padding="md" variant="elevated">
<View style={{ flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between' }}>
<View style={{ flex: 1 }}>
<View style={{ flexDirection: 'row', alignItems: 'center' }}>
<Text style={{ flex: 1, fontSize: 16, fontWeight: '600', color: colors.foreground }} numberOfLines={1}>
{progress.deck_name}
</Text>
<Icon
name={getMasteryIcon(progress.completion_percentage)}
library="Ionicons"
size={20}
color={getMasteryColor(progress.completion_percentage)}
/>
</View>
const getMasteryIcon = (percentage: number) => {
if (percentage >= 80) return 'trophy';
if (percentage >= 60) return 'medal';
if (percentage >= 40) return 'ribbon';
if (percentage >= 20) return 'school';
return 'book';
};
{/* Progress Bar */}
<View style={{ marginTop: 8, height: 8, overflow: 'hidden', borderRadius: 9999, backgroundColor: colors.muted }}>
<View
style={{
height: '100%',
backgroundColor: '#3b82f6',
width: `${progress.completion_percentage}%`
}}
/>
</View>
return (
<Pressable
onPress={() => router.push(`/deck/${progress.deck_id}`)}
style={({ pressed }) => pressed && { opacity: 0.7 }}
>
<Card padding="md" variant="elevated">
<View
style={{ flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between' }}
>
<View style={{ flex: 1 }}>
<View style={{ flexDirection: 'row', alignItems: 'center' }}>
<Text
style={{ flex: 1, fontSize: 16, fontWeight: '600', color: colors.foreground }}
numberOfLines={1}
>
{progress.deck_name}
</Text>
<Icon
name={getMasteryIcon(progress.completion_percentage)}
library="Ionicons"
size={20}
color={getMasteryColor(progress.completion_percentage)}
/>
</View>
{/* Stats */}
<View style={{ marginTop: 8, flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between' }}>
<View style={{ flexDirection: 'row', alignItems: 'center', gap: 12 }}>
<View style={{ flexDirection: 'row', alignItems: 'center' }}>
<View style={{ height: 8, width: 8, borderRadius: 9999, backgroundColor: '#10b981' }} />
<Text style={{ marginLeft: 4, fontSize: 12, color: colors.mutedForeground }}>
{progress.mastered_cards} gemeistert
</Text>
</View>
<View style={{ flexDirection: 'row', alignItems: 'center' }}>
<View style={{ height: 8, width: 8, borderRadius: 9999, backgroundColor: '#eab308' }} />
<Text style={{ marginLeft: 4, fontSize: 12, color: colors.mutedForeground }}>
{progress.learning_cards} lernen
</Text>
</View>
<View style={{ flexDirection: 'row', alignItems: 'center' }}>
<View style={{ height: 8, width: 8, borderRadius: 9999, backgroundColor: colors.muted }} />
<Text style={{ marginLeft: 4, fontSize: 12, color: colors.mutedForeground }}>
{progress.new_cards} neu
</Text>
</View>
</View>
{/* Progress Bar */}
<View
style={{
marginTop: 8,
height: 8,
overflow: 'hidden',
borderRadius: 9999,
backgroundColor: colors.muted,
}}
>
<View
style={{
height: '100%',
backgroundColor: '#3b82f6',
width: `${progress.completion_percentage}%`,
}}
/>
</View>
<Text
style={{ fontSize: 14, fontWeight: 'bold', color: getMasteryColor(progress.completion_percentage) }}>
{progress.completion_percentage}%
</Text>
</View>
{/* Stats */}
<View
style={{
marginTop: 8,
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
}}
>
<View style={{ flexDirection: 'row', alignItems: 'center', gap: 12 }}>
<View style={{ flexDirection: 'row', alignItems: 'center' }}>
<View
style={{ height: 8, width: 8, borderRadius: 9999, backgroundColor: '#10b981' }}
/>
<Text style={{ marginLeft: 4, fontSize: 12, color: colors.mutedForeground }}>
{progress.mastered_cards} gemeistert
</Text>
</View>
<View style={{ flexDirection: 'row', alignItems: 'center' }}>
<View
style={{ height: 8, width: 8, borderRadius: 9999, backgroundColor: '#eab308' }}
/>
<Text style={{ marginLeft: 4, fontSize: 12, color: colors.mutedForeground }}>
{progress.learning_cards} lernen
</Text>
</View>
<View style={{ flexDirection: 'row', alignItems: 'center' }}>
<View
style={{
height: 8,
width: 8,
borderRadius: 9999,
backgroundColor: colors.muted,
}}
/>
<Text style={{ marginLeft: 4, fontSize: 12, color: colors.mutedForeground }}>
{progress.new_cards} neu
</Text>
</View>
</View>
{/* Ease Factor */}
<View style={{ marginTop: 4, flexDirection: 'row', alignItems: 'center' }}>
<Icon name="speedometer-outline" library="Ionicons" size={12} color={colors.mutedForeground} />
<Text style={{ marginLeft: 4, fontSize: 12, color: colors.mutedForeground }}>
Schwierigkeit: {progress.average_ease_factor.toFixed(1)}
</Text>
</View>
</View>
</View>
</Card>
</Pressable>
);
<Text
style={{
fontSize: 14,
fontWeight: 'bold',
color: getMasteryColor(progress.completion_percentage),
}}
>
{progress.completion_percentage}%
</Text>
</View>
{/* Ease Factor */}
<View style={{ marginTop: 4, flexDirection: 'row', alignItems: 'center' }}>
<Icon
name="speedometer-outline"
library="Ionicons"
size={12}
color={colors.mutedForeground}
/>
<Text style={{ marginLeft: 4, fontSize: 12, color: colors.mutedForeground }}>
Schwierigkeit: {progress.average_ease_factor.toFixed(1)}
</Text>
</View>
</View>
</View>
</Card>
</Pressable>
);
};

View file

@ -5,129 +5,129 @@ import { DailyProgress } from '../../store/progressStore';
import { useThemeColors } from '~/utils/themeUtils';
interface HeatmapCalendarProps {
data: Map<string, DailyProgress>;
data: Map<string, DailyProgress>;
}
export const HeatmapCalendar: React.FC<HeatmapCalendarProps> = ({ data }) => {
const colors = useThemeColors();
// Generate grid for last 12 weeks
const weeks = 12;
const daysPerWeek = 7;
const today = new Date();
const grid: (DailyProgress | null)[][] = [];
const colors = useThemeColors();
// Start from 12 weeks ago
const startDate = new Date(today);
startDate.setDate(today.getDate() - weeks * 7 + (7 - today.getDay()));
// Generate grid for last 12 weeks
const weeks = 12;
const daysPerWeek = 7;
const today = new Date();
const grid: (DailyProgress | null)[][] = [];
// Generate grid
for (let week = 0; week < weeks; week++) {
const weekData: (DailyProgress | null)[] = [];
// Start from 12 weeks ago
const startDate = new Date(today);
startDate.setDate(today.getDate() - weeks * 7 + (7 - today.getDay()));
for (let day = 0; day < daysPerWeek; day++) {
const currentDate = new Date(startDate);
currentDate.setDate(startDate.getDate() + week * 7 + day);
// Generate grid
for (let week = 0; week < weeks; week++) {
const weekData: (DailyProgress | null)[] = [];
if (currentDate <= today) {
const dateStr = currentDate.toISOString().split('T')[0];
weekData.push(data.get(dateStr) || null);
} else {
weekData.push(null);
}
}
for (let day = 0; day < daysPerWeek; day++) {
const currentDate = new Date(startDate);
currentDate.setDate(startDate.getDate() + week * 7 + day);
grid.push(weekData);
}
if (currentDate <= today) {
const dateStr = currentDate.toISOString().split('T')[0];
weekData.push(data.get(dateStr) || null);
} else {
weekData.push(null);
}
}
const getIntensityColor = (progress: DailyProgress | null) => {
if (!progress || progress.cards_studied === 0) return colors.muted;
if (progress.cards_studied <= 5) return '#bbf7d0'; // green-200
if (progress.cards_studied <= 15) return '#4ade80'; // green-400
if (progress.cards_studied <= 30) return '#22c55e'; // green-500
return '#16a34a'; // green-600
};
grid.push(weekData);
}
const weekDays = ['So', 'Mo', 'Di', 'Mi', 'Do', 'Fr', 'Sa'];
const months = [
'Jan',
'Feb',
'Mär',
'Apr',
'Mai',
'Jun',
'Jul',
'Aug',
'Sep',
'Okt',
'Nov',
'Dez',
];
const getIntensityColor = (progress: DailyProgress | null) => {
if (!progress || progress.cards_studied === 0) return colors.muted;
if (progress.cards_studied <= 5) return '#bbf7d0'; // green-200
if (progress.cards_studied <= 15) return '#4ade80'; // green-400
if (progress.cards_studied <= 30) return '#22c55e'; // green-500
return '#16a34a'; // green-600
};
// Calculate which months to show
const monthLabels: { month: string; week: number }[] = [];
let lastMonth = -1;
const weekDays = ['So', 'Mo', 'Di', 'Mi', 'Do', 'Fr', 'Sa'];
const months = [
'Jan',
'Feb',
'Mär',
'Apr',
'Mai',
'Jun',
'Jul',
'Aug',
'Sep',
'Okt',
'Nov',
'Dez',
];
for (let week = 0; week < weeks; week++) {
const weekStart = new Date(startDate);
weekStart.setDate(startDate.getDate() + week * 7);
const month = weekStart.getMonth();
// Calculate which months to show
const monthLabels: { month: string; week: number }[] = [];
let lastMonth = -1;
if (month !== lastMonth) {
monthLabels.push({ month: months[month], week });
lastMonth = month;
}
}
for (let week = 0; week < weeks; week++) {
const weekStart = new Date(startDate);
weekStart.setDate(startDate.getDate() + week * 7);
const month = weekStart.getMonth();
return (
<View>
{/* Month labels */}
<View style={{ marginBottom: 8, flexDirection: 'row' }}>
<View style={{ width: 32 }} />
<View style={{ flex: 1, flexDirection: 'row' }}>
{monthLabels.map((label, index) => (
<View key={index} style={{ flex: 1, marginLeft: label.week * 12 }}>
<Text variant="small" style={{ color: colors.mutedForeground }}>
{label.month}
</Text>
</View>
))}
</View>
</View>
if (month !== lastMonth) {
monthLabels.push({ month: months[month], week });
lastMonth = month;
}
}
{/* Calendar grid */}
<View style={{ flexDirection: 'row' }}>
{/* Week day labels */}
<View style={{ marginRight: 8 }}>
{weekDays.map((day, index) => (
<View key={index} style={{ height: 12, justifyContent: 'center', marginBottom: 2 }}>
<Text variant="small" style={{ color: colors.mutedForeground }}>
{index % 2 === 1 ? day : ''}
</Text>
</View>
))}
</View>
return (
<View>
{/* Month labels */}
<View style={{ marginBottom: 8, flexDirection: 'row' }}>
<View style={{ width: 32 }} />
<View style={{ flex: 1, flexDirection: 'row' }}>
{monthLabels.map((label, index) => (
<View key={index} style={{ flex: 1, marginLeft: label.week * 12 }}>
<Text variant="small" style={{ color: colors.mutedForeground }}>
{label.month}
</Text>
</View>
))}
</View>
</View>
{/* Heatmap grid */}
<View style={{ flex: 1, flexDirection: 'row' }}>
{grid.map((week, weekIndex) => (
<View key={weekIndex} style={{ marginRight: 4 }}>
{week.map((day, dayIndex) => (
<View
key={dayIndex}
style={{
height: 12,
width: 12,
borderRadius: 2,
backgroundColor: day === null ? 'transparent' : getIntensityColor(day),
marginBottom: 2
}}
/>
))}
</View>
))}
</View>
</View>
</View>
);
{/* Calendar grid */}
<View style={{ flexDirection: 'row' }}>
{/* Week day labels */}
<View style={{ marginRight: 8 }}>
{weekDays.map((day, index) => (
<View key={index} style={{ height: 12, justifyContent: 'center', marginBottom: 2 }}>
<Text variant="small" style={{ color: colors.mutedForeground }}>
{index % 2 === 1 ? day : ''}
</Text>
</View>
))}
</View>
{/* Heatmap grid */}
<View style={{ flex: 1, flexDirection: 'row' }}>
{grid.map((week, weekIndex) => (
<View key={weekIndex} style={{ marginRight: 4 }}>
{week.map((day, dayIndex) => (
<View
key={dayIndex}
style={{
height: 12,
width: 12,
borderRadius: 2,
backgroundColor: day === null ? 'transparent' : getIntensityColor(day),
marginBottom: 2,
}}
/>
))}
</View>
))}
</View>
</View>
</View>
);
};

View file

@ -5,140 +5,159 @@ import { useProgressStore } from '../../store/progressStore';
import { useThemeColors } from '~/utils/themeUtils';
interface ProgressChartProps {
type: 'accuracy' | 'cards' | 'time';
period: 'week' | 'month' | 'year';
type: 'accuracy' | 'cards' | 'time';
period: 'week' | 'month' | 'year';
}
export const ProgressChart: React.FC<ProgressChartProps> = ({ type, period }) => {
const { getChartData } = useProgressStore();
const data = getChartData(type);
const colors = useThemeColors();
const { getChartData } = useProgressStore();
const data = getChartData(type);
const colors = useThemeColors();
if (data.length === 0) {
return (
<View style={{ height: 192, alignItems: 'center', justifyContent: 'center' }}>
<Text style={{ color: colors.mutedForeground }}>Keine Daten verfügbar</Text>
</View>
);
}
if (data.length === 0) {
return (
<View style={{ height: 192, alignItems: 'center', justifyContent: 'center' }}>
<Text style={{ color: colors.mutedForeground }}>Keine Daten verfügbar</Text>
</View>
);
}
const getYLabel = () => {
switch (type) {
case 'accuracy':
return 'Genauigkeit (%)';
case 'cards':
return 'Karten';
case 'time':
return 'Minuten';
default:
return '';
}
};
const getYLabel = () => {
switch (type) {
case 'accuracy':
return 'Genauigkeit (%)';
case 'cards':
return 'Karten';
case 'time':
return 'Minuten';
default:
return '';
}
};
const getColor = () => {
switch (type) {
case 'accuracy':
return '#10B981'; // green
case 'cards':
return '#3B82F6'; // blue
case 'time':
return '#F59E0B'; // amber
default:
return '#6B7280';
}
};
const getColor = () => {
switch (type) {
case 'accuracy':
return '#10B981'; // green
case 'cards':
return '#3B82F6'; // blue
case 'time':
return '#F59E0B'; // amber
default:
return '#6B7280';
}
};
// Get max value for scaling
const maxValue = Math.max(...data.map((d) => d.value));
const minValue = Math.min(...data.map((d) => d.value));
// Get max value for scaling
const maxValue = Math.max(...data.map((d) => d.value));
const minValue = Math.min(...data.map((d) => d.value));
// Simple bar chart implementation
return (
<View>
<View style={{ marginBottom: 8, flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between' }}>
<Text variant="caption" style={{ fontWeight: '500', color: colors.foreground }}>
{getYLabel()}
</Text>
<View style={{ flexDirection: 'row', alignItems: 'center' }}>
<View style={{ height: 8, width: 8, borderRadius: 9999, backgroundColor: getColor() }} />
<Text variant="caption" style={{ marginLeft: 4, color: colors.mutedForeground }}>
Ø {Math.round(data.reduce((sum, d) => sum + d.value, 0) / data.length)}
{type === 'accuracy' ? '%' : ''}
</Text>
</View>
</View>
// Simple bar chart implementation
return (
<View>
<View
style={{
marginBottom: 8,
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
}}
>
<Text variant="caption" style={{ fontWeight: '500', color: colors.foreground }}>
{getYLabel()}
</Text>
<View style={{ flexDirection: 'row', alignItems: 'center' }}>
<View style={{ height: 8, width: 8, borderRadius: 9999, backgroundColor: getColor() }} />
<Text variant="caption" style={{ marginLeft: 4, color: colors.mutedForeground }}>
Ø {Math.round(data.reduce((sum, d) => sum + d.value, 0) / data.length)}
{type === 'accuracy' ? '%' : ''}
</Text>
</View>
</View>
{/* Simple Bar Chart */}
<View style={{
height: 192,
borderBottomWidth: 1,
borderBottomColor: colors.border,
borderLeftWidth: 1,
borderLeftColor: colors.border
}}>
<View style={{ flex: 1, flexDirection: 'row', alignItems: 'flex-end', justifyContent: 'space-between', paddingHorizontal: 4 }}>
{data.slice(-7).map((item, index) => {
const height = maxValue > 0 ? (item.value / maxValue) * 100 : 0;
return (
<View key={index} style={{ flex: 1, alignItems: 'center' }}>
<View
style={{
marginHorizontal: 2,
width: '100%',
borderTopLeftRadius: 4,
borderTopRightRadius: 4,
height: `${height}%`,
backgroundColor: getColor(),
opacity: 0.8,
}}
/>
<Text variant="small" style={{ marginTop: 4, color: colors.mutedForeground }}>
{item.label}
</Text>
</View>
);
})}
</View>
</View>
{/* Simple Bar Chart */}
<View
style={{
height: 192,
borderBottomWidth: 1,
borderBottomColor: colors.border,
borderLeftWidth: 1,
borderLeftColor: colors.border,
}}
>
<View
style={{
flex: 1,
flexDirection: 'row',
alignItems: 'flex-end',
justifyContent: 'space-between',
paddingHorizontal: 4,
}}
>
{data.slice(-7).map((item, index) => {
const height = maxValue > 0 ? (item.value / maxValue) * 100 : 0;
return (
<View key={index} style={{ flex: 1, alignItems: 'center' }}>
<View
style={{
marginHorizontal: 2,
width: '100%',
borderTopLeftRadius: 4,
borderTopRightRadius: 4,
height: `${height}%`,
backgroundColor: getColor(),
opacity: 0.8,
}}
/>
<Text variant="small" style={{ marginTop: 4, color: colors.mutedForeground }}>
{item.label}
</Text>
</View>
);
})}
</View>
</View>
{/* Summary Stats */}
<View style={{
marginTop: 8,
flexDirection: 'row',
justifyContent: 'space-around',
borderTopWidth: 1,
borderTopColor: colors.border,
paddingTop: 8
}}>
<View style={{ alignItems: 'center' }}>
<Text variant="small" style={{ color: colors.mutedForeground }}>
Min
</Text>
<Text variant="caption" style={{ fontWeight: '600', color: colors.foreground }}>
{Math.min(...data.map((d) => d.value))}
{type === 'accuracy' ? '%' : ''}
</Text>
</View>
<View style={{ alignItems: 'center' }}>
<Text variant="small" style={{ color: colors.mutedForeground }}>
Max
</Text>
<Text variant="caption" style={{ fontWeight: '600', color: colors.foreground }}>
{Math.max(...data.map((d) => d.value))}
{type === 'accuracy' ? '%' : ''}
</Text>
</View>
<View style={{ alignItems: 'center' }}>
<Text variant="small" style={{ color: colors.mutedForeground }}>
Gesamt
</Text>
<Text variant="caption" style={{ fontWeight: '600', color: colors.foreground }}>
{type === 'accuracy'
? `${Math.round(data.reduce((sum, d) => sum + d.value, 0) / data.length)}%`
: data.reduce((sum, d) => sum + d.value, 0)}
</Text>
</View>
</View>
</View>
);
{/* Summary Stats */}
<View
style={{
marginTop: 8,
flexDirection: 'row',
justifyContent: 'space-around',
borderTopWidth: 1,
borderTopColor: colors.border,
paddingTop: 8,
}}
>
<View style={{ alignItems: 'center' }}>
<Text variant="small" style={{ color: colors.mutedForeground }}>
Min
</Text>
<Text variant="caption" style={{ fontWeight: '600', color: colors.foreground }}>
{Math.min(...data.map((d) => d.value))}
{type === 'accuracy' ? '%' : ''}
</Text>
</View>
<View style={{ alignItems: 'center' }}>
<Text variant="small" style={{ color: colors.mutedForeground }}>
Max
</Text>
<Text variant="caption" style={{ fontWeight: '600', color: colors.foreground }}>
{Math.max(...data.map((d) => d.value))}
{type === 'accuracy' ? '%' : ''}
</Text>
</View>
<View style={{ alignItems: 'center' }}>
<Text variant="small" style={{ color: colors.mutedForeground }}>
Gesamt
</Text>
<Text variant="caption" style={{ fontWeight: '600', color: colors.foreground }}>
{type === 'accuracy'
? `${Math.round(data.reduce((sum, d) => sum + d.value, 0) / data.length)}%`
: data.reduce((sum, d) => sum + d.value, 0)}
</Text>
</View>
</View>
</View>
);
};

View file

@ -7,195 +7,207 @@ import { StreakInfo } from '../../store/progressStore';
import { useThemeColors } from '~/utils/themeUtils';
interface StreakCardProps {
streakInfo: StreakInfo | null;
streakInfo: StreakInfo | null;
}
export const StreakCard: React.FC<StreakCardProps> = ({ streakInfo }) => {
const colors = useThemeColors();
if (!streakInfo) {
return (
<Card padding="lg" variant="elevated">
<View style={{ flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between' }}>
<View>
<Text variant="caption" style={{ color: colors.mutedForeground }}>
Lernstreak
</Text>
<Text variant="h2" style={{ color: colors.foreground }}>
0 Tage
</Text>
</View>
<View style={{
height: 64,
width: 64,
alignItems: 'center',
justifyContent: 'center',
borderRadius: 32,
backgroundColor: colors.muted
}}>
<Text style={{ fontSize: 24 }}>💤</Text>
</View>
</View>
<Text variant="caption" style={{ marginTop: 8, color: colors.mutedForeground }}>
Starte jetzt deine erste Lernsession!
</Text>
</Card>
);
}
const colors = useThemeColors();
const getStreakEmoji = (streak: number) => {
if (streak === 0) return '💤';
if (streak < 3) return '✨';
if (streak < 7) return '🔥';
if (streak < 14) return '💪';
if (streak < 30) return '🚀';
if (streak < 60) return '⭐';
if (streak < 100) return '🏆';
return '👑';
};
if (!streakInfo) {
return (
<Card padding="lg" variant="elevated">
<View
style={{ flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between' }}
>
<View>
<Text variant="caption" style={{ color: colors.mutedForeground }}>
Lernstreak
</Text>
<Text variant="h2" style={{ color: colors.foreground }}>
0 Tage
</Text>
</View>
<View
style={{
height: 64,
width: 64,
alignItems: 'center',
justifyContent: 'center',
borderRadius: 32,
backgroundColor: colors.muted,
}}
>
<Text style={{ fontSize: 24 }}>💤</Text>
</View>
</View>
<Text variant="caption" style={{ marginTop: 8, color: colors.mutedForeground }}>
Starte jetzt deine erste Lernsession!
</Text>
</Card>
);
}
const getMotivationalMessage = (streak: number) => {
if (streak === 0) return 'Zeit wieder zu lernen!';
if (streak === 1) return 'Guter Start! Bleib dran!';
if (streak < 3) return 'Weiter so! Baue eine Gewohnheit auf!';
if (streak < 7) return 'Fantastisch! Fast eine Woche!';
if (streak < 14) return 'Beeindruckend! Du bist auf Kurs!';
if (streak < 30) return 'Unglaublich! Ein Monat ist in Sicht!';
if (streak < 60) return 'Meisterhaft! Du bist nicht zu stoppen!';
if (streak < 100) return 'Legendär! 100 Tage sind nah!';
return 'Unbesiegbar! Du bist eine Lernmaschine!';
};
const getStreakEmoji = (streak: number) => {
if (streak === 0) return '💤';
if (streak < 3) return '✨';
if (streak < 7) return '🔥';
if (streak < 14) return '💪';
if (streak < 30) return '🚀';
if (streak < 60) return '⭐';
if (streak < 100) return '🏆';
return '👑';
};
const daysSinceLastStudy = streakInfo.last_study_date
? Math.floor((new Date().getTime() - new Date(streakInfo.last_study_date).getTime()) / 86400000)
: 999;
const getMotivationalMessage = (streak: number) => {
if (streak === 0) return 'Zeit wieder zu lernen!';
if (streak === 1) return 'Guter Start! Bleib dran!';
if (streak < 3) return 'Weiter so! Baue eine Gewohnheit auf!';
if (streak < 7) return 'Fantastisch! Fast eine Woche!';
if (streak < 14) return 'Beeindruckend! Du bist auf Kurs!';
if (streak < 30) return 'Unglaublich! Ein Monat ist in Sicht!';
if (streak < 60) return 'Meisterhaft! Du bist nicht zu stoppen!';
if (streak < 100) return 'Legendär! 100 Tage sind nah!';
return 'Unbesiegbar! Du bist eine Lernmaschine!';
};
const isStreakActive = daysSinceLastStudy <= 1;
const daysSinceLastStudy = streakInfo.last_study_date
? Math.floor((new Date().getTime() - new Date(streakInfo.last_study_date).getTime()) / 86400000)
: 999;
return (
<Card padding="lg" variant="elevated">
<View style={{ flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between' }}>
<View style={{ flex: 1 }}>
<Text variant="caption" style={{ color: colors.mutedForeground }}>
Aktueller Streak
</Text>
<View style={{ flexDirection: 'row', alignItems: 'baseline' }}>
<Text
variant="h2"
style={{ color: isStreakActive ? '#ea580c' : colors.mutedForeground }}>
{streakInfo.current_streak}
</Text>
<Text variant="h4" style={{ marginLeft: 8, color: colors.mutedForeground }}>
{streakInfo.current_streak === 1 ? 'Tag' : 'Tage'}
</Text>
</View>
<Text variant="caption" style={{ marginTop: 4, color: colors.mutedForeground }}>
{getMotivationalMessage(streakInfo.current_streak)}
</Text>
</View>
const isStreakActive = daysSinceLastStudy <= 1;
<View style={{ alignItems: 'center' }}>
<View
style={{
height: 64,
width: 64,
alignItems: 'center',
justifyContent: 'center',
borderRadius: 32,
backgroundColor: isStreakActive ? '#fed7aa' : colors.muted
}}>
<Text style={{ fontSize: 30 }}>{getStreakEmoji(streakInfo.current_streak)}</Text>
</View>
{!isStreakActive && daysSinceLastStudy > 1 && (
<Text variant="small" style={{ marginTop: 4, color: '#ef4444' }}>
{daysSinceLastStudy}d inaktiv
</Text>
)}
</View>
</View>
return (
<Card padding="lg" variant="elevated">
<View style={{ flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between' }}>
<View style={{ flex: 1 }}>
<Text variant="caption" style={{ color: colors.mutedForeground }}>
Aktueller Streak
</Text>
<View style={{ flexDirection: 'row', alignItems: 'baseline' }}>
<Text
variant="h2"
style={{ color: isStreakActive ? '#ea580c' : colors.mutedForeground }}
>
{streakInfo.current_streak}
</Text>
<Text variant="h4" style={{ marginLeft: 8, color: colors.mutedForeground }}>
{streakInfo.current_streak === 1 ? 'Tag' : 'Tage'}
</Text>
</View>
<Text variant="caption" style={{ marginTop: 4, color: colors.mutedForeground }}>
{getMotivationalMessage(streakInfo.current_streak)}
</Text>
</View>
{/* Stats Row */}
<View style={{
marginTop: 16,
flexDirection: 'row',
justifyContent: 'space-around',
borderTopWidth: 1,
borderTopColor: colors.border,
paddingTop: 12
}}>
<View style={{ alignItems: 'center' }}>
<Text variant="h4" style={{ fontWeight: '600', color: colors.foreground }}>
{streakInfo.longest_streak}
</Text>
<Text variant="small" style={{ color: colors.mutedForeground }}>
Längster
</Text>
</View>
<View style={{ alignItems: 'center' }}>
<Text variant="h4" style={{ fontWeight: '600', color: colors.foreground }}>
{streakInfo.total_study_days}
</Text>
<Text variant="small" style={{ color: colors.mutedForeground }}>
Gesamt
</Text>
</View>
<View style={{ alignItems: 'center' }}>
<Text variant="h4" style={{ fontWeight: '600', color: colors.foreground }}>
{streakInfo.last_study_date
? new Date(streakInfo.last_study_date).toLocaleDateString('de-DE', {
day: 'numeric',
month: 'short',
})
: '-'}
</Text>
<Text variant="small" style={{ color: colors.mutedForeground }}>
Letztes Mal
</Text>
</View>
</View>
<View style={{ alignItems: 'center' }}>
<View
style={{
height: 64,
width: 64,
alignItems: 'center',
justifyContent: 'center',
borderRadius: 32,
backgroundColor: isStreakActive ? '#fed7aa' : colors.muted,
}}
>
<Text style={{ fontSize: 30 }}>{getStreakEmoji(streakInfo.current_streak)}</Text>
</View>
{!isStreakActive && daysSinceLastStudy > 1 && (
<Text variant="small" style={{ marginTop: 4, color: '#ef4444' }}>
{daysSinceLastStudy}d inaktiv
</Text>
)}
</View>
</View>
{/* Progress to next milestone */}
{streakInfo.current_streak > 0 && (
<View style={{ marginTop: 12 }}>
<View style={{ flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between' }}>
<Text variant="small" style={{ color: colors.mutedForeground }}>
Nächster Meilenstein
</Text>
<Text variant="small" style={{ fontWeight: '500', color: colors.foreground }}>
{streakInfo.current_streak < 7
? `7 Tage (noch ${7 - streakInfo.current_streak})`
: streakInfo.current_streak < 30
? `30 Tage (noch ${30 - streakInfo.current_streak})`
: streakInfo.current_streak < 100
? `100 Tage (noch ${100 - streakInfo.current_streak})`
: '365 Tage'}
</Text>
</View>
<View style={{
marginTop: 4,
height: 8,
overflow: 'hidden',
borderRadius: 9999,
backgroundColor: colors.muted
}}>
<View
style={{
height: '100%',
backgroundColor: '#fb923c',
width: `${
streakInfo.current_streak < 7
? (streakInfo.current_streak / 7) * 100
: streakInfo.current_streak < 30
? (streakInfo.current_streak / 30) * 100
: streakInfo.current_streak < 100
? (streakInfo.current_streak / 100) * 100
: 100
}%`,
}}
/>
</View>
</View>
)}
</Card>
);
{/* Stats Row */}
<View
style={{
marginTop: 16,
flexDirection: 'row',
justifyContent: 'space-around',
borderTopWidth: 1,
borderTopColor: colors.border,
paddingTop: 12,
}}
>
<View style={{ alignItems: 'center' }}>
<Text variant="h4" style={{ fontWeight: '600', color: colors.foreground }}>
{streakInfo.longest_streak}
</Text>
<Text variant="small" style={{ color: colors.mutedForeground }}>
Längster
</Text>
</View>
<View style={{ alignItems: 'center' }}>
<Text variant="h4" style={{ fontWeight: '600', color: colors.foreground }}>
{streakInfo.total_study_days}
</Text>
<Text variant="small" style={{ color: colors.mutedForeground }}>
Gesamt
</Text>
</View>
<View style={{ alignItems: 'center' }}>
<Text variant="h4" style={{ fontWeight: '600', color: colors.foreground }}>
{streakInfo.last_study_date
? new Date(streakInfo.last_study_date).toLocaleDateString('de-DE', {
day: 'numeric',
month: 'short',
})
: '-'}
</Text>
<Text variant="small" style={{ color: colors.mutedForeground }}>
Letztes Mal
</Text>
</View>
</View>
{/* Progress to next milestone */}
{streakInfo.current_streak > 0 && (
<View style={{ marginTop: 12 }}>
<View
style={{ flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between' }}
>
<Text variant="small" style={{ color: colors.mutedForeground }}>
Nächster Meilenstein
</Text>
<Text variant="small" style={{ fontWeight: '500', color: colors.foreground }}>
{streakInfo.current_streak < 7
? `7 Tage (noch ${7 - streakInfo.current_streak})`
: streakInfo.current_streak < 30
? `30 Tage (noch ${30 - streakInfo.current_streak})`
: streakInfo.current_streak < 100
? `100 Tage (noch ${100 - streakInfo.current_streak})`
: '365 Tage'}
</Text>
</View>
<View
style={{
marginTop: 4,
height: 8,
overflow: 'hidden',
borderRadius: 9999,
backgroundColor: colors.muted,
}}
>
<View
style={{
height: '100%',
backgroundColor: '#fb923c',
width: `${
streakInfo.current_streak < 7
? (streakInfo.current_streak / 7) * 100
: streakInfo.current_streak < 30
? (streakInfo.current_streak / 30) * 100
: streakInfo.current_streak < 100
? (streakInfo.current_streak / 100) * 100
: 100
}%`,
}}
/>
</View>
</View>
)}
</Card>
);
};

View file

@ -9,152 +9,197 @@ import { useThemeColors } from '~/utils/themeUtils';
export type StudyMode = 'all' | 'new' | 'review' | 'favorites' | 'random';
interface StudyModeSelectorProps {
visible: boolean;
onClose: () => void;
onSelectMode: (mode: StudyMode) => void;
cardStats?: {
total: number;
favorites: number;
new: number;
review: number;
};
visible: boolean;
onClose: () => void;
onSelectMode: (mode: StudyMode) => void;
cardStats?: {
total: number;
favorites: number;
new: number;
review: number;
};
}
interface StudyModeOption {
id: StudyMode;
name: string;
description: string;
icon: string;
getColor: (colors: ReturnType<typeof useThemeColors>) => string;
disabled?: boolean;
id: StudyMode;
name: string;
description: string;
icon: string;
getColor: (colors: ReturnType<typeof useThemeColors>) => string;
disabled?: boolean;
}
const getStudyModes = (colors: ReturnType<typeof useThemeColors>): StudyModeOption[] => [
{
id: 'all' as StudyMode,
name: 'Alle Karten',
description: 'Alle Karten der Reihe nach durchgehen',
icon: 'albums-outline',
getColor: (c) => c.primary,
},
{
id: 'random' as StudyMode,
name: 'Zufällig',
description: 'Karten in zufälliger Reihenfolge',
icon: 'shuffle-outline',
getColor: (c) => c.secondary,
},
{
id: 'favorites' as StudyMode,
name: 'Favoriten',
description: 'Nur als Favorit markierte Karten',
icon: 'heart-outline',
getColor: (c) => c.destructive,
},
{
id: 'new' as StudyMode,
name: 'Neue Karten',
description: 'Noch nicht gelernte Karten',
icon: 'sparkles-outline',
getColor: (c) => c.accent,
disabled: false,
},
{
id: 'review' as StudyMode,
name: 'Wiederholung',
description: 'Fällige Karten wiederholen',
icon: 'refresh-outline',
getColor: (c) => c.secondary,
disabled: false,
},
{
id: 'all' as StudyMode,
name: 'Alle Karten',
description: 'Alle Karten der Reihe nach durchgehen',
icon: 'albums-outline',
getColor: (c) => c.primary,
},
{
id: 'random' as StudyMode,
name: 'Zufällig',
description: 'Karten in zufälliger Reihenfolge',
icon: 'shuffle-outline',
getColor: (c) => c.secondary,
},
{
id: 'favorites' as StudyMode,
name: 'Favoriten',
description: 'Nur als Favorit markierte Karten',
icon: 'heart-outline',
getColor: (c) => c.destructive,
},
{
id: 'new' as StudyMode,
name: 'Neue Karten',
description: 'Noch nicht gelernte Karten',
icon: 'sparkles-outline',
getColor: (c) => c.accent,
disabled: false,
},
{
id: 'review' as StudyMode,
name: 'Wiederholung',
description: 'Fällige Karten wiederholen',
icon: 'refresh-outline',
getColor: (c) => c.secondary,
disabled: false,
},
];
export const StudyModeSelector: React.FC<StudyModeSelectorProps> = ({
visible,
onClose,
onSelectMode,
cardStats,
visible,
onClose,
onSelectMode,
cardStats,
}) => {
const colors = useThemeColors();
const studyModes = getStudyModes(colors);
return (
<Modal visible={visible} transparent animationType="slide" onRequestClose={onClose}>
<View style={{ flex: 1, justifyContent: 'flex-end', backgroundColor: 'rgba(0,0,0,0.5)' }}>
<View style={{ borderTopLeftRadius: 24, borderTopRightRadius: 24, backgroundColor: colors.surface, paddingBottom: 32, paddingTop: 24 }}>
<View style={{ marginBottom: 24, flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between', paddingHorizontal: 24 }}>
<Text style={{ fontSize: 20, fontWeight: 'bold', color: colors.foreground }}>Lernmodus wählen</Text>
<Pressable onPress={onClose} style={({ pressed }) => pressed && { opacity: 0.7 }}>
<Icon name="close" library="Ionicons" size={24} color={colors.foreground} />
</Pressable>
</View>
const colors = useThemeColors();
const studyModes = getStudyModes(colors);
<View style={{ paddingHorizontal: 24 }}>
{studyModes.map((mode) => (
<Pressable
key={mode.id}
onPress={() => {
if (!mode.disabled) {
onSelectMode(mode.id);
onClose();
}
}}
disabled={mode.disabled}
style={({ pressed }) => ({ opacity: pressed && !mode.disabled ? 0.7 : 1 })}>
<Card
variant={mode.disabled ? 'outlined' : 'elevated'}
padding="md"
style={{ marginBottom: 12, opacity: mode.disabled ? 0.5 : 1 }}>
<View style={{ flexDirection: 'row', alignItems: 'center' }}>
<View
style={{
height: 48,
width: 48,
backgroundColor: mode.disabled ? colors.muted : mode.getColor(colors),
marginRight: 16,
alignItems: 'center',
justifyContent: 'center',
borderRadius: 8,
}}>
<Icon name={mode.icon} library="Ionicons" size={24} color={mode.disabled ? colors.mutedForeground : "white"} />
</View>
<View style={{ flex: 1 }}>
<View style={{ flexDirection: 'row', alignItems: 'center' }}>
<Text style={{ fontSize: 16, fontWeight: '600', color: colors.foreground }}>{mode.name}</Text>
{mode.disabled && (
<View style={{ marginLeft: 8, borderRadius: 12, backgroundColor: colors.muted, paddingHorizontal: 8, paddingVertical: 2 }}>
<Text style={{ fontSize: 12, color: colors.mutedForeground }}>Bald verfügbar</Text>
</View>
)}
</View>
<Text style={{ marginTop: 4, fontSize: 14, color: colors.mutedForeground }}>{mode.description}</Text>
{cardStats && mode.id === 'all' && (
<Text style={{ marginTop: 4, fontSize: 12, color: colors.mutedForeground }}>
{cardStats.total} Karten verfügbar
</Text>
)}
{cardStats && mode.id === 'favorites' && (
<Text style={{ marginTop: 4, fontSize: 12, color: colors.mutedForeground }}>
{cardStats.favorites} Favoriten verfügbar
</Text>
)}
</View>
{!mode.disabled && (
<Icon name="chevron-forward" library="Ionicons" size={20} color={colors.mutedForeground} />
)}
</View>
</Card>
</Pressable>
))}
return (
<Modal visible={visible} transparent animationType="slide" onRequestClose={onClose}>
<View style={{ flex: 1, justifyContent: 'flex-end', backgroundColor: 'rgba(0,0,0,0.5)' }}>
<View
style={{
borderTopLeftRadius: 24,
borderTopRightRadius: 24,
backgroundColor: colors.surface,
paddingBottom: 32,
paddingTop: 24,
}}
>
<View
style={{
marginBottom: 24,
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
paddingHorizontal: 24,
}}
>
<Text style={{ fontSize: 20, fontWeight: 'bold', color: colors.foreground }}>
Lernmodus wählen
</Text>
<Pressable onPress={onClose} style={({ pressed }) => pressed && { opacity: 0.7 }}>
<Icon name="close" library="Ionicons" size={24} color={colors.foreground} />
</Pressable>
</View>
<View style={{ marginTop: 16 }}>
<Button onPress={onClose} variant="outline" fullWidth>
Abbrechen
</Button>
</View>
</View>
</View>
</View>
</Modal>
);
<View style={{ paddingHorizontal: 24 }}>
{studyModes.map((mode) => (
<Pressable
key={mode.id}
onPress={() => {
if (!mode.disabled) {
onSelectMode(mode.id);
onClose();
}
}}
disabled={mode.disabled}
style={({ pressed }) => ({ opacity: pressed && !mode.disabled ? 0.7 : 1 })}
>
<Card
variant={mode.disabled ? 'outlined' : 'elevated'}
padding="md"
style={{ marginBottom: 12, opacity: mode.disabled ? 0.5 : 1 }}
>
<View style={{ flexDirection: 'row', alignItems: 'center' }}>
<View
style={{
height: 48,
width: 48,
backgroundColor: mode.disabled ? colors.muted : mode.getColor(colors),
marginRight: 16,
alignItems: 'center',
justifyContent: 'center',
borderRadius: 8,
}}
>
<Icon
name={mode.icon}
library="Ionicons"
size={24}
color={mode.disabled ? colors.mutedForeground : 'white'}
/>
</View>
<View style={{ flex: 1 }}>
<View style={{ flexDirection: 'row', alignItems: 'center' }}>
<Text style={{ fontSize: 16, fontWeight: '600', color: colors.foreground }}>
{mode.name}
</Text>
{mode.disabled && (
<View
style={{
marginLeft: 8,
borderRadius: 12,
backgroundColor: colors.muted,
paddingHorizontal: 8,
paddingVertical: 2,
}}
>
<Text style={{ fontSize: 12, color: colors.mutedForeground }}>
Bald verfügbar
</Text>
</View>
)}
</View>
<Text style={{ marginTop: 4, fontSize: 14, color: colors.mutedForeground }}>
{mode.description}
</Text>
{cardStats && mode.id === 'all' && (
<Text style={{ marginTop: 4, fontSize: 12, color: colors.mutedForeground }}>
{cardStats.total} Karten verfügbar
</Text>
)}
{cardStats && mode.id === 'favorites' && (
<Text style={{ marginTop: 4, fontSize: 12, color: colors.mutedForeground }}>
{cardStats.favorites} Favoriten verfügbar
</Text>
)}
</View>
{!mode.disabled && (
<Icon
name="chevron-forward"
library="Ionicons"
size={20}
color={colors.mutedForeground}
/>
)}
</View>
</Card>
</Pressable>
))}
<View style={{ marginTop: 16 }}>
<Button onPress={onClose} variant="outline" fullWidth>
Abbrechen
</Button>
</View>
</View>
</View>
</View>
</Modal>
);
};

View file

@ -1,223 +1,235 @@
import React from 'react';
import { Pressable, ActivityIndicator, View, PressableProps, Platform, Animated, ViewStyle, TextStyle } from 'react-native';
import {
Pressable,
ActivityIndicator,
View,
PressableProps,
Platform,
Animated,
ViewStyle,
TextStyle,
} from 'react-native';
import { Text } from './Text';
import { cva, type VariantProps } from 'class-variance-authority';
import { useThemeColors } from '~/utils/themeUtils';
const buttonVariants = cva('flex-row items-center justify-center rounded-lg transition-all', {
variants: {
variant: {
primary: '',
secondary: '',
outline: 'border-2',
ghost: '',
danger: '',
},
size: {
sm: 'px-3 py-4',
md: 'px-4 py-5',
lg: 'px-6 py-6',
xl: 'px-8 py-7',
},
fullWidth: {
true: 'w-full',
false: '',
},
disabled: {
true: 'opacity-50',
false: '',
},
},
defaultVariants: {
variant: 'primary',
size: 'md',
fullWidth: false,
disabled: false,
},
variants: {
variant: {
primary: '',
secondary: '',
outline: 'border-2',
ghost: '',
danger: '',
},
size: {
sm: 'px-3 py-4',
md: 'px-4 py-5',
lg: 'px-6 py-6',
xl: 'px-8 py-7',
},
fullWidth: {
true: 'w-full',
false: '',
},
disabled: {
true: 'opacity-50',
false: '',
},
},
defaultVariants: {
variant: 'primary',
size: 'md',
fullWidth: false,
disabled: false,
},
});
const textVariants = cva('font-semibold text-center', {
variants: {
variant: {
primary: '',
secondary: '',
outline: '',
ghost: '',
danger: '',
},
size: {
sm: 'text-base',
md: 'text-lg',
lg: 'text-xl',
xl: 'text-2xl',
},
},
defaultVariants: {
variant: 'primary',
size: 'md',
},
variants: {
variant: {
primary: '',
secondary: '',
outline: '',
ghost: '',
danger: '',
},
size: {
sm: 'text-base',
md: 'text-lg',
lg: 'text-xl',
xl: 'text-2xl',
},
},
defaultVariants: {
variant: 'primary',
size: 'md',
},
});
export interface ButtonProps
extends VariantProps<typeof buttonVariants>,
Omit<PressableProps, 'style' | 'children'> {
children: React.ReactNode;
loading?: boolean;
leftIcon?: React.ReactNode;
rightIcon?: React.ReactNode;
className?: string;
extends VariantProps<typeof buttonVariants>,
Omit<PressableProps, 'style' | 'children'> {
children: React.ReactNode;
loading?: boolean;
leftIcon?: React.ReactNode;
rightIcon?: React.ReactNode;
className?: string;
}
export const Button = React.forwardRef<View, ButtonProps>(
(
{
className,
variant,
size,
fullWidth,
disabled,
loading,
leftIcon,
rightIcon,
children,
onPress,
...props
},
ref
) => {
const isDisabled = disabled || loading;
const scaleAnim = React.useRef(new Animated.Value(1)).current;
const colors = useThemeColors();
(
{
className,
variant,
size,
fullWidth,
disabled,
loading,
leftIcon,
rightIcon,
children,
onPress,
...props
},
ref
) => {
const isDisabled = disabled || loading;
const scaleAnim = React.useRef(new Animated.Value(1)).current;
const colors = useThemeColors();
const handlePressIn = () => {
if (Platform.OS === 'ios') {
Animated.spring(scaleAnim, {
toValue: 0.95,
useNativeDriver: true,
speed: 50,
bounciness: 4,
}).start();
}
};
const handlePressIn = () => {
if (Platform.OS === 'ios') {
Animated.spring(scaleAnim, {
toValue: 0.95,
useNativeDriver: true,
speed: 50,
bounciness: 4,
}).start();
}
};
const handlePressOut = () => {
if (Platform.OS === 'ios') {
Animated.spring(scaleAnim, {
toValue: 1,
useNativeDriver: true,
speed: 50,
bounciness: 4,
}).start();
}
};
const handlePressOut = () => {
if (Platform.OS === 'ios') {
Animated.spring(scaleAnim, {
toValue: 1,
useNativeDriver: true,
speed: 50,
bounciness: 4,
}).start();
}
};
// Get variant-specific colors
const getVariantColors = (): { backgroundColor: string; borderColor: string; textColor: string } => {
switch (variant) {
case 'primary':
return {
backgroundColor: colors.primary,
borderColor: colors.primary,
textColor: colors.primaryForeground,
};
case 'secondary':
return {
backgroundColor: colors.secondary,
borderColor: colors.secondary,
textColor: colors.secondaryForeground,
};
case 'outline':
return {
backgroundColor: 'transparent',
borderColor: colors.border,
textColor: colors.foreground,
};
case 'ghost':
return {
backgroundColor: 'transparent',
borderColor: 'transparent',
textColor: colors.foreground,
};
case 'danger':
return {
backgroundColor: colors.destructive,
borderColor: colors.destructive,
textColor: colors.destructiveForeground,
};
default:
return {
backgroundColor: colors.primary,
borderColor: colors.primary,
textColor: colors.primaryForeground,
};
}
};
// Get variant-specific colors
const getVariantColors = (): {
backgroundColor: string;
borderColor: string;
textColor: string;
} => {
switch (variant) {
case 'primary':
return {
backgroundColor: colors.primary,
borderColor: colors.primary,
textColor: colors.primaryForeground,
};
case 'secondary':
return {
backgroundColor: colors.secondary,
borderColor: colors.secondary,
textColor: colors.secondaryForeground,
};
case 'outline':
return {
backgroundColor: 'transparent',
borderColor: colors.border,
textColor: colors.foreground,
};
case 'ghost':
return {
backgroundColor: 'transparent',
borderColor: 'transparent',
textColor: colors.foreground,
};
case 'danger':
return {
backgroundColor: colors.destructive,
borderColor: colors.destructive,
textColor: colors.destructiveForeground,
};
default:
return {
backgroundColor: colors.primary,
borderColor: colors.primary,
textColor: colors.primaryForeground,
};
}
};
const variantColors = getVariantColors();
const variantColors = getVariantColors();
// Android ripple color based on variant
const getRippleColor = () => {
switch (variant) {
case 'primary':
case 'danger':
return 'rgba(255, 255, 255, 0.3)';
case 'secondary':
case 'outline':
case 'ghost':
default:
return 'rgba(0, 0, 0, 0.1)';
}
};
// Android ripple color based on variant
const getRippleColor = () => {
switch (variant) {
case 'primary':
case 'danger':
return 'rgba(255, 255, 255, 0.3)';
case 'secondary':
case 'outline':
case 'ghost':
default:
return 'rgba(0, 0, 0, 0.1)';
}
};
return (
<Animated.View style={{ transform: [{ scale: scaleAnim }] }}>
<Pressable
ref={ref}
className={buttonVariants({
variant,
size,
fullWidth,
disabled: isDisabled,
className,
})}
disabled={isDisabled}
onPress={onPress}
onPressIn={handlePressIn}
onPressOut={handlePressOut}
android_ripple={{
color: getRippleColor(),
borderless: false,
}}
style={({ pressed }) => ({
backgroundColor: variantColors.backgroundColor,
borderColor: variantColors.borderColor,
borderWidth: variant === 'outline' ? 2 : 1,
...(Platform.OS === 'ios' ? { opacity: pressed ? 0.85 : 1 } : {}),
})}
{...props}>
{loading ? (
<ActivityIndicator
size="small"
color={variantColors.textColor}
/>
) : (
<>
{leftIcon && <View className="mr-2">{leftIcon}</View>}
<Text
className={textVariants({
variant,
size,
})}
style={{ color: variantColors.textColor }}>
{children}
</Text>
{rightIcon && <View className="ml-2">{rightIcon}</View>}
</>
)}
</Pressable>
</Animated.View>
);
}
return (
<Animated.View style={{ transform: [{ scale: scaleAnim }] }}>
<Pressable
ref={ref}
className={buttonVariants({
variant,
size,
fullWidth,
disabled: isDisabled,
className,
})}
disabled={isDisabled}
onPress={onPress}
onPressIn={handlePressIn}
onPressOut={handlePressOut}
android_ripple={{
color: getRippleColor(),
borderless: false,
}}
style={({ pressed }) => ({
backgroundColor: variantColors.backgroundColor,
borderColor: variantColors.borderColor,
borderWidth: variant === 'outline' ? 2 : 1,
...(Platform.OS === 'ios' ? { opacity: pressed ? 0.85 : 1 } : {}),
})}
{...props}
>
{loading ? (
<ActivityIndicator size="small" color={variantColors.textColor} />
) : (
<>
{leftIcon && <View className="mr-2">{leftIcon}</View>}
<Text
className={textVariants({
variant,
size,
})}
style={{ color: variantColors.textColor }}
>
{children}
</Text>
{rightIcon && <View className="ml-2">{rightIcon}</View>}
</>
)}
</Pressable>
</Animated.View>
);
}
);
Button.displayName = 'Button';

View file

@ -3,92 +3,93 @@ import { View, Pressable, ViewProps, ViewStyle } from 'react-native';
import { useThemeColors } from '~/utils/themeUtils';
interface CardProps extends ViewProps {
children: React.ReactNode;
onPress?: () => void;
variant?: 'default' | 'outlined' | 'elevated';
padding?: 'none' | 'sm' | 'md' | 'lg';
className?: string;
style?: ViewStyle;
children: React.ReactNode;
onPress?: () => void;
variant?: 'default' | 'outlined' | 'elevated';
padding?: 'none' | 'sm' | 'md' | 'lg';
className?: string;
style?: ViewStyle;
}
export const Card: React.FC<CardProps> = ({
children,
onPress,
variant = 'default',
padding = 'md',
className = '',
style,
...props
children,
onPress,
variant = 'default',
padding = 'md',
className = '',
style,
...props
}) => {
const colors = useThemeColors();
const paddingValues = {
none: 0,
sm: 8,
md: 16,
lg: 24,
};
const colors = useThemeColors();
const getVariantStyles = (): ViewStyle => {
switch (variant) {
case 'outlined':
return {
backgroundColor: colors.surfaceElevated,
borderWidth: 1,
borderColor: colors.border,
};
case 'elevated':
return {
backgroundColor: colors.surface,
shadowColor: '#000',
shadowOffset: { width: 0, height: 4 },
shadowOpacity: 0.15,
shadowRadius: 12,
elevation: 6,
borderWidth: 1.5,
borderColor: colors.border,
};
case 'default':
default:
return {
backgroundColor: colors.surfaceElevated,
shadowColor: '#000',
shadowOffset: { width: 0, height: 2 },
shadowOpacity: 0.10,
shadowRadius: 6,
elevation: 4,
borderWidth: 1,
borderColor: colors.border,
};
}
};
const paddingValues = {
none: 0,
sm: 8,
md: 16,
lg: 24,
};
const containerStyle: ViewStyle = {
borderRadius: 16,
overflow: 'hidden',
padding: paddingValues[padding],
...getVariantStyles(),
...style,
};
const getVariantStyles = (): ViewStyle => {
switch (variant) {
case 'outlined':
return {
backgroundColor: colors.surfaceElevated,
borderWidth: 1,
borderColor: colors.border,
};
case 'elevated':
return {
backgroundColor: colors.surface,
shadowColor: '#000',
shadowOffset: { width: 0, height: 4 },
shadowOpacity: 0.15,
shadowRadius: 12,
elevation: 6,
borderWidth: 1.5,
borderColor: colors.border,
};
case 'default':
default:
return {
backgroundColor: colors.surfaceElevated,
shadowColor: '#000',
shadowOffset: { width: 0, height: 2 },
shadowOpacity: 0.1,
shadowRadius: 6,
elevation: 4,
borderWidth: 1,
borderColor: colors.border,
};
}
};
if (onPress) {
return (
<Pressable
onPress={onPress}
style={({ pressed }) => ({
...containerStyle,
opacity: pressed ? 0.95 : 1,
transform: pressed ? [{ scale: 0.98 }] : [{ scale: 1 }],
})}
{...props}>
{children}
</Pressable>
);
}
const containerStyle: ViewStyle = {
borderRadius: 16,
overflow: 'hidden',
padding: paddingValues[padding],
...getVariantStyles(),
...style,
};
return (
<View style={containerStyle} {...props}>
{children}
</View>
);
if (onPress) {
return (
<Pressable
onPress={onPress}
style={({ pressed }) => ({
...containerStyle,
opacity: pressed ? 0.95 : 1,
transform: pressed ? [{ scale: 0.98 }] : [{ scale: 1 }],
})}
{...props}
>
{children}
</Pressable>
);
}
return (
<View style={containerStyle} {...props}>
{children}
</View>
);
};

View file

@ -5,98 +5,104 @@ import { Icon } from './Icon';
import { useThemeColors } from '~/utils/themeUtils';
export type FilterOption = {
id: string;
label: string;
icon?: string;
iconLibrary?: 'Ionicons' | 'FontAwesome' | 'MaterialIcons' | 'Feather';
count?: number;
id: string;
label: string;
icon?: string;
iconLibrary?: 'Ionicons' | 'FontAwesome' | 'MaterialIcons' | 'Feather';
count?: number;
};
interface FilterBarProps {
options: FilterOption[];
activeFilter: string;
onFilterChange: (filterId: string) => void;
options: FilterOption[];
activeFilter: string;
onFilterChange: (filterId: string) => void;
}
export const FilterBar: React.FC<FilterBarProps> = ({ options, activeFilter, onFilterChange }) => {
const colors = useThemeColors();
const colors = useThemeColors();
return (
<View
style={{
backgroundColor: colors.background,
borderTopWidth: 1,
borderTopColor: colors.border,
shadowColor: '#000',
shadowOffset: { width: 0, height: -2 },
shadowOpacity: 0.05,
shadowRadius: 3,
elevation: 5,
}}>
<ScrollView
horizontal
showsHorizontalScrollIndicator={false}
contentContainerStyle={{
paddingHorizontal: 16,
paddingVertical: 12,
}}>
{options.map((option) => {
const isActive = activeFilter === option.id;
return (
<View
style={{
backgroundColor: colors.background,
borderTopWidth: 1,
borderTopColor: colors.border,
shadowColor: '#000',
shadowOffset: { width: 0, height: -2 },
shadowOpacity: 0.05,
shadowRadius: 3,
elevation: 5,
}}
>
<ScrollView
horizontal
showsHorizontalScrollIndicator={false}
contentContainerStyle={{
paddingHorizontal: 16,
paddingVertical: 12,
}}
>
{options.map((option) => {
const isActive = activeFilter === option.id;
return (
<Pressable
key={option.id}
onPress={() => onFilterChange(option.id)}
style={({ pressed }) => ({
flexDirection: 'row',
alignItems: 'center',
paddingHorizontal: 16,
paddingVertical: 8,
borderRadius: 20,
backgroundColor: isActive ? colors.primary : colors.muted,
opacity: pressed ? 0.7 : 1,
marginRight: 8,
})}>
{option.icon && (
<Icon
name={option.icon}
size={16}
library={option.iconLibrary || 'Ionicons'}
color={isActive ? 'white' : colors.mutedForeground}
style={{ marginRight: 6 }}
/>
)}
<Text
style={{
color: isActive ? 'white' : colors.mutedForeground,
fontSize: 14,
fontWeight: '600',
}}>
{option.label}
</Text>
{option.count !== undefined && (
<View
style={{
backgroundColor: isActive ? 'rgba(255,255,255,0.2)' : colors.background,
paddingHorizontal: 6,
paddingVertical: 2,
borderRadius: 10,
marginLeft: 6,
}}>
<Text
style={{
color: isActive ? 'white' : colors.mutedForeground,
fontSize: 12,
fontWeight: '600',
}}>
{option.count}
</Text>
</View>
)}
</Pressable>
);
})}
</ScrollView>
</View>
);
return (
<Pressable
key={option.id}
onPress={() => onFilterChange(option.id)}
style={({ pressed }) => ({
flexDirection: 'row',
alignItems: 'center',
paddingHorizontal: 16,
paddingVertical: 8,
borderRadius: 20,
backgroundColor: isActive ? colors.primary : colors.muted,
opacity: pressed ? 0.7 : 1,
marginRight: 8,
})}
>
{option.icon && (
<Icon
name={option.icon}
size={16}
library={option.iconLibrary || 'Ionicons'}
color={isActive ? 'white' : colors.mutedForeground}
style={{ marginRight: 6 }}
/>
)}
<Text
style={{
color: isActive ? 'white' : colors.mutedForeground,
fontSize: 14,
fontWeight: '600',
}}
>
{option.label}
</Text>
{option.count !== undefined && (
<View
style={{
backgroundColor: isActive ? 'rgba(255,255,255,0.2)' : colors.background,
paddingHorizontal: 6,
paddingVertical: 2,
borderRadius: 10,
marginLeft: 6,
}}
>
<Text
style={{
color: isActive ? 'white' : colors.mutedForeground,
fontSize: 12,
fontWeight: '600',
}}
>
{option.count}
</Text>
</View>
)}
</Pressable>
);
})}
</ScrollView>
</View>
);
};

View file

@ -4,56 +4,57 @@ import { Icon } from './Icon';
import { useThemeColors } from '~/utils/themeUtils';
interface FloatingActionButtonProps {
icon: string;
iconLibrary?: 'Ionicons' | 'FontAwesome' | 'MaterialIcons' | 'Feather';
onPress: () => void;
size?: number;
icon: string;
iconLibrary?: 'Ionicons' | 'FontAwesome' | 'MaterialIcons' | 'Feather';
onPress: () => void;
size?: number;
}
export const FloatingActionButton: React.FC<FloatingActionButtonProps> = ({
icon,
iconLibrary = 'Ionicons',
onPress,
size = 56,
icon,
iconLibrary = 'Ionicons',
onPress,
size = 56,
}) => {
const colors = useThemeColors();
const iconSize = size === 56 ? 24 : size === 48 ? 20 : 28;
const colors = useThemeColors();
const iconSize = size === 56 ? 24 : size === 48 ? 20 : 28;
return (
<Pressable
onPress={onPress}
style={({ pressed }) => [
styles.container,
{
width: size,
height: size,
borderRadius: size / 2,
backgroundColor: colors.primary,
opacity: pressed ? 0.8 : 1,
transform: [{ scale: pressed ? 0.95 : 1 }],
},
]}>
<Icon name={icon} size={iconSize} color={colors.primaryForeground} library={iconLibrary} />
</Pressable>
);
return (
<Pressable
onPress={onPress}
style={({ pressed }) => [
styles.container,
{
width: size,
height: size,
borderRadius: size / 2,
backgroundColor: colors.primary,
opacity: pressed ? 0.8 : 1,
transform: [{ scale: pressed ? 0.95 : 1 }],
},
]}
>
<Icon name={icon} size={iconSize} color={colors.primaryForeground} library={iconLibrary} />
</Pressable>
);
};
const styles = StyleSheet.create({
container: {
alignItems: 'center',
justifyContent: 'center',
shadowColor: '#000',
shadowOffset: {
width: 0,
height: 4,
},
shadowOpacity: 0.3,
shadowRadius: 8,
elevation: 8,
...Platform.select({
web: {
cursor: 'pointer',
},
}),
},
container: {
alignItems: 'center',
justifyContent: 'center',
shadowColor: '#000',
shadowOffset: {
width: 0,
height: 4,
},
shadowOpacity: 0.3,
shadowRadius: 8,
elevation: 8,
...Platform.select({
web: {
cursor: 'pointer',
},
}),
},
});

View file

@ -5,36 +5,36 @@ import { StyleProp, ViewStyle } from 'react-native';
type IconLibrary = 'FontAwesome' | 'Ionicons' | 'MaterialIcons' | 'Feather';
interface IconProps {
name: string;
size?: number;
color?: string;
library?: IconLibrary;
className?: string;
style?: StyleProp<ViewStyle>;
name: string;
size?: number;
color?: string;
library?: IconLibrary;
className?: string;
style?: StyleProp<ViewStyle>;
}
export const Icon: React.FC<IconProps> = ({
name,
size = 24,
color,
library = 'FontAwesome',
...props
name,
size = 24,
color,
library = 'FontAwesome',
...props
}) => {
const iconProps = {
size,
color,
...props,
};
const iconProps = {
size,
color,
...props,
};
switch (library) {
case 'Ionicons':
return <Ionicons name={name as any} {...iconProps} />;
case 'MaterialIcons':
return <MaterialIcons name={name as any} {...iconProps} />;
case 'Feather':
return <Feather name={name as any} {...iconProps} />;
case 'FontAwesome':
default:
return <FontAwesome name={name as any} {...iconProps} />;
}
switch (library) {
case 'Ionicons':
return <Ionicons name={name as any} {...iconProps} />;
case 'MaterialIcons':
return <MaterialIcons name={name as any} {...iconProps} />;
case 'Feather':
return <Feather name={name as any} {...iconProps} />;
case 'FontAwesome':
default:
return <FontAwesome name={name as any} {...iconProps} />;
}
};

View file

@ -3,106 +3,110 @@ import { TextInput, View, Text, Pressable, TextInputProps } from 'react-native';
import { Icon } from './Icon';
export interface InputProps extends TextInputProps {
label?: string;
error?: string;
leftIcon?: string;
rightIcon?: string;
onLeftIconPress?: () => void;
onRightIconPress?: () => void;
containerClassName?: string;
inputClassName?: string;
type?: 'text' | 'email' | 'password';
label?: string;
error?: string;
leftIcon?: string;
rightIcon?: string;
onLeftIconPress?: () => void;
onRightIconPress?: () => void;
containerClassName?: string;
inputClassName?: string;
type?: 'text' | 'email' | 'password';
}
export const Input = React.forwardRef<TextInput, InputProps>(
(
{
label,
error,
leftIcon,
rightIcon,
onLeftIconPress,
onRightIconPress,
containerClassName = '',
inputClassName = '',
type = 'text',
secureTextEntry,
...props
},
ref
) => {
const [showPassword, setShowPassword] = useState(false);
const isPassword = type === 'password';
(
{
label,
error,
leftIcon,
rightIcon,
onLeftIconPress,
onRightIconPress,
containerClassName = '',
inputClassName = '',
type = 'text',
secureTextEntry,
...props
},
ref
) => {
const [showPassword, setShowPassword] = useState(false);
const isPassword = type === 'password';
const getKeyboardType = () => {
if (type === 'email') return 'email-address';
return props.keyboardType || 'default';
};
const getKeyboardType = () => {
if (type === 'email') return 'email-address';
return props.keyboardType || 'default';
};
const getAutoCapitalize = () => {
if (type === 'email') return 'none';
return props.autoCapitalize || 'sentences';
};
const getAutoCapitalize = () => {
if (type === 'email') return 'none';
return props.autoCapitalize || 'sentences';
};
return (
<View className={`mb-4 ${containerClassName}`}>
{label && <Text className="mb-1.5 text-sm font-medium text-gray-700">{label}</Text>}
return (
<View className={`mb-4 ${containerClassName}`}>
{label && <Text className="mb-1.5 text-sm font-medium text-gray-700">{label}</Text>}
<View
className={`
<View
className={`
flex-row items-center
rounded-lg border px-3 py-2
${error ? 'border-red-500' : 'border-gray-300'}
${props.editable === false ? 'bg-gray-100' : 'bg-white'}
`}>
{leftIcon && (
<Pressable
onPress={onLeftIconPress}
disabled={!onLeftIconPress}
style={[{ marginRight: 8 }, ({ pressed }) => pressed && { opacity: 0.7 }]}>
<Icon name={leftIcon} size={20} color="#6B7280" library="Ionicons" />
</Pressable>
)}
`}
>
{leftIcon && (
<Pressable
onPress={onLeftIconPress}
disabled={!onLeftIconPress}
style={[{ marginRight: 8 }, ({ pressed }) => pressed && { opacity: 0.7 }]}
>
<Icon name={leftIcon} size={20} color="#6B7280" library="Ionicons" />
</Pressable>
)}
<TextInput
ref={ref}
className={`flex-1 text-base text-gray-900 ${inputClassName}`}
placeholderTextColor="#9CA3AF"
keyboardType={getKeyboardType()}
autoCapitalize={getAutoCapitalize()}
secureTextEntry={isPassword && !showPassword}
{...props}
/>
<TextInput
ref={ref}
className={`flex-1 text-base text-gray-900 ${inputClassName}`}
placeholderTextColor="#9CA3AF"
keyboardType={getKeyboardType()}
autoCapitalize={getAutoCapitalize()}
secureTextEntry={isPassword && !showPassword}
{...props}
/>
{isPassword && (
<Pressable
onPress={() => setShowPassword(!showPassword)}
className="p-1"
style={({ pressed }) => pressed && { opacity: 0.7 }}>
<Icon
name={showPassword ? 'eye-outline' : 'eye-off-outline'}
size={20}
color="#6B7280"
library="Ionicons"
/>
</Pressable>
)}
{isPassword && (
<Pressable
onPress={() => setShowPassword(!showPassword)}
className="p-1"
style={({ pressed }) => pressed && { opacity: 0.7 }}
>
<Icon
name={showPassword ? 'eye-outline' : 'eye-off-outline'}
size={20}
color="#6B7280"
library="Ionicons"
/>
</Pressable>
)}
{rightIcon && !isPassword && (
<Pressable
onPress={onRightIconPress}
disabled={!onRightIconPress}
className="p-1"
style={({ pressed }) => pressed && { opacity: 0.7 }}>
<Icon name={rightIcon} size={20} color="#6B7280" library="Ionicons" />
</Pressable>
)}
</View>
{rightIcon && !isPassword && (
<Pressable
onPress={onRightIconPress}
disabled={!onRightIconPress}
className="p-1"
style={({ pressed }) => pressed && { opacity: 0.7 }}
>
<Icon name={rightIcon} size={20} color="#6B7280" library="Ionicons" />
</Pressable>
)}
</View>
{error && <Text className="mt-1 text-sm text-red-500">{error}</Text>}
</View>
);
}
{error && <Text className="mt-1 text-sm text-red-500">{error}</Text>}
</View>
);
}
);
Input.displayName = 'Input';

View file

@ -6,36 +6,38 @@ import { useSafeAreaInsets } from 'react-native-safe-area-context';
import { spacing } from '~/utils/spacing';
interface PageHeaderProps {
title: string;
withHorizontalPadding?: boolean;
title: string;
withHorizontalPadding?: boolean;
}
export function PageHeader({ title, withHorizontalPadding = true }: PageHeaderProps) {
const colors = useThemeColors();
const insets = useSafeAreaInsets();
const colors = useThemeColors();
const insets = useSafeAreaInsets();
return (
<View
style={{
paddingTop: Platform.OS === 'ios' ? insets.top + spacing.lg : spacing.lg,
paddingHorizontal: withHorizontalPadding ? spacing.container.horizontal : 0,
paddingBottom: spacing.lg,
backgroundColor: colors.background,
borderBottomWidth: 1,
borderBottomColor: colors.border,
}}>
<View style={{ paddingHorizontal: withHorizontalPadding ? 0 : spacing.container.horizontal }}>
<Text
style={{
fontSize: 28,
fontWeight: '700',
color: colors.foreground,
letterSpacing: 0.3,
lineHeight: 34,
}}>
{title}
</Text>
</View>
</View>
);
return (
<View
style={{
paddingTop: Platform.OS === 'ios' ? insets.top + spacing.lg : spacing.lg,
paddingHorizontal: withHorizontalPadding ? spacing.container.horizontal : 0,
paddingBottom: spacing.lg,
backgroundColor: colors.background,
borderBottomWidth: 1,
borderBottomColor: colors.border,
}}
>
<View style={{ paddingHorizontal: withHorizontalPadding ? 0 : spacing.container.horizontal }}>
<Text
style={{
fontSize: 28,
fontWeight: '700',
color: colors.foreground,
letterSpacing: 0.3,
lineHeight: 34,
}}
>
{title}
</Text>
</View>
</View>
);
}

View file

@ -5,79 +5,84 @@ import { Icon } from './Icon';
import { useThemeColors } from '~/utils/themeUtils';
interface SettingsItemProps {
title: string;
description?: string;
icon?: string;
onPress?: () => void;
rightElement?: React.ReactNode;
isLast?: boolean;
title: string;
description?: string;
icon?: string;
onPress?: () => void;
rightElement?: React.ReactNode;
isLast?: boolean;
}
export const SettingsItem: React.FC<SettingsItemProps> = ({
title,
description,
icon,
onPress,
rightElement,
isLast = false,
title,
description,
icon,
onPress,
rightElement,
isLast = false,
}) => {
const colors = useThemeColors();
const colors = useThemeColors();
const containerStyle: ViewStyle = {
flexDirection: 'row',
alignItems: 'center',
paddingHorizontal: 16,
paddingVertical: 12,
borderBottomWidth: isLast ? 0 : 1,
borderBottomColor: colors.border,
};
const containerStyle: ViewStyle = {
flexDirection: 'row',
alignItems: 'center',
paddingHorizontal: 16,
paddingVertical: 12,
borderBottomWidth: isLast ? 0 : 1,
borderBottomColor: colors.border,
};
const renderContent = () => (
<View style={{ flexDirection: 'row', alignItems: 'center', flex: 1 }}>
{icon && (
<View
style={{
marginRight: 12,
height: 40,
width: 40,
alignItems: 'center',
justifyContent: 'center',
borderRadius: 20,
backgroundColor: colors.muted,
}}>
<Icon name={icon} size={20} color={colors.primary} library="Ionicons" />
</View>
)}
<View style={{ flex: 1, justifyContent: 'center' }}>
<Text style={{ fontSize: 16, fontWeight: '500', color: colors.foreground }}>
{title}
</Text>
{description && (
<Text style={{ marginTop: 2, fontSize: 14, color: colors.mutedForeground }}>
{description}
</Text>
)}
</View>
<View style={{ marginLeft: 12 }}>
{rightElement || (
<Icon name="chevron-forward-outline" size={20} color={colors.mutedForeground} library="Ionicons" />
)}
</View>
</View>
);
const renderContent = () => (
<View style={{ flexDirection: 'row', alignItems: 'center', flex: 1 }}>
{icon && (
<View
style={{
marginRight: 12,
height: 40,
width: 40,
alignItems: 'center',
justifyContent: 'center',
borderRadius: 20,
backgroundColor: colors.muted,
}}
>
<Icon name={icon} size={20} color={colors.primary} library="Ionicons" />
</View>
)}
<View style={{ flex: 1, justifyContent: 'center' }}>
<Text style={{ fontSize: 16, fontWeight: '500', color: colors.foreground }}>{title}</Text>
{description && (
<Text style={{ marginTop: 2, fontSize: 14, color: colors.mutedForeground }}>
{description}
</Text>
)}
</View>
<View style={{ marginLeft: 12 }}>
{rightElement || (
<Icon
name="chevron-forward-outline"
size={20}
color={colors.mutedForeground}
library="Ionicons"
/>
)}
</View>
</View>
);
if (onPress) {
return (
<Pressable
onPress={onPress}
style={({ pressed }) => ({
...containerStyle,
opacity: pressed ? 0.7 : 1,
})}>
{renderContent()}
</Pressable>
);
}
if (onPress) {
return (
<Pressable
onPress={onPress}
style={({ pressed }) => ({
...containerStyle,
opacity: pressed ? 0.7 : 1,
})}
>
{renderContent()}
</Pressable>
);
}
return <View style={containerStyle}>{renderContent()}</View>;
};
return <View style={containerStyle}>{renderContent()}</View>;
};

View file

@ -6,29 +6,32 @@ import { useThemeColors } from '~/utils/themeUtils';
import { spacing } from '~/utils/spacing';
interface SettingsSectionProps {
title: string;
children: React.ReactNode;
noPadding?: boolean;
title: string;
children: React.ReactNode;
noPadding?: boolean;
}
export const SettingsSection: React.FC<SettingsSectionProps> = ({ title, children, noPadding = false }) => {
const colors = useThemeColors();
export const SettingsSection: React.FC<SettingsSectionProps> = ({
title,
children,
noPadding = false,
}) => {
const colors = useThemeColors();
return (
<Card padding="none" variant="elevated" style={{ marginBottom: spacing.section }}>
<View
style={{
borderBottomWidth: noPadding ? 0 : 1,
borderBottomColor: colors.border,
paddingHorizontal: spacing.lg,
paddingTop: spacing.content.small,
paddingBottom: noPadding ? 0 : spacing.content.small,
}}>
<Text style={{ fontSize: 18, fontWeight: '600', color: colors.foreground }}>
{title}
</Text>
</View>
{children}
</Card>
);
};
return (
<Card padding="none" variant="elevated" style={{ marginBottom: spacing.section }}>
<View
style={{
borderBottomWidth: noPadding ? 0 : 1,
borderBottomColor: colors.border,
paddingHorizontal: spacing.lg,
paddingTop: spacing.content.small,
paddingBottom: noPadding ? 0 : spacing.content.small,
}}
>
<Text style={{ fontSize: 18, fontWeight: '600', color: colors.foreground }}>{title}</Text>
</View>
{children}
</Card>
);
};

View file

@ -3,24 +3,24 @@ import { Switch as RNSwitch, SwitchProps } from 'react-native';
import { useTheme } from '../ThemeProvider';
export interface CustomSwitchProps extends Omit<SwitchProps, 'trackColor' | 'thumbColor'> {
value: boolean;
onValueChange: (value: boolean) => void;
value: boolean;
onValueChange: (value: boolean) => void;
}
export function Switch({ value, onValueChange, ...props }: CustomSwitchProps) {
const { colors } = useTheme();
const { colors } = useTheme();
return (
<RNSwitch
value={value}
onValueChange={onValueChange}
trackColor={{
false: colors.muted,
true: colors.primary,
}}
thumbColor={value ? colors.background : colors.mutedForeground}
ios_backgroundColor={colors.muted}
{...props}
/>
);
return (
<RNSwitch
value={value}
onValueChange={onValueChange}
trackColor={{
false: colors.muted,
true: colors.primary,
}}
thumbColor={value ? colors.background : colors.mutedForeground}
ios_backgroundColor={colors.muted}
{...props}
/>
);
}

View file

@ -4,43 +4,43 @@ import { Text as RNText, TextProps as RNTextProps } from 'react-native';
type TextVariant = 'h1' | 'h2' | 'h3' | 'h4' | 'body' | 'caption' | 'small';
interface TextProps extends RNTextProps {
variant?: TextVariant;
className?: string;
variant?: TextVariant;
className?: string;
}
const getVariantClasses = (variant: TextVariant): string => {
switch (variant) {
case 'h1':
return 'text-4xl font-bold';
case 'h2':
return 'text-3xl font-bold';
case 'h3':
return 'text-2xl font-semibold';
case 'h4':
return 'text-xl font-semibold';
case 'body':
return 'text-base';
case 'caption':
return 'text-sm';
case 'small':
return 'text-xs';
default:
return 'text-base';
}
switch (variant) {
case 'h1':
return 'text-4xl font-bold';
case 'h2':
return 'text-3xl font-bold';
case 'h3':
return 'text-2xl font-semibold';
case 'h4':
return 'text-xl font-semibold';
case 'body':
return 'text-base';
case 'caption':
return 'text-sm';
case 'small':
return 'text-xs';
default:
return 'text-base';
}
};
export const Text: React.FC<TextProps> = ({
variant = 'body',
className = '',
children,
...props
variant = 'body',
className = '',
children,
...props
}) => {
const variantClasses = getVariantClasses(variant);
const combinedClassName = `${variantClasses} ${className}`.trim();
const variantClasses = getVariantClasses(variant);
const combinedClassName = `${variantClasses} ${className}`.trim();
return (
<RNText className={combinedClassName} {...props}>
{children}
</RNText>
);
return (
<RNText className={combinedClassName} {...props}>
{children}
</RNText>
);
};

View file

@ -5,30 +5,30 @@ import { useThemeStore, useIsDark, useCurrentColors, useRootClassName } from '~/
import { themes } from '~/themes';
export function ThemeDebug() {
const { theme, mode } = useThemeStore();
const isDark = useIsDark();
const currentColors = useCurrentColors();
const rootClassName = useRootClassName();
const { theme, mode } = useThemeStore();
const isDark = useIsDark();
const currentColors = useCurrentColors();
const rootClassName = useRootClassName();
// Fallback to default theme if currentColors is undefined
const safeColors = currentColors || themes.default.light;
// Fallback to default theme if currentColors is undefined
const safeColors = currentColors || themes.default.light;
return (
<View className="m-2 rounded-lg border border-border bg-surface p-4">
<Text className="mb-2 text-lg font-bold text-foreground">Theme Debug</Text>
<Text className="text-foreground">Theme: {theme}</Text>
<Text className="text-foreground">Mode: {mode}</Text>
<Text className="text-foreground">Is Dark: {isDark ? 'true' : 'false'}</Text>
<Text className="text-foreground">Root Class: {rootClassName}</Text>
<Text className="text-foreground">Primary Color: {safeColors.primary}</Text>
<Text className="text-foreground">Colors undefined: {currentColors ? 'no' : 'yes'}</Text>
<View className="mt-2 rounded bg-primary p-2">
<Text className="text-primary-foreground">Primary Background Test</Text>
</View>
{/* Test with inline styles */}
<View className="mt-2 rounded p-2" style={{ backgroundColor: `rgb(${safeColors.primary})` }}>
<Text style={{ color: `rgb(${safeColors.primaryForeground})` }}>Inline Style Test</Text>
</View>
</View>
);
return (
<View className="m-2 rounded-lg border border-border bg-surface p-4">
<Text className="mb-2 text-lg font-bold text-foreground">Theme Debug</Text>
<Text className="text-foreground">Theme: {theme}</Text>
<Text className="text-foreground">Mode: {mode}</Text>
<Text className="text-foreground">Is Dark: {isDark ? 'true' : 'false'}</Text>
<Text className="text-foreground">Root Class: {rootClassName}</Text>
<Text className="text-foreground">Primary Color: {safeColors.primary}</Text>
<Text className="text-foreground">Colors undefined: {currentColors ? 'no' : 'yes'}</Text>
<View className="mt-2 rounded bg-primary p-2">
<Text className="text-primary-foreground">Primary Background Test</Text>
</View>
{/* Test with inline styles */}
<View className="mt-2 rounded p-2" style={{ backgroundColor: `rgb(${safeColors.primary})` }}>
<Text style={{ color: `rgb(${safeColors.primaryForeground})` }}>Inline Style Test</Text>
</View>
</View>
);
}

View file

@ -9,172 +9,193 @@ import { useThemeColors } from '~/utils/themeUtils';
import { spacing } from '~/utils/spacing';
export function ThemeSwitcher() {
const { theme, mode, setTheme, setMode } = useTheme();
const colors = useThemeColors();
const { theme, mode, setTheme, setMode } = useTheme();
const colors = useThemeColors();
const modeOptions: { value: ThemeMode; label: string; icon: string }[] = [
{ value: 'light', label: 'Hell', icon: 'sunny' },
{ value: 'dark', label: 'Dunkel', icon: 'moon' },
{ value: 'system', label: 'System', icon: 'phone-portrait' },
];
const modeOptions: { value: ThemeMode; label: string; icon: string }[] = [
{ value: 'light', label: 'Hell', icon: 'sunny' },
{ value: 'dark', label: 'Dunkel', icon: 'moon' },
{ value: 'system', label: 'System', icon: 'phone-portrait' },
];
return (
<View style={{ padding: spacing.lg }}>
{/* Theme Selection */}
<View style={{ marginBottom: spacing.content.title }}>
<Text style={{
fontSize: 14,
fontWeight: '600',
color: colors.mutedForeground,
marginBottom: spacing.content.small,
textTransform: 'uppercase',
letterSpacing: 0.5
}}>
Farbschema
</Text>
<View style={{ gap: spacing.content.small }}>
{themeList.map((themeItem) => {
const isSelected = theme === themeItem.name;
const themeColors = mode === 'dark' || (mode === 'system' && colors.background === 'rgb(3, 7, 18)')
? themes[themeItem.name as ThemeName].dark
: themes[themeItem.name as ThemeName].light;
return (
<View style={{ padding: spacing.lg }}>
{/* Theme Selection */}
<View style={{ marginBottom: spacing.content.title }}>
<Text
style={{
fontSize: 14,
fontWeight: '600',
color: colors.mutedForeground,
marginBottom: spacing.content.small,
textTransform: 'uppercase',
letterSpacing: 0.5,
}}
>
Farbschema
</Text>
<View style={{ gap: spacing.content.small }}>
{themeList.map((themeItem) => {
const isSelected = theme === themeItem.name;
const themeColors =
mode === 'dark' || (mode === 'system' && colors.background === 'rgb(3, 7, 18)')
? themes[themeItem.name as ThemeName].dark
: themes[themeItem.name as ThemeName].light;
return (
<Pressable
key={themeItem.name}
onPress={() => setTheme(themeItem.name as ThemeName)}
style={({ pressed }) => ({
flexDirection: 'row',
alignItems: 'center',
padding: spacing.content.small,
borderRadius: 12,
borderWidth: 2,
borderColor: isSelected ? colors.primary : colors.border,
backgroundColor: isSelected ? `${colors.primary}10` : colors.surface,
opacity: pressed ? 0.7 : 1,
})}>
{/* Theme Preview Colors */}
<View style={{ flexDirection: 'row', gap: 4, marginRight: spacing.content.small }}>
<View style={{
width: 24,
height: 24,
borderRadius: 6,
backgroundColor: `rgb(${themeColors.primary})`,
borderWidth: 1,
borderColor: colors.border,
}} />
<View style={{
width: 24,
height: 24,
borderRadius: 6,
backgroundColor: `rgb(${themeColors.accent})`,
borderWidth: 1,
borderColor: colors.border,
}} />
<View style={{
width: 24,
height: 24,
borderRadius: 6,
backgroundColor: `rgb(${themeColors.surface})`,
borderWidth: 1,
borderColor: colors.border,
}} />
</View>
return (
<Pressable
key={themeItem.name}
onPress={() => setTheme(themeItem.name as ThemeName)}
style={({ pressed }) => ({
flexDirection: 'row',
alignItems: 'center',
padding: spacing.content.small,
borderRadius: 12,
borderWidth: 2,
borderColor: isSelected ? colors.primary : colors.border,
backgroundColor: isSelected ? `${colors.primary}10` : colors.surface,
opacity: pressed ? 0.7 : 1,
})}
>
{/* Theme Preview Colors */}
<View style={{ flexDirection: 'row', gap: 4, marginRight: spacing.content.small }}>
<View
style={{
width: 24,
height: 24,
borderRadius: 6,
backgroundColor: `rgb(${themeColors.primary})`,
borderWidth: 1,
borderColor: colors.border,
}}
/>
<View
style={{
width: 24,
height: 24,
borderRadius: 6,
backgroundColor: `rgb(${themeColors.accent})`,
borderWidth: 1,
borderColor: colors.border,
}}
/>
<View
style={{
width: 24,
height: 24,
borderRadius: 6,
backgroundColor: `rgb(${themeColors.surface})`,
borderWidth: 1,
borderColor: colors.border,
}}
/>
</View>
{/* Theme Info */}
<View style={{ flex: 1 }}>
<Text style={{
fontSize: 16,
fontWeight: '600',
color: colors.foreground,
marginBottom: 2
}}>
{themeItem.displayName}
</Text>
<Text style={{
fontSize: 13,
color: colors.mutedForeground
}}>
{themeItem.description}
</Text>
</View>
{/* Theme Info */}
<View style={{ flex: 1 }}>
<Text
style={{
fontSize: 16,
fontWeight: '600',
color: colors.foreground,
marginBottom: 2,
}}
>
{themeItem.displayName}
</Text>
<Text
style={{
fontSize: 13,
color: colors.mutedForeground,
}}
>
{themeItem.description}
</Text>
</View>
{/* Selection Indicator */}
{isSelected && (
<Icon
name="checkmark-circle"
size={24}
color={colors.primary}
library="Ionicons"
/>
)}
</Pressable>
);
})}
</View>
</View>
{/* Selection Indicator */}
{isSelected && (
<Icon
name="checkmark-circle"
size={24}
color={colors.primary}
library="Ionicons"
/>
)}
</Pressable>
);
})}
</View>
</View>
{/* Mode Selection */}
<View>
<Text style={{
fontSize: 14,
fontWeight: '600',
color: colors.mutedForeground,
marginBottom: spacing.content.small,
textTransform: 'uppercase',
letterSpacing: 0.5
}}>
Helligkeit
</Text>
<View style={{
flexDirection: 'row',
backgroundColor: colors.muted,
borderRadius: 10,
padding: 4,
gap: 4
}}>
{modeOptions.map((option) => {
const isSelected = mode === option.value;
return (
<Pressable
key={option.value}
onPress={() => setMode(option.value)}
style={({ pressed }) => ({
flex: 1,
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
paddingVertical: spacing.content.small,
borderRadius: 8,
backgroundColor: isSelected ? colors.surface : 'transparent',
opacity: pressed ? 0.7 : 1,
...(isSelected && {
shadowColor: '#000',
shadowOffset: { width: 0, height: 1 },
shadowOpacity: 0.1,
shadowRadius: 2,
elevation: 2,
}),
})}>
<Icon
name={option.icon}
size={18}
color={isSelected ? colors.primary : colors.mutedForeground}
library="Ionicons"
style={{ marginRight: 6 }}
/>
<Text style={{
fontSize: 14,
fontWeight: isSelected ? '600' : '400',
color: isSelected ? colors.foreground : colors.mutedForeground
}}>
{option.label}
</Text>
</Pressable>
);
})}
</View>
</View>
</View>
);
{/* Mode Selection */}
<View>
<Text
style={{
fontSize: 14,
fontWeight: '600',
color: colors.mutedForeground,
marginBottom: spacing.content.small,
textTransform: 'uppercase',
letterSpacing: 0.5,
}}
>
Helligkeit
</Text>
<View
style={{
flexDirection: 'row',
backgroundColor: colors.muted,
borderRadius: 10,
padding: 4,
gap: 4,
}}
>
{modeOptions.map((option) => {
const isSelected = mode === option.value;
return (
<Pressable
key={option.value}
onPress={() => setMode(option.value)}
style={({ pressed }) => ({
flex: 1,
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
paddingVertical: spacing.content.small,
borderRadius: 8,
backgroundColor: isSelected ? colors.surface : 'transparent',
opacity: pressed ? 0.7 : 1,
...(isSelected && {
shadowColor: '#000',
shadowOffset: { width: 0, height: 1 },
shadowOpacity: 0.1,
shadowRadius: 2,
elevation: 2,
}),
})}
>
<Icon
name={option.icon}
size={18}
color={isSelected ? colors.primary : colors.mutedForeground}
library="Ionicons"
style={{ marginRight: 6 }}
/>
<Text
style={{
fontSize: 14,
fontWeight: isSelected ? '600' : '400',
color: isSelected ? colors.foreground : colors.mutedForeground,
}}
>
{option.label}
</Text>
</Pressable>
);
})}
</View>
</View>
</View>
);
}