mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-27 19:37:42 +02:00
refactor: restructure
monorepo with apps/ and services/ directories
This commit is contained in:
parent
25824ed0ac
commit
ff80aeec1f
4062 changed files with 2592 additions and 1278 deletions
29
apps/manadeck/apps/mobile/components/EditScreenInfo.tsx
Normal file
29
apps/manadeck/apps/mobile/components/EditScreenInfo.tsx
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
import { Text, View } from 'react-native';
|
||||
|
||||
export const EditScreenInfo = ({ path }: { path: string }) => {
|
||||
const title = 'Open up the code for this screen:';
|
||||
const description =
|
||||
'Change any of the text, save the file, and your app will automatically update.';
|
||||
|
||||
return (
|
||||
<View>
|
||||
<View className={styles.getStartedContainer}>
|
||||
<Text className={styles.getStartedText}>{title}</Text>
|
||||
<View className={styles.codeHighlightContainer + styles.homeScreenFilename}>
|
||||
<Text>{path}</Text>
|
||||
</View>
|
||||
<Text className={styles.getStartedText}>{description}</Text>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
const styles = {
|
||||
codeHighlightContainer: `rounded-md px-1`,
|
||||
getStartedContainer: `items-center mx-12`,
|
||||
getStartedText: `text-lg leading-6 text-center`,
|
||||
helpContainer: `items-center mx-5 mt-4`,
|
||||
helpLink: `py-4`,
|
||||
helpLinkText: `text-center`,
|
||||
homeScreenFilename: `my-2`,
|
||||
};
|
||||
78
apps/manadeck/apps/mobile/components/ErrorBoundary.tsx
Normal file
78
apps/manadeck/apps/mobile/components/ErrorBoundary.tsx
Normal file
|
|
@ -0,0 +1,78 @@
|
|||
import React, { Component, ErrorInfo, ReactNode } from 'react';
|
||||
import { View, Text, Pressable, ScrollView } from 'react-native';
|
||||
import * as Updates from 'expo-updates';
|
||||
|
||||
interface Props {
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
interface State {
|
||||
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 };
|
||||
}
|
||||
|
||||
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 });
|
||||
}
|
||||
|
||||
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>
|
||||
|
||||
<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>
|
||||
)}
|
||||
|
||||
<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;
|
||||
}
|
||||
}
|
||||
33
apps/manadeck/apps/mobile/components/HeaderButton.tsx
Normal file
33
apps/manadeck/apps/mobile/components/HeaderButton.tsx
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
import { forwardRef } from 'react';
|
||||
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>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
HeaderButton.displayName = 'HeaderButton';
|
||||
|
||||
export const styles = StyleSheet.create({
|
||||
headerRight: {
|
||||
marginRight: 15,
|
||||
},
|
||||
});
|
||||
|
|
@ -0,0 +1,191 @@
|
|||
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;
|
||||
}
|
||||
|
||||
/**
|
||||
* Modal shown when user has insufficient mana credits
|
||||
*/
|
||||
export function InsufficientCreditsModal({
|
||||
visible,
|
||||
requiredCredits,
|
||||
availableCredits,
|
||||
operation = 'this operation',
|
||||
onClose,
|
||||
onPurchase,
|
||||
}: InsufficientCreditsModalProps) {
|
||||
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>
|
||||
|
||||
{/* 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>
|
||||
|
||||
{/* 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>
|
||||
);
|
||||
}
|
||||
|
||||
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',
|
||||
},
|
||||
});
|
||||
25
apps/manadeck/apps/mobile/components/ScreenContent.tsx
Normal file
25
apps/manadeck/apps/mobile/components/ScreenContent.tsx
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
import { Text, View } from 'react-native';
|
||||
|
||||
import { EditScreenInfo } from './EditScreenInfo';
|
||||
|
||||
type ScreenContentProps = {
|
||||
title: string;
|
||||
path: string;
|
||||
children?: React.ReactNode;
|
||||
};
|
||||
|
||||
export const ScreenContent = ({ title, path, children }: ScreenContentProps) => {
|
||||
return (
|
||||
<View className={styles.container}>
|
||||
<Text className={styles.title}>{title}</Text>
|
||||
<View className={styles.separator} />
|
||||
<EditScreenInfo path={path} />
|
||||
{children}
|
||||
</View>
|
||||
);
|
||||
};
|
||||
const styles = {
|
||||
container: `items-center flex-1 justify-center`,
|
||||
separator: `h-[1px] my-7 w-4/5 bg-gray-200`,
|
||||
title: `text-xl font-bold`,
|
||||
};
|
||||
13
apps/manadeck/apps/mobile/components/TabBarIcon.tsx
Normal file
13
apps/manadeck/apps/mobile/components/TabBarIcon.tsx
Normal file
|
|
@ -0,0 +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]"
|
||||
/>
|
||||
);
|
||||
};
|
||||
27
apps/manadeck/apps/mobile/components/ThemeProvider.tsx
Normal file
27
apps/manadeck/apps/mobile/components/ThemeProvider.tsx
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
import React from 'react';
|
||||
import { ThemeStoreProvider, useThemeStore } from '~/store/themeStore';
|
||||
import { ThemeContextType } from '~/types/theme';
|
||||
|
||||
interface ThemeProviderProps {
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
// This wrapper component provides the theme store context
|
||||
export function ThemeProvider({ children }: ThemeProviderProps) {
|
||||
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();
|
||||
|
||||
return {
|
||||
theme,
|
||||
mode,
|
||||
isDark,
|
||||
colors,
|
||||
setTheme,
|
||||
setMode,
|
||||
toggleMode,
|
||||
};
|
||||
}
|
||||
47
apps/manadeck/apps/mobile/components/ThemeWrapper.tsx
Normal file
47
apps/manadeck/apps/mobile/components/ThemeWrapper.tsx
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
import React, { useEffect, useState } from 'react';
|
||||
import { View, Platform } from 'react-native';
|
||||
import { useRootClassName } from '~/store/themeStore';
|
||||
|
||||
interface ThemeWrapperProps {
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
export function ThemeWrapper({ children }: ThemeWrapperProps) {
|
||||
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');
|
||||
|
||||
// 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 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>
|
||||
);
|
||||
}
|
||||
207
apps/manadeck/apps/mobile/components/ai/AudioRecorder.tsx
Normal file
207
apps/manadeck/apps/mobile/components/ai/AudioRecorder.tsx
Normal file
|
|
@ -0,0 +1,207 @@
|
|||
import React, { useState, useEffect, useRef } from 'react';
|
||||
import { View, Pressable, Animated } from 'react-native';
|
||||
import { Text } from '../ui/Text';
|
||||
import { Icon } from '../ui/Icon';
|
||||
import { Audio } from 'expo-av';
|
||||
import { useAIStore } from '../../store/aiStore';
|
||||
import { Card } from '../ui/Card';
|
||||
|
||||
interface AudioRecorderProps {
|
||||
onRecordingComplete?: (audioUri: string) => void;
|
||||
onTranscriptionComplete?: (text: string) => void;
|
||||
}
|
||||
|
||||
export const AudioRecorder: React.FC<AudioRecorderProps> = ({
|
||||
onRecordingComplete,
|
||||
onTranscriptionComplete,
|
||||
}) => {
|
||||
const { audioRecording, startRecording, stopRecording, generateCardsFromAudio } = useAIStore();
|
||||
const [recordingDuration, setRecordingDuration] = useState(0);
|
||||
const [isProcessing, setIsProcessing] = useState(false);
|
||||
const pulseAnim = useRef(new Animated.Value(1)).current;
|
||||
const durationInterval = useRef<NodeJS.Timeout | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (audioRecording.isRecording) {
|
||||
// Start pulse animation
|
||||
Animated.loop(
|
||||
Animated.sequence([
|
||||
Animated.timing(pulseAnim, {
|
||||
toValue: 1.2,
|
||||
duration: 500,
|
||||
useNativeDriver: true,
|
||||
}),
|
||||
Animated.timing(pulseAnim, {
|
||||
toValue: 1,
|
||||
duration: 500,
|
||||
useNativeDriver: true,
|
||||
}),
|
||||
])
|
||||
).start();
|
||||
|
||||
// Start duration counter
|
||||
durationInterval.current = setInterval(() => {
|
||||
setRecordingDuration((prev) => prev + 1);
|
||||
}, 1000);
|
||||
} else {
|
||||
// Stop animations and reset
|
||||
pulseAnim.stopAnimation();
|
||||
pulseAnim.setValue(1);
|
||||
|
||||
if (durationInterval.current) {
|
||||
clearInterval(durationInterval.current);
|
||||
durationInterval.current = null;
|
||||
}
|
||||
}
|
||||
|
||||
return () => {
|
||||
if (durationInterval.current) {
|
||||
clearInterval(durationInterval.current);
|
||||
}
|
||||
};
|
||||
}, [audioRecording.isRecording, pulseAnim]);
|
||||
|
||||
const handleStartRecording = async () => {
|
||||
try {
|
||||
setRecordingDuration(0);
|
||||
await startRecording();
|
||||
} catch (error) {
|
||||
console.error('Error starting recording:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleStopRecording = async () => {
|
||||
try {
|
||||
setIsProcessing(true);
|
||||
const uri = await stopRecording();
|
||||
setRecordingDuration(0);
|
||||
|
||||
if (onRecordingComplete) {
|
||||
onRecordingComplete(uri);
|
||||
}
|
||||
|
||||
// Generate cards from audio
|
||||
if (onTranscriptionComplete) {
|
||||
try {
|
||||
const cards = await generateCardsFromAudio(uri);
|
||||
// Extract text from first card for transcription callback
|
||||
const transcribedText = cards.length > 0 ? JSON.stringify(cards[0].content) : '';
|
||||
onTranscriptionComplete(transcribedText);
|
||||
} catch (error) {
|
||||
console.error('Error transcribing audio:', error);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error stopping recording:', error);
|
||||
} finally {
|
||||
setIsProcessing(false);
|
||||
}
|
||||
};
|
||||
|
||||
const formatDuration = (seconds: number): string => {
|
||||
const mins = Math.floor(seconds / 60);
|
||||
const secs = seconds % 60;
|
||||
return `${mins.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}`;
|
||||
};
|
||||
|
||||
return (
|
||||
<Card padding="lg" variant="elevated">
|
||||
<View className="items-center">
|
||||
{/* Recording Status */}
|
||||
<View className="mb-4 items-center">
|
||||
{audioRecording.isRecording ? (
|
||||
<>
|
||||
<Text variant="h4" className="mb-2 font-semibold text-red-600">
|
||||
Aufnahme läuft
|
||||
</Text>
|
||||
<Text variant="h3" className="font-bold text-gray-900">
|
||||
{formatDuration(recordingDuration)}
|
||||
</Text>
|
||||
</>
|
||||
) : isProcessing ? (
|
||||
<>
|
||||
<Text variant="h4" className="mb-2 font-semibold text-blue-600">
|
||||
Verarbeite Audio...
|
||||
</Text>
|
||||
<View className="h-8 w-8 animate-spin rounded-full border-2 border-blue-600 border-t-transparent" />
|
||||
</>
|
||||
) : (
|
||||
<Text variant="h4" className="font-semibold text-gray-700">
|
||||
Drücke zum Aufnehmen
|
||||
</Text>
|
||||
)}
|
||||
</View>
|
||||
|
||||
{/* Recording Button */}
|
||||
<Pressable
|
||||
onPress={audioRecording.isRecording ? handleStopRecording : handleStartRecording}
|
||||
disabled={isProcessing}
|
||||
className="relative">
|
||||
<Animated.View
|
||||
style={{
|
||||
transform: [{ scale: pulseAnim }],
|
||||
}}
|
||||
className={`h-20 w-20 items-center justify-center rounded-full ${
|
||||
audioRecording.isRecording ? 'bg-red-500' : 'bg-blue-500'
|
||||
} ${isProcessing ? 'opacity-50' : ''}`}>
|
||||
<Icon
|
||||
name={audioRecording.isRecording ? 'stop' : 'mic'}
|
||||
size={32}
|
||||
color="white"
|
||||
library="Ionicons"
|
||||
/>
|
||||
</Animated.View>
|
||||
|
||||
{/* Pulse Effect Ring */}
|
||||
{audioRecording.isRecording && (
|
||||
<Animated.View
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: -10,
|
||||
left: -10,
|
||||
right: -10,
|
||||
bottom: -10,
|
||||
borderRadius: 50,
|
||||
borderWidth: 2,
|
||||
borderColor: '#EF4444',
|
||||
opacity: pulseAnim.interpolate({
|
||||
inputRange: [1, 1.2],
|
||||
outputRange: [0.3, 0],
|
||||
}),
|
||||
transform: [{ scale: pulseAnim }],
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</Pressable>
|
||||
|
||||
{/* Instructions */}
|
||||
<Text variant="caption" className="mt-4 text-center text-gray-500">
|
||||
{audioRecording.isRecording
|
||||
? 'Spreche deutlich und drücke Stopp wenn fertig'
|
||||
: isProcessing
|
||||
? 'Audio wird mit KI verarbeitet...'
|
||||
: 'Halte das Mikrofon gedrückt und spreche deinen Lerninhalt'}
|
||||
</Text>
|
||||
|
||||
{/* Audio Waveform Visualization (simplified) */}
|
||||
{audioRecording.isRecording && (
|
||||
<View className="mt-4 flex-row items-center justify-center space-x-1">
|
||||
{[...Array(7)].map((_, i) => (
|
||||
<Animated.View
|
||||
key={i}
|
||||
className="w-1 bg-red-500"
|
||||
style={{
|
||||
height: 20 + Math.random() * 20,
|
||||
opacity: pulseAnim.interpolate({
|
||||
inputRange: [1, 1.2],
|
||||
outputRange: [0.5, 1],
|
||||
}),
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
194
apps/manadeck/apps/mobile/components/ai/ImageCardCreator.tsx
Normal file
194
apps/manadeck/apps/mobile/components/ai/ImageCardCreator.tsx
Normal file
|
|
@ -0,0 +1,194 @@
|
|||
import React, { useState } from 'react';
|
||||
import { View, Image, Pressable, TextInput, ActivityIndicator } from 'react-native';
|
||||
import { Text } from '../ui/Text';
|
||||
import * as ImagePicker from 'expo-image-picker';
|
||||
import { Icon } from '../ui/Icon';
|
||||
import { Card } from '../ui/Card';
|
||||
import { Button } from '../ui/Button';
|
||||
import { useAIStore } from '../../store/aiStore';
|
||||
import { GeneratedCard } from '../../utils/supabaseAIService';
|
||||
|
||||
interface ImageCardCreatorProps {
|
||||
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 pickImage = async (source: 'camera' | 'library') => {
|
||||
try {
|
||||
let result;
|
||||
|
||||
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.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');
|
||||
}
|
||||
};
|
||||
|
||||
const handleGenerateCards = async () => {
|
||||
if (!imageUri) return;
|
||||
|
||||
try {
|
||||
setError(null);
|
||||
const cards = await generateCardsFromImage(imageUri, context);
|
||||
|
||||
if (onCardsGenerated) {
|
||||
onCardsGenerated(cards);
|
||||
}
|
||||
|
||||
// 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);
|
||||
};
|
||||
|
||||
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>
|
||||
|
||||
<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"
|
||||
/>
|
||||
|
||||
<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>
|
||||
|
||||
{/* 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>
|
||||
)}
|
||||
|
||||
{/* 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>
|
||||
);
|
||||
};
|
||||
414
apps/manadeck/apps/mobile/components/ai/SmartCardCreator.tsx
Normal file
414
apps/manadeck/apps/mobile/components/ai/SmartCardCreator.tsx
Normal file
|
|
@ -0,0 +1,414 @@
|
|||
import React, { useState } from 'react';
|
||||
import {
|
||||
View,
|
||||
Text,
|
||||
TextInput,
|
||||
ScrollView,
|
||||
Pressable,
|
||||
ActivityIndicator,
|
||||
KeyboardAvoidingView,
|
||||
Platform,
|
||||
} from 'react-native';
|
||||
import { Icon } from '../ui/Icon';
|
||||
import { Card } from '../ui/Card';
|
||||
import { Button } from '../ui/Button';
|
||||
import { useAIStore } from '../../store/aiStore';
|
||||
import { GeneratedCard, GenerationOptions } from '../../utils/supabaseAIService';
|
||||
import { AudioRecorder } from './AudioRecorder';
|
||||
import { ImageCardCreator } from './ImageCardCreator';
|
||||
|
||||
interface SmartCardCreatorProps {
|
||||
deckId: string;
|
||||
onCardsCreated?: (cards: GeneratedCard[]) => void;
|
||||
}
|
||||
|
||||
export const SmartCardCreator: React.FC<SmartCardCreatorProps> = ({ deckId, onCardsCreated }) => {
|
||||
const {
|
||||
isGenerating,
|
||||
generatedCards,
|
||||
error,
|
||||
generateCardsFromText,
|
||||
clearGeneratedCards,
|
||||
saveGeneratedCards,
|
||||
} = useAIStore();
|
||||
|
||||
const [inputMode, setInputMode] = useState<'text' | 'voice' | '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;
|
||||
|
||||
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;
|
||||
|
||||
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 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);
|
||||
|
||||
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 === '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>
|
||||
|
||||
<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: 'voice', label: 'Sprache', icon: 'mic' },
|
||||
{ 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>
|
||||
)}
|
||||
|
||||
{inputMode === 'voice' && (
|
||||
<View className="mb-4">
|
||||
<AudioRecorder
|
||||
onTranscriptionComplete={(text) => {
|
||||
setTextInput(text);
|
||||
setInputMode('text');
|
||||
}}
|
||||
/>
|
||||
</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>
|
||||
|
||||
{/* 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>
|
||||
|
||||
{/* 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>
|
||||
)}
|
||||
|
||||
{/* 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>
|
||||
|
||||
{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>
|
||||
);
|
||||
};
|
||||
108
apps/manadeck/apps/mobile/components/auth/AppleSignInButton.tsx
Normal file
108
apps/manadeck/apps/mobile/components/auth/AppleSignInButton.tsx
Normal file
|
|
@ -0,0 +1,108 @@
|
|||
import React, { useState } from 'react';
|
||||
import { TouchableOpacity, StyleSheet, Alert, Platform, ActivityIndicator } from 'react-native';
|
||||
import * as AppleAuthentication from 'expo-apple-authentication';
|
||||
import { router } from 'expo-router';
|
||||
import { Text } from '~/components/ui/Text';
|
||||
import { useThemeColors } from '~/utils/themeUtils';
|
||||
import { Ionicons } from '@expo/vector-icons';
|
||||
|
||||
interface AppleSignInButtonProps {
|
||||
onSignInSuccess?: () => void;
|
||||
onSignInError?: (error: string) => void;
|
||||
onSignIn: (identityToken: string) => Promise<void>;
|
||||
}
|
||||
|
||||
export const AppleSignInButton: React.FC<AppleSignInButtonProps> = ({
|
||||
onSignInSuccess,
|
||||
onSignInError,
|
||||
onSignIn,
|
||||
}) => {
|
||||
const colors = useThemeColors();
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
// 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 identityToken = credential.identityToken;
|
||||
|
||||
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');
|
||||
|
||||
// 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);
|
||||
}
|
||||
};
|
||||
|
||||
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',
|
||||
},
|
||||
});
|
||||
121
apps/manadeck/apps/mobile/components/auth/GoogleSignInButton.tsx
Normal file
121
apps/manadeck/apps/mobile/components/auth/GoogleSignInButton.tsx
Normal file
|
|
@ -0,0 +1,121 @@
|
|||
import React, { useEffect, useState } from 'react';
|
||||
import { TouchableOpacity, StyleSheet, Alert, Platform, ActivityIndicator } from 'react-native';
|
||||
import { GoogleSignin, statusCodes } from '@react-native-google-signin/google-signin';
|
||||
import { router } from 'expo-router';
|
||||
import { Text } from '~/components/ui/Text';
|
||||
import { useThemeColors } from '~/utils/themeUtils';
|
||||
import { Ionicons } from '@expo/vector-icons';
|
||||
|
||||
interface GoogleSignInButtonProps {
|
||||
onSignInSuccess?: () => void;
|
||||
onSignInError?: (error: string) => void;
|
||||
onSignIn: (idToken: string) => Promise<void>;
|
||||
}
|
||||
|
||||
export const GoogleSignInButton: React.FC<GoogleSignInButtonProps> = ({
|
||||
onSignInSuccess,
|
||||
onSignInError,
|
||||
onSignIn,
|
||||
}) => {
|
||||
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,
|
||||
});
|
||||
}, []);
|
||||
|
||||
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();
|
||||
|
||||
// Get ID token
|
||||
const tokens = await GoogleSignin.getTokens();
|
||||
const idToken = tokens.idToken;
|
||||
|
||||
console.log('Got Google ID token');
|
||||
|
||||
// Send to backend for validation
|
||||
await onSignIn(idToken);
|
||||
|
||||
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);
|
||||
}
|
||||
};
|
||||
|
||||
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',
|
||||
},
|
||||
});
|
||||
340
apps/manadeck/apps/mobile/components/card/CardList.tsx
Normal file
340
apps/manadeck/apps/mobile/components/card/CardList.tsx
Normal file
|
|
@ -0,0 +1,340 @@
|
|||
import React, { useEffect } from 'react';
|
||||
import { View, FlatList, Pressable, Alert } from 'react-native';
|
||||
import { Text } from '../ui/Text';
|
||||
import { Icon } from '../ui/Icon';
|
||||
import { useCardStore, Card } from '../../store/cardStore';
|
||||
import { router } from 'expo-router';
|
||||
import { Card as UICard } from '../ui/Card';
|
||||
import { Button } from '../ui/Button';
|
||||
import { useThemeColors } from '~/utils/themeUtils';
|
||||
|
||||
interface CardListProps {
|
||||
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;
|
||||
}
|
||||
|
||||
const CardItem: React.FC<CardItemProps> = ({
|
||||
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 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 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 '';
|
||||
}
|
||||
};
|
||||
|
||||
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>
|
||||
|
||||
{/* 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>
|
||||
|
||||
{/* Chevron */}
|
||||
<Icon
|
||||
name="chevron-forward"
|
||||
size={20}
|
||||
color="#9CA3AF"
|
||||
library="Ionicons"
|
||||
style={{ marginLeft: 8 }}
|
||||
/>
|
||||
</View>
|
||||
)}
|
||||
</Pressable>
|
||||
);
|
||||
}
|
||||
|
||||
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={{ marginBottom: 4, fontSize: 18, fontWeight: '600', color: colors.foreground }}>
|
||||
{card.title || `Position ${card.position}`}
|
||||
</Text>
|
||||
|
||||
<Text style={{ color: colors.mutedForeground }} numberOfLines={3}>
|
||||
{getContentPreview(card)}
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
{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,
|
||||
}) => {
|
||||
const { cards, fetchCards, deleteCard, toggleFavorite, isLoading } = useCardStore();
|
||||
const colors = useThemeColors();
|
||||
|
||||
useEffect(() => {
|
||||
fetchCards(deckId);
|
||||
}, [deckId]);
|
||||
|
||||
const displayCards = limit ? cards.slice(0, limit) : cards;
|
||||
|
||||
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 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);
|
||||
};
|
||||
|
||||
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 }} />}
|
||||
/>
|
||||
|
||||
{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>
|
||||
);
|
||||
};
|
||||
167
apps/manadeck/apps/mobile/components/card/CardTypeSelector.tsx
Normal file
167
apps/manadeck/apps/mobile/components/card/CardTypeSelector.tsx
Normal file
|
|
@ -0,0 +1,167 @@
|
|||
import React from 'react';
|
||||
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;
|
||||
}
|
||||
|
||||
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',
|
||||
},
|
||||
];
|
||||
|
||||
interface CardTypeSelectorProps {
|
||||
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,
|
||||
}) => {
|
||||
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={`
|
||||
flex-row items-center rounded-lg border-2 p-3
|
||||
${
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
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'
|
||||
}
|
||||
`}
|
||||
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>
|
||||
);
|
||||
};
|
||||
287
apps/manadeck/apps/mobile/components/card/CardView.tsx
Normal file
287
apps/manadeck/apps/mobile/components/card/CardView.tsx
Normal file
|
|
@ -0,0 +1,287 @@
|
|||
import React, { useState } from 'react';
|
||||
import { View, Pressable, ScrollView } from 'react-native';
|
||||
import { Icon } from '../ui/Icon';
|
||||
import { Text } from '../ui/Text';
|
||||
import {
|
||||
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;
|
||||
}
|
||||
|
||||
export const CardView: React.FC<CardViewProps> = ({
|
||||
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 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 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>
|
||||
|
||||
{/* 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>
|
||||
|
||||
<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 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>
|
||||
|
||||
{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 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>
|
||||
)}
|
||||
|
||||
{/* 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>
|
||||
);
|
||||
};
|
||||
134
apps/manadeck/apps/mobile/components/deck/DeckCard.tsx
Normal file
134
apps/manadeck/apps/mobile/components/deck/DeckCard.tsx
Normal file
|
|
@ -0,0 +1,134 @@
|
|||
import React from 'react';
|
||||
import { View, Pressable, Image } from 'react-native';
|
||||
import { Text } from '../ui/Text';
|
||||
import { Icon } from '../ui/Icon';
|
||||
import { Deck } from '../../store/deckStore';
|
||||
import { useThemeColors } from '~/utils/themeUtils';
|
||||
|
||||
interface DeckCardProps {
|
||||
deck: Deck;
|
||||
onPress: () => void;
|
||||
showProgress?: boolean;
|
||||
isCompact?: boolean;
|
||||
}
|
||||
|
||||
export const DeckCard: React.FC<DeckCardProps> = ({
|
||||
deck,
|
||||
onPress,
|
||||
showProgress = false,
|
||||
isCompact = false,
|
||||
}) => {
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
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>
|
||||
|
||||
{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>
|
||||
);
|
||||
};
|
||||
|
|
@ -0,0 +1,104 @@
|
|||
import React from 'react';
|
||||
import { View, Pressable } from 'react-native';
|
||||
import { Icon } from '../ui/Icon';
|
||||
import { Text } from '../ui/Text';
|
||||
import { Card } from '../ui/Card';
|
||||
import { DeckProgress } from '../../store/progressStore';
|
||||
import { router } from 'expo-router';
|
||||
import { useThemeColors } from '~/utils/themeUtils';
|
||||
|
||||
interface DeckProgressCardProps {
|
||||
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 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';
|
||||
};
|
||||
|
||||
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>
|
||||
|
||||
{/* 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>
|
||||
|
||||
{/* 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>
|
||||
|
||||
<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>
|
||||
);
|
||||
};
|
||||
|
|
@ -0,0 +1,133 @@
|
|||
import React from 'react';
|
||||
import { View } from 'react-native';
|
||||
import { Text } from '../ui/Text';
|
||||
import { DailyProgress } from '../../store/progressStore';
|
||||
import { useThemeColors } from '~/utils/themeUtils';
|
||||
|
||||
interface HeatmapCalendarProps {
|
||||
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)[][] = [];
|
||||
|
||||
// Start from 12 weeks ago
|
||||
const startDate = new Date(today);
|
||||
startDate.setDate(today.getDate() - weeks * 7 + (7 - today.getDay()));
|
||||
|
||||
// Generate grid
|
||||
for (let week = 0; week < weeks; week++) {
|
||||
const weekData: (DailyProgress | null)[] = [];
|
||||
|
||||
for (let day = 0; day < daysPerWeek; day++) {
|
||||
const currentDate = new Date(startDate);
|
||||
currentDate.setDate(startDate.getDate() + week * 7 + day);
|
||||
|
||||
if (currentDate <= today) {
|
||||
const dateStr = currentDate.toISOString().split('T')[0];
|
||||
weekData.push(data.get(dateStr) || null);
|
||||
} else {
|
||||
weekData.push(null);
|
||||
}
|
||||
}
|
||||
|
||||
grid.push(weekData);
|
||||
}
|
||||
|
||||
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
|
||||
};
|
||||
|
||||
const weekDays = ['So', 'Mo', 'Di', 'Mi', 'Do', 'Fr', 'Sa'];
|
||||
const months = [
|
||||
'Jan',
|
||||
'Feb',
|
||||
'Mär',
|
||||
'Apr',
|
||||
'Mai',
|
||||
'Jun',
|
||||
'Jul',
|
||||
'Aug',
|
||||
'Sep',
|
||||
'Okt',
|
||||
'Nov',
|
||||
'Dez',
|
||||
];
|
||||
|
||||
// Calculate which months to show
|
||||
const monthLabels: { month: string; week: number }[] = [];
|
||||
let lastMonth = -1;
|
||||
|
||||
for (let week = 0; week < weeks; week++) {
|
||||
const weekStart = new Date(startDate);
|
||||
weekStart.setDate(startDate.getDate() + week * 7);
|
||||
const month = weekStart.getMonth();
|
||||
|
||||
if (month !== lastMonth) {
|
||||
monthLabels.push({ month: months[month], week });
|
||||
lastMonth = month;
|
||||
}
|
||||
}
|
||||
|
||||
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>
|
||||
|
||||
{/* 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>
|
||||
);
|
||||
};
|
||||
144
apps/manadeck/apps/mobile/components/progress/ProgressChart.tsx
Normal file
144
apps/manadeck/apps/mobile/components/progress/ProgressChart.tsx
Normal file
|
|
@ -0,0 +1,144 @@
|
|||
import React from 'react';
|
||||
import { View } from 'react-native';
|
||||
import { Text } from '../ui/Text';
|
||||
import { useProgressStore } from '../../store/progressStore';
|
||||
import { useThemeColors } from '~/utils/themeUtils';
|
||||
|
||||
interface ProgressChartProps {
|
||||
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();
|
||||
|
||||
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 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));
|
||||
|
||||
// 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>
|
||||
|
||||
{/* 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>
|
||||
);
|
||||
};
|
||||
201
apps/manadeck/apps/mobile/components/progress/StreakCard.tsx
Normal file
201
apps/manadeck/apps/mobile/components/progress/StreakCard.tsx
Normal file
|
|
@ -0,0 +1,201 @@
|
|||
import React from 'react';
|
||||
import { View } from 'react-native';
|
||||
import { Text } from '../ui/Text';
|
||||
import { Icon } from '../ui/Icon';
|
||||
import { Card } from '../ui/Card';
|
||||
import { StreakInfo } from '../../store/progressStore';
|
||||
import { useThemeColors } from '~/utils/themeUtils';
|
||||
|
||||
interface StreakCardProps {
|
||||
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 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 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 daysSinceLastStudy = streakInfo.last_study_date
|
||||
? Math.floor((new Date().getTime() - new Date(streakInfo.last_study_date).getTime()) / 86400000)
|
||||
: 999;
|
||||
|
||||
const isStreakActive = daysSinceLastStudy <= 1;
|
||||
|
||||
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>
|
||||
|
||||
<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>
|
||||
|
||||
{/* 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>
|
||||
);
|
||||
};
|
||||
134
apps/manadeck/apps/mobile/components/study/AudioCard.tsx
Normal file
134
apps/manadeck/apps/mobile/components/study/AudioCard.tsx
Normal file
|
|
@ -0,0 +1,134 @@
|
|||
import React, { useState, useEffect } from 'react';
|
||||
import { View, Text, Pressable } from 'react-native';
|
||||
import { Icon } from '../ui/Icon';
|
||||
import { TTSService } from '../../utils/ttsService';
|
||||
import { Card as CardType } from '../../store/cardStore';
|
||||
|
||||
interface AudioCardProps {
|
||||
card: CardType;
|
||||
autoPlay?: boolean;
|
||||
showControls?: boolean;
|
||||
}
|
||||
|
||||
export const AudioCard: React.FC<AudioCardProps> = ({
|
||||
card,
|
||||
autoPlay = false,
|
||||
showControls = true,
|
||||
}) => {
|
||||
const [isPlaying, setIsPlaying] = useState(false);
|
||||
const [rate, setRate] = useState(1.0);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (autoPlay) {
|
||||
handlePlay();
|
||||
}
|
||||
|
||||
return () => {
|
||||
// Stop any ongoing speech when component unmounts
|
||||
TTSService.stop();
|
||||
};
|
||||
}, [card.id]);
|
||||
|
||||
const handlePlay = async () => {
|
||||
try {
|
||||
setError(null);
|
||||
setIsPlaying(true);
|
||||
|
||||
await TTSService.speakCard(card);
|
||||
|
||||
setIsPlaying(false);
|
||||
} catch (error) {
|
||||
console.error('Error playing audio:', error);
|
||||
setError('Fehler beim Abspielen');
|
||||
setIsPlaying(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleStop = async () => {
|
||||
try {
|
||||
await TTSService.stop();
|
||||
setIsPlaying(false);
|
||||
} catch (error) {
|
||||
console.error('Error stopping audio:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const handlePause = async () => {
|
||||
try {
|
||||
await TTSService.pause();
|
||||
setIsPlaying(false);
|
||||
} catch (error) {
|
||||
console.error('Error pausing audio:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleResume = async () => {
|
||||
try {
|
||||
await TTSService.resume();
|
||||
setIsPlaying(true);
|
||||
} catch (error) {
|
||||
console.error('Error resuming audio:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const adjustRate = (newRate: number) => {
|
||||
setRate(newRate);
|
||||
// Rate will be applied on next play
|
||||
};
|
||||
|
||||
if (!showControls) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<View className="flex-row items-center justify-center space-x-2 rounded-lg bg-gray-50 p-3">
|
||||
{/* Play/Pause Button */}
|
||||
<Pressable
|
||||
onPress={isPlaying ? handlePause : handlePlay}
|
||||
className="rounded-full bg-blue-500 p-3"
|
||||
style={({ pressed }) => pressed && { opacity: 0.7 }}>
|
||||
<Icon name={isPlaying ? 'pause' : 'play'} size={24} color="white" library="Ionicons" />
|
||||
</Pressable>
|
||||
|
||||
{/* Stop Button */}
|
||||
{isPlaying && (
|
||||
<Pressable
|
||||
onPress={handleStop}
|
||||
className="rounded-full bg-gray-400 p-3"
|
||||
style={({ pressed }) => pressed && { opacity: 0.7 }}>
|
||||
<Icon name="stop" size={24} color="white" library="Ionicons" />
|
||||
</Pressable>
|
||||
)}
|
||||
|
||||
{/* Speed Controls */}
|
||||
<View className="ml-4 flex-row items-center space-x-2">
|
||||
<Text className="text-sm text-gray-600">Geschwindigkeit:</Text>
|
||||
|
||||
<Pressable
|
||||
onPress={() => adjustRate(0.75)}
|
||||
className={`rounded px-2 py-1 ${rate === 0.75 ? 'bg-blue-500' : 'bg-gray-200'}`}
|
||||
style={({ pressed }) => pressed && { opacity: 0.7 }}>
|
||||
<Text className={`text-xs ${rate === 0.75 ? 'text-white' : 'text-gray-700'}`}>0.75x</Text>
|
||||
</Pressable>
|
||||
|
||||
<Pressable
|
||||
onPress={() => adjustRate(1.0)}
|
||||
className={`rounded px-2 py-1 ${rate === 1.0 ? 'bg-blue-500' : 'bg-gray-200'}`}
|
||||
style={({ pressed }) => pressed && { opacity: 0.7 }}>
|
||||
<Text className={`text-xs ${rate === 1.0 ? 'text-white' : 'text-gray-700'}`}>1x</Text>
|
||||
</Pressable>
|
||||
|
||||
<Pressable
|
||||
onPress={() => adjustRate(1.25)}
|
||||
className={`rounded px-2 py-1 ${rate === 1.25 ? 'bg-blue-500' : 'bg-gray-200'}`}
|
||||
style={({ pressed }) => pressed && { opacity: 0.7 }}>
|
||||
<Text className={`text-xs ${rate === 1.25 ? 'text-white' : 'text-gray-700'}`}>1.25x</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
|
||||
{/* Error Display */}
|
||||
{error && <Text className="ml-2 text-xs text-red-500">{error}</Text>}
|
||||
</View>
|
||||
);
|
||||
};
|
||||
160
apps/manadeck/apps/mobile/components/study/StudyModeSelector.tsx
Normal file
160
apps/manadeck/apps/mobile/components/study/StudyModeSelector.tsx
Normal file
|
|
@ -0,0 +1,160 @@
|
|||
import React from 'react';
|
||||
import { View, Pressable, Modal } from 'react-native';
|
||||
import { Icon } from '../ui/Icon';
|
||||
import { Text } from '../ui/Text';
|
||||
import { Button } from '../ui/Button';
|
||||
import { Card } from '../ui/Card';
|
||||
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;
|
||||
};
|
||||
}
|
||||
|
||||
interface StudyModeOption {
|
||||
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,
|
||||
},
|
||||
];
|
||||
|
||||
export const StudyModeSelector: React.FC<StudyModeSelectorProps> = ({
|
||||
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>
|
||||
|
||||
<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>
|
||||
);
|
||||
};
|
||||
223
apps/manadeck/apps/mobile/components/ui/Button.tsx
Normal file
223
apps/manadeck/apps/mobile/components/ui/Button.tsx
Normal file
|
|
@ -0,0 +1,223 @@
|
|||
import React from 'react';
|
||||
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,
|
||||
},
|
||||
});
|
||||
|
||||
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',
|
||||
},
|
||||
});
|
||||
|
||||
export interface ButtonProps
|
||||
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();
|
||||
|
||||
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();
|
||||
}
|
||||
};
|
||||
|
||||
// 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();
|
||||
|
||||
// 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>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
Button.displayName = 'Button';
|
||||
94
apps/manadeck/apps/mobile/components/ui/Card.tsx
Normal file
94
apps/manadeck/apps/mobile/components/ui/Card.tsx
Normal file
|
|
@ -0,0 +1,94 @@
|
|||
import React from 'react';
|
||||
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;
|
||||
}
|
||||
|
||||
export const Card: React.FC<CardProps> = ({
|
||||
children,
|
||||
onPress,
|
||||
variant = 'default',
|
||||
padding = 'md',
|
||||
className = '',
|
||||
style,
|
||||
...props
|
||||
}) => {
|
||||
const colors = useThemeColors();
|
||||
|
||||
const paddingValues = {
|
||||
none: 0,
|
||||
sm: 8,
|
||||
md: 16,
|
||||
lg: 24,
|
||||
};
|
||||
|
||||
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 containerStyle: ViewStyle = {
|
||||
borderRadius: 16,
|
||||
overflow: 'hidden',
|
||||
padding: paddingValues[padding],
|
||||
...getVariantStyles(),
|
||||
...style,
|
||||
};
|
||||
|
||||
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>
|
||||
);
|
||||
};
|
||||
102
apps/manadeck/apps/mobile/components/ui/FilterBar.tsx
Normal file
102
apps/manadeck/apps/mobile/components/ui/FilterBar.tsx
Normal file
|
|
@ -0,0 +1,102 @@
|
|||
import React from 'react';
|
||||
import { View, ScrollView, Pressable } from 'react-native';
|
||||
import { Text } from './Text';
|
||||
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;
|
||||
};
|
||||
|
||||
interface FilterBarProps {
|
||||
options: FilterOption[];
|
||||
activeFilter: string;
|
||||
onFilterChange: (filterId: string) => void;
|
||||
}
|
||||
|
||||
export const FilterBar: React.FC<FilterBarProps> = ({ options, activeFilter, onFilterChange }) => {
|
||||
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 (
|
||||
<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>
|
||||
);
|
||||
};
|
||||
|
|
@ -0,0 +1,59 @@
|
|||
import React from 'react';
|
||||
import { Pressable, StyleSheet, Platform } from 'react-native';
|
||||
import { Icon } from './Icon';
|
||||
import { useThemeColors } from '~/utils/themeUtils';
|
||||
|
||||
interface FloatingActionButtonProps {
|
||||
icon: string;
|
||||
iconLibrary?: 'Ionicons' | 'FontAwesome' | 'MaterialIcons' | 'Feather';
|
||||
onPress: () => void;
|
||||
size?: number;
|
||||
}
|
||||
|
||||
export const FloatingActionButton: React.FC<FloatingActionButtonProps> = ({
|
||||
icon,
|
||||
iconLibrary = 'Ionicons',
|
||||
onPress,
|
||||
size = 56,
|
||||
}) => {
|
||||
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>
|
||||
);
|
||||
};
|
||||
|
||||
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',
|
||||
},
|
||||
}),
|
||||
},
|
||||
});
|
||||
40
apps/manadeck/apps/mobile/components/ui/Icon.tsx
Normal file
40
apps/manadeck/apps/mobile/components/ui/Icon.tsx
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
import React from 'react';
|
||||
import { FontAwesome, Ionicons, MaterialIcons, Feather } from '@expo/vector-icons';
|
||||
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>;
|
||||
}
|
||||
|
||||
export const Icon: React.FC<IconProps> = ({
|
||||
name,
|
||||
size = 24,
|
||||
color,
|
||||
library = 'FontAwesome',
|
||||
...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} />;
|
||||
}
|
||||
};
|
||||
108
apps/manadeck/apps/mobile/components/ui/Input.tsx
Normal file
108
apps/manadeck/apps/mobile/components/ui/Input.tsx
Normal file
|
|
@ -0,0 +1,108 @@
|
|||
import React, { useState } from 'react';
|
||||
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';
|
||||
}
|
||||
|
||||
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';
|
||||
|
||||
const getKeyboardType = () => {
|
||||
if (type === 'email') return 'email-address';
|
||||
return props.keyboardType || 'default';
|
||||
};
|
||||
|
||||
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>}
|
||||
|
||||
<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>
|
||||
)}
|
||||
|
||||
<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>
|
||||
)}
|
||||
|
||||
{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>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
Input.displayName = 'Input';
|
||||
41
apps/manadeck/apps/mobile/components/ui/PageHeader.tsx
Normal file
41
apps/manadeck/apps/mobile/components/ui/PageHeader.tsx
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
import React from 'react';
|
||||
import { View, Platform } from 'react-native';
|
||||
import { Text } from './Text';
|
||||
import { useThemeColors } from '~/utils/themeUtils';
|
||||
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||
import { spacing } from '~/utils/spacing';
|
||||
|
||||
interface PageHeaderProps {
|
||||
title: string;
|
||||
withHorizontalPadding?: boolean;
|
||||
}
|
||||
|
||||
export function PageHeader({ title, withHorizontalPadding = true }: PageHeaderProps) {
|
||||
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>
|
||||
);
|
||||
}
|
||||
83
apps/manadeck/apps/mobile/components/ui/SettingsItem.tsx
Normal file
83
apps/manadeck/apps/mobile/components/ui/SettingsItem.tsx
Normal file
|
|
@ -0,0 +1,83 @@
|
|||
import React from 'react';
|
||||
import { View, Pressable, ViewStyle } from 'react-native';
|
||||
import { Text } from './Text';
|
||||
import { Icon } from './Icon';
|
||||
import { useThemeColors } from '~/utils/themeUtils';
|
||||
|
||||
interface SettingsItemProps {
|
||||
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,
|
||||
}) => {
|
||||
const colors = useThemeColors();
|
||||
|
||||
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>
|
||||
);
|
||||
|
||||
if (onPress) {
|
||||
return (
|
||||
<Pressable
|
||||
onPress={onPress}
|
||||
style={({ pressed }) => ({
|
||||
...containerStyle,
|
||||
opacity: pressed ? 0.7 : 1,
|
||||
})}>
|
||||
{renderContent()}
|
||||
</Pressable>
|
||||
);
|
||||
}
|
||||
|
||||
return <View style={containerStyle}>{renderContent()}</View>;
|
||||
};
|
||||
34
apps/manadeck/apps/mobile/components/ui/SettingsSection.tsx
Normal file
34
apps/manadeck/apps/mobile/components/ui/SettingsSection.tsx
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
import React from 'react';
|
||||
import { View } from 'react-native';
|
||||
import { Text } from './Text';
|
||||
import { Card } from './Card';
|
||||
import { useThemeColors } from '~/utils/themeUtils';
|
||||
import { spacing } from '~/utils/spacing';
|
||||
|
||||
interface SettingsSectionProps {
|
||||
title: string;
|
||||
children: React.ReactNode;
|
||||
noPadding?: boolean;
|
||||
}
|
||||
|
||||
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>
|
||||
);
|
||||
};
|
||||
26
apps/manadeck/apps/mobile/components/ui/Switch.tsx
Normal file
26
apps/manadeck/apps/mobile/components/ui/Switch.tsx
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
import React from 'react';
|
||||
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;
|
||||
}
|
||||
|
||||
export function Switch({ value, onValueChange, ...props }: CustomSwitchProps) {
|
||||
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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
46
apps/manadeck/apps/mobile/components/ui/Text.tsx
Normal file
46
apps/manadeck/apps/mobile/components/ui/Text.tsx
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
import React from 'react';
|
||||
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;
|
||||
}
|
||||
|
||||
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';
|
||||
}
|
||||
};
|
||||
|
||||
export const Text: React.FC<TextProps> = ({
|
||||
variant = 'body',
|
||||
className = '',
|
||||
children,
|
||||
...props
|
||||
}) => {
|
||||
const variantClasses = getVariantClasses(variant);
|
||||
const combinedClassName = `${variantClasses} ${className}`.trim();
|
||||
|
||||
return (
|
||||
<RNText className={combinedClassName} {...props}>
|
||||
{children}
|
||||
</RNText>
|
||||
);
|
||||
};
|
||||
34
apps/manadeck/apps/mobile/components/ui/ThemeDebug.tsx
Normal file
34
apps/manadeck/apps/mobile/components/ui/ThemeDebug.tsx
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
import React from 'react';
|
||||
import { View } from 'react-native';
|
||||
import { Text } from './Text';
|
||||
import { useThemeStore, useIsDark, useCurrentColors, useRootClassName } from '~/store/themeStore';
|
||||
import { themes } from '~/themes';
|
||||
|
||||
export function ThemeDebug() {
|
||||
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;
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
180
apps/manadeck/apps/mobile/components/ui/ThemeSwitcher.tsx
Normal file
180
apps/manadeck/apps/mobile/components/ui/ThemeSwitcher.tsx
Normal file
|
|
@ -0,0 +1,180 @@
|
|||
import React from 'react';
|
||||
import { View, Pressable } from 'react-native';
|
||||
import { Text } from './Text';
|
||||
import { Icon } from './Icon';
|
||||
import { useTheme } from '~/components/ThemeProvider';
|
||||
import { themeList, themes } from '~/themes';
|
||||
import { ThemeName, ThemeMode } from '~/types/theme';
|
||||
import { useThemeColors } from '~/utils/themeUtils';
|
||||
import { spacing } from '~/utils/spacing';
|
||||
|
||||
export function ThemeSwitcher() {
|
||||
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' },
|
||||
];
|
||||
|
||||
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>
|
||||
|
||||
{/* 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>
|
||||
|
||||
{/* 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>
|
||||
);
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue