mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-24 02:56:43 +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
64
apps/memoro/apps/mobile/components/HeaderButton.tsx
Normal file
64
apps/memoro/apps/mobile/components/HeaderButton.tsx
Normal file
|
|
@ -0,0 +1,64 @@
|
|||
import { forwardRef } from 'react';
|
||||
import { Pressable, View, StyleSheet } from 'react-native';
|
||||
import * as Haptics from 'expo-haptics';
|
||||
import { useTheme } from '~/features/theme/ThemeProvider';
|
||||
import Icon from '~/components/atoms/Icon';
|
||||
import NotificationBadge from '~/components/atoms/NotificationBadge';
|
||||
import { useUnuploadedCount } from '~/features/storage/hooks/useUnuploadedCount';
|
||||
import { useInitializeUploadStatus } from '~/features/storage/store/uploadStatusStore';
|
||||
|
||||
export const HeaderButton = forwardRef<typeof Pressable, { onPress?: () => void }>(
|
||||
({ onPress }, ref) => {
|
||||
const { tw } = useTheme();
|
||||
|
||||
// Initialize upload status store
|
||||
useInitializeUploadStatus();
|
||||
|
||||
// Get count of unuploaded audio files
|
||||
const unuploadedCount = useUnuploadedCount();
|
||||
|
||||
const handlePress = async () => {
|
||||
try {
|
||||
await Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
|
||||
} catch (error) {
|
||||
console.debug('Haptic feedback error:', error);
|
||||
}
|
||||
onPress?.();
|
||||
};
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<Pressable ref={ref} onPress={handlePress}>
|
||||
{({ pressed }) => (
|
||||
<Icon
|
||||
name="settings-outline"
|
||||
size={24}
|
||||
useThemeColor
|
||||
className={tw(`${pressed ? 'opacity-50' : 'opacity-100'}`)}
|
||||
/>
|
||||
)}
|
||||
</Pressable>
|
||||
{unuploadedCount > 0 && (
|
||||
<NotificationBadge
|
||||
count={unuploadedCount}
|
||||
size="small"
|
||||
style={styles.badge}
|
||||
/>
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
position: 'relative',
|
||||
marginRight: 16,
|
||||
},
|
||||
badge: {
|
||||
position: 'absolute',
|
||||
top: -2,
|
||||
right: -2,
|
||||
zIndex: 10,
|
||||
},
|
||||
});
|
||||
148
apps/memoro/apps/mobile/components/LoadingAnimationDemo.tsx
Normal file
148
apps/memoro/apps/mobile/components/LoadingAnimationDemo.tsx
Normal file
|
|
@ -0,0 +1,148 @@
|
|||
import React, { useState } from 'react';
|
||||
import { View, ScrollView } from 'react-native';
|
||||
import { useTheme } from '~/features/theme/ThemeProvider';
|
||||
import Text from './atoms/Text';
|
||||
import Button from './atoms/Button';
|
||||
import { PulsingLogoAnimation } from './molecules/PulsingLogoAnimation';
|
||||
import { SpinnerAnimation } from './molecules/SpinnerAnimation';
|
||||
import { DotsAnimation } from './molecules/DotsAnimation';
|
||||
import { LogoSpinnerAnimation } from './molecules/LogoSpinnerAnimation';
|
||||
|
||||
/**
|
||||
* Demo-Screen zum Vergleichen aller Loading-Animationen
|
||||
*
|
||||
* Zum Testen: Importiere diese Komponente temporär in einer Route
|
||||
*/
|
||||
export default function LoadingAnimationDemo() {
|
||||
const { isDark, colors } = useTheme();
|
||||
const [selectedAnimation, setSelectedAnimation] = useState<string>('all');
|
||||
|
||||
const animations = [
|
||||
{
|
||||
id: 'pulsing',
|
||||
name: 'Pulsing Logo',
|
||||
description: 'Minimalistisch - Logo pulst sanft',
|
||||
component: <PulsingLogoAnimation size={80} />,
|
||||
recommended: true,
|
||||
},
|
||||
{
|
||||
id: 'spinner',
|
||||
name: 'Spinner',
|
||||
description: 'Modern - Rotierender Ring',
|
||||
component: <SpinnerAnimation size={60} />,
|
||||
recommended: false,
|
||||
},
|
||||
{
|
||||
id: 'dots',
|
||||
name: 'Three Dots',
|
||||
description: 'Subtil - Drei pulsierende Punkte',
|
||||
component: <DotsAnimation size={60} />,
|
||||
recommended: false,
|
||||
},
|
||||
{
|
||||
id: 'logo-spinner',
|
||||
name: 'Logo + Spinner',
|
||||
description: 'Branded - Logo mit rotierendem Ring',
|
||||
component: <LogoSpinnerAnimation size={100} />,
|
||||
recommended: true,
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<View style={{ flex: 1, backgroundColor: isDark ? '#121212' : '#FFFFFF' }}>
|
||||
<ScrollView contentContainerStyle={{ padding: 20 }}>
|
||||
<Text style={{ fontSize: 24, fontWeight: 'bold', marginBottom: 8 }}>
|
||||
Loading Animations
|
||||
</Text>
|
||||
<Text style={{ fontSize: 14, opacity: 0.7, marginBottom: 24 }}>
|
||||
Wähle eine Animation oder betrachte alle
|
||||
</Text>
|
||||
|
||||
{/* Filter Buttons */}
|
||||
<View style={{ flexDirection: 'row', flexWrap: 'wrap', gap: 8, marginBottom: 32 }}>
|
||||
<Button
|
||||
title="Alle"
|
||||
variant={selectedAnimation === 'all' ? 'primary' : 'secondary'}
|
||||
onPress={() => setSelectedAnimation('all')}
|
||||
style={{ height: 36, paddingHorizontal: 16 }}
|
||||
/>
|
||||
{animations.map((anim) => (
|
||||
<Button
|
||||
key={anim.id}
|
||||
title={anim.name}
|
||||
variant={selectedAnimation === anim.id ? 'primary' : 'secondary'}
|
||||
onPress={() => setSelectedAnimation(anim.id)}
|
||||
style={{ height: 36, paddingHorizontal: 16 }}
|
||||
/>
|
||||
))}
|
||||
</View>
|
||||
|
||||
{/* Animation Display */}
|
||||
{animations
|
||||
.filter((anim) => selectedAnimation === 'all' || selectedAnimation === anim.id)
|
||||
.map((anim) => (
|
||||
<View
|
||||
key={anim.id}
|
||||
style={{
|
||||
backgroundColor: isDark ? '#1E1E1E' : '#F5F5F5',
|
||||
borderRadius: 16,
|
||||
padding: 24,
|
||||
marginBottom: 20,
|
||||
borderWidth: anim.recommended ? 2 : 0,
|
||||
borderColor: colors.primary,
|
||||
}}>
|
||||
<View style={{ flexDirection: 'row', alignItems: 'center', marginBottom: 8 }}>
|
||||
<Text style={{ fontSize: 18, fontWeight: '600', flex: 1 }}>
|
||||
{anim.name}
|
||||
</Text>
|
||||
{anim.recommended && (
|
||||
<View
|
||||
style={{
|
||||
backgroundColor: colors.primary,
|
||||
paddingHorizontal: 8,
|
||||
paddingVertical: 4,
|
||||
borderRadius: 12,
|
||||
}}>
|
||||
<Text style={{ fontSize: 11, fontWeight: '600', color: '#FFFFFF' }}>
|
||||
Empfohlen
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
|
||||
<Text style={{ fontSize: 14, opacity: 0.7, marginBottom: 24 }}>
|
||||
{anim.description}
|
||||
</Text>
|
||||
|
||||
<View
|
||||
style={{
|
||||
height: 150,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
}}>
|
||||
{anim.component}
|
||||
</View>
|
||||
</View>
|
||||
))}
|
||||
|
||||
{/* Usage Instructions */}
|
||||
<View
|
||||
style={{
|
||||
backgroundColor: isDark ? '#1E1E1E' : '#F5F5F5',
|
||||
borderRadius: 16,
|
||||
padding: 20,
|
||||
marginTop: 20,
|
||||
}}>
|
||||
<Text style={{ fontSize: 16, fontWeight: '600', marginBottom: 12 }}>
|
||||
Verwendung
|
||||
</Text>
|
||||
<Text style={{ fontSize: 14, opacity: 0.7, lineHeight: 20 }}>
|
||||
Ersetze in LoadingScreen.tsx die LottieAnimation durch eine dieser Komponenten:{'\n\n'}
|
||||
{`import { LogoSpinnerAnimation } from '~/components/molecules/LogoSpinnerAnimation';\n\n`}
|
||||
{`<LogoSpinnerAnimation size={100} />`}
|
||||
</Text>
|
||||
</View>
|
||||
</ScrollView>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
78
apps/memoro/apps/mobile/components/LoadingScreen.tsx
Normal file
78
apps/memoro/apps/mobile/components/LoadingScreen.tsx
Normal file
|
|
@ -0,0 +1,78 @@
|
|||
import React, { useMemo } from 'react';
|
||||
import { View, Dimensions } from 'react-native';
|
||||
import { useTheme } from '~/features/theme/ThemeProvider';
|
||||
import Text from './atoms/Text';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { LogoSpinnerAnimation } from './molecules/LogoSpinnerAnimation';
|
||||
|
||||
/**
|
||||
* Einfacher Ladebildschirm für die App
|
||||
* @param {Object} props - Komponenten-Props
|
||||
* @param {string} [props.message] - Optionale Nachricht, die angezeigt werden soll
|
||||
*/
|
||||
interface LoadingScreenProps {
|
||||
message?: string;
|
||||
}
|
||||
|
||||
export default function LoadingScreen({ message }: LoadingScreenProps) {
|
||||
const { isDark } = useTheme();
|
||||
|
||||
// Safe translation usage with fallback
|
||||
let displayMessage = message;
|
||||
try {
|
||||
// Only try to use translation if no explicit message is provided
|
||||
if (!displayMessage) {
|
||||
const { t } = useTranslation();
|
||||
displayMessage = t('common.loading', 'Wird geladen...');
|
||||
}
|
||||
} catch (error) {
|
||||
// Fallback if translation fails
|
||||
displayMessage = displayMessage || 'Wird geladen...';
|
||||
}
|
||||
|
||||
// Use same responsive sizing logic as recording button
|
||||
const logoSize = useMemo(() => {
|
||||
const { width, height } = Dimensions.get('window');
|
||||
const minDimension = Math.min(width, height);
|
||||
|
||||
if (minDimension <= 375) {
|
||||
return 140; // iPhone SE, 13 mini
|
||||
} else if (minDimension <= 390) {
|
||||
return 150; // iPhone 13/14/15
|
||||
} else if (minDimension <= 393) {
|
||||
return 155; // iPhone 14 Pro, 16
|
||||
} else if (minDimension <= 402) {
|
||||
return 160; // iPhone 16 Pro
|
||||
} else if (minDimension <= 428) {
|
||||
return 170; // iPhone 13/14 Pro Max
|
||||
} else if (minDimension <= 430) {
|
||||
return 180; // iPhone 15/16 Pro Max
|
||||
} else {
|
||||
return 200; // iPad & Tablets
|
||||
}
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<View style={{
|
||||
flex: 1,
|
||||
position: 'relative',
|
||||
backgroundColor: isDark ? '#121212' : '#FFFFFF'
|
||||
}}>
|
||||
{/* Logo and text positioned exactly like recording button */}
|
||||
<View style={{
|
||||
position: 'absolute',
|
||||
top: '50%',
|
||||
left: 0,
|
||||
right: 0,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
transform: [{ translateY: -(logoSize / 2 + 10) }]
|
||||
}}>
|
||||
<LogoSpinnerAnimation size={logoSize} />
|
||||
<Text variant='h3' style={{ marginTop: 32, fontWeight: '600' }}>
|
||||
{displayMessage}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
24
apps/memoro/apps/mobile/components/TabBarIcon.tsx
Normal file
24
apps/memoro/apps/mobile/components/TabBarIcon.tsx
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
import { View } from 'react-native';
|
||||
import Icon from '~/components/atoms/Icon';
|
||||
|
||||
export const TabBarIcon = (props: {
|
||||
name: string;
|
||||
color: string;
|
||||
focused?: boolean;
|
||||
size?: number;
|
||||
}) => {
|
||||
// If focused, use the filled version of the icon by removing '-outline' suffix
|
||||
const iconName = props.focused
|
||||
? props.name.replace('-outline', '')
|
||||
: props.name;
|
||||
|
||||
return (
|
||||
<View className="mb-[-3px]">
|
||||
<Icon
|
||||
name={iconName}
|
||||
size={props.size || 28}
|
||||
color={props.color}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
86
apps/memoro/apps/mobile/components/atoms/ActionButton.tsx
Normal file
86
apps/memoro/apps/mobile/components/atoms/ActionButton.tsx
Normal file
|
|
@ -0,0 +1,86 @@
|
|||
import React from 'react';
|
||||
import {
|
||||
Pressable,
|
||||
PressableProps,
|
||||
} from 'react-native';
|
||||
import * as Haptics from 'expo-haptics';
|
||||
import { useTheme } from '~/features/theme/ThemeProvider';
|
||||
import Icon from './Icon';
|
||||
|
||||
export interface ActionButtonProps extends PressableProps {
|
||||
iconName: string;
|
||||
size?: number;
|
||||
active?: boolean;
|
||||
activeColor?: string;
|
||||
inactiveColor?: string;
|
||||
buttonSize?: number;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* ActionButton-Komponente
|
||||
*
|
||||
* Ein runder Button mit Icon für Aktionen wie Pause, Abbrechen und Einstellungen.
|
||||
*
|
||||
* Beispiel:
|
||||
* ```tsx
|
||||
* <ActionButton
|
||||
* iconName="settings-outline"
|
||||
* active={true}
|
||||
* onPress={handlePress}
|
||||
* />
|
||||
* ```
|
||||
*/
|
||||
function ActionButton({
|
||||
iconName,
|
||||
size = 28,
|
||||
active = false,
|
||||
activeColor,
|
||||
inactiveColor,
|
||||
buttonSize = 48,
|
||||
style,
|
||||
className,
|
||||
onPress,
|
||||
...rest
|
||||
}: ActionButtonProps) {
|
||||
const { isDark, themeVariant } = useTheme();
|
||||
|
||||
const handlePress = async (event: any) => {
|
||||
try {
|
||||
await Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
|
||||
} catch (error) {
|
||||
console.debug('Haptic feedback error:', error);
|
||||
}
|
||||
onPress?.(event);
|
||||
};
|
||||
|
||||
// Bestimme die Farben basierend auf dem Theme
|
||||
const activeColorToUse = activeColor || (isDark ? `var(--color-dark-${themeVariant}-primary)` : `var(--color-${themeVariant}-primary)`);
|
||||
const inactiveColorToUse = inactiveColor || (isDark ? '#333333' : '#F5F5F5');
|
||||
const textColorToUse = active ? '#FFFFFF' : (isDark ? '#FFFFFF' : '#000000');
|
||||
|
||||
return (
|
||||
<Pressable
|
||||
className={`items-center justify-center ${className || ''}`}
|
||||
style={[
|
||||
{
|
||||
width: buttonSize,
|
||||
height: buttonSize,
|
||||
borderRadius: buttonSize / 2,
|
||||
backgroundColor: active ? activeColorToUse : inactiveColorToUse,
|
||||
},
|
||||
style,
|
||||
]}
|
||||
onPress={handlePress}
|
||||
{...rest}
|
||||
>
|
||||
<Icon
|
||||
name={iconName}
|
||||
size={size}
|
||||
color={textColorToUse}
|
||||
/>
|
||||
</Pressable>
|
||||
);
|
||||
}
|
||||
|
||||
export default ActionButton;
|
||||
37
apps/memoro/apps/mobile/components/atoms/Alert.tsx
Normal file
37
apps/memoro/apps/mobile/components/atoms/Alert.tsx
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
import { Alert as RNAlert } from 'react-native';
|
||||
import { AlertButtonProps, IAlertService } from './alert.types';
|
||||
|
||||
/**
|
||||
* Alert component that wraps React Native's Alert API
|
||||
* This is the base implementation used by native platforms
|
||||
*
|
||||
* Provides a cross-platform alert dialog interface
|
||||
*
|
||||
* Example:
|
||||
* ```tsx
|
||||
* Alert.alert(
|
||||
* 'Title',
|
||||
* 'Message',
|
||||
* [
|
||||
* { text: 'Cancel', style: 'cancel' },
|
||||
* { text: 'OK', onPress: () => console.log('OK Pressed') }
|
||||
* ]
|
||||
* );
|
||||
* ```
|
||||
*/
|
||||
class AlertService implements IAlertService {
|
||||
/**
|
||||
* Displays an alert dialog with the specified title, message, and buttons
|
||||
*
|
||||
* @param title The title of the alert
|
||||
* @param message The message to display in the alert
|
||||
* @param buttons Array of buttons to display in the alert
|
||||
*/
|
||||
alert(title: string, message?: string, buttons?: AlertButtonProps[]): void {
|
||||
RNAlert.alert(title, message, buttons);
|
||||
}
|
||||
}
|
||||
|
||||
// Create and export singleton instance
|
||||
const alertService = new AlertService();
|
||||
export default alertService;
|
||||
347
apps/memoro/apps/mobile/components/atoms/BaseModal.tsx
Normal file
347
apps/memoro/apps/mobile/components/atoms/BaseModal.tsx
Normal file
|
|
@ -0,0 +1,347 @@
|
|||
import React, { ReactNode, useState, useEffect } from 'react';
|
||||
import { View, Modal, Pressable, Platform, ScrollView, Dimensions } from 'react-native';
|
||||
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||
import { useTheme } from '~/features/theme/ThemeProvider';
|
||||
import Text from '~/components/atoms/Text';
|
||||
import Icon from '~/components/atoms/Icon';
|
||||
import Button from '~/components/atoms/Button';
|
||||
import colors from 'tailwindcss/colors';
|
||||
|
||||
interface BaseModalProps {
|
||||
isVisible: boolean;
|
||||
onClose: () => void;
|
||||
title: string;
|
||||
children: ReactNode;
|
||||
footerContent?: ReactNode;
|
||||
|
||||
animationType?: 'slide' | 'fade' | 'none';
|
||||
closeOnOverlayPress?: boolean;
|
||||
showCloseButton?: boolean;
|
||||
hideFooter?: boolean;
|
||||
overlayOpacity?: number;
|
||||
position?: 'center' | 'top';
|
||||
scrollable?: boolean;
|
||||
noPadding?: boolean;
|
||||
|
||||
primaryButtonText?: string;
|
||||
secondaryButtonText?: string;
|
||||
onPrimaryButtonPress?: () => void;
|
||||
onSecondaryButtonPress?: () => void;
|
||||
primaryButtonLoading?: boolean;
|
||||
secondaryButtonLoading?: boolean;
|
||||
primaryButtonDisabled?: boolean;
|
||||
secondaryButtonDisabled?: boolean;
|
||||
|
||||
size?: 'small' | 'medium' | 'large' | 'full';
|
||||
maxWidth?: number;
|
||||
}
|
||||
|
||||
// Responsive breakpoints
|
||||
const BREAKPOINTS = {
|
||||
mobile: 768,
|
||||
tablet: 1024,
|
||||
desktop: 1440,
|
||||
} as const;
|
||||
|
||||
// Max widths for different modal sizes
|
||||
const MODAL_MAX_WIDTHS = {
|
||||
small: {
|
||||
mobile: 400,
|
||||
tablet: 400,
|
||||
desktop: 400,
|
||||
},
|
||||
medium: {
|
||||
mobile: 500,
|
||||
tablet: 600,
|
||||
desktop: 700,
|
||||
},
|
||||
large: {
|
||||
mobile: 600,
|
||||
tablet: 700,
|
||||
desktop: 800,
|
||||
},
|
||||
full: {
|
||||
mobile: '90%',
|
||||
tablet: '85%',
|
||||
desktop: '80%',
|
||||
},
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* Base Modal component that provides a consistent layout and behavior for all modals in the app.
|
||||
*
|
||||
* Features:
|
||||
* - Standardized header with title and close button
|
||||
* - Flexible content area
|
||||
* - Optional footer with configurable buttons
|
||||
* - Theme-aware styling
|
||||
* - Customizable animation and behavior
|
||||
*/
|
||||
const BaseModal: React.FC<BaseModalProps> = ({
|
||||
isVisible,
|
||||
onClose,
|
||||
title,
|
||||
children,
|
||||
footerContent,
|
||||
|
||||
animationType = 'none',
|
||||
closeOnOverlayPress = true,
|
||||
showCloseButton = true,
|
||||
hideFooter = false,
|
||||
overlayOpacity = 0.5,
|
||||
position = 'center',
|
||||
scrollable = true,
|
||||
|
||||
primaryButtonText,
|
||||
secondaryButtonText,
|
||||
onPrimaryButtonPress,
|
||||
onSecondaryButtonPress,
|
||||
primaryButtonLoading = false,
|
||||
secondaryButtonLoading = false,
|
||||
primaryButtonDisabled = false,
|
||||
noPadding = false,
|
||||
secondaryButtonDisabled = false,
|
||||
size = 'medium',
|
||||
maxWidth,
|
||||
}) => {
|
||||
const { isDark, themeVariant } = useTheme();
|
||||
const insets = useSafeAreaInsets();
|
||||
const [windowWidth, setWindowWidth] = useState(() =>
|
||||
Platform.OS === 'web' ? Dimensions.get('window').width : 0
|
||||
);
|
||||
|
||||
// Update window width on resize
|
||||
useEffect(() => {
|
||||
if (Platform.OS !== 'web') return;
|
||||
|
||||
const updateWindowWidth = () => {
|
||||
setWindowWidth(Dimensions.get('window').width);
|
||||
};
|
||||
|
||||
const subscription = Dimensions.addEventListener('change', updateWindowWidth);
|
||||
return () => subscription?.remove();
|
||||
}, []);
|
||||
|
||||
// Debug borders (set to true to enable)
|
||||
const DEBUG_BORDERS = false;
|
||||
|
||||
// Holen der Theme-Farben basierend auf der aktuellen Variante
|
||||
const themeColors = (colors as any).theme?.extend?.colors as Record<string, any>;
|
||||
|
||||
// Hintergrundfarbe für das Modal basierend auf dem Theme (wie Header)
|
||||
const modalBgColor = isDark
|
||||
? themeColors?.dark?.[themeVariant]?.menuBackground || '#252525'
|
||||
: themeColors?.[themeVariant]?.menuBackground || '#DDDDDD';
|
||||
|
||||
// Textfarbe basierend auf dem Theme
|
||||
const textColor = isDark
|
||||
? themeColors?.dark?.[themeVariant]?.text || '#FFFFFF'
|
||||
: themeColors?.[themeVariant]?.text || '#000000';
|
||||
|
||||
// Primärfarbe für Akzente
|
||||
const primaryColor = isDark
|
||||
? themeColors?.dark?.[themeVariant]?.primary || '#f8d62b'
|
||||
: themeColors?.[themeVariant]?.primary || '#f8d62b';
|
||||
|
||||
// Rahmenfarbe basierend auf dem Theme
|
||||
const borderColor = isDark
|
||||
? themeColors?.dark?.[themeVariant]?.border || '#424242'
|
||||
: themeColors?.[themeVariant]?.border || '#e6e6e6';
|
||||
|
||||
// Standardmäßige Footer-Buttons, wenn keine benutzerdefinierten bereitgestellt werden
|
||||
const renderDefaultFooter = () => {
|
||||
if (!primaryButtonText && !secondaryButtonText) return null;
|
||||
|
||||
return (
|
||||
<View
|
||||
className='flex-row justify-between items-center w-full'
|
||||
style={DEBUG_BORDERS ? { borderWidth: 2, borderColor: 'yellow' } : {}}
|
||||
>
|
||||
{secondaryButtonText && (
|
||||
<Button
|
||||
variant='secondary'
|
||||
title={secondaryButtonText}
|
||||
onPress={onSecondaryButtonPress || onClose}
|
||||
loading={secondaryButtonLoading}
|
||||
disabled={secondaryButtonDisabled}
|
||||
style={{
|
||||
flex: 1,
|
||||
marginRight: 12,
|
||||
...(DEBUG_BORDERS ? { borderWidth: 2, borderColor: 'cyan' } : {})
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{primaryButtonText && (
|
||||
<Button
|
||||
variant='primary'
|
||||
title={primaryButtonText}
|
||||
onPress={onPrimaryButtonPress}
|
||||
loading={primaryButtonLoading}
|
||||
disabled={primaryButtonDisabled}
|
||||
style={{
|
||||
flex: 1,
|
||||
...(DEBUG_BORDERS ? { borderWidth: 2, borderColor: 'magenta' } : {})
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
const maxModalHeight = Platform.select({
|
||||
ios: `${100 - ((insets.top + insets.bottom) / 8)}%`,
|
||||
android: '90%',
|
||||
default: '90%'
|
||||
});
|
||||
|
||||
// Calculate responsive max width
|
||||
const getResponsiveMaxWidth = () => {
|
||||
// Priority 1: Explicit maxWidth prop
|
||||
if (maxWidth !== undefined) return maxWidth;
|
||||
|
||||
// Priority 2: Size-based responsive widths
|
||||
const sizeConfig = MODAL_MAX_WIDTHS[size];
|
||||
|
||||
if (Platform.OS !== 'web') {
|
||||
// On mobile platforms, use mobile size
|
||||
return typeof sizeConfig.mobile === 'string' ? sizeConfig.mobile : sizeConfig.mobile;
|
||||
}
|
||||
|
||||
// Web: responsive based on window width
|
||||
if (windowWidth < BREAKPOINTS.mobile) {
|
||||
return sizeConfig.mobile;
|
||||
} else if (windowWidth < BREAKPOINTS.tablet) {
|
||||
return sizeConfig.tablet;
|
||||
} else {
|
||||
return sizeConfig.desktop;
|
||||
}
|
||||
};
|
||||
|
||||
const responsiveMaxWidth = getResponsiveMaxWidth();
|
||||
|
||||
return (
|
||||
<Modal
|
||||
visible={isVisible}
|
||||
animationType={animationType}
|
||||
transparent={true}
|
||||
onRequestClose={onClose}
|
||||
>
|
||||
<View
|
||||
style={{
|
||||
flex: 1,
|
||||
justifyContent: position === 'top' ? 'flex-start' : 'center',
|
||||
alignItems: 'center',
|
||||
backgroundColor: `rgba(0, 0, 0, ${overlayOpacity})`,
|
||||
paddingTop: position === 'top' ? insets.top + 20 : insets.top,
|
||||
paddingBottom: insets.bottom,
|
||||
paddingHorizontal: 16
|
||||
}}
|
||||
>
|
||||
{closeOnOverlayPress && (
|
||||
<Pressable
|
||||
className='absolute inset-0'
|
||||
onPress={onClose}
|
||||
/>
|
||||
)}
|
||||
<View
|
||||
style={{
|
||||
backgroundColor: modalBgColor,
|
||||
borderWidth: DEBUG_BORDERS ? 3 : 1,
|
||||
borderColor: DEBUG_BORDERS ? 'red' : borderColor,
|
||||
maxHeight: maxModalHeight,
|
||||
width: '100%',
|
||||
maxWidth: responsiveMaxWidth
|
||||
}}
|
||||
className='rounded-2xl shadow-lg overflow-hidden'
|
||||
>
|
||||
{/* Header mit Padding */}
|
||||
<View
|
||||
className='px-5 pt-5'
|
||||
style={DEBUG_BORDERS ? { borderWidth: 2, borderColor: 'blue' } : {}}
|
||||
>
|
||||
<View className='flex-row justify-between items-center mb-4'>
|
||||
<Text
|
||||
style={{ color: textColor }}
|
||||
className='text-xl font-bold flex-1 mr-2'
|
||||
numberOfLines={1}
|
||||
>
|
||||
{title}
|
||||
</Text>
|
||||
{showCloseButton && (
|
||||
<Pressable
|
||||
className='p-1'
|
||||
onPress={onClose}
|
||||
accessibilityRole='button'
|
||||
accessibilityLabel='Close'
|
||||
>
|
||||
<Icon
|
||||
name='close'
|
||||
size={Platform.OS === 'ios' ? 20 : 24}
|
||||
color={textColor}
|
||||
/>
|
||||
</Pressable>
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* Divider nach dem Header - volle Breite */}
|
||||
<View style={{ height: 1, backgroundColor: isDark ? '#444444' : '#DDDDDD', width: '100%' }} />
|
||||
|
||||
{/* Content mit Padding - scrollbar wenn nötig */}
|
||||
{scrollable ? (
|
||||
<ScrollView
|
||||
style={{
|
||||
flexGrow: 1,
|
||||
...(DEBUG_BORDERS ? { borderWidth: 2, borderColor: 'green' } : {})
|
||||
}}
|
||||
showsVerticalScrollIndicator={true}
|
||||
bounces={false}
|
||||
contentContainerStyle={{ flexGrow: 1 }}
|
||||
>
|
||||
<View
|
||||
className={noPadding ? 'w-full' : 'px-5 pt-6 pb-4'}
|
||||
style={DEBUG_BORDERS ? { borderWidth: 2, borderColor: 'orange' } : {}}
|
||||
>
|
||||
<View className='w-full'>
|
||||
{children}
|
||||
</View>
|
||||
</View>
|
||||
</ScrollView>
|
||||
) : (
|
||||
<View
|
||||
style={{
|
||||
flexGrow: 1,
|
||||
...(DEBUG_BORDERS ? { borderWidth: 2, borderColor: 'green' } : {})
|
||||
}}
|
||||
className={noPadding ? 'w-full' : 'px-5 pt-6 pb-4'}
|
||||
>
|
||||
<View
|
||||
className='w-full'
|
||||
style={DEBUG_BORDERS ? { borderWidth: 2, borderColor: 'orange' } : {}}
|
||||
>
|
||||
{children}
|
||||
</View>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* Divider vor dem Footer - volle Breite */}
|
||||
{!hideFooter && (
|
||||
<View style={{ height: 1, backgroundColor: isDark ? '#444444' : '#DDDDDD', width: '100%' }} />
|
||||
)}
|
||||
|
||||
{/* Footer mit Padding - immer sichtbar am unteren Rand */}
|
||||
{!hideFooter && (
|
||||
<View
|
||||
className='w-full px-5 py-4'
|
||||
style={DEBUG_BORDERS ? { borderWidth: 2, borderColor: 'purple' } : {}}
|
||||
>
|
||||
{footerContent || renderDefaultFooter()}
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
export default BaseModal;
|
||||
231
apps/memoro/apps/mobile/components/atoms/Button.tsx
Normal file
231
apps/memoro/apps/mobile/components/atoms/Button.tsx
Normal file
|
|
@ -0,0 +1,231 @@
|
|||
import React, { forwardRef, useState } from 'react';
|
||||
import {
|
||||
Pressable,
|
||||
View,
|
||||
PressableProps,
|
||||
ViewStyle,
|
||||
TextStyle,
|
||||
Platform,
|
||||
} from 'react-native';
|
||||
import * as Haptics from 'expo-haptics';
|
||||
import Text from './Text';
|
||||
import { useTheme } from '~/features/theme/ThemeProvider';
|
||||
import Icon from './Icon';
|
||||
|
||||
|
||||
interface ButtonProps extends Omit<PressableProps, 'style'> {
|
||||
title?: string;
|
||||
variant?: 'primary' | 'secondary' | 'icon' | 'text' | 'danger';
|
||||
iconName?: string;
|
||||
iconSize?: number;
|
||||
iconColor?: string;
|
||||
leftIcon?: () => React.ReactNode;
|
||||
iconButtonStyle?: boolean;
|
||||
active?: boolean;
|
||||
textStyle?: TextStyle;
|
||||
noBorder?: boolean;
|
||||
disabled?: boolean;
|
||||
loading?: boolean;
|
||||
style?: ViewStyle | ViewStyle[];
|
||||
numberOfLines?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Button-Komponente
|
||||
*
|
||||
* Eine flexible Button-Komponente, die verschiedene Varianten und Stile unterstützt.
|
||||
*
|
||||
* Beispiel:
|
||||
* ```tsx
|
||||
* <Button title="Klick mich" variant="primary" />
|
||||
* <Button title="Abbrechen" variant="secondary" />
|
||||
* <Button iconName="trash-outline" variant="danger" />
|
||||
* <Button iconName="settings-outline" variant="icon" iconButtonStyle />
|
||||
* ```
|
||||
*/
|
||||
const Button = forwardRef<View, ButtonProps>(
|
||||
({
|
||||
title,
|
||||
variant = 'primary',
|
||||
iconName,
|
||||
iconSize,
|
||||
iconColor,
|
||||
leftIcon,
|
||||
style,
|
||||
iconButtonStyle = false,
|
||||
active = false,
|
||||
textStyle,
|
||||
noBorder = false,
|
||||
disabled = false,
|
||||
loading = false,
|
||||
numberOfLines,
|
||||
onPress,
|
||||
...props
|
||||
}, ref) => {
|
||||
const { isDark, themeVariant, colors } = useTheme();
|
||||
const [isHovered, setIsHovered] = useState(false);
|
||||
|
||||
// Haptic feedback for button press
|
||||
const triggerButtonHaptic = async () => {
|
||||
try {
|
||||
await Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
|
||||
} catch (error) {
|
||||
console.debug('Haptic feedback error:', error);
|
||||
}
|
||||
};
|
||||
|
||||
// Enhanced onPress handler with haptic feedback
|
||||
const handlePress = async (event: any) => {
|
||||
if (!disabled && !loading) {
|
||||
await triggerButtonHaptic();
|
||||
onPress?.(event);
|
||||
}
|
||||
};
|
||||
|
||||
// Get theme colors based on the current theme variant
|
||||
const getThemeColor = (isDarkMode: boolean, variant: string) => {
|
||||
// Use the actual theme colors from the provider instead of hardcoded fallbacks
|
||||
return colors.primaryButton;
|
||||
};
|
||||
|
||||
// Get primaryButton color from the current theme
|
||||
const getPrimaryButtonColor = () => {
|
||||
// Debug logging to see what colors we're getting
|
||||
// @Till kannsch wieder rein machen wenns brauchsch ansonsten spamt mir das die console zu.r
|
||||
// if (__DEV__ && variant === 'primary') {
|
||||
// console.debug('🎨 Button primaryButton color:', colors.primaryButton, 'isDark:', isDark, 'themeVariant:', themeVariant);
|
||||
// }
|
||||
return colors.primaryButton;
|
||||
};
|
||||
|
||||
// Get button hover color - etwas dunklere Version der primaryButton-Farbe
|
||||
const getButtonHoverColor = () => {
|
||||
// For now, use the same color as primary button
|
||||
// Could be extended later to support hover variants
|
||||
return colors.primaryButton;
|
||||
};
|
||||
|
||||
// Determine colors based on theme and variant
|
||||
function getBackgroundColor() {
|
||||
if (disabled || loading) return isDark ? '#333333' : '#EEEEEE';
|
||||
if (variant === 'text') return 'transparent';
|
||||
if (variant === 'primary') return isHovered ? getButtonHoverColor() : getPrimaryButtonColor();
|
||||
if (active) return getThemeColor(isDark, themeVariant);
|
||||
if (variant === 'danger') return '#e74c3c';
|
||||
if (variant === 'secondary') return isHovered ? colors.contentBackgroundHover : colors.secondaryButton;
|
||||
return isDark ? '#1E1E1E' : '#FFFFFF';
|
||||
}
|
||||
|
||||
// Get border color from the current theme
|
||||
const getThemeBorderColor = () => {
|
||||
return colors.borderStrong || colors.border;
|
||||
};
|
||||
|
||||
function getBorderColor() {
|
||||
if (disabled || loading) return isDark ? '#444444' : '#DDDDDD';
|
||||
if (noBorder) return 'transparent';
|
||||
if (variant === 'primary') return getPrimaryButtonColor(); // Use same color as background for primary buttons
|
||||
if (active) return getThemeColor(isDark, themeVariant);
|
||||
if (variant === 'danger') return '#e74c3c';
|
||||
if (variant === 'secondary') return colors.border;
|
||||
return isDark ? '#444444' : '#DDDDDD';
|
||||
}
|
||||
|
||||
// Get button text color from the current theme
|
||||
const getButtonTextColor = () => {
|
||||
return colors.primaryButtonText;
|
||||
};
|
||||
|
||||
function getTextColor() {
|
||||
if (disabled || loading) return isDark ? '#777777' : '#999999';
|
||||
if (variant === 'primary') return getButtonTextColor();
|
||||
if (variant === 'danger') return '#FFFFFF';
|
||||
if (variant === 'secondary') return colors.text;
|
||||
if (variant === 'text') return colors.text; // Use theme text color for text variant
|
||||
return getThemeColor(isDark, themeVariant);
|
||||
}
|
||||
|
||||
// Determine the icon color - should match text color
|
||||
const defaultIconColor = getTextColor();
|
||||
let finalIconColor = iconColor || defaultIconColor;
|
||||
if (disabled) {
|
||||
finalIconColor = isDark ? '#777777' : '#999999';
|
||||
}
|
||||
|
||||
const finalIconSize = iconSize || 20;
|
||||
|
||||
// Hover-Props für Web
|
||||
const hoverProps = Platform.OS === 'web'
|
||||
? {
|
||||
onMouseEnter: () => setIsHovered(true),
|
||||
onMouseLeave: () => setIsHovered(false),
|
||||
}
|
||||
: {};
|
||||
|
||||
return (
|
||||
<Pressable
|
||||
ref={ref}
|
||||
style={[
|
||||
{
|
||||
// Base styles
|
||||
width: iconButtonStyle ? 48 : undefined,
|
||||
height: 48,
|
||||
paddingHorizontal: iconButtonStyle ? 0 : 16,
|
||||
borderRadius: 8,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
borderWidth: variant === 'text' ? 0 : 1,
|
||||
// Theme styles
|
||||
backgroundColor: getBackgroundColor(),
|
||||
borderColor: getBorderColor(),
|
||||
opacity: disabled || loading ? 0.5 : 1,
|
||||
},
|
||||
style
|
||||
]}
|
||||
disabled={disabled || loading}
|
||||
onPress={handlePress}
|
||||
accessibilityRole="button"
|
||||
accessibilityState={{ disabled: disabled || loading }}
|
||||
{...hoverProps}
|
||||
{...props}
|
||||
>
|
||||
<View style={{
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
flex: 1,
|
||||
minWidth: 0,
|
||||
}}>
|
||||
{leftIcon && leftIcon()}
|
||||
|
||||
{iconName && (
|
||||
<View style={{ marginRight: title ? 8 : 0 }}>
|
||||
<Icon
|
||||
name={loading ? 'sync-outline' : iconName}
|
||||
size={finalIconSize}
|
||||
color={finalIconColor}
|
||||
/>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{title && !iconButtonStyle && (
|
||||
<Text
|
||||
variant="body"
|
||||
style={[
|
||||
{ color: getTextColor(), fontWeight: '500' },
|
||||
textStyle
|
||||
]}
|
||||
numberOfLines={numberOfLines}
|
||||
>
|
||||
{title}
|
||||
</Text>
|
||||
)}
|
||||
</View>
|
||||
</Pressable>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
Button.displayName = 'Button';
|
||||
|
||||
export default Button;
|
||||
71
apps/memoro/apps/mobile/components/atoms/Divider.tsx
Normal file
71
apps/memoro/apps/mobile/components/atoms/Divider.tsx
Normal file
|
|
@ -0,0 +1,71 @@
|
|||
import React from 'react';
|
||||
import { View, ViewStyle, DimensionValue } from 'react-native';
|
||||
import { useTheme } from '~/features/theme/ThemeProvider';
|
||||
|
||||
interface DividerProps {
|
||||
orientation?: 'horizontal' | 'vertical';
|
||||
color?: string;
|
||||
thickness?: number;
|
||||
length?: DimensionValue;
|
||||
spacing?: 'small' | 'medium' | 'large';
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Divider-Komponente
|
||||
*
|
||||
* Eine einfache Trennlinie, die horizontal oder vertikal dargestellt werden kann.
|
||||
*
|
||||
* Beispiel:
|
||||
* ```tsx
|
||||
* <Divider />
|
||||
* <Divider orientation="vertical" length={24} />
|
||||
* <Divider spacing="large" />
|
||||
* ```
|
||||
*/
|
||||
function Divider({
|
||||
orientation = 'horizontal',
|
||||
color,
|
||||
thickness = 1,
|
||||
length = '100%',
|
||||
spacing = 'medium',
|
||||
className,
|
||||
}: DividerProps) {
|
||||
const { isDark, themeVariant } = useTheme();
|
||||
|
||||
// Bestimme die Farbe basierend auf dem Theme
|
||||
const dividerColor = color || (isDark ? '#444444' : '#DDDDDD');
|
||||
|
||||
const isHorizontal = orientation === 'horizontal';
|
||||
|
||||
// Bestimme den Abstand basierend auf dem Spacing-Parameter
|
||||
const getSpacing = () => {
|
||||
switch (spacing) {
|
||||
case 'small': return 4;
|
||||
case 'large': return 16;
|
||||
case 'medium':
|
||||
default: return 8;
|
||||
}
|
||||
};
|
||||
|
||||
const spacingValue = getSpacing();
|
||||
|
||||
// Erstelle das Style-Objekt basierend auf der Orientierung
|
||||
const dividerStyle: ViewStyle = isHorizontal
|
||||
? {
|
||||
backgroundColor: dividerColor,
|
||||
height: thickness,
|
||||
width: length,
|
||||
marginVertical: spacingValue,
|
||||
}
|
||||
: {
|
||||
backgroundColor: dividerColor,
|
||||
width: thickness,
|
||||
height: length,
|
||||
marginHorizontal: spacingValue,
|
||||
};
|
||||
|
||||
return <View style={dividerStyle} className={className} />;
|
||||
}
|
||||
|
||||
export default Divider;
|
||||
172
apps/memoro/apps/mobile/components/atoms/HighlightedText.tsx
Normal file
172
apps/memoro/apps/mobile/components/atoms/HighlightedText.tsx
Normal file
|
|
@ -0,0 +1,172 @@
|
|||
import React from 'react';
|
||||
import { Text } from 'react-native';
|
||||
import { useTheme } from '~/features/theme/ThemeProvider';
|
||||
|
||||
interface HighlightedTextProps {
|
||||
text: string;
|
||||
searchQuery: string;
|
||||
style?: any;
|
||||
highlightStyle?: any;
|
||||
numberOfLines?: number;
|
||||
ellipsizeMode?: 'head' | 'middle' | 'tail' | 'clip';
|
||||
currentResultIndex?: number;
|
||||
searchResults?: Array<{id: string; type: string; text: string; index: number; matchIndex: number}>;
|
||||
textType?: string; // 'title', 'intro', 'transcript', etc.
|
||||
}
|
||||
|
||||
/**
|
||||
* HighlightedText Component
|
||||
*
|
||||
* Renders text with highlighted search matches.
|
||||
* Supports case-insensitive search highlighting.
|
||||
*/
|
||||
const HighlightedText: React.FC<HighlightedTextProps> = (props) => {
|
||||
// Destructure with safe defaults
|
||||
const {
|
||||
text = '',
|
||||
searchQuery = '',
|
||||
style,
|
||||
highlightStyle,
|
||||
numberOfLines,
|
||||
ellipsizeMode = 'tail',
|
||||
currentResultIndex,
|
||||
searchResults = [],
|
||||
textType = ''
|
||||
} = props || {};
|
||||
|
||||
let isDark = false;
|
||||
let themeVariant = 'lume';
|
||||
|
||||
// Safely try to get theme
|
||||
try {
|
||||
const theme = useTheme();
|
||||
isDark = theme?.isDark || false;
|
||||
themeVariant = theme?.themeVariant || 'lume';
|
||||
} catch (error) {
|
||||
console.warn('Theme hook failed, using defaults');
|
||||
}
|
||||
|
||||
const getHighlightedText = () => {
|
||||
// Early return if no text or search query
|
||||
if (!text || !searchQuery?.trim()) {
|
||||
return [{ text: text || '', isHighlighted: false, isCurrent: false }];
|
||||
}
|
||||
|
||||
try {
|
||||
// Create regex safely
|
||||
const escapedQuery = String(searchQuery).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
const regex = new RegExp(`(${escapedQuery})`, 'gi');
|
||||
|
||||
// Split text safely
|
||||
const parts = String(text).split(regex);
|
||||
|
||||
// If no valid search results, just highlight all matches in yellow
|
||||
if (!searchResults || !Array.isArray(searchResults) || searchResults.length === 0) {
|
||||
return parts.map((part, index) => ({
|
||||
text: part || '',
|
||||
isHighlighted: index % 2 === 1,
|
||||
isCurrent: false
|
||||
}));
|
||||
}
|
||||
|
||||
// Check if we have a valid current result index
|
||||
const validCurrentIndex = typeof currentResultIndex === 'number' &&
|
||||
currentResultIndex >= 0 &&
|
||||
currentResultIndex < searchResults.length;
|
||||
|
||||
if (!validCurrentIndex) {
|
||||
// No valid current index, highlight all in yellow
|
||||
return parts.map((part, index) => ({
|
||||
text: part || '',
|
||||
isHighlighted: index % 2 === 1,
|
||||
isCurrent: false
|
||||
}));
|
||||
}
|
||||
|
||||
// Get current result safely
|
||||
const currentResult = searchResults[currentResultIndex];
|
||||
if (!currentResult || !textType) {
|
||||
return parts.map((part, index) => ({
|
||||
text: part || '',
|
||||
isHighlighted: index % 2 === 1,
|
||||
isCurrent: false
|
||||
}));
|
||||
}
|
||||
|
||||
// Check if this text matches the current result
|
||||
const isCurrentTextAndType = currentResult.type === textType && currentResult.text === text;
|
||||
|
||||
let currentMatchIndexInThisText = -1;
|
||||
if (isCurrentTextAndType) {
|
||||
try {
|
||||
// Count matches in this text that come before the current result
|
||||
currentMatchIndexInThisText = searchResults
|
||||
.slice(0, currentResultIndex)
|
||||
.filter(r => r && r.type === textType && r.text === text)
|
||||
.length;
|
||||
} catch (error) {
|
||||
currentMatchIndexInThisText = -1;
|
||||
}
|
||||
}
|
||||
|
||||
let matchCounter = 0;
|
||||
|
||||
return parts.map((part, index) => {
|
||||
const isMatch = index % 2 === 1;
|
||||
let isCurrent = false;
|
||||
|
||||
if (isMatch) {
|
||||
isCurrent = isCurrentTextAndType && matchCounter === currentMatchIndexInThisText;
|
||||
matchCounter++;
|
||||
}
|
||||
|
||||
return {
|
||||
text: part || '',
|
||||
isHighlighted: isMatch,
|
||||
isCurrent: isCurrent
|
||||
};
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
console.warn('Error in getHighlightedText:', error);
|
||||
// Fallback: return text without highlighting
|
||||
return [{ text: text || '', isHighlighted: false, isCurrent: false }];
|
||||
}
|
||||
};
|
||||
|
||||
try {
|
||||
const textParts = getHighlightedText();
|
||||
|
||||
return (
|
||||
<Text style={style} numberOfLines={numberOfLines} ellipsizeMode={ellipsizeMode}>
|
||||
{textParts.map((part, index) => {
|
||||
let partHighlightStyle = undefined;
|
||||
|
||||
if (part.isHighlighted) {
|
||||
const backgroundColor = part.isCurrent
|
||||
? (isDark ? '#FF6B35' : '#FF9500') // Orange for current result
|
||||
: (isDark ? '#FFD60A' : '#FFEB3B'); // Yellow for other results
|
||||
|
||||
partHighlightStyle = {
|
||||
backgroundColor,
|
||||
color: isDark ? '#000000' : '#000000',
|
||||
fontWeight: '600',
|
||||
};
|
||||
}
|
||||
|
||||
return (
|
||||
<Text key={index} style={partHighlightStyle}>
|
||||
{part.text}
|
||||
</Text>
|
||||
);
|
||||
})}
|
||||
</Text>
|
||||
);
|
||||
} catch (error) {
|
||||
console.warn('Error rendering HighlightedText:', error);
|
||||
// Ultimate fallback: just render plain text
|
||||
return <Text style={style}>{text || ''}</Text>;
|
||||
}
|
||||
};
|
||||
|
||||
export default HighlightedText;
|
||||
190
apps/memoro/apps/mobile/components/atoms/Icon.tsx
Normal file
190
apps/memoro/apps/mobile/components/atoms/Icon.tsx
Normal file
|
|
@ -0,0 +1,190 @@
|
|||
import React, { useMemo } from 'react';
|
||||
import { View, Pressable, PressableProps, Platform } from 'react-native';
|
||||
import { Ionicons } from '@expo/vector-icons';
|
||||
import * as Haptics from 'expo-haptics';
|
||||
import { useTheme } from '~/features/theme/ThemeProvider';
|
||||
import { getIconMapping } from '~/features/icons/iconMapping';
|
||||
|
||||
// Conditionally import SymbolView only on iOS
|
||||
let SymbolView: any = null;
|
||||
if (Platform.OS === 'ios') {
|
||||
try {
|
||||
SymbolView = require('expo-symbols').SymbolView;
|
||||
} catch (e) {
|
||||
// expo-symbols not available, will use Ionicons
|
||||
}
|
||||
}
|
||||
|
||||
// Unterstützte Icon-Typen
|
||||
type IconType = 'ionicons';
|
||||
|
||||
// Props für die Icon-Komponente
|
||||
type IconProps = {
|
||||
// Icon-Name (aus Ionicons)
|
||||
name: string;
|
||||
// Größe des Icons
|
||||
size?: number;
|
||||
// Farbe des Icons (überschreibt die Theme-Farbe)
|
||||
color?: string;
|
||||
// Automatisch die Theme-Farbe verwenden
|
||||
useThemeColor?: boolean;
|
||||
// Zusätzliche Tailwind-Klassen
|
||||
className?: string;
|
||||
// Icon-Typ (für zukünftige Erweiterungen)
|
||||
type?: IconType;
|
||||
// Als Button anzeigen
|
||||
asButton?: boolean;
|
||||
// Hintergrundfarbe für Button-Variante
|
||||
buttonBackgroundColor?: string;
|
||||
// Button-Eigenschaften, wenn asButton=true
|
||||
onPress?: PressableProps['onPress'];
|
||||
// Zusätzliche Tailwind-Klassen für den Button-Container
|
||||
buttonClassName?: string;
|
||||
// Deaktiviert den Button
|
||||
disabled?: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
* Icon-Komponente
|
||||
*
|
||||
* Ein Wrapper für verschiedene Icon-Bibliotheken, der automatisch die Theme-Farben verwendet.
|
||||
* Standardmäßig wird Ionicons verwendet.
|
||||
*
|
||||
* Beispiel:
|
||||
* ```tsx
|
||||
* <Icon name="heart" size={24} />
|
||||
* <Icon name="settings-outline" size={20} color="red" />
|
||||
* <Icon name="home" className="ml-2" useThemeColor />
|
||||
* ```
|
||||
*/
|
||||
/**
|
||||
* Icon-Komponente mit Memoization für bessere Performance
|
||||
*
|
||||
* Kann auch als Button verwendet werden, indem asButton=true gesetzt wird
|
||||
* und onPress übergeben wird.
|
||||
*/
|
||||
export const Icon: React.FC<IconProps> = React.memo(({
|
||||
name,
|
||||
size = 24,
|
||||
color,
|
||||
useThemeColor = false,
|
||||
className = '',
|
||||
type = 'ionicons',
|
||||
asButton = false,
|
||||
buttonBackgroundColor,
|
||||
onPress,
|
||||
buttonClassName = '',
|
||||
disabled = false,
|
||||
}: IconProps) => {
|
||||
const { tw, themeVariant, isDark, themeVersion } = useTheme();
|
||||
|
||||
// Bestimme die Farbe basierend auf den Props und dem Theme
|
||||
// Verwende useMemo, um die Farbe bei Theme-Änderungen neu zu berechnen
|
||||
const iconColor = useMemo(() => {
|
||||
if (useThemeColor) {
|
||||
// Verwende die primäre Themefarbe, wenn keine Farbe angegeben ist
|
||||
return isDark
|
||||
? `var(--color-dark-${themeVariant}-primary)`
|
||||
: `var(--color-${themeVariant}-primary)`;
|
||||
} else if (!color && className) {
|
||||
// Versuche, die Farbe aus den Tailwind-Klassen zu extrahieren
|
||||
const colorClass = className.match(/text-([a-z0-9-]+)/)?.[0];
|
||||
if (colorClass) {
|
||||
const twClass = tw(colorClass);
|
||||
// Extrahiere die Farbe aus der generierten Klasse
|
||||
return twClass.includes('text-dark-')
|
||||
? `var(--color-${twClass.replace('text-dark-', '')})`
|
||||
: `var(--color-${twClass.replace('text-', '')})`;
|
||||
}
|
||||
}
|
||||
return color;
|
||||
}, [color, className, useThemeColor, themeVariant, isDark, tw, themeVersion]);
|
||||
|
||||
// Rendere das Icon basierend auf dem Typ
|
||||
const renderIcon = () => {
|
||||
// Check if we should use SF Symbols on iOS
|
||||
if (Platform.OS === 'ios' && SymbolView) {
|
||||
const mapping = getIconMapping(name);
|
||||
if (mapping?.sfSymbol) {
|
||||
// Calculate proper scale for SF Symbols based on size
|
||||
let symbolScale = 'medium';
|
||||
if (size <= 16) {
|
||||
symbolScale = 'small';
|
||||
} else if (size >= 32) {
|
||||
symbolScale = 'large';
|
||||
}
|
||||
|
||||
return (
|
||||
<SymbolView
|
||||
name={mapping.sfSymbol}
|
||||
scale={symbolScale}
|
||||
tintColor={iconColor}
|
||||
style={{ width: size, height: size }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Default to Ionicons
|
||||
const mapping = getIconMapping(name);
|
||||
const ioniconName = mapping?.ionicon || name;
|
||||
|
||||
return (
|
||||
<Ionicons
|
||||
name={ioniconName as any}
|
||||
size={size}
|
||||
color={iconColor}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
// Handler für Button-Press mit Haptic Feedback
|
||||
const handlePress = async () => {
|
||||
if (!disabled && onPress) {
|
||||
try {
|
||||
await Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
|
||||
} catch (error) {
|
||||
console.debug('Haptic feedback error:', error);
|
||||
}
|
||||
onPress();
|
||||
}
|
||||
};
|
||||
|
||||
// Wenn als Button angezeigt werden soll
|
||||
if (asButton) {
|
||||
return (
|
||||
<Pressable
|
||||
onPress={handlePress}
|
||||
disabled={disabled}
|
||||
style={({ pressed }) => [
|
||||
{
|
||||
padding: 8,
|
||||
opacity: pressed ? 0.7 : 1,
|
||||
}
|
||||
]}
|
||||
>
|
||||
<View
|
||||
style={{
|
||||
width: 36,
|
||||
height: 36,
|
||||
borderRadius: 8,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
backgroundColor: buttonBackgroundColor
|
||||
}}
|
||||
>
|
||||
{renderIcon()}
|
||||
</View>
|
||||
</Pressable>
|
||||
);
|
||||
}
|
||||
|
||||
// Standard-Icon ohne Button
|
||||
return (
|
||||
<View style={className ? tw(className) : undefined}>
|
||||
{renderIcon()}
|
||||
</View>
|
||||
);
|
||||
});
|
||||
|
||||
export default Icon as React.FC<IconProps>;
|
||||
142
apps/memoro/apps/mobile/components/atoms/Input.tsx
Normal file
142
apps/memoro/apps/mobile/components/atoms/Input.tsx
Normal file
|
|
@ -0,0 +1,142 @@
|
|||
import React, { forwardRef, useState } from 'react';
|
||||
import { TextInput, TextInputProps, View, TouchableOpacity } from 'react-native';
|
||||
import { useTheme } from '~/features/theme/ThemeProvider';
|
||||
import { Icon } from './Icon';
|
||||
import colors from 'tailwindcss/colors';
|
||||
|
||||
interface InputProps extends Omit<TextInputProps, 'placeholderTextColor'> {
|
||||
value: string;
|
||||
onChangeText: (text: string) => void;
|
||||
placeholder?: string;
|
||||
secureTextEntry?: boolean;
|
||||
multiline?: boolean;
|
||||
numberOfLines?: number;
|
||||
textArea?: boolean; // Neue Eigenschaft für größere Texteingabefelder
|
||||
minHeight?: number; // Optionale benutzerdefinierte Mindesthöhe
|
||||
showPasswordToggle?: boolean; // Neue Eigenschaft für Passwort-Toggle
|
||||
textContentType?: TextInputProps['textContentType']; // iOS autofill support
|
||||
autoComplete?: TextInputProps['autoComplete']; // Android autofill support
|
||||
}
|
||||
|
||||
// Keine StyleSheet-Styles mehr, wir verwenden NativeWind und direkte Style-Objekte
|
||||
|
||||
const Input = forwardRef<TextInput, InputProps>((props, ref) => {
|
||||
const {
|
||||
value,
|
||||
onChangeText,
|
||||
placeholder = '',
|
||||
secureTextEntry = false,
|
||||
showPasswordToggle = false,
|
||||
style,
|
||||
...restProps
|
||||
} = props;
|
||||
|
||||
const [isPasswordVisible, setIsPasswordVisible] = useState(false);
|
||||
|
||||
const { isDark, themeVariant } = useTheme();
|
||||
|
||||
// Holen der Theme-Farben basierend auf der aktuellen Variante
|
||||
const themeColors = (colors as any).theme?.extend?.colors as Record<string, any>;
|
||||
|
||||
// Textfarbe basierend auf dem Theme
|
||||
const textColor = isDark
|
||||
? themeColors?.dark?.[themeVariant]?.text || '#FFFFFF'
|
||||
: themeColors?.[themeVariant]?.text || '#000000';
|
||||
|
||||
// Rahmenfarbe basierend auf dem Theme
|
||||
const borderColor = isDark
|
||||
? themeColors?.dark?.[themeVariant]?.border || '#424242'
|
||||
: themeColors?.[themeVariant]?.border || '#e6e6e6';
|
||||
|
||||
// Hintergrundfarbe basierend auf dem Theme
|
||||
const bgColor = isDark
|
||||
? themeColors?.dark?.[themeVariant]?.contentBackground || '#1E1E1E'
|
||||
: themeColors?.[themeVariant]?.contentBackground || '#FFFFFF';
|
||||
|
||||
// Platzhalterfarbe
|
||||
const placeholderColor = isDark ? 'rgba(255, 255, 255, 0.5)' : 'rgba(0, 0, 0, 0.5)';
|
||||
|
||||
// Extrahiere multiline und textArea aus den Props
|
||||
const { multiline = false, textArea = false, numberOfLines = 5 } = props;
|
||||
|
||||
// Berechne die Mindesthöhe basierend auf den Props
|
||||
const calculatedMinHeight = props.minHeight || (textArea ? 150 : multiline ? 100 : undefined);
|
||||
|
||||
// Bestimme ob das Passwort verborgen werden soll
|
||||
const shouldHidePassword = secureTextEntry && !isPasswordVisible;
|
||||
|
||||
// Basis-Styles für alle Inputs
|
||||
const baseStyles = {
|
||||
height: multiline || textArea ? undefined : 48,
|
||||
minHeight: calculatedMinHeight,
|
||||
borderRadius: 12,
|
||||
paddingHorizontal: 16,
|
||||
paddingRight: secureTextEntry && showPasswordToggle ? 50 : 16,
|
||||
paddingVertical: multiline || textArea ? 12 : undefined,
|
||||
fontSize: 16,
|
||||
lineHeight: multiline || textArea ? 24 : undefined, // Erhöhter Zeilenabstand für mehrzeilige Eingaben
|
||||
width: '100%' as const, // TypeScript-Typ-Annotation
|
||||
borderWidth: 1,
|
||||
borderColor,
|
||||
color: textColor,
|
||||
backgroundColor: bgColor,
|
||||
textAlignVertical: multiline || textArea ? ('top' as const) : undefined,
|
||||
};
|
||||
|
||||
if (secureTextEntry && showPasswordToggle) {
|
||||
return (
|
||||
<View style={{ position: 'relative', width: '100%' }}>
|
||||
<TextInput
|
||||
ref={ref}
|
||||
style={[baseStyles, style]}
|
||||
value={value}
|
||||
onChangeText={onChangeText}
|
||||
placeholder={placeholder}
|
||||
secureTextEntry={shouldHidePassword}
|
||||
placeholderTextColor={placeholderColor}
|
||||
multiline={multiline || textArea}
|
||||
numberOfLines={textArea ? numberOfLines : undefined}
|
||||
{...restProps}
|
||||
/>
|
||||
<TouchableOpacity
|
||||
style={{
|
||||
position: 'absolute',
|
||||
right: 12,
|
||||
top: 0,
|
||||
bottom: 0,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
width: 32,
|
||||
height: '100%',
|
||||
}}
|
||||
onPress={() => setIsPasswordVisible(!isPasswordVisible)}
|
||||
activeOpacity={0.7}>
|
||||
<Icon
|
||||
name={isPasswordVisible ? 'eye-off-outline' : 'eye-outline'}
|
||||
size={20}
|
||||
color={placeholderColor}
|
||||
/>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<TextInput
|
||||
ref={ref}
|
||||
style={[baseStyles, style]}
|
||||
value={value}
|
||||
onChangeText={onChangeText}
|
||||
placeholder={placeholder}
|
||||
secureTextEntry={secureTextEntry}
|
||||
placeholderTextColor={placeholderColor}
|
||||
multiline={multiline || textArea}
|
||||
numberOfLines={textArea ? numberOfLines : undefined}
|
||||
{...restProps}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
Input.displayName = 'Input';
|
||||
|
||||
export default Input;
|
||||
185
apps/memoro/apps/mobile/components/atoms/LoadingOverlay.tsx
Normal file
185
apps/memoro/apps/mobile/components/atoms/LoadingOverlay.tsx
Normal file
|
|
@ -0,0 +1,185 @@
|
|||
import React from 'react';
|
||||
import { View, StyleSheet, Modal } from 'react-native';
|
||||
import { useTheme } from '~/features/theme/ThemeProvider';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import Text from './Text';
|
||||
import { SpinnerAnimation } from '../molecules/SpinnerAnimation';
|
||||
|
||||
interface LoadingOverlayProps {
|
||||
/**
|
||||
* Ob das Loading-Overlay sichtbar ist
|
||||
*/
|
||||
visible: boolean;
|
||||
|
||||
/**
|
||||
* Nachricht die angezeigt werden soll
|
||||
*/
|
||||
message?: string;
|
||||
|
||||
/**
|
||||
* Ob ein Modal verwendet werden soll (blockiert komplette App)
|
||||
* Standard: false (nur als Overlay über aktueller View)
|
||||
*/
|
||||
modal?: boolean;
|
||||
|
||||
/**
|
||||
* Größe des ActivityIndicator
|
||||
*/
|
||||
size?: 'small' | 'large';
|
||||
|
||||
/**
|
||||
* Ob das Overlay abgebrochen werden kann (nur bei modal: true)
|
||||
*/
|
||||
cancelable?: boolean;
|
||||
|
||||
/**
|
||||
* Callback wenn das Overlay abgebrochen wird
|
||||
*/
|
||||
onCancel?: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Wiederverwendbare Loading-Overlay-Komponente
|
||||
*
|
||||
* Kann als Overlay über einer View oder als vollständiges Modal verwendet werden.
|
||||
* Unterstützt verschiedene Größen, Nachrichten und Theme-angepasste Farben.
|
||||
*
|
||||
* @example
|
||||
* // Als Overlay über einer View
|
||||
* <View style={{ flex: 1 }}>
|
||||
* <YourContent />
|
||||
* <LoadingOverlay
|
||||
* visible={isLoading}
|
||||
* message="Übersetzung wird erstellt..."
|
||||
* />
|
||||
* </View>
|
||||
*
|
||||
* @example
|
||||
* // Als blockierendes Modal
|
||||
* <LoadingOverlay
|
||||
* visible={isProcessing}
|
||||
* message="Verarbeitung läuft..."
|
||||
* modal={true}
|
||||
* cancelable={true}
|
||||
* onCancel={() => setCanceled(true)}
|
||||
* />
|
||||
*/
|
||||
const LoadingOverlay: React.FC<LoadingOverlayProps> = ({
|
||||
visible,
|
||||
message,
|
||||
modal = false,
|
||||
size = 'large',
|
||||
cancelable = false,
|
||||
onCancel
|
||||
}) => {
|
||||
const { isDark } = useTheme();
|
||||
const { t } = useTranslation();
|
||||
|
||||
// Standard-Nachricht falls keine angegeben
|
||||
const displayMessage = message || t('common.loading', 'Wird geladen...');
|
||||
|
||||
// Dynamische Styles basierend auf Theme
|
||||
const styles = StyleSheet.create({
|
||||
overlay: {
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
backgroundColor: 'transparent',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
zIndex: 9999,
|
||||
},
|
||||
modalOverlay: {
|
||||
flex: 1,
|
||||
backgroundColor: isDark ? 'rgba(0, 0, 0, 0.8)' : 'rgba(255, 255, 255, 0.8)',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
},
|
||||
content: {
|
||||
backgroundColor: isDark ? '#2C2C2C' : '#FFFFFF',
|
||||
borderRadius: 16,
|
||||
padding: 24,
|
||||
paddingHorizontal: 32,
|
||||
alignItems: 'center',
|
||||
minWidth: 200,
|
||||
marginHorizontal: 16,
|
||||
shadowColor: '#000',
|
||||
shadowOffset: { width: 0, height: 2 },
|
||||
shadowOpacity: 0.25,
|
||||
shadowRadius: 3.84,
|
||||
elevation: 5,
|
||||
},
|
||||
message: {
|
||||
marginTop: 16,
|
||||
textAlign: 'center',
|
||||
fontSize: 16,
|
||||
color: isDark ? '#FFFFFF' : '#000000',
|
||||
},
|
||||
cancelButton: {
|
||||
marginTop: 16,
|
||||
paddingVertical: 8,
|
||||
paddingHorizontal: 16,
|
||||
borderRadius: 8,
|
||||
backgroundColor: isDark ? 'rgba(255, 255, 255, 0.1)' : 'rgba(0, 0, 0, 0.1)',
|
||||
},
|
||||
cancelText: {
|
||||
color: isDark ? '#FFFFFF' : '#000000',
|
||||
fontSize: 14,
|
||||
},
|
||||
});
|
||||
|
||||
// Spinner Animation mit Theme-Farbe
|
||||
const spinnerSize = size === 'large' ? 60 : 40;
|
||||
|
||||
// Content-Bereich
|
||||
const content = (
|
||||
<View style={styles.content}>
|
||||
<SpinnerAnimation size={spinnerSize} />
|
||||
<Text style={styles.message}>
|
||||
{displayMessage}
|
||||
</Text>
|
||||
{cancelable && onCancel && (
|
||||
<View
|
||||
style={styles.cancelButton}
|
||||
onTouchEnd={onCancel}
|
||||
>
|
||||
<Text style={styles.cancelText}>
|
||||
{t('common.cancel', 'Abbrechen')}
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
|
||||
// Nicht sichtbar
|
||||
if (!visible) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Als Modal rendern
|
||||
if (modal) {
|
||||
return (
|
||||
<Modal
|
||||
transparent={true}
|
||||
visible={visible}
|
||||
onRequestClose={cancelable ? onCancel : undefined}
|
||||
animationType="fade"
|
||||
>
|
||||
<View style={styles.modalOverlay}>
|
||||
{content}
|
||||
</View>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
// Als Overlay rendern
|
||||
return (
|
||||
<View style={styles.overlay}>
|
||||
{content}
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
export default LoadingOverlay;
|
||||
37
apps/memoro/apps/mobile/components/atoms/MemoroLogo.tsx
Normal file
37
apps/memoro/apps/mobile/components/atoms/MemoroLogo.tsx
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
import React, { memo } from 'react';
|
||||
import { View, ImageStyle } from 'react-native';
|
||||
import Svg, { Path } from 'react-native-svg';
|
||||
|
||||
interface MemoroLogoProps {
|
||||
color?: string;
|
||||
size?: number;
|
||||
style?: ImageStyle;
|
||||
}
|
||||
|
||||
/**
|
||||
* MemoroLogo-Komponente
|
||||
*
|
||||
* Das Logo der Memoro-App als SVG-Komponente.
|
||||
*
|
||||
* Beispiel:
|
||||
* ```tsx
|
||||
* <MemoroLogo size={48} />
|
||||
* ```
|
||||
*/
|
||||
function MemoroLogo({ color = '#F7D44C', size = 24, style = {} }: MemoroLogoProps) {
|
||||
return (
|
||||
<View style={[{ width: size, height: size }, style]}>
|
||||
<Svg width={size} height={size} viewBox="0 0 280 280">
|
||||
<Path
|
||||
fillRule="evenodd"
|
||||
clipRule="evenodd"
|
||||
d="M280 140C280 217.32 217.32 280 140 280C62.6801 280 0 217.32 0 140C0 62.6801 62.6801 0 140 0C217.32 0 280 62.6801 280 140ZM247.988 140C247.988 199.64 199.64 241.988 140 241.988C80.3598 241.988 32.0118 199.64 32.0118 140C32.0118 111.918 36.7308 95.3397 54.3005 76.1331C58.5193 71.5212 70.5 63 79.3937 74.511L119.781 131.788C134.5 149 149 147 160.218 131.788L200.605 74.5101C208 64 221.48 71.5203 225.699 76.1321C243.269 95.3388 247.988 111.918 247.988 140Z"
|
||||
fill={color}
|
||||
/>
|
||||
</Svg>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
// Verwende React.memo, um unnötige Neuzeichnungen zu vermeiden
|
||||
export default memo(MemoroLogo);
|
||||
|
|
@ -0,0 +1,77 @@
|
|||
/**
|
||||
* NotificationBadge Component
|
||||
*
|
||||
* A small red badge with a number indicating notification count.
|
||||
* Typically used to show unread items, pending actions, etc.
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { View, StyleSheet, ViewStyle } from 'react-native';
|
||||
import Text from './Text';
|
||||
|
||||
interface NotificationBadgeProps {
|
||||
count: number;
|
||||
size?: 'small' | 'medium';
|
||||
style?: ViewStyle;
|
||||
}
|
||||
|
||||
export const NotificationBadge: React.FC<NotificationBadgeProps> = ({
|
||||
count,
|
||||
size = 'small',
|
||||
style,
|
||||
}) => {
|
||||
// Don't render if count is 0
|
||||
if (count <= 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Determine badge size
|
||||
const badgeSize = size === 'small' ? 18 : 22;
|
||||
const fontSize = size === 'small' ? 11 : 13;
|
||||
|
||||
// Format count (max 99+)
|
||||
const displayCount = count > 99 ? '99+' : count.toString();
|
||||
|
||||
return (
|
||||
<View
|
||||
style={[
|
||||
styles.badge,
|
||||
{
|
||||
width: badgeSize,
|
||||
height: badgeSize,
|
||||
borderRadius: badgeSize / 2,
|
||||
minWidth: badgeSize,
|
||||
},
|
||||
style,
|
||||
]}
|
||||
>
|
||||
<Text
|
||||
style={[
|
||||
styles.badgeText,
|
||||
{
|
||||
fontSize,
|
||||
lineHeight: fontSize + 2,
|
||||
},
|
||||
]}
|
||||
>
|
||||
{displayCount}
|
||||
</Text>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
badge: {
|
||||
backgroundColor: '#EF4444', // Red-500
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
paddingHorizontal: 2,
|
||||
},
|
||||
badgeText: {
|
||||
color: '#FFFFFF',
|
||||
fontWeight: '700',
|
||||
textAlign: 'center',
|
||||
},
|
||||
});
|
||||
|
||||
export default NotificationBadge;
|
||||
|
|
@ -0,0 +1,57 @@
|
|||
import React from 'react';
|
||||
import { ImagePickerAsset } from 'expo-image-picker';
|
||||
import Button from './Button';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { photoStorageService } from '~/features/storage/photoStorage.service';
|
||||
|
||||
interface PhotoUploadButtonProps {
|
||||
onPhotosSelected: (photos: ImagePickerAsset[]) => void;
|
||||
loading?: boolean;
|
||||
disabled?: boolean;
|
||||
variant?: 'primary' | 'secondary' | 'outline';
|
||||
title?: string;
|
||||
iconName?: string;
|
||||
style?: any;
|
||||
allowsMultipleSelection?: boolean;
|
||||
}
|
||||
|
||||
export default function PhotoUploadButton({
|
||||
onPhotosSelected,
|
||||
loading = false,
|
||||
disabled = false,
|
||||
variant = 'secondary',
|
||||
title,
|
||||
iconName = 'image-outline',
|
||||
style,
|
||||
allowsMultipleSelection = true,
|
||||
}: PhotoUploadButtonProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const handlePress = async () => {
|
||||
try {
|
||||
const photos = await photoStorageService.selectPhotos({
|
||||
allowsMultipleSelection,
|
||||
quality: 0.8,
|
||||
});
|
||||
|
||||
if (photos.length > 0) {
|
||||
onPhotosSelected(photos);
|
||||
}
|
||||
} catch (error) {
|
||||
console.debug('Error selecting photos:', error);
|
||||
// You might want to show an error toast here
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Button
|
||||
variant={variant}
|
||||
title={title || t('memo.add_photos_button', 'Add Photos')}
|
||||
iconName={iconName}
|
||||
onPress={handlePress}
|
||||
loading={loading}
|
||||
disabled={disabled || loading}
|
||||
style={style}
|
||||
/>
|
||||
);
|
||||
}
|
||||
435
apps/memoro/apps/mobile/components/atoms/Pill.tsx
Normal file
435
apps/memoro/apps/mobile/components/atoms/Pill.tsx
Normal file
|
|
@ -0,0 +1,435 @@
|
|||
import React, { useState } from 'react';
|
||||
import { Pressable, StyleSheet, ViewStyle, Platform, ActionSheetIOS } from 'react-native';
|
||||
import * as Haptics from 'expo-haptics';
|
||||
import { useTheme } from '~/features/theme/ThemeProvider';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import Text from './Text';
|
||||
import WebContextMenu from '~/components/molecules/WebContextMenu';
|
||||
import colors from '~/tailwind.config.js';
|
||||
|
||||
// TODO: Migrate to @expo/ui ContextMenu
|
||||
// import { MenuView } from '@react-native-menu/menu';
|
||||
|
||||
interface PillProps {
|
||||
label: string;
|
||||
isSelected?: boolean;
|
||||
onPress?: () => void;
|
||||
color?: string;
|
||||
style?: ViewStyle;
|
||||
disabled?: boolean;
|
||||
maxLength?: number; // Maximale Zeichenanzahl für den Text
|
||||
size?: 'small' | 'large'; // Größe der Pill: klein oder groß
|
||||
variant?: 'default' | 'underlined'; // Variante der Pill: standard oder unterstrichen
|
||||
/**
|
||||
* If true, enables long press context menu
|
||||
*/
|
||||
enableContextMenu?: boolean;
|
||||
/**
|
||||
* Called when the pin option is selected from the context menu
|
||||
*/
|
||||
onTogglePin?: () => void;
|
||||
/**
|
||||
* Called when the edit option is selected from the context menu
|
||||
*/
|
||||
onEdit?: () => void;
|
||||
/**
|
||||
* Called when the delete option is selected from the context menu
|
||||
*/
|
||||
onDelete?: () => void;
|
||||
/**
|
||||
* Whether this tag is pinned (affects the context menu label)
|
||||
*/
|
||||
isPinned?: boolean;
|
||||
/**
|
||||
* Label for the edit action in context menu (default: "Bearbeiten")
|
||||
*/
|
||||
editLabel?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Pill component for selectable options
|
||||
*/
|
||||
const Pill: React.FC<PillProps> = ({
|
||||
label,
|
||||
isSelected = false,
|
||||
onPress,
|
||||
color,
|
||||
style,
|
||||
disabled = false,
|
||||
maxLength = 15, // Standardwert für maximale Zeichenanzahl
|
||||
size = 'small', // Standardwert für die Größe
|
||||
variant = 'default', // Standardwert für die Variante
|
||||
enableContextMenu = false,
|
||||
onTogglePin,
|
||||
onEdit,
|
||||
onDelete,
|
||||
isPinned = false,
|
||||
editLabel = "Bearbeiten",
|
||||
}) => {
|
||||
const { isDark, themeVariant, tw, colors: themeColors } = useTheme();
|
||||
const { t } = useTranslation();
|
||||
const [isHovered, setIsHovered] = useState(false);
|
||||
const [webContextMenu, setWebContextMenu] = useState({
|
||||
isVisible: false,
|
||||
position: { x: 0, y: 0 }
|
||||
});
|
||||
|
||||
// Icon-Farbe basierend auf Theme (weiß im Dark Mode, dunkel im Light Mode)
|
||||
const iconColor = '#AEAEB2'; // Light gray icon color for both light and dark mode
|
||||
|
||||
/**
|
||||
* Hellt eine Hex-Farbe um einen bestimmten Prozentsatz auf
|
||||
* @param hexColor Die Hex-Farbe (z.B. '#4FC3F7')
|
||||
* @param percent Der Prozentsatz, um den die Farbe aufgehellt werden soll (0-100)
|
||||
* @returns Die aufgehellte Hex-Farbe
|
||||
*/
|
||||
const lightenColor = (hexColor: string, percent: number): string => {
|
||||
// Wenn die Farbe nicht mit # beginnt oder nicht das richtige Format hat, gib sie unverändert zurück
|
||||
if (!hexColor || !hexColor.startsWith('#') || ![4, 7].includes(hexColor.length)) {
|
||||
return hexColor;
|
||||
}
|
||||
|
||||
// Konvertiere die Hex-Farbe in RGB
|
||||
let r = parseInt(hexColor.length === 4 ? hexColor[1] + hexColor[1] : hexColor.substring(1, 3), 16);
|
||||
let g = parseInt(hexColor.length === 4 ? hexColor[2] + hexColor[2] : hexColor.substring(3, 5), 16);
|
||||
let b = parseInt(hexColor.length === 4 ? hexColor[3] + hexColor[3] : hexColor.substring(5, 7), 16);
|
||||
|
||||
// Helle die Farbe um den angegebenen Prozentsatz auf
|
||||
r = Math.min(255, Math.round(r * (1 + percent / 100)));
|
||||
g = Math.min(255, Math.round(g * (1 + percent / 100)));
|
||||
b = Math.min(255, Math.round(b * (1 + percent / 100)));
|
||||
|
||||
// Konvertiere zurück in Hex und gib die aufgehellte Farbe zurück
|
||||
return `#${r.toString(16).padStart(2, '0')}${g.toString(16).padStart(2, '0')}${b.toString(16).padStart(2, '0')}`;
|
||||
};
|
||||
|
||||
|
||||
// Determine colors based on theme and props
|
||||
const getThemeColor = () => {
|
||||
if (color) return color;
|
||||
|
||||
if (themeVariant === 'nature') {
|
||||
return '#81C784';
|
||||
} else if (themeVariant === 'stone') {
|
||||
return '#90A4AE';
|
||||
} else if (themeVariant === 'ocean') {
|
||||
return '#4FC3F7';
|
||||
} else {
|
||||
// Lume theme
|
||||
return '#f8d62b';
|
||||
}
|
||||
};
|
||||
|
||||
const themeColor = getThemeColor();
|
||||
|
||||
// Get contentBackground color from theme
|
||||
const contentBackgroundColor = isDark
|
||||
? ((colors as any).theme?.extend?.colors?.dark)?.[themeVariant]?.contentBackground || '#1E1E1E'
|
||||
: ((colors as any).theme?.extend?.colors)?.[themeVariant]?.contentBackground || '#FFFFFF';
|
||||
|
||||
// Get contentBackgroundHover color from theme
|
||||
const contentBackgroundHoverColor = isDark
|
||||
? ((colors as any).theme?.extend?.colors?.dark)?.[themeVariant]?.contentBackgroundHover || '#333333'
|
||||
: ((colors as any).theme?.extend?.colors)?.[themeVariant]?.contentBackgroundHover || '#f5f5f5';
|
||||
|
||||
// Simplified background logic
|
||||
const getBackgroundColor = () => {
|
||||
if (isSelected) {
|
||||
// Active pill: use custom color with transparency or contentBackground
|
||||
if (color) {
|
||||
return isHovered && Platform.OS === 'web'
|
||||
? `${lightenColor(color, 25)}33` // Hover: lighter color with 20% transparency
|
||||
: `${color}33`; // Normal: color with 20% transparency
|
||||
}
|
||||
return isHovered && Platform.OS === 'web'
|
||||
? contentBackgroundHoverColor
|
||||
: contentBackgroundColor; // Use solid contentBackground for "Alle" pill
|
||||
} else {
|
||||
// Inactive pill: contentBackground with transparency, but solid on hover
|
||||
return isHovered && Platform.OS === 'web'
|
||||
? contentBackgroundHoverColor
|
||||
: `${contentBackgroundColor}4D`; // 30% transparency (4D in hex)
|
||||
}
|
||||
};
|
||||
const textColor = isDark ? '#FFFFFF' : '#000000';
|
||||
|
||||
// Simplified border logic
|
||||
const tailwindColors = (colors as any).theme?.extend?.colors;
|
||||
|
||||
// Get border hover colors
|
||||
const borderHoverColor = isDark
|
||||
? tailwindColors?.dark?.[themeVariant]?.borderStrong || '#555555'
|
||||
: tailwindColors?.[themeVariant]?.borderStrong || '#cccccc';
|
||||
|
||||
const getBorderColor = () => {
|
||||
if (color) {
|
||||
// For custom colors, lighten on hover
|
||||
return isHovered && Platform.OS === 'web'
|
||||
? lightenColor(color, 25)
|
||||
: color;
|
||||
}
|
||||
|
||||
// Apply hover effect to all pills
|
||||
if (isHovered && Platform.OS === 'web') {
|
||||
return borderHoverColor;
|
||||
}
|
||||
|
||||
if (isDark) {
|
||||
return isSelected
|
||||
? tailwindColors?.dark?.[themeVariant]?.border || '#424242'
|
||||
: tailwindColors?.dark?.[themeVariant]?.borderLight || '#333333';
|
||||
}
|
||||
return isSelected
|
||||
? tailwindColors?.[themeVariant]?.border || '#e6e6e6'
|
||||
: tailwindColors?.[themeVariant]?.borderLight || '#f2f2f2';
|
||||
};
|
||||
|
||||
const pillBackgroundColor = getBackgroundColor();
|
||||
const pillBorderColor = getBorderColor();
|
||||
|
||||
// Bestimme die Styles basierend auf der Größe
|
||||
const sizeStyles = size === 'large' ? styles.pillLarge : styles.pillSmall;
|
||||
|
||||
// Bestimme die Styles basierend auf der Variante
|
||||
const getVariantStyles = () => {
|
||||
if (variant === 'underlined') {
|
||||
return {
|
||||
backgroundColor: 'transparent',
|
||||
borderColor: 'transparent',
|
||||
paddingHorizontal: 0,
|
||||
paddingVertical: 0,
|
||||
minWidth: 0,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
backgroundColor: pillBackgroundColor,
|
||||
borderColor: pillBorderColor,
|
||||
};
|
||||
};
|
||||
|
||||
// variantStyles now reflect inverted active/default
|
||||
const variantStyles = getVariantStyles();
|
||||
|
||||
// Get menu items for Zeego DropdownMenu
|
||||
const getMenuItems = () => {
|
||||
const items = [];
|
||||
|
||||
if (onTogglePin) {
|
||||
items.push({
|
||||
key: 'pin',
|
||||
title: isPinned ? t('tags.unpin', 'Unpin') : t('tags.pin', 'Pin'),
|
||||
systemIcon: 'pin',
|
||||
onSelect: onTogglePin,
|
||||
});
|
||||
}
|
||||
|
||||
if (onEdit) {
|
||||
const editText = editLabel === 'Bearbeiten' ? t('common.edit', 'Edit') : editLabel;
|
||||
items.push({
|
||||
key: 'edit',
|
||||
title: editText,
|
||||
systemIcon: editLabel === 'Info' ? 'info.circle' : 'pencil',
|
||||
onSelect: onEdit,
|
||||
});
|
||||
}
|
||||
|
||||
if (onDelete) {
|
||||
items.push({
|
||||
key: 'delete',
|
||||
title: t('common.delete', 'Delete'),
|
||||
systemIcon: 'trash',
|
||||
destructive: true,
|
||||
onSelect: onDelete,
|
||||
});
|
||||
}
|
||||
|
||||
return items;
|
||||
};
|
||||
|
||||
// Haptic feedback for long press
|
||||
const triggerLongPressHaptic = async () => {
|
||||
try {
|
||||
await Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Medium);
|
||||
} catch (error) {
|
||||
console.debug('Haptic feedback error:', error);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
// Handle right-click for web
|
||||
const handleWebContextMenu = (e: React.MouseEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setWebContextMenu({
|
||||
isVisible: true,
|
||||
position: { x: e.clientX, y: e.clientY }
|
||||
});
|
||||
};
|
||||
|
||||
// Get web context menu items
|
||||
const getWebContextMenuItems = () => {
|
||||
const items = [];
|
||||
|
||||
if (onTogglePin) {
|
||||
items.push({
|
||||
title: isPinned ? t('tags.unpin', 'Unpin') : t('tags.pin', 'Pin'),
|
||||
icon: 'pin',
|
||||
onPress: onTogglePin
|
||||
});
|
||||
}
|
||||
|
||||
if (onEdit) {
|
||||
const editText = editLabel === 'Bearbeiten' ? t('common.edit', 'Edit') : editLabel;
|
||||
items.push({
|
||||
title: editText,
|
||||
icon: editLabel === 'Info' ? 'information-circle-outline' : 'pencil-outline',
|
||||
onPress: onEdit
|
||||
});
|
||||
}
|
||||
|
||||
if (onDelete) {
|
||||
items.push({
|
||||
title: t('common.delete', 'Delete'),
|
||||
icon: 'trash-outline',
|
||||
destructive: true,
|
||||
onPress: onDelete
|
||||
});
|
||||
}
|
||||
|
||||
return items;
|
||||
};
|
||||
|
||||
// Pill content
|
||||
const pillContent = (
|
||||
<Text
|
||||
variant={size === 'large' ? 'body' : 'small'}
|
||||
style={[
|
||||
styles.label,
|
||||
variant === 'underlined' && {
|
||||
textDecorationLine: 'underline',
|
||||
color: color || textColor,
|
||||
},
|
||||
variant !== 'underlined' && { color: textColor }
|
||||
]}
|
||||
numberOfLines={1}
|
||||
ellipsizeMode="tail"
|
||||
>
|
||||
{label.length > maxLength ? `${label.substring(0, maxLength)}...` : label}
|
||||
</Text>
|
||||
);
|
||||
|
||||
// Base pressable props
|
||||
const pressableProps = {
|
||||
style: [
|
||||
styles.pill,
|
||||
sizeStyles,
|
||||
variantStyles,
|
||||
{
|
||||
opacity: disabled ? 0.6 : 1,
|
||||
...(Platform.OS === 'web' && onPress && !disabled && {
|
||||
cursor: 'pointer',
|
||||
}),
|
||||
},
|
||||
style
|
||||
],
|
||||
onPress,
|
||||
disabled,
|
||||
onHoverIn: () => Platform.OS === 'web' && setIsHovered(true),
|
||||
onHoverOut: () => Platform.OS === 'web' && setIsHovered(false)
|
||||
};
|
||||
|
||||
// If context menu is enabled and we're on a native platform, use ActionSheet
|
||||
if (enableContextMenu && Platform.OS !== 'web') {
|
||||
const menuItems = getMenuItems();
|
||||
if (menuItems.length > 0) {
|
||||
return (
|
||||
<Pressable
|
||||
{...pressableProps}
|
||||
onLongPress={() => {
|
||||
triggerLongPressHaptic();
|
||||
// Show action sheet on long press
|
||||
if (Platform.OS === 'ios') {
|
||||
ActionSheetIOS.showActionSheetWithOptions(
|
||||
{
|
||||
options: [...menuItems.map(item => item.title), 'Cancel'],
|
||||
destructiveButtonIndex: menuItems.findIndex(item => item.destructive),
|
||||
cancelButtonIndex: menuItems.length,
|
||||
},
|
||||
(buttonIndex) => {
|
||||
if (buttonIndex < menuItems.length) {
|
||||
menuItems[buttonIndex].onSelect?.();
|
||||
}
|
||||
}
|
||||
);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{pillContent}
|
||||
</Pressable>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Web rendering with context menu support
|
||||
if (Platform.OS === 'web' && enableContextMenu && (onTogglePin || onEdit || onDelete)) {
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
onContextMenu={handleWebContextMenu}
|
||||
style={{ display: 'inline-block' }}
|
||||
>
|
||||
<Pressable {...pressableProps}>
|
||||
{pillContent}
|
||||
</Pressable>
|
||||
</div>
|
||||
<WebContextMenu
|
||||
isVisible={webContextMenu.isVisible}
|
||||
position={webContextMenu.position}
|
||||
items={getWebContextMenuItems()}
|
||||
onClose={() => setWebContextMenu({ isVisible: false, position: { x: 0, y: 0 } })}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// Default rendering without context menu
|
||||
return (
|
||||
<Pressable {...pressableProps}>
|
||||
{pillContent}
|
||||
</Pressable>
|
||||
);
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
pill: {
|
||||
borderRadius: 999, // Maximale Rundung für immer runde Pills
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
borderWidth: 1,
|
||||
position: 'relative',
|
||||
alignSelf: 'flex-start', // Wichtig: Beschränkt die Breite auf den Inhalt
|
||||
},
|
||||
// Kleine Variante für Modal und MemoPreview
|
||||
pillSmall: {
|
||||
paddingHorizontal: 14,
|
||||
paddingVertical: 7, // Increased from 6 to 7 to match add button height
|
||||
minWidth: 40,
|
||||
maxWidth: 150,
|
||||
borderRadius: 999, // Maximale Rundung
|
||||
},
|
||||
// Große Variante für PillFilter
|
||||
pillLarge: {
|
||||
paddingHorizontal: 18,
|
||||
paddingVertical: 10,
|
||||
minWidth: 60,
|
||||
maxWidth: 180,
|
||||
borderRadius: 999, // Maximale Rundung
|
||||
},
|
||||
label: {
|
||||
fontWeight: '500',
|
||||
textAlign: 'center',
|
||||
},
|
||||
});
|
||||
|
||||
export default Pill;
|
||||
1180
apps/memoro/apps/mobile/components/atoms/RecordingButton.tsx
Normal file
1180
apps/memoro/apps/mobile/components/atoms/RecordingButton.tsx
Normal file
File diff suppressed because it is too large
Load diff
63
apps/memoro/apps/mobile/components/atoms/RoundCheckbox.tsx
Normal file
63
apps/memoro/apps/mobile/components/atoms/RoundCheckbox.tsx
Normal file
|
|
@ -0,0 +1,63 @@
|
|||
import React from 'react';
|
||||
import { Pressable } from 'react-native';
|
||||
import Icon from '@/components/atoms/Icon';
|
||||
import { useTheme } from '~/features/theme/ThemeProvider';
|
||||
|
||||
/**
|
||||
* A large round checkbox for selection UIs.
|
||||
* @param checked Whether the checkbox is checked.
|
||||
* @param onPress Called when the checkbox is pressed.
|
||||
* @param disabled If true, disables interaction.
|
||||
* @param style Optional custom style.
|
||||
*/
|
||||
interface RoundCheckboxProps {
|
||||
checked: boolean;
|
||||
onPress: () => void;
|
||||
disabled?: boolean;
|
||||
style?: any;
|
||||
}
|
||||
|
||||
const RoundCheckbox: React.FC<RoundCheckboxProps> = ({ checked, onPress, disabled = false, style }) => {
|
||||
// Get theme and variant from ThemeProvider
|
||||
const { themeVariant, isDark } = useTheme();
|
||||
|
||||
// Use theme CSS variables for border and background
|
||||
const borderColor = isDark
|
||||
? `var(--color-dark-${themeVariant}-primary)`
|
||||
: `var(--color-${themeVariant}-primary)`;
|
||||
|
||||
// Use CSS variable with alpha for background if checked, else white
|
||||
// This assumes your theme supports CSS variable with alpha, otherwise fallback to rgba
|
||||
const backgroundColor = checked
|
||||
? `rgba(var(--color-rgb-${isDark ? `dark-${themeVariant}` : themeVariant}-primary), 0.5)`
|
||||
: '#fff';
|
||||
|
||||
return (
|
||||
<Pressable
|
||||
onPress={onPress}
|
||||
disabled={disabled}
|
||||
accessibilityRole="checkbox"
|
||||
accessibilityState={{ checked, disabled }}
|
||||
style={({ pressed }) => [
|
||||
{
|
||||
width: 32,
|
||||
height: 32,
|
||||
borderRadius: 16,
|
||||
borderWidth: 2,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
borderColor,
|
||||
backgroundColor,
|
||||
opacity: pressed ? 0.8 : 1,
|
||||
},
|
||||
style,
|
||||
]}
|
||||
>
|
||||
{checked && (
|
||||
<Icon name="checkmark" size={22} color="#fff" />
|
||||
)}
|
||||
</Pressable>
|
||||
);
|
||||
};
|
||||
|
||||
export default RoundCheckbox;
|
||||
146
apps/memoro/apps/mobile/components/atoms/SelectableItem.tsx
Normal file
146
apps/memoro/apps/mobile/components/atoms/SelectableItem.tsx
Normal file
|
|
@ -0,0 +1,146 @@
|
|||
import React from 'react';
|
||||
import { View, Pressable, StyleSheet, Platform } from 'react-native';
|
||||
import { useTheme } from '~/features/theme/ThemeProvider';
|
||||
import Text from '~/components/atoms/Text';
|
||||
import Icon from '~/components/atoms/Icon';
|
||||
|
||||
const tailwindConfig = require('../../tailwind.config.js');
|
||||
|
||||
interface SelectableItemProps {
|
||||
title: string;
|
||||
isSelected: boolean;
|
||||
onToggle: () => void;
|
||||
renderContent?: () => React.ReactNode;
|
||||
}
|
||||
|
||||
/**
|
||||
* Eine wiederverwendbare Komponente für auswählbare Elemente mit Checkbox.
|
||||
* Kann für verschiedene Auswahlszenarien verwendet werden, z.B. in Bottom Sheets oder Listen.
|
||||
*/
|
||||
const SelectableItem = ({
|
||||
title,
|
||||
isSelected,
|
||||
onToggle,
|
||||
renderContent,
|
||||
}: SelectableItemProps): React.ReactElement => {
|
||||
const { isDark, themeVariant } = useTheme();
|
||||
|
||||
// Get theme background color
|
||||
const getThemeBackgroundColor = () => {
|
||||
try {
|
||||
if (tailwindConfig && tailwindConfig.theme && tailwindConfig.theme.extend && tailwindConfig.theme.extend.colors) {
|
||||
const colors = tailwindConfig.theme.extend.colors;
|
||||
|
||||
if (isDark && colors.dark && colors.dark[themeVariant]) {
|
||||
return isSelected
|
||||
? colors.dark[themeVariant].contentBackgroundHover
|
||||
: colors.dark[themeVariant].contentBackground;
|
||||
} else if (colors[themeVariant]) {
|
||||
return isSelected
|
||||
? colors[themeVariant].contentBackgroundHover
|
||||
: colors[themeVariant].contentBackground;
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback
|
||||
return isSelected
|
||||
? (isDark ? 'rgba(60, 60, 60, 0.8)' : 'rgba(220, 220, 220, 0.8)')
|
||||
: (isDark ? 'rgba(40, 40, 40, 0.8)' : 'rgba(240, 240, 240, 0.8)');
|
||||
} catch (e) {
|
||||
console.debug('Error loading theme colors:', e);
|
||||
return isSelected
|
||||
? (isDark ? 'rgba(60, 60, 60, 0.8)' : 'rgba(220, 220, 220, 0.8)')
|
||||
: (isDark ? 'rgba(40, 40, 40, 0.8)' : 'rgba(240, 240, 240, 0.8)');
|
||||
}
|
||||
};
|
||||
|
||||
// Get primary button color for checkbox
|
||||
const getPrimaryButtonColor = () => {
|
||||
try {
|
||||
if (tailwindConfig && tailwindConfig.theme && tailwindConfig.theme.extend && tailwindConfig.theme.extend.colors) {
|
||||
const colors = tailwindConfig.theme.extend.colors;
|
||||
|
||||
if (isDark && colors.dark && colors.dark[themeVariant] && colors.dark[themeVariant].primaryButton) {
|
||||
return colors.dark[themeVariant].primaryButton;
|
||||
} else if (colors[themeVariant] && colors[themeVariant].primaryButton) {
|
||||
return colors[themeVariant].primaryButton;
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback
|
||||
return '#f8d62b';
|
||||
} catch (e) {
|
||||
console.debug('Error loading theme colors:', e);
|
||||
return '#f8d62b';
|
||||
}
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
backgroundColor: getThemeBackgroundColor(),
|
||||
borderRadius: 8,
|
||||
borderWidth: 1,
|
||||
borderColor: isDark ? 'rgba(255, 255, 255, 0.1)' : 'rgba(0, 0, 0, 0.1)',
|
||||
shadowColor: '#000',
|
||||
shadowOffset: { width: 0, height: 1 },
|
||||
shadowOpacity: 0.1,
|
||||
shadowRadius: 1,
|
||||
...(Platform.OS !== 'android' && {
|
||||
elevation: 1,
|
||||
}),
|
||||
padding: 12,
|
||||
},
|
||||
checkbox: {
|
||||
width: 24,
|
||||
height: 24,
|
||||
borderRadius: 4,
|
||||
borderWidth: 2,
|
||||
borderColor: isDark ? 'rgba(255, 255, 255, 0.5)' : 'rgba(0, 0, 0, 0.3)',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
marginRight: 16,
|
||||
},
|
||||
checkboxSelected: {
|
||||
backgroundColor: getPrimaryButtonColor(),
|
||||
borderColor: getPrimaryButtonColor(),
|
||||
},
|
||||
contentContainer: {
|
||||
flex: 1,
|
||||
},
|
||||
title: {
|
||||
fontSize: 16,
|
||||
fontWeight: 'bold',
|
||||
color: isDark ? '#FFFFFF' : '#000000',
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<Pressable
|
||||
style={styles.container}
|
||||
onPress={onToggle}
|
||||
>
|
||||
<View style={[
|
||||
styles.checkbox,
|
||||
isSelected && styles.checkboxSelected
|
||||
]}>
|
||||
{isSelected && (
|
||||
<Icon name="checkmark" size={12} color="#FFFFFF" />
|
||||
)}
|
||||
</View>
|
||||
<View style={styles.contentContainer}>
|
||||
<Text
|
||||
style={styles.title}
|
||||
numberOfLines={1}
|
||||
ellipsizeMode="tail"
|
||||
>
|
||||
{title}
|
||||
</Text>
|
||||
{renderContent && renderContent()}
|
||||
</View>
|
||||
</Pressable>
|
||||
);
|
||||
};
|
||||
|
||||
export default SelectableItem;
|
||||
102
apps/memoro/apps/mobile/components/atoms/Text.tsx
Normal file
102
apps/memoro/apps/mobile/components/atoms/Text.tsx
Normal file
|
|
@ -0,0 +1,102 @@
|
|||
import React, { useMemo, forwardRef } from 'react';
|
||||
import {
|
||||
Text as RNText,
|
||||
TextProps as RNTextProps,
|
||||
StyleSheet,
|
||||
} from 'react-native';
|
||||
import { useTheme } from '~/features/theme/ThemeProvider';
|
||||
|
||||
interface TextProps extends Omit<RNTextProps, 'style'> {
|
||||
variant?: 'h1' | 'h2' | 'h3' | 'body' | 'caption' | 'small' | 'tiny';
|
||||
children?: React.ReactNode;
|
||||
style?: RNTextProps['style'];
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Text-Komponente
|
||||
*
|
||||
* Eine Wrapper-Komponente für den nativen Text, die automatisch die Theme-Farben verwendet
|
||||
* und verschiedene Textvarianten unterstützt.
|
||||
*
|
||||
* Beispiel:
|
||||
* ```tsx
|
||||
* <Text variant="h1">Überschrift</Text>
|
||||
* <Text variant="body">Normaler Text</Text>
|
||||
* <Text variant="caption">Unterschrift</Text>
|
||||
* <Text variant="small">Kleiner Text</Text>
|
||||
* ```
|
||||
*/
|
||||
const Text = forwardRef<RNText, TextProps>((
|
||||
{
|
||||
children = null,
|
||||
variant = 'body',
|
||||
style = {},
|
||||
className = '',
|
||||
...props
|
||||
},
|
||||
ref
|
||||
) => {
|
||||
const { colors } = useTheme();
|
||||
|
||||
// Erstelle Styles basierend auf dem aktuellen Theme
|
||||
const styles = useMemo(() => StyleSheet.create({
|
||||
text: {
|
||||
color: colors.text,
|
||||
},
|
||||
h1: {
|
||||
fontSize: 24,
|
||||
fontWeight: 'bold',
|
||||
lineHeight: 28,
|
||||
color: colors.text,
|
||||
},
|
||||
h2: {
|
||||
fontSize: 18,
|
||||
fontWeight: '600',
|
||||
lineHeight: 22,
|
||||
color: colors.text,
|
||||
},
|
||||
h3: {
|
||||
fontSize: 16,
|
||||
fontWeight: '600',
|
||||
lineHeight: 20,
|
||||
color: colors.text,
|
||||
},
|
||||
body: {
|
||||
fontSize: 16,
|
||||
lineHeight: 20,
|
||||
color: colors.text,
|
||||
},
|
||||
caption: {
|
||||
fontSize: 14,
|
||||
color: colors.textSecondary,
|
||||
lineHeight: 18,
|
||||
},
|
||||
small: {
|
||||
fontSize: 12,
|
||||
lineHeight: 16,
|
||||
color: colors.text,
|
||||
},
|
||||
tiny: {
|
||||
fontSize: 14,
|
||||
color: colors.textTertiary,
|
||||
lineHeight: 18,
|
||||
},
|
||||
}), [colors]); // Neu berechnen, wenn sich colors ändert
|
||||
|
||||
// Kombiniere die Styles basierend auf der Variante
|
||||
const combinedStyles = [styles[variant], style];
|
||||
|
||||
return (
|
||||
<RNText
|
||||
ref={ref}
|
||||
style={combinedStyles}
|
||||
className={className}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</RNText>
|
||||
);
|
||||
});
|
||||
|
||||
export default Text;
|
||||
43
apps/memoro/apps/mobile/components/atoms/ThemedStatusBar.tsx
Normal file
43
apps/memoro/apps/mobile/components/atoms/ThemedStatusBar.tsx
Normal file
|
|
@ -0,0 +1,43 @@
|
|||
import React, { useMemo } from 'react';
|
||||
import { StatusBar } from 'expo-status-bar';
|
||||
import { Platform } from 'react-native';
|
||||
import { useTheme } from '~/features/theme/ThemeProvider';
|
||||
import colors from '~/tailwind.config.js';
|
||||
|
||||
/**
|
||||
* ThemedStatusBar component
|
||||
*
|
||||
* A global StatusBar component that automatically matches the header background color
|
||||
* based on the current theme variant and dark mode setting.
|
||||
*/
|
||||
const ThemedStatusBar: React.FC = () => {
|
||||
const { isDark, themeVariant } = useTheme();
|
||||
|
||||
// Get the header background color from theme config
|
||||
const headerBackgroundColor = useMemo(() =>
|
||||
isDark
|
||||
? colors.theme.extend.colors.dark[themeVariant].menuBackground
|
||||
: colors.theme.extend.colors[themeVariant].menuBackground,
|
||||
[isDark, themeVariant]);
|
||||
|
||||
// Determine status bar style based on background color brightness
|
||||
const statusBarStyle = useMemo(() => {
|
||||
// For dark mode, use light content (white text/icons)
|
||||
if (isDark) {
|
||||
return 'light';
|
||||
}
|
||||
|
||||
// For light mode, use dark content (dark text/icons)
|
||||
return 'dark';
|
||||
}, [isDark]);
|
||||
|
||||
return (
|
||||
<StatusBar
|
||||
style={statusBarStyle}
|
||||
backgroundColor={Platform.OS === 'android' ? headerBackgroundColor : undefined}
|
||||
translucent={Platform.OS === 'android'}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default ThemedStatusBar;
|
||||
60
apps/memoro/apps/mobile/components/atoms/Toggle.tsx
Normal file
60
apps/memoro/apps/mobile/components/atoms/Toggle.tsx
Normal file
|
|
@ -0,0 +1,60 @@
|
|||
import React from 'react';
|
||||
import { Switch, Platform } from 'react-native';
|
||||
import { useTheme } from '~/features/theme/ThemeProvider';
|
||||
import colors from '~/tailwind.config.js';
|
||||
|
||||
interface ToggleProps {
|
||||
isOn: boolean;
|
||||
onToggle: (value: boolean) => void;
|
||||
disabled?: boolean;
|
||||
size?: 'small' | 'medium' | 'large';
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggle-Komponente
|
||||
*
|
||||
* Eine native Switch-Komponente, die die Primary-Farbe aus der Tailwind-Konfiguration verwendet.
|
||||
*
|
||||
* @param {boolean} isOn - Der aktuelle Zustand des Toggles (an/aus)
|
||||
* @param {function} onToggle - Callback-Funktion, die beim Umschalten aufgerufen wird
|
||||
* @param {boolean} disabled - Ob der Toggle deaktiviert ist
|
||||
* @param {string} size - Die Größe des Toggles (wird auf iOS ignoriert, da native Größe verwendet wird)
|
||||
*/
|
||||
const Toggle: React.FC<ToggleProps> = ({
|
||||
isOn,
|
||||
onToggle,
|
||||
disabled = false,
|
||||
size = 'medium',
|
||||
}) => {
|
||||
const { isDark, themeVariant } = useTheme();
|
||||
|
||||
// Zugriff auf die Theme-Farben
|
||||
const themeColors = (colors as any).theme?.extend?.colors;
|
||||
|
||||
// Primary-Farbe direkt aus der Tailwind-Konfiguration
|
||||
const primaryColor = isDark
|
||||
? themeColors?.dark?.[themeVariant]?.primary || '#f8d62b'
|
||||
: themeColors?.[themeVariant]?.primary || '#f8d62b';
|
||||
|
||||
// Track-Farbe (Hintergrund) für iOS
|
||||
const trackColor = {
|
||||
false: isDark ? '#3A3A3A' : '#E0E0E0',
|
||||
true: primaryColor,
|
||||
};
|
||||
|
||||
// Thumb-Farbe (Kreis)
|
||||
const thumbColor = Platform.OS === 'ios' ? '#FFFFFF' : (isOn ? primaryColor : '#F4F3F4');
|
||||
|
||||
return (
|
||||
<Switch
|
||||
value={isOn}
|
||||
onValueChange={onToggle}
|
||||
disabled={disabled}
|
||||
trackColor={trackColor}
|
||||
thumbColor={thumbColor}
|
||||
ios_backgroundColor={trackColor.false}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default Toggle;
|
||||
177
apps/memoro/apps/mobile/components/atoms/UploadStatusBadge.tsx
Normal file
177
apps/memoro/apps/mobile/components/atoms/UploadStatusBadge.tsx
Normal file
|
|
@ -0,0 +1,177 @@
|
|||
/**
|
||||
* Upload Status Badge Component
|
||||
*
|
||||
* Displays visual indicator for audio recording upload status.
|
||||
* Shows icon, label, and appropriate theming for each status state.
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { View, StyleSheet } from 'react-native';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import Icon from './Icon';
|
||||
import Text from './Text';
|
||||
import { useTheme } from '~/features/theme/ThemeProvider';
|
||||
import { UploadStatus } from '~/features/storage/uploadStatus.types';
|
||||
|
||||
interface UploadStatusBadgeProps {
|
||||
/** Current upload status */
|
||||
status: UploadStatus;
|
||||
|
||||
/** Badge size variant */
|
||||
size?: 'small' | 'medium' | 'large';
|
||||
|
||||
/** Whether to show text label */
|
||||
showLabel?: boolean;
|
||||
|
||||
/** Number of upload attempts (shown for failed uploads) */
|
||||
attemptCount?: number;
|
||||
|
||||
/** Custom style for container */
|
||||
style?: any;
|
||||
}
|
||||
|
||||
interface StatusConfig {
|
||||
icon: string;
|
||||
color: string;
|
||||
backgroundColor: string;
|
||||
labelKey: string;
|
||||
label?: string; // fallback if translation missing
|
||||
}
|
||||
|
||||
/**
|
||||
* Upload Status Badge
|
||||
*
|
||||
* Visual indicator showing the current upload status of an audio recording.
|
||||
* Supports multiple sizes and optional text labels.
|
||||
*
|
||||
* @example
|
||||
* ```tsx
|
||||
* <UploadStatusBadge status={UploadStatus.SUCCESS} />
|
||||
* <UploadStatusBadge
|
||||
* status={UploadStatus.FAILED}
|
||||
* attemptCount={3}
|
||||
* size="large"
|
||||
* showLabel
|
||||
* />
|
||||
* ```
|
||||
*/
|
||||
export const UploadStatusBadge: React.FC<UploadStatusBadgeProps> = ({
|
||||
status,
|
||||
size = 'medium',
|
||||
showLabel = true,
|
||||
attemptCount = 0,
|
||||
style,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const { isDark } = useTheme();
|
||||
|
||||
// Get status-specific configuration
|
||||
const getStatusConfig = (): StatusConfig => {
|
||||
switch (status) {
|
||||
case UploadStatus.SUCCESS:
|
||||
return {
|
||||
icon: 'checkmark-circle',
|
||||
color: '#10b981', // green-500
|
||||
backgroundColor: isDark ? 'rgba(6, 78, 59, 0.2)' : 'rgba(209, 250, 229, 0.5)', // green-900/900 : green-100
|
||||
labelKey: 'audio_archive.status.uploaded',
|
||||
label: 'Uploaded',
|
||||
};
|
||||
|
||||
case UploadStatus.PENDING:
|
||||
return {
|
||||
icon: 'time-outline',
|
||||
color: '#f59e0b', // amber-500
|
||||
backgroundColor: isDark ? 'rgba(120, 53, 15, 0.2)' : 'rgba(254, 243, 199, 0.5)', // amber-900 : amber-100
|
||||
labelKey: 'audio_archive.status.pending',
|
||||
label: 'Pending',
|
||||
};
|
||||
|
||||
case UploadStatus.UPLOADING:
|
||||
return {
|
||||
icon: 'cloud-upload',
|
||||
color: '#3b82f6', // blue-500
|
||||
backgroundColor: isDark ? 'rgba(30, 58, 138, 0.2)' : 'rgba(219, 234, 254, 0.5)', // blue-900 : blue-100
|
||||
labelKey: 'audio_archive.status.uploading',
|
||||
label: 'Uploading...',
|
||||
};
|
||||
|
||||
case UploadStatus.FAILED:
|
||||
return {
|
||||
icon: 'alert-circle',
|
||||
color: '#ef4444', // red-500
|
||||
backgroundColor: isDark ? 'rgba(127, 29, 29, 0.2)' : 'rgba(254, 226, 226, 0.5)', // red-900 : red-100
|
||||
labelKey: 'audio_archive.status.failed',
|
||||
label: attemptCount > 0 ? `Failed (${attemptCount} attempts)` : 'Failed',
|
||||
};
|
||||
|
||||
case UploadStatus.NOT_UPLOADED:
|
||||
default:
|
||||
return {
|
||||
icon: 'cloud-outline',
|
||||
color: isDark ? '#9ca3af' : '#6b7280', // gray-400 : gray-500
|
||||
backgroundColor: isDark ? 'rgba(55, 65, 81, 0.2)' : 'rgba(243, 244, 246, 0.5)', // gray-700 : gray-100
|
||||
labelKey: 'audio_archive.status.not_uploaded',
|
||||
label: 'Not uploaded',
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
const config = getStatusConfig();
|
||||
|
||||
// Calculate icon size based on badge size
|
||||
const iconSize = size === 'small' ? 14 : size === 'large' ? 24 : 18;
|
||||
|
||||
// Get translated label with fallback
|
||||
const label = t(config.labelKey, config.label);
|
||||
|
||||
// Show attempt count for failed uploads
|
||||
const displayLabel =
|
||||
status === UploadStatus.FAILED && attemptCount > 0
|
||||
? `${label} (${attemptCount})`
|
||||
: label;
|
||||
|
||||
return (
|
||||
<View
|
||||
style={[
|
||||
styles.container,
|
||||
{
|
||||
backgroundColor: config.backgroundColor,
|
||||
paddingHorizontal: size === 'small' ? 6 : size === 'large' ? 12 : 8,
|
||||
paddingVertical: size === 'small' ? 2 : size === 'large' ? 6 : 4,
|
||||
},
|
||||
style,
|
||||
]}
|
||||
>
|
||||
<Icon name={config.icon} size={iconSize} color={config.color} />
|
||||
|
||||
{showLabel && (
|
||||
<Text
|
||||
style={[
|
||||
styles.label,
|
||||
{
|
||||
color: config.color,
|
||||
fontSize: size === 'small' ? 11 : size === 'large' ? 14 : 12,
|
||||
},
|
||||
]}
|
||||
>
|
||||
{displayLabel}
|
||||
</Text>
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
borderRadius: 12,
|
||||
gap: 6,
|
||||
alignSelf: 'flex-start', // Don't stretch to full width
|
||||
},
|
||||
label: {
|
||||
fontWeight: '600',
|
||||
},
|
||||
});
|
||||
|
||||
export default UploadStatusBadge;
|
||||
28
apps/memoro/apps/mobile/components/atoms/alert.types.ts
Normal file
28
apps/memoro/apps/mobile/components/atoms/alert.types.ts
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
/**
|
||||
* Interface for Alert button properties
|
||||
*/
|
||||
export interface AlertButtonProps {
|
||||
text: string;
|
||||
onPress?: () => void;
|
||||
style?: 'default' | 'cancel' | 'destructive';
|
||||
}
|
||||
|
||||
/**
|
||||
* Interface for the Alert service to ensure consistent implementation
|
||||
* across all platform-specific services
|
||||
*/
|
||||
export interface IAlertService {
|
||||
/**
|
||||
* Displays an alert dialog with the specified title, message, and buttons
|
||||
*
|
||||
* @param title The title of the alert
|
||||
* @param message The message to display in the alert
|
||||
* @param buttons Array of buttons to display in the alert
|
||||
* @returns A function to dismiss the alert (web only)
|
||||
*/
|
||||
alert(
|
||||
title: string,
|
||||
message?: string,
|
||||
buttons?: AlertButtonProps[],
|
||||
): void | (() => void);
|
||||
}
|
||||
|
|
@ -0,0 +1,352 @@
|
|||
import { useState, useEffect } from 'react';
|
||||
import { StyleSheet, View, Alert } from 'react-native';
|
||||
import { useTheme } from '~/features/theme/ThemeProvider';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import Text from '~/components/atoms/Text';
|
||||
import AudioPlayer from '~/components/organisms/AudioPlayer';
|
||||
import TranscriptDisplay from '~/components/organisms/TranscriptDisplay';
|
||||
import { getAuthenticatedClient } from '~/features/auth/lib/supabaseClient';
|
||||
|
||||
interface AdditionalRecordingProps {
|
||||
recording: {
|
||||
path: string;
|
||||
type: string;
|
||||
timestamp: string;
|
||||
status: string;
|
||||
transcript?: string;
|
||||
speakers?: Record<string, Array<{text: string; offset?: number; duration?: number}>>;
|
||||
languages?: string[];
|
||||
memo_metadata?: {
|
||||
original_title?: string;
|
||||
display_title?: string;
|
||||
display_date?: string;
|
||||
original_created_at?: string;
|
||||
combine_index?: number;
|
||||
};
|
||||
utterances?: Array<{ text: string; speakerId: string; offset: number; duration: number }>;
|
||||
};
|
||||
index: number;
|
||||
isEditMode: boolean;
|
||||
getSignedUrl: (filePath: string) => Promise<string | null>;
|
||||
containerStyle?: object;
|
||||
speakerLabels?: Record<string, string>;
|
||||
handleUpdateSpeakerLabels?: (speakerMappings: Array<{id: string; label: string}>) => Promise<void>;
|
||||
setIsSpeakerLabelModalVisible?: (visible: boolean) => void;
|
||||
memo?: any; // Memo object for transcript updates
|
||||
memoId?: string;
|
||||
onDelete?: () => void;
|
||||
}
|
||||
|
||||
export default function AdditionalRecording({
|
||||
recording,
|
||||
index,
|
||||
isEditMode,
|
||||
getSignedUrl,
|
||||
containerStyle,
|
||||
speakerLabels,
|
||||
handleUpdateSpeakerLabels,
|
||||
setIsSpeakerLabelModalVisible,
|
||||
memo,
|
||||
memoId,
|
||||
onDelete
|
||||
}: AdditionalRecordingProps) {
|
||||
const { isDark } = useTheme();
|
||||
const { t } = useTranslation();
|
||||
const [recordingUrl, setRecordingUrl] = useState<string | null>(null);
|
||||
|
||||
// Formatiere das Datum für die Anzeige
|
||||
const formatDate = (dateString: string): string => {
|
||||
const date = new Date(dateString);
|
||||
return date.toLocaleDateString();
|
||||
};
|
||||
|
||||
// Prüfe ob es sich um ein kombiniertes Memo ohne Audio handelt
|
||||
const isCombinedMemoWithoutAudio = recording.type === 'combined_memo';
|
||||
const hasAudio = recording.type === 'audio' && recording.path && !recording.path.startsWith('combined-memo-');
|
||||
|
||||
// Lade die signierte URL für echte Audiodateien
|
||||
useEffect(() => {
|
||||
const loadUrl = async () => {
|
||||
if (hasAudio && recording.path) {
|
||||
try {
|
||||
const url = await getSignedUrl(recording.path);
|
||||
setRecordingUrl(url);
|
||||
} catch (error) {
|
||||
console.debug('Fehler beim Generieren der URL für zusätzliche Aufnahme:', error);
|
||||
}
|
||||
} else if (isCombinedMemoWithoutAudio || !hasAudio) {
|
||||
// Für kombinierte Memos ohne Audio ist keine URL nötig
|
||||
setRecordingUrl('no-audio');
|
||||
}
|
||||
};
|
||||
|
||||
loadUrl();
|
||||
}, [recording.path, getSignedUrl, hasAudio, isCombinedMemoWithoutAudio]);
|
||||
|
||||
// Warte bis URL-Status geklärt ist
|
||||
if (!recordingUrl) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
marginBottom: 16,
|
||||
backgroundColor: isDark ? 'rgba(30, 30, 30, 0.5)' : 'rgba(245, 245, 245, 0.5)',
|
||||
borderRadius: 12,
|
||||
overflow: 'hidden',
|
||||
padding: 8,
|
||||
...containerStyle
|
||||
},
|
||||
transcriptContainer: {
|
||||
marginTop: 8
|
||||
},
|
||||
statusText: {
|
||||
fontSize: 14,
|
||||
color: isDark ? 'rgba(255, 255, 255, 0.6)' : 'rgba(0, 0, 0, 0.6)',
|
||||
fontStyle: 'italic',
|
||||
textAlign: 'center',
|
||||
padding: 12
|
||||
}
|
||||
});
|
||||
|
||||
// Bestimme den passenden Titel basierend auf den Metadaten
|
||||
const getDisplayTitle = () => {
|
||||
// Für kombinierte Memos: Verwende den ursprünglichen Titel
|
||||
if (recording.memo_metadata?.display_title || recording.memo_metadata?.original_title) {
|
||||
return recording.memo_metadata.display_title || recording.memo_metadata.original_title;
|
||||
}
|
||||
// Fallback für normale zusätzliche Aufnahmen
|
||||
return t('memo.additional_recording', 'Zusätzliche Aufnahme {{number}}', { number: index + 1 });
|
||||
};
|
||||
|
||||
const getDisplayDate = () => {
|
||||
// Für kombinierte Memos: Verwende das ursprüngliche Erstellungsdatum wenn verfügbar
|
||||
if (recording.memo_metadata?.original_created_at) {
|
||||
return t('memo.created_on', 'Erstellt am {{date}}', {
|
||||
date: formatDate(recording.memo_metadata.original_created_at)
|
||||
});
|
||||
}
|
||||
// Fallback für normale zusätzliche Aufnahmen
|
||||
return recording.timestamp ? t('memo.recorded_on', 'Aufgenommen am {{date}}', {
|
||||
date: formatDate(recording.timestamp)
|
||||
}) : '';
|
||||
};
|
||||
|
||||
const getTranscriptTitle = () => {
|
||||
// Für kombinierte Memos: Verwende "Transkript: [Original Titel]"
|
||||
if (recording.memo_metadata?.display_title || recording.memo_metadata?.original_title) {
|
||||
const originalTitle = recording.memo_metadata.display_title || recording.memo_metadata.original_title;
|
||||
return `Transkript: ${originalTitle}`;
|
||||
}
|
||||
// Fallback für normale zusätzliche Aufnahmen
|
||||
return t('memo.transcript_number', 'Transkript {{number}}', { number: index + 1 });
|
||||
};
|
||||
|
||||
// For combined memos, don't wrap in container
|
||||
if (isCombinedMemoWithoutAudio || recording.memo_metadata) {
|
||||
return (
|
||||
<>
|
||||
{/* Audio Player für Aufnahmen mit Audio-Dateien */}
|
||||
{hasAudio && recordingUrl && recordingUrl !== 'no-audio' && (
|
||||
<View style={{ marginBottom: 16, marginHorizontal: 20 }}>
|
||||
<AudioPlayer
|
||||
audioUri={recordingUrl}
|
||||
headlineText={getDisplayTitle()}
|
||||
dateText={getDisplayDate()}
|
||||
onDelete={onDelete}
|
||||
/>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* Für Aufnahmen ohne Audio: Zeige nur Titel und Datum */}
|
||||
{!hasAudio && (
|
||||
<View style={{ padding: 12, marginBottom: 8 }}>
|
||||
<Text
|
||||
style={{
|
||||
fontSize: 16,
|
||||
fontWeight: 'bold',
|
||||
marginBottom: 4,
|
||||
color: isDark ? 'white' : 'black'
|
||||
}}
|
||||
>
|
||||
{getDisplayTitle()}
|
||||
</Text>
|
||||
<Text
|
||||
style={{
|
||||
fontSize: 14,
|
||||
opacity: 0.7,
|
||||
color: isDark ? 'white' : 'black'
|
||||
}}
|
||||
>
|
||||
{getDisplayDate()}
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* Transkript der zusätzlichen Aufnahme - immer mit TranscriptDisplay anzeigen */}
|
||||
{recording.transcript && (
|
||||
<View style={{ marginBottom: 16 }}>
|
||||
<TranscriptDisplay
|
||||
data={{
|
||||
...recording,
|
||||
type: 'audio',
|
||||
utterances: recording.utterances || [],
|
||||
speakers: recording.speakers || {}
|
||||
}}
|
||||
defaultExpanded={true}
|
||||
title={getTranscriptTitle()}
|
||||
speakerLabels={speakerLabels}
|
||||
onUpdateSpeakerLabels={handleUpdateSpeakerLabels}
|
||||
onNameSpeakersPress={() => setIsSpeakerLabelModalVisible?.(true)}
|
||||
isEditing={isEditMode}
|
||||
onTranscriptChange={isEditMode ? async (newTranscript) => {
|
||||
try {
|
||||
const supabase = await getAuthenticatedClient();
|
||||
|
||||
// Update the additional recording's transcript
|
||||
const updatedRecordings = [...(memo?.metadata?.additional_recordings || [])];
|
||||
if (updatedRecordings[index]) {
|
||||
updatedRecordings[index] = {
|
||||
...updatedRecordings[index],
|
||||
transcript: newTranscript
|
||||
};
|
||||
}
|
||||
|
||||
const updatedMetadata = {
|
||||
...memo?.metadata,
|
||||
additional_recordings: updatedRecordings
|
||||
};
|
||||
|
||||
const { error } = await supabase
|
||||
.from('memos')
|
||||
.update({ metadata: updatedMetadata })
|
||||
.eq('id', memo?.id);
|
||||
|
||||
if (error) {
|
||||
console.debug('Error updating additional recording transcript:', error.message);
|
||||
Alert.alert('Fehler', 'Das Transkript konnte nicht aktualisiert werden.');
|
||||
return;
|
||||
}
|
||||
|
||||
console.debug('Additional recording transcript updated successfully');
|
||||
} catch (error) {
|
||||
console.debug('Error in onTranscriptChange:', error);
|
||||
Alert.alert('Fehler', 'Ein unerwarteter Fehler ist aufgetreten.');
|
||||
}
|
||||
} : undefined}
|
||||
/>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* Zeige Verarbeitungsstatus an, wenn keine Transkription vorhanden ist */}
|
||||
{!recording.transcript && recording.status === 'processing' && (
|
||||
<View style={{ marginBottom: 16, padding: 12 }}>
|
||||
<Text style={styles.statusText}>{t('memo.transcribing', 'Transkription wird verarbeitet...')}</Text>
|
||||
</View>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// For regular additional recordings, keep the container
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
{/* Audio Player für Aufnahmen mit Audio-Dateien */}
|
||||
{hasAudio && recordingUrl && recordingUrl !== 'no-audio' && (
|
||||
<AudioPlayer
|
||||
audioUri={recordingUrl}
|
||||
headlineText={getDisplayTitle()}
|
||||
dateText={getDisplayDate()}
|
||||
onDelete={onDelete}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Für Aufnahmen ohne Audio: Zeige nur Titel und Datum */}
|
||||
{!hasAudio && (
|
||||
<View style={{ padding: 12 }}>
|
||||
<Text
|
||||
style={{
|
||||
fontSize: 16,
|
||||
fontWeight: 'bold',
|
||||
marginBottom: 4,
|
||||
color: isDark ? 'white' : 'black'
|
||||
}}
|
||||
>
|
||||
{getDisplayTitle()}
|
||||
</Text>
|
||||
<Text
|
||||
style={{
|
||||
fontSize: 14,
|
||||
opacity: 0.7,
|
||||
color: isDark ? 'white' : 'black'
|
||||
}}
|
||||
>
|
||||
{getDisplayDate()}
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* Transkript der zusätzlichen Aufnahme - immer mit TranscriptDisplay anzeigen */}
|
||||
{recording.transcript && (
|
||||
<View style={styles.transcriptContainer}>
|
||||
<TranscriptDisplay
|
||||
data={{
|
||||
...recording,
|
||||
type: 'audio',
|
||||
utterances: recording.utterances || [],
|
||||
speakers: recording.speakers || {}
|
||||
}}
|
||||
defaultExpanded={true}
|
||||
title={getTranscriptTitle()}
|
||||
speakerLabels={speakerLabels}
|
||||
onUpdateSpeakerLabels={handleUpdateSpeakerLabels}
|
||||
onNameSpeakersPress={() => setIsSpeakerLabelModalVisible?.(true)}
|
||||
isEditing={isEditMode}
|
||||
onTranscriptChange={isEditMode ? async (newTranscript) => {
|
||||
try {
|
||||
const supabase = await getAuthenticatedClient();
|
||||
|
||||
// Update the additional recording's transcript
|
||||
const updatedRecordings = [...(memo?.metadata?.additional_recordings || [])];
|
||||
if (updatedRecordings[index]) {
|
||||
updatedRecordings[index] = {
|
||||
...updatedRecordings[index],
|
||||
transcript: newTranscript
|
||||
};
|
||||
}
|
||||
|
||||
const updatedMetadata = {
|
||||
...memo?.metadata,
|
||||
additional_recordings: updatedRecordings
|
||||
};
|
||||
|
||||
const { error } = await supabase
|
||||
.from('memos')
|
||||
.update({ metadata: updatedMetadata })
|
||||
.eq('id', memo?.id);
|
||||
|
||||
if (error) {
|
||||
console.debug('Error updating additional recording transcript:', error.message);
|
||||
Alert.alert('Fehler', 'Das Transkript konnte nicht aktualisiert werden.');
|
||||
return;
|
||||
}
|
||||
|
||||
console.debug('Additional recording transcript updated successfully');
|
||||
} catch (error) {
|
||||
console.debug('Error in onTranscriptChange:', error);
|
||||
Alert.alert('Fehler', 'Ein unerwarteter Fehler ist aufgetreten.');
|
||||
}
|
||||
} : undefined}
|
||||
/>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* Zeige Verarbeitungsstatus an, wenn keine Transkription vorhanden ist */}
|
||||
{!recording.transcript && recording.status === 'processing' && (
|
||||
<View style={styles.transcriptContainer}>
|
||||
<Text style={styles.statusText}>{t('memo.transcribing', 'Transkription wird verarbeitet...')}</Text>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,174 @@
|
|||
import React from 'react';
|
||||
import { View, StyleSheet } from 'react-native';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useTheme } from '~/features/theme/ThemeProvider';
|
||||
import Text from '~/components/atoms/Text';
|
||||
import Icon from '~/components/atoms/Icon';
|
||||
|
||||
interface ArchiveStatisticsProps {
|
||||
totalCount: number;
|
||||
totalDurationSeconds: number;
|
||||
totalSizeBytes: number;
|
||||
isLoading?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Komponente zur Anzeige der Archiv-Statistiken
|
||||
*/
|
||||
const ArchiveStatistics = ({
|
||||
totalCount,
|
||||
totalDurationSeconds,
|
||||
totalSizeBytes,
|
||||
isLoading = false
|
||||
}: ArchiveStatisticsProps) => {
|
||||
const { t } = useTranslation();
|
||||
const { isDark, themeVariant } = useTheme();
|
||||
|
||||
// Formatiere die Dauer als HH:MM:SS oder MM:SS
|
||||
const formatDuration = (seconds: number): string => {
|
||||
const hours = Math.floor(seconds / 3600);
|
||||
const minutes = Math.floor((seconds % 3600) / 60);
|
||||
const remainingSeconds = Math.floor(seconds % 60);
|
||||
|
||||
if (hours > 0) {
|
||||
return `${hours}:${minutes.toString().padStart(2, '0')}:${remainingSeconds.toString().padStart(2, '0')}`;
|
||||
} else {
|
||||
return `${minutes}:${remainingSeconds.toString().padStart(2, '0')}`;
|
||||
}
|
||||
};
|
||||
|
||||
// Formatiere die Dateigröße
|
||||
const formatFileSize = (bytes: number): string => {
|
||||
if (bytes === 0) return '0 B';
|
||||
|
||||
const k = 1024;
|
||||
const sizes = ['B', 'KB', 'MB', 'GB'];
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||
|
||||
return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + ' ' + sizes[i];
|
||||
};
|
||||
|
||||
// Theme-Farben
|
||||
const textColor = isDark ? '#FFFFFF' : '#000000';
|
||||
const secondaryTextColor = isDark ? '#CCCCCC' : '#666666';
|
||||
const cardBackgroundColor = isDark ? '#2A2A2A' : '#F5F5F5';
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
backgroundColor: cardBackgroundColor,
|
||||
borderRadius: 12,
|
||||
padding: 16,
|
||||
marginHorizontal: 16,
|
||||
marginBottom: 16,
|
||||
},
|
||||
title: {
|
||||
fontSize: 18,
|
||||
fontWeight: 'bold',
|
||||
color: textColor,
|
||||
marginBottom: 12,
|
||||
textAlign: 'center',
|
||||
},
|
||||
statsRow: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
marginBottom: 8,
|
||||
},
|
||||
statItem: {
|
||||
flex: 1,
|
||||
alignItems: 'center',
|
||||
},
|
||||
statValue: {
|
||||
fontSize: 16,
|
||||
fontWeight: '600',
|
||||
color: textColor,
|
||||
marginTop: 4,
|
||||
},
|
||||
statLabel: {
|
||||
fontSize: 12,
|
||||
color: secondaryTextColor,
|
||||
textAlign: 'center',
|
||||
marginTop: 2,
|
||||
},
|
||||
divider: {
|
||||
width: 1,
|
||||
height: 40,
|
||||
backgroundColor: secondaryTextColor,
|
||||
opacity: 0.3,
|
||||
marginHorizontal: 12,
|
||||
},
|
||||
loadingText: {
|
||||
fontSize: 14,
|
||||
color: secondaryTextColor,
|
||||
textAlign: 'center',
|
||||
fontStyle: 'italic',
|
||||
},
|
||||
localStorageText: {
|
||||
fontSize: 12,
|
||||
color: secondaryTextColor,
|
||||
textAlign: 'center',
|
||||
marginTop: 12,
|
||||
paddingTop: 12,
|
||||
borderTopWidth: 1,
|
||||
borderTopColor: secondaryTextColor,
|
||||
opacity: 0.7,
|
||||
},
|
||||
});
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<Text style={styles.title}>
|
||||
{t('audio_archive.statistics', 'Archiv-Statistiken')}
|
||||
</Text>
|
||||
<Text style={styles.loadingText}>
|
||||
{t('audio_archive.loading_stats', 'Statistiken werden geladen...')}
|
||||
</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<Text style={styles.title}>
|
||||
{t('audio_archive.statistics', 'Archiv-Statistiken')}
|
||||
</Text>
|
||||
|
||||
<View style={styles.statsRow}>
|
||||
<View style={styles.statItem}>
|
||||
<Icon name="musical-notes" size={24} color={textColor} />
|
||||
<Text style={styles.statValue}>{totalCount}</Text>
|
||||
<Text style={styles.statLabel}>
|
||||
{t('audio_archive.recordings', 'Aufnahmen')}
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
<View style={styles.divider} />
|
||||
|
||||
<View style={styles.statItem}>
|
||||
<Icon name="time" size={24} color={textColor} />
|
||||
<Text style={styles.statValue}>{formatDuration(totalDurationSeconds)}</Text>
|
||||
<Text style={styles.statLabel}>
|
||||
{t('audio_archive.duration', 'Gesamtlänge')}
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
<View style={styles.divider} />
|
||||
|
||||
<View style={styles.statItem}>
|
||||
<Icon name="server" size={24} color={textColor} />
|
||||
<Text style={styles.statValue}>{formatFileSize(totalSizeBytes)}</Text>
|
||||
<Text style={styles.statLabel}>
|
||||
{t('audio_archive.size', 'Speicherplatz')}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<Text style={styles.localStorageText}>
|
||||
{t('audio_archive.local_recordings', 'Lokale Aufnahmen auf deinem Gerät.')}
|
||||
</Text>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
export default ArchiveStatistics;
|
||||
|
|
@ -0,0 +1,469 @@
|
|||
import React, { useState, useMemo, useCallback, useEffect } from 'react';
|
||||
import { View, ScrollView, Pressable, ActivityIndicator, Animated } from 'react-native';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useTheme } from '~/features/theme/ThemeProvider';
|
||||
import Input from '~/components/atoms/Input';
|
||||
import Icon from '~/components/atoms/Icon';
|
||||
import Text from '~/components/atoms/Text';
|
||||
import { formatLanguageDisplay } from '~/utils/languageDisplay';
|
||||
import { useRecordingLanguage } from '~/features/audioRecordingV2';
|
||||
|
||||
export interface LanguageItem {
|
||||
code: string;
|
||||
nativeName: string;
|
||||
emoji: string;
|
||||
locale?: string;
|
||||
isExperimental?: boolean;
|
||||
}
|
||||
|
||||
interface BaseLanguageSelectorProps {
|
||||
languages: Record<string, LanguageItem>;
|
||||
selectedLanguages: string[];
|
||||
onSelect: (languages: string[]) => void;
|
||||
mode?: 'single' | 'multi';
|
||||
showAutoDetect?: boolean;
|
||||
showExperimentalWarning?: boolean;
|
||||
loading?: boolean;
|
||||
placeholder?: string;
|
||||
searchPlaceholder?: string;
|
||||
height?: number;
|
||||
priorityLanguages?: string[];
|
||||
onClose?: () => void;
|
||||
autoSelectOnSingle?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Unified base component for all language selection needs.
|
||||
* Supports both single and multi-selection modes with consistent UI.
|
||||
*/
|
||||
const BaseLanguageSelector: React.FC<BaseLanguageSelectorProps> = ({
|
||||
languages,
|
||||
selectedLanguages,
|
||||
onSelect,
|
||||
mode = 'multi',
|
||||
showAutoDetect = false,
|
||||
showExperimentalWarning = false,
|
||||
loading = false,
|
||||
placeholder,
|
||||
searchPlaceholder,
|
||||
height = 450,
|
||||
priorityLanguages = ['de', 'en', 'fr', 'es', 'it'],
|
||||
onClose,
|
||||
autoSelectOnSingle = false,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const { isDark, themeVariant } = useTheme();
|
||||
const { dialectChangeNotification, clearDialectNotification } = useRecordingLanguage();
|
||||
|
||||
// State for search
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
|
||||
// Animation value for notification
|
||||
const notificationOpacity = useState(new Animated.Value(0))[0];
|
||||
const notificationTranslateY = useState(new Animated.Value(-20))[0];
|
||||
|
||||
// Get theme colors
|
||||
const getThemeColor = useCallback((colorKey: string) => {
|
||||
try {
|
||||
const colors = require('../../tailwind.config.js');
|
||||
if (isDark && colors.theme?.extend?.colors?.dark?.[themeVariant]) {
|
||||
return colors.theme.extend.colors.dark[themeVariant][colorKey];
|
||||
} else if (colors.theme?.extend?.colors?.[themeVariant]) {
|
||||
return colors.theme.extend.colors[themeVariant][colorKey];
|
||||
}
|
||||
} catch (e) {
|
||||
// Fallback colors
|
||||
}
|
||||
|
||||
// Fallback colors
|
||||
if (isDark) {
|
||||
return {
|
||||
text: '#FFFFFF',
|
||||
contentBackground: '#1E1E1E',
|
||||
contentBackgroundHover: '#333333',
|
||||
border: '#374151',
|
||||
primary: '#f8d62b',
|
||||
secondary: 'rgba(255, 255, 255, 0.7)',
|
||||
muted: 'rgba(255, 255, 255, 0.5)',
|
||||
}[colorKey] || '#FFFFFF';
|
||||
} else {
|
||||
return {
|
||||
text: '#000000',
|
||||
contentBackground: '#FFFFFF',
|
||||
contentBackgroundHover: '#F5F5F5',
|
||||
border: '#E5E7EB',
|
||||
primary: '#f8d62b',
|
||||
secondary: 'rgba(0, 0, 0, 0.7)',
|
||||
muted: 'rgba(0, 0, 0, 0.5)',
|
||||
}[colorKey] || '#000000';
|
||||
}
|
||||
}, [isDark, themeVariant]);
|
||||
|
||||
const themeColors = (require('../../tailwind.config.js') as any).theme?.extend?.colors as Record<string, any>;
|
||||
|
||||
const textColor = getThemeColor('text');
|
||||
const hoverColor = getThemeColor('contentBackgroundHover');
|
||||
const borderColor = getThemeColor('border');
|
||||
const primaryColor = getThemeColor('primary');
|
||||
const secondaryColor = getThemeColor('secondary');
|
||||
const mutedColor = getThemeColor('muted');
|
||||
const contentBackgroundColor = getThemeColor('contentBackground');
|
||||
|
||||
// Handle notification animation
|
||||
useEffect(() => {
|
||||
if (dialectChangeNotification) {
|
||||
// Animate in
|
||||
Animated.parallel([
|
||||
Animated.timing(notificationOpacity, {
|
||||
toValue: 1,
|
||||
duration: 300,
|
||||
useNativeDriver: true,
|
||||
}),
|
||||
Animated.timing(notificationTranslateY, {
|
||||
toValue: 0,
|
||||
duration: 300,
|
||||
useNativeDriver: true,
|
||||
}),
|
||||
]).start();
|
||||
} else {
|
||||
// Animate out
|
||||
Animated.parallel([
|
||||
Animated.timing(notificationOpacity, {
|
||||
toValue: 0,
|
||||
duration: 200,
|
||||
useNativeDriver: true,
|
||||
}),
|
||||
Animated.timing(notificationTranslateY, {
|
||||
toValue: -20,
|
||||
duration: 200,
|
||||
useNativeDriver: true,
|
||||
}),
|
||||
]).start();
|
||||
}
|
||||
}, [dialectChangeNotification]);
|
||||
|
||||
// Filter and sort languages
|
||||
const filteredLanguages = useMemo(() => {
|
||||
const entries = Object.entries(languages);
|
||||
|
||||
// Filter based on search query
|
||||
const filtered = searchQuery.trim()
|
||||
? entries.filter(([code, language]) => {
|
||||
const query = searchQuery.toLowerCase();
|
||||
return (
|
||||
code.toLowerCase().includes(query) ||
|
||||
language.nativeName.toLowerCase().includes(query)
|
||||
);
|
||||
})
|
||||
: entries;
|
||||
|
||||
// Sort languages with priority
|
||||
return filtered.sort((a, b) => {
|
||||
// "Auto" always first if enabled
|
||||
if (showAutoDetect) {
|
||||
if (a[0] === 'auto') return -1;
|
||||
if (b[0] === 'auto') return 1;
|
||||
}
|
||||
|
||||
// Selected languages next (only in multi mode)
|
||||
if (mode === 'multi') {
|
||||
const aSelected = selectedLanguages.includes(a[0]);
|
||||
const bSelected = selectedLanguages.includes(b[0]);
|
||||
|
||||
if (aSelected && !bSelected) return -1;
|
||||
if (!aSelected && bSelected) return 1;
|
||||
}
|
||||
|
||||
// Priority languages
|
||||
const aPriority = priorityLanguages.indexOf(a[0]);
|
||||
const bPriority = priorityLanguages.indexOf(b[0]);
|
||||
|
||||
if (aPriority !== -1 && bPriority !== -1) {
|
||||
return aPriority - bPriority;
|
||||
}
|
||||
if (aPriority !== -1) return -1;
|
||||
if (bPriority !== -1) return 1;
|
||||
|
||||
// Official languages before experimental (if applicable)
|
||||
if (showExperimentalWarning) {
|
||||
const aExperimental = a[1].isExperimental || false;
|
||||
const bExperimental = b[1].isExperimental || false;
|
||||
|
||||
if (!aExperimental && bExperimental) return -1;
|
||||
if (aExperimental && !bExperimental) return 1;
|
||||
}
|
||||
|
||||
// Finally alphabetically by native name
|
||||
return a[1].nativeName.localeCompare(b[1].nativeName);
|
||||
});
|
||||
}, [searchQuery, languages, selectedLanguages, mode, showAutoDetect, priorityLanguages, showExperimentalWarning]);
|
||||
|
||||
// Handle language selection
|
||||
const handleLanguagePress = useCallback((languageCode: string) => {
|
||||
if (mode === 'single') {
|
||||
onSelect([languageCode]);
|
||||
if (autoSelectOnSingle && onClose) {
|
||||
onClose();
|
||||
}
|
||||
} else {
|
||||
// Multi-select mode
|
||||
if (languageCode === 'auto' && showAutoDetect) {
|
||||
// If auto is selected, deselect all others
|
||||
if (selectedLanguages.includes('auto')) {
|
||||
onSelect([]);
|
||||
} else {
|
||||
onSelect(['auto']);
|
||||
}
|
||||
} else {
|
||||
// Remove auto if selecting a specific language
|
||||
const filteredSelection = selectedLanguages.filter(lang => lang !== 'auto');
|
||||
|
||||
if (selectedLanguages.includes(languageCode)) {
|
||||
// Deselect
|
||||
onSelect(filteredSelection.filter(lang => lang !== languageCode));
|
||||
} else {
|
||||
// Select
|
||||
onSelect([...filteredSelection, languageCode]);
|
||||
}
|
||||
}
|
||||
}
|
||||
}, [mode, selectedLanguages, onSelect, showAutoDetect, autoSelectOnSingle, onClose]);
|
||||
|
||||
// Handle search clear
|
||||
const handleClearSearch = useCallback(() => {
|
||||
setSearchQuery('');
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<View style={{ height, display: 'flex', flexDirection: 'column' }}>
|
||||
{/* In-Modal Notification */}
|
||||
{dialectChangeNotification && (
|
||||
<Animated.View
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: 10,
|
||||
left: 10,
|
||||
right: 10,
|
||||
backgroundColor: isDark
|
||||
? themeColors?.dark?.[themeVariant]?.menuBackground || '#252525'
|
||||
: themeColors?.[themeVariant]?.menuBackground || '#DDDDDD',
|
||||
borderRadius: 16,
|
||||
padding: 16,
|
||||
opacity: notificationOpacity,
|
||||
transform: [{ translateY: notificationTranslateY }],
|
||||
zIndex: 10,
|
||||
elevation: 5,
|
||||
shadowColor: '#000',
|
||||
shadowOffset: { width: 0, height: 2 },
|
||||
shadowOpacity: 0.1,
|
||||
shadowRadius: 3.84,
|
||||
}}
|
||||
>
|
||||
{/* Icon and Title Row */}
|
||||
<View style={{ flexDirection: 'row', alignItems: 'center', marginBottom: 6 }}>
|
||||
<View style={{
|
||||
backgroundColor: primaryColor,
|
||||
borderRadius: 6,
|
||||
padding: 4,
|
||||
marginRight: 8,
|
||||
}}>
|
||||
<Icon name="swap-horizontal" size={16} color={isDark ? '#000000' : '#000000'} />
|
||||
</View>
|
||||
<Text style={{
|
||||
color: textColor,
|
||||
fontSize: 16,
|
||||
fontWeight: '600',
|
||||
flex: 1,
|
||||
}}>
|
||||
{t('recording.dialect_switch_title', 'Sprachvariante gewechselt')}
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
{/* Message */}
|
||||
<Text style={{
|
||||
color: textColor,
|
||||
fontSize: 14,
|
||||
lineHeight: 20,
|
||||
marginLeft: 32,
|
||||
}}>
|
||||
{t('recording.dialect_switch_explanation', {
|
||||
oldDialect: dialectChangeNotification.oldDialect,
|
||||
newDialect: dialectChangeNotification.newDialect,
|
||||
defaultValue: `Die Transkription unterstützt nur eine Sprachvariante gleichzeitig. ${dialectChangeNotification.newDialect} ist jetzt aktiv, ${dialectChangeNotification.oldDialect} wurde deaktiviert.`
|
||||
})}
|
||||
</Text>
|
||||
</Animated.View>
|
||||
)}
|
||||
|
||||
{/* Search Field - Unified Design */}
|
||||
<View style={{
|
||||
paddingHorizontal: 20,
|
||||
paddingTop: 16,
|
||||
marginBottom: 16,
|
||||
position: 'relative',
|
||||
}}>
|
||||
<Input
|
||||
value={searchQuery}
|
||||
onChangeText={setSearchQuery}
|
||||
placeholder={searchPlaceholder || t('common.search_language_count', {
|
||||
count: Object.keys(languages).length,
|
||||
defaultValue: `${Object.keys(languages).length} ${t('common.languages_search', 'Sprachen durchsuchen...')}`
|
||||
})}
|
||||
style={{ paddingLeft: 44, paddingRight: searchQuery.length > 0 ? 44 : 16 }}
|
||||
autoCapitalize="none"
|
||||
autoCorrect={false}
|
||||
editable={!loading}
|
||||
/>
|
||||
|
||||
{/* Search Icon */}
|
||||
<View style={{
|
||||
position: 'absolute',
|
||||
left: 32,
|
||||
top: 16,
|
||||
bottom: 0,
|
||||
height: '100%',
|
||||
justifyContent: 'center',
|
||||
pointerEvents: 'none',
|
||||
}}>
|
||||
<Icon
|
||||
name="search-outline"
|
||||
size={20}
|
||||
color={textColor}
|
||||
/>
|
||||
</View>
|
||||
|
||||
{/* Clear Button */}
|
||||
{searchQuery.length > 0 && (
|
||||
<Pressable
|
||||
style={{
|
||||
position: 'absolute',
|
||||
right: 12,
|
||||
height: '100%',
|
||||
width: 32,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
}}
|
||||
onPress={handleClearSearch}
|
||||
accessibilityLabel={t('common.clear_search', 'Suche zurücksetzen')}
|
||||
disabled={loading}
|
||||
>
|
||||
<Icon name="close" size={16} color={secondaryColor} />
|
||||
</Pressable>
|
||||
)}
|
||||
</View>
|
||||
|
||||
{/* Language List */}
|
||||
<ScrollView style={{ flex: 1 }}>
|
||||
<View style={{
|
||||
gap: 8,
|
||||
paddingHorizontal: 20,
|
||||
paddingBottom: 24
|
||||
}}>
|
||||
{loading ? (
|
||||
<View style={{
|
||||
flex: 1,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
paddingVertical: 40
|
||||
}}>
|
||||
<ActivityIndicator size="large" color={primaryColor} />
|
||||
</View>
|
||||
) : filteredLanguages.length > 0 ? (
|
||||
filteredLanguages.map(([code, language]) => {
|
||||
const isSelected = selectedLanguages.includes(code);
|
||||
const isExperimental = language.isExperimental || false;
|
||||
|
||||
return (
|
||||
<Pressable
|
||||
key={code}
|
||||
style={{
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
padding: 16,
|
||||
backgroundColor: isSelected ? (mode === 'single' ? hoverColor : contentBackgroundColor) : 'transparent',
|
||||
borderWidth: 1,
|
||||
borderColor: isSelected && mode === 'multi' ? primaryColor : borderColor,
|
||||
borderRadius: 12,
|
||||
shadowColor: '#000',
|
||||
shadowOffset: { width: 0, height: 1 },
|
||||
shadowOpacity: 0.1,
|
||||
shadowRadius: 2,
|
||||
elevation: 1,
|
||||
}}
|
||||
onPress={() => handleLanguagePress(code)}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={`${language.nativeName} ${isSelected ? t('common.selected', 'ausgewählt') : ''}`}
|
||||
disabled={loading}
|
||||
android_ripple={{
|
||||
color: isDark ? 'rgba(255, 255, 255, 0.1)' : 'rgba(0, 0, 0, 0.1)',
|
||||
borderless: false
|
||||
}}
|
||||
>
|
||||
<Text style={{ fontSize: 20, marginRight: 16, lineHeight: 24 }}>
|
||||
{language.emoji}
|
||||
</Text>
|
||||
|
||||
<View style={{ flex: 1 }}>
|
||||
<Text
|
||||
style={{
|
||||
fontSize: 16,
|
||||
fontWeight: isSelected ? '600' : '400',
|
||||
color: textColor,
|
||||
}}
|
||||
>
|
||||
{formatLanguageDisplay(code, language.nativeName, t)}
|
||||
{isExperimental && showExperimentalWarning && ' ⚠️'}
|
||||
</Text>
|
||||
{isExperimental && showExperimentalWarning && (
|
||||
<Text
|
||||
style={{
|
||||
fontSize: 12,
|
||||
color: mutedColor,
|
||||
marginTop: 2,
|
||||
}}
|
||||
>
|
||||
{t('common.experimental', 'Experimentell')}
|
||||
</Text>
|
||||
)}
|
||||
</View>
|
||||
|
||||
{/* Selection Indicator */}
|
||||
{mode === 'multi' && isSelected && (
|
||||
<View
|
||||
style={{
|
||||
width: 28,
|
||||
height: 28,
|
||||
borderRadius: 14,
|
||||
backgroundColor: primaryColor,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
}}
|
||||
>
|
||||
<Icon name="checkmark" size={18} color={isDark ? '#000000' : '#000000'} />
|
||||
</View>
|
||||
)}
|
||||
|
||||
{mode === 'single' && isSelected && (
|
||||
<Icon name="checkmark" size={20} color={primaryColor} />
|
||||
)}
|
||||
</Pressable>
|
||||
);
|
||||
})
|
||||
) : (
|
||||
<Text style={{
|
||||
padding: 16,
|
||||
textAlign: 'center',
|
||||
fontSize: 16,
|
||||
marginTop: 40,
|
||||
color: textColor
|
||||
}}>
|
||||
{t('common.no_results', 'Keine Ergebnisse gefunden')}
|
||||
</Text>
|
||||
)}
|
||||
</View>
|
||||
</ScrollView>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
export default BaseLanguageSelector;
|
||||
|
|
@ -0,0 +1,342 @@
|
|||
import React, { useState, useEffect } from 'react';
|
||||
import { View, FlatList, Pressable } from 'react-native';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import '~/features/i18n'; // Ensure i18n is loaded
|
||||
import { useTheme } from '~/features/theme/ThemeProvider';
|
||||
import BaseModal from '~/components/atoms/BaseModal';
|
||||
import Text from '~/components/atoms/Text';
|
||||
import Icon from '~/components/atoms/Icon';
|
||||
import LoadingOverlay from '~/components/atoms/LoadingOverlay';
|
||||
import { getAuthenticatedClient } from '~/features/auth/lib/supabaseClient';
|
||||
import { creditService } from '~/features/credits/creditService';
|
||||
import ManaIcon from '~/features/subscription/ManaIcon';
|
||||
import themeColors from '~/tailwind.config.js';
|
||||
|
||||
interface Blueprint {
|
||||
id: string;
|
||||
name: {
|
||||
de?: string;
|
||||
en?: string;
|
||||
};
|
||||
description?: {
|
||||
de?: string;
|
||||
en?: string;
|
||||
};
|
||||
category?: {
|
||||
id: string;
|
||||
name: {
|
||||
de?: string;
|
||||
en?: string;
|
||||
};
|
||||
description?: {
|
||||
de?: string;
|
||||
en?: string;
|
||||
};
|
||||
style?: { color?: string; [key: string]: any };
|
||||
};
|
||||
is_public: boolean;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
user_id: string;
|
||||
}
|
||||
|
||||
interface CombineMemosModalProps {
|
||||
isVisible: boolean;
|
||||
onClose: () => void;
|
||||
selectedMemoIds: string[];
|
||||
onCombine: (blueprintId: string, prompt?: string) => void;
|
||||
}
|
||||
|
||||
export default function CombineMemosModal({
|
||||
isVisible,
|
||||
onClose,
|
||||
selectedMemoIds,
|
||||
onCombine,
|
||||
}: CombineMemosModalProps) {
|
||||
const { t, i18n } = useTranslation();
|
||||
const { isDark, themeVariant } = useTheme();
|
||||
const [blueprints, setBlueprints] = useState<Blueprint[]>([]);
|
||||
const [selectedBlueprint, setSelectedBlueprint] = useState<Blueprint | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
|
||||
// Theme colors
|
||||
const backgroundColor = isDark
|
||||
? themeColors?.colors?.dark?.[themeVariant]?.surface || '#1a1a1a'
|
||||
: themeColors?.colors?.[themeVariant]?.surface || '#ffffff';
|
||||
|
||||
const textColor = isDark
|
||||
? themeColors?.colors?.dark?.[themeVariant]?.onSurface || '#ffffff'
|
||||
: themeColors?.colors?.[themeVariant]?.onSurface || '#000000';
|
||||
|
||||
const surfaceVariantColor = isDark
|
||||
? themeColors?.colors?.dark?.[themeVariant]?.surfaceVariant || '#2a2a2a'
|
||||
: themeColors?.colors?.[themeVariant]?.surfaceVariant || '#f5f5f5';
|
||||
|
||||
useEffect(() => {
|
||||
if (isVisible) {
|
||||
fetchBlueprints();
|
||||
// Preload pricing in background
|
||||
creditService.getPricing().catch(console.error);
|
||||
// Vorselektiere "Transkripte kombinieren"
|
||||
setSelectedBlueprint({
|
||||
id: 'transcript_only',
|
||||
name: { de: 'Transkripte kombinieren', en: 'Combine Transcripts' },
|
||||
description: { de: 'Kombiniert nur die Transkripte ohne AI-Verarbeitung', en: 'Combines only transcripts without AI processing' },
|
||||
is_public: true,
|
||||
created_at: new Date().toISOString(),
|
||||
updated_at: new Date().toISOString(),
|
||||
user_id: 'system',
|
||||
category: undefined
|
||||
});
|
||||
}
|
||||
}, [isVisible]);
|
||||
|
||||
const fetchBlueprints = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const supabase = await getAuthenticatedClient();
|
||||
|
||||
if (!supabase) {
|
||||
console.error('No authenticated Supabase client available');
|
||||
return;
|
||||
}
|
||||
|
||||
console.debug('Fetching public blueprints...');
|
||||
|
||||
const { data, error } = await supabase
|
||||
.from('blueprints')
|
||||
.select('id, name, description, is_public, created_at, updated_at, user_id, category')
|
||||
.eq('is_public', true)
|
||||
.order('created_at', { ascending: false });
|
||||
|
||||
console.log('Blueprints data:', data);
|
||||
console.log('Blueprints error:', error);
|
||||
|
||||
if (error) {
|
||||
console.error('Error fetching blueprints:', error);
|
||||
return;
|
||||
}
|
||||
|
||||
// Hardcodierte Blueprints hinzufügen
|
||||
const hardcodedBlueprints: Blueprint[] = [
|
||||
{
|
||||
id: 'transcript_only',
|
||||
name: { de: 'Transkripte kombinieren', en: 'Combine Transcripts' },
|
||||
description: { de: 'Kombiniert nur die Transkripte ohne AI-Verarbeitung', en: 'Combines only transcripts without AI processing' },
|
||||
is_public: true,
|
||||
created_at: new Date().toISOString(),
|
||||
updated_at: new Date().toISOString(),
|
||||
user_id: 'system',
|
||||
category: undefined
|
||||
}
|
||||
];
|
||||
|
||||
const allBlueprints = [...hardcodedBlueprints, ...(data || [])];
|
||||
setBlueprints(allBlueprints);
|
||||
console.log('Blueprints set:', allBlueprints?.length || 0, 'items (', hardcodedBlueprints.length, 'hardcoded +', data?.length || 0, 'from DB)');
|
||||
} catch (error) {
|
||||
console.error('Error fetching blueprints:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleBlueprintSelect = (blueprint: Blueprint) => {
|
||||
setSelectedBlueprint(
|
||||
selectedBlueprint?.id === blueprint.id ? null : blueprint
|
||||
);
|
||||
};
|
||||
|
||||
const handleSubmit = async (prompt?: string) => {
|
||||
if (!selectedBlueprint) return;
|
||||
|
||||
setIsSubmitting(true);
|
||||
try {
|
||||
await onCombine(selectedBlueprint.id, prompt);
|
||||
onClose();
|
||||
setSelectedBlueprint(null); // Reset selection
|
||||
} catch (error) {
|
||||
console.error('Error combining memos:', error);
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const currentLanguage = i18n.language.startsWith('de') ? 'de' : 'en';
|
||||
|
||||
const renderBlueprintItem = ({ item }: { item: Blueprint }) => {
|
||||
console.log('Rendering blueprint item:', item.id, item.name);
|
||||
const displayName = item.name?.[currentLanguage] || item.name?.en || item.name?.de || 'Unnamed Blueprint';
|
||||
const displayDescription = item.description?.[currentLanguage] || item.description?.en || item.description?.de || '';
|
||||
const isSelected = selectedBlueprint?.id === item.id;
|
||||
|
||||
console.log('Display name:', displayName, 'Description:', displayDescription);
|
||||
|
||||
return (
|
||||
<Pressable
|
||||
onPress={() => handleBlueprintSelect(item)}
|
||||
style={{
|
||||
marginBottom: 12,
|
||||
padding: 16,
|
||||
borderRadius: 12,
|
||||
borderWidth: 2,
|
||||
minHeight: 80,
|
||||
backgroundColor: isSelected
|
||||
? (isDark ? '#1e3a8a40' : '#3b82f640')
|
||||
: backgroundColor,
|
||||
borderColor: isSelected
|
||||
? '#3b82f6'
|
||||
: (isDark ? '#404040' : '#e5e5e5')
|
||||
}}
|
||||
>
|
||||
<View style={{ position: 'relative' }}>
|
||||
<View style={{ paddingRight: 40 }}>
|
||||
<Text
|
||||
style={{
|
||||
fontSize: 18,
|
||||
fontWeight: '600',
|
||||
marginBottom: 8,
|
||||
color: textColor
|
||||
}}
|
||||
>
|
||||
{displayName}
|
||||
</Text>
|
||||
{displayDescription && (
|
||||
<Text
|
||||
style={{
|
||||
fontSize: 14,
|
||||
color: textColor,
|
||||
opacity: 0.7,
|
||||
lineHeight: 20
|
||||
}}
|
||||
>
|
||||
{displayDescription}
|
||||
</Text>
|
||||
)}
|
||||
</View>
|
||||
{isSelected && (
|
||||
<View style={{
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
right: 0,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
width: 32,
|
||||
height: 32
|
||||
}}>
|
||||
<Icon
|
||||
name="checkmark-circle"
|
||||
size={24}
|
||||
color="#3b82f6"
|
||||
/>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
</Pressable>
|
||||
);
|
||||
};
|
||||
|
||||
const handlePrimaryAction = () => {
|
||||
if (selectedBlueprint) {
|
||||
handleSubmit();
|
||||
}
|
||||
};
|
||||
|
||||
// Fallback translations if i18n is not ready
|
||||
const title = t('memo.combine_memos', 'Memos kombinieren');
|
||||
const combineButtonText = t('memo.combine', 'Kombinieren');
|
||||
const cancelButtonText = t('common.cancel', 'Abbrechen');
|
||||
|
||||
return (
|
||||
<BaseModal
|
||||
isVisible={isVisible}
|
||||
onClose={onClose}
|
||||
title={title}
|
||||
primaryButtonText={combineButtonText}
|
||||
secondaryButtonText={cancelButtonText}
|
||||
onPrimaryButtonPress={handlePrimaryAction}
|
||||
onSecondaryButtonPress={onClose}
|
||||
primaryButtonDisabled={!selectedBlueprint || isSubmitting}
|
||||
primaryButtonLoading={isSubmitting}
|
||||
>
|
||||
<View style={{ height: 400, minHeight: 400, marginTop: -24, marginHorizontal: -20 }}>
|
||||
{/* Mana cost display */}
|
||||
{selectedMemoIds.length > 0 && (
|
||||
<View>
|
||||
<View style={{
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'flex-start',
|
||||
marginHorizontal: 20,
|
||||
marginTop: 12,
|
||||
marginBottom: 4
|
||||
}}>
|
||||
<ManaIcon size={16} color="#0099FF" />
|
||||
<Text style={{
|
||||
marginLeft: 6,
|
||||
fontSize: 14,
|
||||
fontWeight: '600',
|
||||
color: '#0099FF'
|
||||
}}>
|
||||
{creditService.calculateMemoCombineCostSync(selectedMemoIds.length)}
|
||||
</Text>
|
||||
<Text style={{
|
||||
marginLeft: 4,
|
||||
fontSize: 14,
|
||||
fontWeight: '600',
|
||||
color: '#0099FF'
|
||||
}}>
|
||||
Mana
|
||||
</Text>
|
||||
<Text style={{
|
||||
marginLeft: 4,
|
||||
fontSize: 14,
|
||||
color: isDark ? '#888' : '#666'
|
||||
}}>
|
||||
({selectedMemoIds.length} × {creditService.getOperationCostSync('MEMO_COMBINE')})
|
||||
</Text>
|
||||
</View>
|
||||
<View style={{
|
||||
height: 1,
|
||||
backgroundColor: isDark ? '#444444' : '#DDDDDD',
|
||||
marginVertical: 4
|
||||
}} />
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* Blueprint list */}
|
||||
<View style={{ flex: 1 }}>
|
||||
{loading ? (
|
||||
<View style={{ flex: 1, justifyContent: 'center', alignItems: 'center', paddingHorizontal: 20 }}>
|
||||
<Text style={{ color: textColor }}>
|
||||
{t('common.loading', 'Wird geladen')}...
|
||||
</Text>
|
||||
</View>
|
||||
) : blueprints.length === 0 ? (
|
||||
<View style={{ flex: 1, justifyContent: 'center', alignItems: 'center', paddingHorizontal: 20 }}>
|
||||
<Text style={{ color: textColor }}>
|
||||
{t('blueprints.no_blueprints', 'Keine Blueprints verfügbar')}
|
||||
</Text>
|
||||
</View>
|
||||
) : (
|
||||
<FlatList
|
||||
data={blueprints}
|
||||
renderItem={renderBlueprintItem}
|
||||
keyExtractor={(item) => item.id}
|
||||
showsVerticalScrollIndicator={true}
|
||||
style={{ flex: 1 }}
|
||||
contentContainerStyle={{ paddingBottom: 16, paddingTop: 8, paddingHorizontal: 20 }}
|
||||
/>
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<LoadingOverlay
|
||||
visible={isSubmitting}
|
||||
message={t('memo.combining', 'Kombiniere...')}
|
||||
modal={false}
|
||||
/>
|
||||
</BaseModal>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,237 @@
|
|||
import React, { useState, useEffect } from 'react';
|
||||
import { View, ScrollView, ActivityIndicator, Alert } from 'react-native';
|
||||
import { useTheme } from '~/features/theme/ThemeProvider';
|
||||
import { getTheme } from '~/features/theme/constants';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import BaseModal from '~/components/atoms/BaseModal';
|
||||
import Text from '~/components/atoms/Text';
|
||||
import PromptPreview from '~/components/molecules/PromptPreview';
|
||||
import LoadingOverlay from '~/components/atoms/LoadingOverlay';
|
||||
import { getAuthenticatedClient } from '~/features/auth/lib/supabaseClient';
|
||||
import { creditService } from '~/features/credits/creditService';
|
||||
import ManaIcon from '~/features/subscription/ManaIcon';
|
||||
|
||||
interface Prompt {
|
||||
id: string;
|
||||
memory_title: Record<string, string>;
|
||||
description: Record<string, string>;
|
||||
prompt_text: Record<string, string>;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
is_public?: boolean;
|
||||
}
|
||||
|
||||
interface CreateMemoryModalProps {
|
||||
visible: boolean;
|
||||
onClose: () => void;
|
||||
memoId: string;
|
||||
onMemoryCreated: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Modal zum Erstellen einer neuen Memory durch Auswahl eines Prompts
|
||||
*/
|
||||
const CreateMemoryModal: React.FC<CreateMemoryModalProps> = ({
|
||||
visible,
|
||||
onClose,
|
||||
memoId,
|
||||
onMemoryCreated
|
||||
}) => {
|
||||
const { isDark, themeVariant, colorScheme } = useTheme();
|
||||
const theme = getTheme(colorScheme, themeVariant);
|
||||
const { t } = useTranslation();
|
||||
|
||||
const [prompts, setPrompts] = useState<Prompt[]>([]);
|
||||
const [selectedPromptId, setSelectedPromptId] = useState<string | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [isCreating, setIsCreating] = useState(false);
|
||||
|
||||
// Prompts laden wenn Modal geöffnet wird
|
||||
useEffect(() => {
|
||||
if (visible) {
|
||||
loadPrompts();
|
||||
}
|
||||
}, [visible]);
|
||||
|
||||
const loadPrompts = async () => {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
const supabase = await getAuthenticatedClient();
|
||||
|
||||
const { data, error } = await supabase
|
||||
.from('prompts')
|
||||
.select('*')
|
||||
.eq('is_public', true)
|
||||
.order('memory_title->de', { ascending: true });
|
||||
|
||||
if (error) {
|
||||
console.error('Fehler beim Laden der Prompts:', error);
|
||||
Alert.alert(t('memo.error', 'Fehler'), t('memo.prompts_load_error', 'Prompts konnten nicht geladen werden.'));
|
||||
return;
|
||||
}
|
||||
|
||||
setPrompts(data || []);
|
||||
} catch (error) {
|
||||
console.error('Fehler beim Laden der Prompts:', error);
|
||||
Alert.alert(t('memo.error', 'Fehler'), t('memo.unexpected_error', 'Ein unerwarteter Fehler ist aufgetreten.'));
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCreateMemory = async () => {
|
||||
if (!selectedPromptId) {
|
||||
Alert.alert(t('memo.note', 'Hinweis'), t('memo.please_select_prompt', 'Bitte wählen Sie einen Prompt aus.'));
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setIsCreating(true);
|
||||
|
||||
// Call the Supabase Edge Function directly
|
||||
const supabase = await getAuthenticatedClient();
|
||||
const { data, error } = await supabase.functions.invoke('create-memory', {
|
||||
body: {
|
||||
memo_id: memoId,
|
||||
prompt_id: selectedPromptId
|
||||
}
|
||||
});
|
||||
|
||||
if (error) {
|
||||
console.error('Edge Function error:', error);
|
||||
throw new Error(error.message || t('memo.memory_creation_error', 'Unbekannter Fehler bei der Memory-Erstellung'));
|
||||
}
|
||||
|
||||
if (data?.success && data?.memory_id) {
|
||||
// Notify credit system about consumption if provided
|
||||
if (data.creditsConsumed) {
|
||||
creditService.triggerCreditUpdate(data.creditsConsumed);
|
||||
}
|
||||
|
||||
// Close modal and notify parent
|
||||
onClose();
|
||||
onMemoryCreated();
|
||||
} else {
|
||||
throw new Error(data?.error || t('memo.memory_creation_error', 'Unbekannter Fehler bei der Memory-Erstellung'));
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error('Fehler bei der Memory-Erstellung:', error);
|
||||
|
||||
// Don't check for insufficient credits - let the global interceptor handle 402 errors
|
||||
Alert.alert(t('memo.error', 'Fehler'), t('memo.memory_creation_error', 'Bei der Erstellung der Memory ist ein Fehler aufgetreten.'));
|
||||
} finally {
|
||||
setIsCreating(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Zeige alle Prompts in einer Liste (keine Kategorien)
|
||||
const allPrompts = prompts;
|
||||
|
||||
return (
|
||||
<BaseModal
|
||||
isVisible={visible}
|
||||
onClose={onClose}
|
||||
title={t('memo.create_new_memory', 'Neue Memory erstellen')}
|
||||
primaryButtonText={isCreating ? t('memo.creating_memory', 'Erstelle...') : t('common.create', 'Erstellen')}
|
||||
primaryButtonDisabled={!selectedPromptId || isLoading || isCreating}
|
||||
primaryButtonLoading={isCreating}
|
||||
onPrimaryButtonPress={handleCreateMemory}
|
||||
secondaryButtonText={t('common.cancel', 'Abbrechen')}
|
||||
onSecondaryButtonPress={onClose}
|
||||
>
|
||||
{isLoading ? (
|
||||
<View style={{ padding: 40, alignItems: 'center' }}>
|
||||
<ActivityIndicator
|
||||
size="large"
|
||||
color={isDark ? '#FFFFFF' : '#000000'}
|
||||
/>
|
||||
<Text style={{
|
||||
marginTop: 16,
|
||||
color: isDark ? '#FFFFFF' : '#000000'
|
||||
}}>
|
||||
{t('memo.prompts_loading', 'Prompts werden geladen...')}
|
||||
</Text>
|
||||
</View>
|
||||
) : (
|
||||
<>
|
||||
<ScrollView
|
||||
style={{ flex: 1 }}
|
||||
contentContainerStyle={{ paddingTop: 8, paddingHorizontal: 6 }}
|
||||
showsVerticalScrollIndicator={false}
|
||||
>
|
||||
{allPrompts.map((prompt) => (
|
||||
<View
|
||||
key={prompt.id}
|
||||
style={{
|
||||
borderWidth: 2,
|
||||
borderColor: selectedPromptId === prompt.id
|
||||
? theme.colors.primary
|
||||
: 'transparent',
|
||||
borderRadius: 12,
|
||||
overflow: 'visible',
|
||||
marginBottom: 12,
|
||||
backgroundColor: 'transparent'
|
||||
}}
|
||||
>
|
||||
<PromptPreview
|
||||
prompt={prompt}
|
||||
onPress={() => setSelectedPromptId(prompt.id)}
|
||||
isSelected={selectedPromptId === prompt.id}
|
||||
disableContextMenu={true}
|
||||
/>
|
||||
</View>
|
||||
))}
|
||||
|
||||
{prompts.length === 0 && !isLoading && (
|
||||
<View style={{ padding: 20, alignItems: 'center' }}>
|
||||
<Text style={{
|
||||
color: isDark ? 'rgba(255, 255, 255, 0.6)' : 'rgba(0, 0, 0, 0.6)',
|
||||
textAlign: 'center'
|
||||
}}>
|
||||
{t('memo.no_prompts_available', 'Keine Prompts verfügbar')}
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
</ScrollView>
|
||||
|
||||
{/* Mana Cost Display */}
|
||||
{selectedPromptId && (
|
||||
<View style={{
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
marginTop: 16,
|
||||
paddingVertical: 8
|
||||
}}>
|
||||
<ManaIcon size={16} color={theme.colors.primary} />
|
||||
<Text style={{
|
||||
marginLeft: 6,
|
||||
fontSize: 14,
|
||||
fontWeight: '600',
|
||||
color: theme.colors.primary
|
||||
}}>
|
||||
{creditService.getOperationCostSync('CREATE_MEMORY')}
|
||||
</Text>
|
||||
<Text style={{
|
||||
marginLeft: 4,
|
||||
fontSize: 12,
|
||||
color: isDark ? '#888' : '#666'
|
||||
}}>
|
||||
Mana
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Central Loading Overlay */}
|
||||
<LoadingOverlay
|
||||
visible={isCreating}
|
||||
message={t('memory.creating', 'Memory wird erstellt...')}
|
||||
/>
|
||||
</BaseModal>
|
||||
);
|
||||
};
|
||||
|
||||
export default CreateMemoryModal;
|
||||
501
apps/memoro/apps/mobile/components/molecules/DirectMemoTitle.tsx
Normal file
501
apps/memoro/apps/mobile/components/molecules/DirectMemoTitle.tsx
Normal file
|
|
@ -0,0 +1,501 @@
|
|||
import React, { useEffect, useState, memo } from 'react';
|
||||
import { Text, Animated, StyleSheet, View } from 'react-native';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { getAuthenticatedClient } from '~/features/auth/lib/supabaseClient';
|
||||
import { useRecordingStore } from '~/features/audioRecordingV2/store/recordingStore';
|
||||
import { RecordingStatus } from '~/features/audioRecordingV2/types';
|
||||
import { useDirectMemoTitle } from '~/features/memos/hooks/useDirectMemoTitle';
|
||||
import { useAuthContext } from '~/features/auth/contexts/AuthContext';
|
||||
import { hasTranscript } from '~/features/memos/utils/transcriptUtils';
|
||||
|
||||
interface DirectMemoTitleProps {
|
||||
memoId: string;
|
||||
initialTitle?: string;
|
||||
titleClasses?: string;
|
||||
numberOfLines?: number;
|
||||
ellipsizeMode?: 'head' | 'middle' | 'tail' | 'clip';
|
||||
style?: any; // Add style prop
|
||||
textColor?: string; // Add textColor prop
|
||||
reactToGlobalRecordingStatus?: boolean; // Add prop to control recording status reaction
|
||||
}
|
||||
|
||||
/**
|
||||
* A component that directly subscribes to Supabase for title updates
|
||||
* This bypasses all other state management to ensure reliable updates
|
||||
*
|
||||
* Supports both legacy (direct subscription) and new (centralized service) implementations
|
||||
* based on feature flags for gradual migration
|
||||
*/
|
||||
const DirectMemoTitle: React.FC<DirectMemoTitleProps> = memo(({ memoId, initialTitle = 'New Recording', titleClasses, numberOfLines = 2, ellipsizeMode = 'tail', style, textColor, reactToGlobalRecordingStatus = false }) => {
|
||||
const { t } = useTranslation();
|
||||
const { user } = useAuthContext();
|
||||
|
||||
|
||||
return (
|
||||
<DirectMemoTitleCentralized
|
||||
memoId={memoId}
|
||||
initialTitle={initialTitle}
|
||||
titleClasses={titleClasses}
|
||||
numberOfLines={numberOfLines}
|
||||
ellipsizeMode={ellipsizeMode}
|
||||
style={style}
|
||||
textColor={textColor}
|
||||
reactToGlobalRecordingStatus={reactToGlobalRecordingStatus}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
/**
|
||||
* New implementation using centralized MemoRealtimeService
|
||||
*/
|
||||
const DirectMemoTitleCentralized: React.FC<DirectMemoTitleProps> = memo(({ memoId, initialTitle = 'New Recording', titleClasses, numberOfLines = 2, ellipsizeMode = 'tail', style, textColor, reactToGlobalRecordingStatus = false }) => {
|
||||
// Animation value for smooth transitions
|
||||
const [fadeAnim] = useState(new Animated.Value(1));
|
||||
const [displayTitle, setDisplayTitle] = useState(initialTitle);
|
||||
|
||||
// Use the centralized hook
|
||||
const { title, isLoading } = useDirectMemoTitle({
|
||||
memoId,
|
||||
initialTitle,
|
||||
reactToGlobalRecordingStatus,
|
||||
onTitleChange: (newTitle, isInitial) => {
|
||||
// Handle title changes with animation (except for initial load)
|
||||
if (!isInitial && newTitle !== displayTitle) {
|
||||
updateTitleWithAnimation(newTitle);
|
||||
} else {
|
||||
setDisplayTitle(newTitle);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Function to update title with animation (same as legacy)
|
||||
const updateTitleWithAnimation = (newTitle: string) => {
|
||||
if (newTitle === displayTitle) return;
|
||||
|
||||
requestAnimationFrame(() => {
|
||||
console.log(`DirectMemoTitleCentralized: Updating from "${displayTitle}" to "${newTitle}"`);
|
||||
|
||||
// Fade out
|
||||
Animated.timing(fadeAnim, {
|
||||
toValue: 0,
|
||||
duration: 100,
|
||||
useNativeDriver: true,
|
||||
}).start(() => {
|
||||
// Update title
|
||||
setDisplayTitle(newTitle);
|
||||
|
||||
// Fade in
|
||||
Animated.timing(fadeAnim, {
|
||||
toValue: 1,
|
||||
duration: 100,
|
||||
useNativeDriver: true,
|
||||
}).start();
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
// Sync title from hook to local state
|
||||
useEffect(() => {
|
||||
if (title !== displayTitle) {
|
||||
setDisplayTitle(title);
|
||||
}
|
||||
}, [title, displayTitle]);
|
||||
|
||||
return (
|
||||
<DirectMemoTitleUI
|
||||
title={displayTitle}
|
||||
titleClasses={titleClasses}
|
||||
numberOfLines={numberOfLines}
|
||||
ellipsizeMode={ellipsizeMode}
|
||||
style={style}
|
||||
textColor={textColor}
|
||||
fadeAnim={fadeAnim}
|
||||
isLoading={isLoading}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
/**
|
||||
* Legacy implementation (original DirectMemoTitle logic)
|
||||
*/
|
||||
const DirectMemoTitleLegacy: React.FC<DirectMemoTitleProps> = memo(({ memoId, initialTitle = 'New Recording', titleClasses, numberOfLines = 2, ellipsizeMode = 'tail', style, textColor, reactToGlobalRecordingStatus = false }) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
// Local state for the title - start with initial title but prioritize fresh data
|
||||
const [title, setTitle] = useState(initialTitle);
|
||||
const [hasFetchedFreshData, setHasFetchedFreshData] = useState(false);
|
||||
|
||||
// Animation value for smooth transitions
|
||||
const [fadeAnim] = useState(new Animated.Value(1));
|
||||
|
||||
// Get recording status from the store only if we should react to it
|
||||
const recordingStatus = useRecordingStore(state => reactToGlobalRecordingStatus ? state.status : undefined);
|
||||
|
||||
// Function to update title with animation
|
||||
const updateTitleWithAnimation = (newTitle: string) => {
|
||||
if (newTitle === title) return;
|
||||
|
||||
// Use requestAnimationFrame to ensure we're not updating during render
|
||||
requestAnimationFrame(() => {
|
||||
console.log(`DirectMemoTitle: Updating from "${title}" to "${newTitle}"`);
|
||||
|
||||
// Fade out
|
||||
Animated.timing(fadeAnim, {
|
||||
toValue: 0,
|
||||
duration: 100,
|
||||
useNativeDriver: true,
|
||||
}).start(() => {
|
||||
// Update title
|
||||
setTitle(newTitle);
|
||||
|
||||
// Fade in
|
||||
Animated.timing(fadeAnim, {
|
||||
toValue: 1,
|
||||
duration: 100,
|
||||
useNativeDriver: true,
|
||||
}).start();
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
// Handle recording status changes only if we should react to them
|
||||
useEffect(() => {
|
||||
let isMounted = true;
|
||||
|
||||
// Only handle recording status if reactToGlobalRecordingStatus is true AND we have recording status
|
||||
if (reactToGlobalRecordingStatus && recordingStatus !== undefined) {
|
||||
console.log(`DirectMemoTitle ${memoId}: Handling recording status:`, recordingStatus);
|
||||
|
||||
// Use setTimeout to ensure we're not updating during render
|
||||
setTimeout(() => {
|
||||
if (!isMounted) return;
|
||||
|
||||
// Show recording/uploading status based on the global recording state
|
||||
if (recordingStatus === RecordingStatus.RECORDING || recordingStatus === RecordingStatus.PAUSED) {
|
||||
console.log(`DirectMemoTitle ${memoId}: Setting recording in progress`);
|
||||
updateTitleWithAnimation(t('memo.status.recording_in_progress'));
|
||||
} else if (recordingStatus === RecordingStatus.UPLOADING) {
|
||||
console.log(`DirectMemoTitle ${memoId}: Setting uploading recording`);
|
||||
updateTitleWithAnimation(t('memo.status.uploading_recording'));
|
||||
}
|
||||
}, 0);
|
||||
}
|
||||
|
||||
return () => {
|
||||
isMounted = false;
|
||||
};
|
||||
}, [recordingStatus, reactToGlobalRecordingStatus, t, memoId]);
|
||||
|
||||
// Setup direct Supabase subscription - only for latest memo to avoid conflicts
|
||||
useEffect(() => {
|
||||
// Temporarily disable individual subscriptions for non-latest memos
|
||||
// to reduce subscription conflicts and simplify state management
|
||||
if (!reactToGlobalRecordingStatus) {
|
||||
console.log(`DirectMemoTitle ${memoId}: Skipping subscription (not latest memo), using initial title`);
|
||||
// For non-latest memos, just use the initialTitle that should come from MemoList
|
||||
if (initialTitle !== title && initialTitle !== 'Loading...') {
|
||||
setTitle(initialTitle);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
let isMounted = true;
|
||||
|
||||
// Function to determine display title based on memo state
|
||||
const getDisplayTitle = (memo: any): string => {
|
||||
console.log(`DirectMemoTitle getDisplayTitle for ${memoId}:`, {
|
||||
title: memo.title,
|
||||
transcriptionStatus: memo.metadata?.processing?.transcription?.status,
|
||||
headlineStatus: memo.metadata?.processing?.headline_and_intro?.status,
|
||||
hasTranscript: hasTranscript(memo),
|
||||
hasPath: !!memo.source?.path
|
||||
});
|
||||
|
||||
// Priority 1: Check for recording status (uploading)
|
||||
if (memo.metadata?.recordingStatus === 'uploading') {
|
||||
console.log(`DirectMemoTitle: Recording uploading`);
|
||||
return t('memo.status.uploading_recording');
|
||||
}
|
||||
|
||||
// Priority 2: Check processing statuses FIRST (before checking completed states)
|
||||
const transcriptionStatus = memo.metadata?.processing?.transcription?.status;
|
||||
const headlineStatus = memo.metadata?.processing?.headline_and_intro?.status;
|
||||
// Also check the new transcription_status field from Option 3 implementation
|
||||
const simpleTranscriptionStatus = memo.metadata?.transcription_status;
|
||||
|
||||
// Check for pending or ongoing transcription (check both old and new status fields)
|
||||
if (transcriptionStatus === 'pending' || transcriptionStatus === 'processing' ||
|
||||
simpleTranscriptionStatus === 'pending' || simpleTranscriptionStatus === 'processing') {
|
||||
console.log(`DirectMemoTitle: Transcription ${transcriptionStatus || simpleTranscriptionStatus}`);
|
||||
return t('memo.status.memo_transcribing');
|
||||
}
|
||||
|
||||
// Check for failed transcription
|
||||
if (transcriptionStatus === 'failed' || simpleTranscriptionStatus === 'failed') {
|
||||
console.log(`DirectMemoTitle: Transcription failed`);
|
||||
return t('memo.status.transcription_failed', 'Transcription failed');
|
||||
}
|
||||
|
||||
// Check for pending or ongoing headline generation
|
||||
if (headlineStatus === 'pending' || headlineStatus === 'processing') {
|
||||
console.log(`DirectMemoTitle: Headline ${headlineStatus}`);
|
||||
return t('memo.status.headline_generating');
|
||||
}
|
||||
|
||||
// Check for completed transcription but no headline yet
|
||||
if ((transcriptionStatus === 'completed' || simpleTranscriptionStatus === 'completed') && (!headlineStatus || headlineStatus === 'pending')) {
|
||||
console.log(`DirectMemoTitle: Transcription done, generating headline`);
|
||||
return t('memo.status.headline_generating');
|
||||
}
|
||||
|
||||
// Check if we have transcript but no title (should generate headline)
|
||||
if (memo.source?.transcript && !memo.title) {
|
||||
console.log(`DirectMemoTitle: Has transcript but no title, generating headline`);
|
||||
return t('memo.status.headline_generating');
|
||||
}
|
||||
|
||||
// Priority 3: Check if we have a real, meaningful title
|
||||
const hasRealTitle = memo.title &&
|
||||
memo.title !== 'Unbenanntes Memo' &&
|
||||
memo.title !== 'Neue Aufnahme' &&
|
||||
memo.title.trim() !== '';
|
||||
|
||||
if (hasRealTitle) {
|
||||
console.log(`DirectMemoTitle: Using real title: ${memo.title}`);
|
||||
return memo.title;
|
||||
}
|
||||
|
||||
// Priority 4: Check for completed headline generation
|
||||
if (memo.metadata?.processing?.headline_and_intro?.status === 'completed') {
|
||||
// If we have a title, use it
|
||||
if (memo.title && memo.title.trim() !== '') {
|
||||
console.log(`DirectMemoTitle: Using completed title: ${memo.title}`);
|
||||
return memo.title;
|
||||
}
|
||||
// If headline is completed but no title, this is an error state - keep showing processing
|
||||
console.log(`DirectMemoTitle: Headline completed but no title available, showing headline generating status`);
|
||||
return t('memo.status.headline_generating');
|
||||
}
|
||||
|
||||
// Check if we have audio_path but no transcript (still transcribing)
|
||||
if (memo.source?.audio_path && !memo.source?.transcript) {
|
||||
console.log(`DirectMemoTitle: Has audio_path but no transcript, transcribing`);
|
||||
return t('memo.status.memo_transcribing');
|
||||
}
|
||||
|
||||
// Check if we have audio_path but no processing metadata (still uploading)
|
||||
if (memo.source?.audio_path && !memo.metadata?.processing) {
|
||||
console.log(`DirectMemoTitle: Has audio_path but no processing metadata, uploading`);
|
||||
return t('memo.status.uploading_recording');
|
||||
}
|
||||
|
||||
// Default fallback - check if we have any processing metadata
|
||||
if (memo.metadata?.processing) {
|
||||
console.log(`DirectMemoTitle: Has processing metadata, showing transcribing status`);
|
||||
return t('memo.status.memo_transcribing');
|
||||
}
|
||||
|
||||
// Only show "memo ready" if we truly have no processing information
|
||||
const result = memo.title || t('memo.status.memo_transcribing');
|
||||
console.log(`DirectMemoTitle: Default fallback: ${result}`);
|
||||
return result;
|
||||
};
|
||||
|
||||
// Setup Supabase subscription
|
||||
const setupSubscription = async () => {
|
||||
try {
|
||||
const supabase = await getAuthenticatedClient();
|
||||
if (!supabase) {
|
||||
console.error('Failed to get authenticated Supabase client');
|
||||
return;
|
||||
}
|
||||
|
||||
// First get the current memo data
|
||||
const { data: memo, error } = await supabase
|
||||
.from('memos')
|
||||
.select('*')
|
||||
.eq('id', memoId)
|
||||
.single();
|
||||
|
||||
if (error) {
|
||||
console.error(`DirectMemoTitle ${memoId}: Error fetching memo:`, error);
|
||||
} else if (memo && isMounted) {
|
||||
console.log(`DirectMemoTitle ${memoId}: Initial memo data:`, {
|
||||
title: memo.title,
|
||||
transcriptionStatus: memo.metadata?.processing?.transcription?.status,
|
||||
headlineStatus: memo.metadata?.processing?.headline_and_intro?.status,
|
||||
hasTranscript: hasTranscript(memo)
|
||||
});
|
||||
// Always use fresh data from Supabase, ignore initial title
|
||||
const freshTitle = getDisplayTitle(memo);
|
||||
console.log(`DirectMemoTitle ${memoId}: Setting fresh title to:`, freshTitle);
|
||||
setTitle(freshTitle); // Set directly without animation for initial load
|
||||
setHasFetchedFreshData(true);
|
||||
}
|
||||
|
||||
// Subscribe to updates for this specific memo
|
||||
const subscription = supabase
|
||||
.channel(`direct-memo-${memoId}`)
|
||||
.on('postgres_changes', {
|
||||
event: 'UPDATE',
|
||||
schema: 'public',
|
||||
table: 'memos',
|
||||
filter: `id=eq.${memoId}`
|
||||
}, (payload) => {
|
||||
console.log(`DirectMemoTitle ${memoId}: Received update for memo`, payload.new.id);
|
||||
console.log(`DirectMemoTitle ${memoId}: New title:`, payload.new.title);
|
||||
console.log(`DirectMemoTitle ${memoId}: Transcription status:`, payload.new.metadata?.processing?.transcription?.status);
|
||||
console.log(`DirectMemoTitle ${memoId}: Headline status:`, payload.new.metadata?.processing?.headline_and_intro?.status);
|
||||
console.log(`DirectMemoTitle ${memoId}: Has transcript:`, !!payload.new.source?.transcript);
|
||||
|
||||
if (isMounted) {
|
||||
// Use requestAnimationFrame to ensure we're not updating during render
|
||||
requestAnimationFrame(() => {
|
||||
if (isMounted) {
|
||||
const newTitle = getDisplayTitle(payload.new);
|
||||
console.log(`DirectMemoTitle ${memoId} (Realtime): Processing update to "${newTitle}"`);
|
||||
updateTitleWithAnimation(newTitle);
|
||||
}
|
||||
});
|
||||
}
|
||||
})
|
||||
.subscribe();
|
||||
// No polling needed - realtime subscription is reliable enough
|
||||
console.log('DirectMemoTitle: Using realtime subscription only (no polling)');
|
||||
|
||||
// Return cleanup function
|
||||
return () => {
|
||||
subscription.unsubscribe();
|
||||
};
|
||||
} catch (err) {
|
||||
console.error('Error setting up subscription:', err);
|
||||
}
|
||||
};
|
||||
|
||||
setupSubscription();
|
||||
|
||||
// Cleanup
|
||||
return () => {
|
||||
isMounted = false;
|
||||
};
|
||||
}, [memoId, t, reactToGlobalRecordingStatus, initialTitle, title]);
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
// Container stays stable during animations
|
||||
position: 'relative',
|
||||
},
|
||||
title: {
|
||||
fontSize: 16,
|
||||
fontWeight: 'bold',
|
||||
},
|
||||
placeholderText: {
|
||||
// Invisible text to maintain layout
|
||||
opacity: 0,
|
||||
},
|
||||
animatedText: {
|
||||
// Positioned absolutely to overlay the placeholder
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
},
|
||||
});
|
||||
|
||||
// Use a fixed height container to prevent layout shifts
|
||||
// Only animate the text content, not the container
|
||||
return (
|
||||
<DirectMemoTitleUI
|
||||
title={title}
|
||||
titleClasses={titleClasses}
|
||||
numberOfLines={numberOfLines}
|
||||
ellipsizeMode={ellipsizeMode}
|
||||
style={style}
|
||||
textColor={textColor}
|
||||
fadeAnim={fadeAnim}
|
||||
isLoading={false}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
/**
|
||||
* Shared UI component for both implementations
|
||||
*/
|
||||
interface DirectMemoTitleUIProps {
|
||||
title: string;
|
||||
titleClasses?: string;
|
||||
numberOfLines?: number;
|
||||
ellipsizeMode?: 'head' | 'middle' | 'tail' | 'clip';
|
||||
style?: any;
|
||||
textColor?: string;
|
||||
fadeAnim: Animated.Value;
|
||||
isLoading?: boolean;
|
||||
}
|
||||
|
||||
const DirectMemoTitleUI: React.FC<DirectMemoTitleUIProps> = memo(({
|
||||
title,
|
||||
titleClasses,
|
||||
numberOfLines = 2,
|
||||
ellipsizeMode = 'tail',
|
||||
style,
|
||||
textColor,
|
||||
fadeAnim,
|
||||
isLoading = false
|
||||
}) => {
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
// Container stays stable during animations
|
||||
position: 'relative',
|
||||
},
|
||||
title: {
|
||||
fontSize: 16,
|
||||
fontWeight: 'bold',
|
||||
},
|
||||
placeholderText: {
|
||||
// Invisible text to maintain layout
|
||||
opacity: 0,
|
||||
},
|
||||
animatedText: {
|
||||
// Positioned absolutely to overlay the placeholder
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
},
|
||||
loadingText: {
|
||||
opacity: 0.6,
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<View style={[styles.container, style]}>
|
||||
{/* Invisible text to maintain consistent layout */}
|
||||
<Text
|
||||
className={titleClasses}
|
||||
numberOfLines={numberOfLines}
|
||||
ellipsizeMode={ellipsizeMode}
|
||||
style={[styles.title, styles.placeholderText, textColor && { color: textColor }]}
|
||||
>
|
||||
{title}
|
||||
</Text>
|
||||
|
||||
{/* Animated text that fades in/out on top */}
|
||||
<Animated.Text
|
||||
style={[
|
||||
styles.title,
|
||||
styles.animatedText,
|
||||
{ opacity: fadeAnim },
|
||||
textColor && { color: textColor },
|
||||
isLoading && styles.loadingText
|
||||
]}
|
||||
className={titleClasses}
|
||||
numberOfLines={numberOfLines}
|
||||
ellipsizeMode={ellipsizeMode}
|
||||
>
|
||||
{title}
|
||||
</Animated.Text>
|
||||
</View>
|
||||
);
|
||||
});
|
||||
|
||||
export default DirectMemoTitle;
|
||||
|
|
@ -0,0 +1,92 @@
|
|||
import React, { useEffect, useRef } from 'react';
|
||||
import { View, Animated, ViewStyle } from 'react-native';
|
||||
import { useTheme } from '~/features/theme/ThemeProvider';
|
||||
|
||||
interface DotsAnimationProps {
|
||||
size?: number;
|
||||
style?: ViewStyle;
|
||||
color?: string;
|
||||
dotSize?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Drei pulsierende Punkte
|
||||
* Subtil und unaufdringlich - perfekt für inline Loading States
|
||||
*/
|
||||
export function DotsAnimation({
|
||||
size = 60,
|
||||
style,
|
||||
color,
|
||||
dotSize = 12
|
||||
}: DotsAnimationProps) {
|
||||
const { colors } = useTheme();
|
||||
|
||||
const dot1Anim = useRef(new Animated.Value(0.4)).current;
|
||||
const dot2Anim = useRef(new Animated.Value(0.4)).current;
|
||||
const dot3Anim = useRef(new Animated.Value(0.4)).current;
|
||||
|
||||
const dotColor = color || colors.primary;
|
||||
|
||||
useEffect(() => {
|
||||
const createDotAnimation = (animValue: Animated.Value, delay: number) => {
|
||||
return Animated.loop(
|
||||
Animated.sequence([
|
||||
Animated.delay(delay),
|
||||
Animated.timing(animValue, {
|
||||
toValue: 1,
|
||||
duration: 400,
|
||||
useNativeDriver: true,
|
||||
}),
|
||||
Animated.timing(animValue, {
|
||||
toValue: 0.4,
|
||||
duration: 400,
|
||||
useNativeDriver: true,
|
||||
}),
|
||||
Animated.delay(400),
|
||||
])
|
||||
);
|
||||
};
|
||||
|
||||
const animations = Animated.parallel([
|
||||
createDotAnimation(dot1Anim, 0),
|
||||
createDotAnimation(dot2Anim, 200),
|
||||
createDotAnimation(dot3Anim, 400),
|
||||
]);
|
||||
|
||||
animations.start();
|
||||
|
||||
return () => animations.stop();
|
||||
}, [dot1Anim, dot2Anim, dot3Anim]);
|
||||
|
||||
return (
|
||||
<View style={[{ flexDirection: 'row', alignItems: 'center', justifyContent: 'center', gap: 8 }, style]}>
|
||||
<Animated.View
|
||||
style={{
|
||||
width: dotSize,
|
||||
height: dotSize,
|
||||
borderRadius: dotSize / 2,
|
||||
backgroundColor: dotColor,
|
||||
opacity: dot1Anim,
|
||||
}}
|
||||
/>
|
||||
<Animated.View
|
||||
style={{
|
||||
width: dotSize,
|
||||
height: dotSize,
|
||||
borderRadius: dotSize / 2,
|
||||
backgroundColor: dotColor,
|
||||
opacity: dot2Anim,
|
||||
}}
|
||||
/>
|
||||
<Animated.View
|
||||
style={{
|
||||
width: dotSize,
|
||||
height: dotSize,
|
||||
borderRadius: dotSize / 2,
|
||||
backgroundColor: dotColor,
|
||||
opacity: dot3Anim,
|
||||
}}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,129 @@
|
|||
import React from 'react';
|
||||
import { View } from 'react-native';
|
||||
import { router } from 'expo-router';
|
||||
import { useTheme } from '~/features/theme/ThemeProvider';
|
||||
import Text from '~/components/atoms/Text';
|
||||
import Button from '~/components/atoms/Button';
|
||||
import BaseModal from '~/components/atoms/BaseModal';
|
||||
import ManaIcon from '~/features/subscription/ManaIcon';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
interface InsufficientCreditsModalProps {
|
||||
isVisible: boolean;
|
||||
onClose: () => void;
|
||||
requiredCredits?: number;
|
||||
availableCredits?: number;
|
||||
operation?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Modal that is displayed when a user doesn't have enough mana/credits for an operation.
|
||||
* Shows the current credit status and provides a button to navigate to the subscription page.
|
||||
*/
|
||||
const InsufficientCreditsModal: React.FC<InsufficientCreditsModalProps> = ({
|
||||
isVisible,
|
||||
onClose,
|
||||
requiredCredits,
|
||||
availableCredits,
|
||||
operation,
|
||||
}) => {
|
||||
const { isDark } = useTheme();
|
||||
const { t } = useTranslation();
|
||||
|
||||
const handleBuyCredits = () => {
|
||||
onClose();
|
||||
router.push('/(protected)/subscription');
|
||||
};
|
||||
|
||||
const renderFooter = () => (
|
||||
<View style={{ flexDirection: 'row', justifyContent: 'space-between' }}>
|
||||
<Button
|
||||
title={t('common.cancel', 'Abbrechen')}
|
||||
onPress={onClose}
|
||||
variant='secondary'
|
||||
style={{ flex: 1, marginRight: 8 }}
|
||||
/>
|
||||
<Button
|
||||
title={t('subscription.buy_credits', 'Mana kaufen')}
|
||||
onPress={handleBuyCredits}
|
||||
variant='primary'
|
||||
iconName='card-outline'
|
||||
style={{ flex: 1 }}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
|
||||
return (
|
||||
<BaseModal
|
||||
isVisible={isVisible}
|
||||
onClose={onClose}
|
||||
title={t('credits.insufficient_title', 'Nicht genügend Mana')}
|
||||
animationType='fade'
|
||||
closeOnOverlayPress={true}
|
||||
footerContent={renderFooter()}
|
||||
>
|
||||
<View style={{ alignItems: 'center', paddingVertical: 16 }}>
|
||||
{/* Mana Icon */}
|
||||
<View style={{ marginBottom: 16 }}>
|
||||
<ManaIcon size={64} />
|
||||
</View>
|
||||
|
||||
{/* Main message */}
|
||||
<Text
|
||||
style={{
|
||||
textAlign: 'center',
|
||||
fontSize: 16,
|
||||
marginBottom: 12,
|
||||
color: isDark ? '#FFFFFF' : '#000000'
|
||||
}}
|
||||
>
|
||||
{requiredCredits && availableCredits !== undefined ?
|
||||
t('credits.insufficient_detailed', {
|
||||
required: requiredCredits,
|
||||
available: availableCredits,
|
||||
defaultValue: `Du benötigst ${requiredCredits} Mana, hast aber nur ${availableCredits} verfügbar.`
|
||||
}) :
|
||||
t('credits.insufficient_message', 'Du hast nicht genügend Mana für diese Operation.')
|
||||
}
|
||||
</Text>
|
||||
|
||||
{/* Secondary message */}
|
||||
<Text
|
||||
style={{
|
||||
textAlign: 'center',
|
||||
fontSize: 14,
|
||||
opacity: 0.7,
|
||||
color: isDark ? '#FFFFFF' : '#000000'
|
||||
}}
|
||||
>
|
||||
{t('credits.insufficient_subtitle', 'Lade dein Konto auf, um fortzufahren.')}
|
||||
</Text>
|
||||
|
||||
{/* Information about local storage */}
|
||||
<View
|
||||
style={{
|
||||
backgroundColor: isDark ? 'rgba(76, 175, 80, 0.1)' : 'rgba(76, 175, 80, 0.1)',
|
||||
borderRadius: 8,
|
||||
padding: 12,
|
||||
marginTop: 16,
|
||||
borderWidth: 1,
|
||||
borderColor: isDark ? 'rgba(76, 175, 80, 0.3)' : 'rgba(76, 175, 80, 0.3)'
|
||||
}}
|
||||
>
|
||||
<Text
|
||||
style={{
|
||||
textAlign: 'center',
|
||||
fontSize: 13,
|
||||
lineHeight: 18,
|
||||
color: isDark ? '#FFFFFF' : '#000000'
|
||||
}}
|
||||
>
|
||||
💾 {t('credits.memo_saved_locally', 'Dein Memo wurde trotzdem auf dem Gerät gespeichert und kann über das Audio-Archiv hochgeladen werden, sobald wieder Mana verfügbar ist.')}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
</BaseModal>
|
||||
);
|
||||
};
|
||||
|
||||
export default InsufficientCreditsModal;
|
||||
|
|
@ -0,0 +1,59 @@
|
|||
import React from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import BaseModal from '~/components/atoms/BaseModal';
|
||||
import BaseLanguageSelector from '~/components/molecules/BaseLanguageSelector';
|
||||
|
||||
interface LanguageSelectionModalProps {
|
||||
isVisible: boolean;
|
||||
onClose: () => void;
|
||||
languages: Record<string, { locale: string; nativeName: string; emoji: string }>;
|
||||
selectedLanguages: string[];
|
||||
onToggleLanguage: (languageCode: string) => void;
|
||||
}
|
||||
|
||||
const LanguageSelectionModal: React.FC<LanguageSelectionModalProps> = ({
|
||||
isVisible,
|
||||
onClose,
|
||||
languages,
|
||||
selectedLanguages,
|
||||
onToggleLanguage,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
// Handler for selection changes
|
||||
const handleSelectionChange = (newSelection: string[]) => {
|
||||
// Find the difference to determine which language was toggled
|
||||
const added = newSelection.find(lang => !selectedLanguages.includes(lang));
|
||||
const removed = selectedLanguages.find(lang => !newSelection.includes(lang));
|
||||
|
||||
if (added) {
|
||||
onToggleLanguage(added);
|
||||
} else if (removed) {
|
||||
onToggleLanguage(removed);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<BaseModal
|
||||
isVisible={isVisible}
|
||||
onClose={onClose}
|
||||
title={t('upload.select_recording_language', 'Aufnahmesprache auswählen')}
|
||||
animationType="fade"
|
||||
closeOnOverlayPress={true}
|
||||
hideFooter={true}
|
||||
noPadding={true}
|
||||
size="medium"
|
||||
>
|
||||
<BaseLanguageSelector
|
||||
languages={languages}
|
||||
selectedLanguages={selectedLanguages}
|
||||
onSelect={handleSelectionChange}
|
||||
mode="multi"
|
||||
showAutoDetect={true}
|
||||
height={450}
|
||||
/>
|
||||
</BaseModal>
|
||||
);
|
||||
};
|
||||
|
||||
export default LanguageSelectionModal;
|
||||
|
|
@ -0,0 +1,96 @@
|
|||
import React, { useEffect, useRef } from 'react';
|
||||
import { View, Animated, ViewStyle } from 'react-native';
|
||||
import MemoroLogo from '~/components/atoms/MemoroLogo';
|
||||
import { useTheme } from '~/features/theme/ThemeProvider';
|
||||
|
||||
interface LogoSpinnerAnimationProps {
|
||||
size?: number;
|
||||
style?: ViewStyle;
|
||||
color?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* MemoroLogo mit rotierendem Ring
|
||||
* Perfekte Balance zwischen Branding und Loading-Indikator
|
||||
*/
|
||||
export function LogoSpinnerAnimation({
|
||||
size = 100,
|
||||
style,
|
||||
color
|
||||
}: LogoSpinnerAnimationProps) {
|
||||
const { colors } = useTheme();
|
||||
const rotateAnim = useRef(new Animated.Value(0)).current;
|
||||
const scaleAnim = useRef(new Animated.Value(1)).current;
|
||||
|
||||
const logoColor = color || colors.primary;
|
||||
const logoSize = size * 0.4; // Logo ist 40% der Gesamtgröße - mehr Abstand zum Ring
|
||||
const ringThickness = 5; // Dickerer Ring für bessere Sichtbarkeit
|
||||
|
||||
useEffect(() => {
|
||||
// Rotation für den Ring
|
||||
const rotateAnimation = Animated.loop(
|
||||
Animated.timing(rotateAnim, {
|
||||
toValue: 1,
|
||||
duration: 1500,
|
||||
useNativeDriver: true,
|
||||
})
|
||||
);
|
||||
|
||||
// Subtiler Pulse für das Logo
|
||||
const pulseAnimation = Animated.loop(
|
||||
Animated.sequence([
|
||||
Animated.timing(scaleAnim, {
|
||||
toValue: 1.05,
|
||||
duration: 1500,
|
||||
useNativeDriver: true,
|
||||
}),
|
||||
Animated.timing(scaleAnim, {
|
||||
toValue: 1,
|
||||
duration: 1500,
|
||||
useNativeDriver: true,
|
||||
}),
|
||||
])
|
||||
);
|
||||
|
||||
rotateAnimation.start();
|
||||
pulseAnimation.start();
|
||||
|
||||
return () => {
|
||||
rotateAnimation.stop();
|
||||
pulseAnimation.stop();
|
||||
};
|
||||
}, [rotateAnim, scaleAnim]);
|
||||
|
||||
const rotate = rotateAnim.interpolate({
|
||||
inputRange: [0, 1],
|
||||
outputRange: ['0deg', '360deg'],
|
||||
});
|
||||
|
||||
return (
|
||||
<View style={[{ alignItems: 'center', justifyContent: 'center', width: size, height: size }, style]}>
|
||||
{/* Rotierender Ring */}
|
||||
<Animated.View
|
||||
style={{
|
||||
position: 'absolute',
|
||||
width: size,
|
||||
height: size,
|
||||
borderRadius: size / 2,
|
||||
borderWidth: ringThickness,
|
||||
borderColor: 'transparent',
|
||||
borderTopColor: logoColor,
|
||||
borderRightColor: logoColor,
|
||||
opacity: 0.6,
|
||||
transform: [{ rotate }],
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Logo in der Mitte */}
|
||||
<Animated.View
|
||||
style={{
|
||||
transform: [{ scale: scaleAnim }],
|
||||
}}>
|
||||
<MemoroLogo size={logoSize} color={logoColor} />
|
||||
</Animated.View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,34 @@
|
|||
import React from 'react';
|
||||
import { View, ViewStyle } from 'react-native';
|
||||
import LottieView from 'lottie-react-native';
|
||||
|
||||
interface LottieAnimationProps {
|
||||
source: any; // Lottie JSON file
|
||||
style?: ViewStyle;
|
||||
autoPlay?: boolean;
|
||||
loop?: boolean;
|
||||
speed?: number;
|
||||
onAnimationFinish?: () => void;
|
||||
}
|
||||
|
||||
export function LottieAnimation({
|
||||
source,
|
||||
style,
|
||||
autoPlay = true,
|
||||
loop = true,
|
||||
speed = 1,
|
||||
onAnimationFinish,
|
||||
}: LottieAnimationProps) {
|
||||
return (
|
||||
<View style={[{ width: 200, height: 200 }, style]}>
|
||||
<LottieView
|
||||
source={source}
|
||||
autoPlay={autoPlay}
|
||||
loop={loop}
|
||||
speed={speed}
|
||||
style={{ flex: 1 }}
|
||||
onAnimationFinish={onAnimationFinish}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
243
apps/memoro/apps/mobile/components/molecules/MemoBottomBar.tsx
Normal file
243
apps/memoro/apps/mobile/components/molecules/MemoBottomBar.tsx
Normal file
|
|
@ -0,0 +1,243 @@
|
|||
import React, { useState, useCallback } from 'react';
|
||||
import { View, Pressable, Platform, StyleSheet, ActivityIndicator } from 'react-native';
|
||||
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||
import * as Haptics from 'expo-haptics';
|
||||
import Icon from '~/components/atoms/Icon';
|
||||
import MemoMenu from '~/features/menus/MemoMenu';
|
||||
import TableOfContentsMenu from './TableOfContentsMenu';
|
||||
import { useTheme } from '~/features/theme/ThemeProvider';
|
||||
import { useRecordingStore } from '~/features/audioRecordingV2/store/recordingStore';
|
||||
import colors from '~/tailwind.config.js';
|
||||
|
||||
interface TableOfContentsItem {
|
||||
id: string;
|
||||
title: string;
|
||||
icon: string;
|
||||
onPress: () => void;
|
||||
}
|
||||
|
||||
interface MemoBottomBarProps {
|
||||
onShare?: () => void;
|
||||
onAddRecording?: () => void;
|
||||
onEdit?: () => void;
|
||||
// MemoMenu props
|
||||
onPin?: () => void;
|
||||
onCopyTranscript?: () => void;
|
||||
onManageSpaces?: () => void;
|
||||
onReplaceWord?: () => void;
|
||||
onLabelSpeakers?: () => void;
|
||||
onSearch?: () => void;
|
||||
onTranslate?: () => void;
|
||||
onAskQuestion?: () => void;
|
||||
onCreateMemory?: () => void;
|
||||
onAddPhoto?: () => void;
|
||||
onReprocess?: () => void;
|
||||
onDelete?: () => void;
|
||||
isPinned?: boolean;
|
||||
hasStructuredTranscript?: boolean;
|
||||
// Table of Contents
|
||||
tableOfContentsItems?: TableOfContentsItem[];
|
||||
// Edit loading state
|
||||
isEditMode?: boolean;
|
||||
// Custom background color (for memo detail page)
|
||||
backgroundColor?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* MemoBottomBar - Eine untere Aktionsleiste für Memo-Details
|
||||
*
|
||||
* Diese Komponente zeigt die gleichen Icons wie im Header am unteren Rand der Seite an.
|
||||
* Sie enthält die vier Hauptaktionen: Teilen, Aufnahme hinzufügen, Bearbeiten und das MemoMenu.
|
||||
*/
|
||||
const MemoBottomBar: React.FC<MemoBottomBarProps> = ({
|
||||
onShare,
|
||||
onAddRecording,
|
||||
onEdit,
|
||||
onPin,
|
||||
onCopyTranscript,
|
||||
onManageSpaces,
|
||||
onReplaceWord,
|
||||
onLabelSpeakers,
|
||||
onSearch,
|
||||
onTranslate,
|
||||
onAskQuestion,
|
||||
onCreateMemory,
|
||||
onAddPhoto,
|
||||
onReprocess,
|
||||
onDelete,
|
||||
isPinned = false,
|
||||
hasStructuredTranscript = false,
|
||||
tableOfContentsItems = [],
|
||||
isEditMode = false,
|
||||
backgroundColor,
|
||||
}) => {
|
||||
const { isDark, themeVariant } = useTheme();
|
||||
const insets = useSafeAreaInsets();
|
||||
const { isRecording } = useRecordingStore();
|
||||
const [isEditLoading, setIsEditLoading] = useState(false);
|
||||
|
||||
// Reset loading state when edit mode changes
|
||||
React.useEffect(() => {
|
||||
if (isEditMode) {
|
||||
setIsEditLoading(false);
|
||||
}
|
||||
}, [isEditMode]);
|
||||
|
||||
// Get theme colors like PromptBar
|
||||
// Use provided backgroundColor or fall back to menuBackground
|
||||
const menuBackgroundColor = React.useMemo(() => {
|
||||
if (backgroundColor) {
|
||||
return backgroundColor;
|
||||
}
|
||||
const themeColors = colors.theme?.extend?.colors as Record<string, any>;
|
||||
return isDark
|
||||
? themeColors?.dark?.[themeVariant]?.menuBackground || '#252525'
|
||||
: themeColors?.[themeVariant]?.menuBackground || '#FFFFFF';
|
||||
}, [backgroundColor, isDark, themeVariant]);
|
||||
|
||||
const borderColor = React.useMemo(() => {
|
||||
const themeColors = colors.theme?.extend?.colors as Record<string, any>;
|
||||
return isDark
|
||||
? themeColors?.dark?.[themeVariant]?.border || '#424242'
|
||||
: themeColors?.[themeVariant]?.border || '#e6e6e6';
|
||||
}, [isDark, themeVariant]);
|
||||
|
||||
const borderLightColor = React.useMemo(() => {
|
||||
const themeColors = colors.theme?.extend?.colors as Record<string, any>;
|
||||
return isDark
|
||||
? themeColors?.dark?.[themeVariant]?.borderLight || '#333333'
|
||||
: themeColors?.[themeVariant]?.borderLight || '#f2f2f2';
|
||||
}, [isDark, themeVariant]);
|
||||
|
||||
// Icon-Farbe basierend auf dem Theme
|
||||
const iconColor = isDark ? '#FFFFFF' : '#000000';
|
||||
|
||||
// Icon-Farbe für deaktivierte Buttons
|
||||
const disabledIconColor = isDark ? '#666666' : '#CCCCCC';
|
||||
|
||||
// Haptic feedback helper
|
||||
const triggerHaptic = useCallback(async () => {
|
||||
try {
|
||||
await Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
|
||||
} catch (error) {
|
||||
console.debug('Haptic feedback error:', error);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Handlers with haptic feedback
|
||||
const handleAddRecordingPress = useCallback(async () => {
|
||||
await triggerHaptic();
|
||||
onAddRecording?.();
|
||||
}, [onAddRecording, triggerHaptic]);
|
||||
|
||||
const handleEditPress = useCallback(async () => {
|
||||
await triggerHaptic();
|
||||
setIsEditLoading(true);
|
||||
onEdit?.();
|
||||
// The loading state will be cleared by the parent component when edit mode is ready
|
||||
}, [onEdit, triggerHaptic]);
|
||||
|
||||
const handleSharePress = useCallback(async () => {
|
||||
await triggerHaptic();
|
||||
onShare?.();
|
||||
}, [onShare, triggerHaptic]);
|
||||
|
||||
return (
|
||||
<View style={[
|
||||
styles.container,
|
||||
{
|
||||
backgroundColor: menuBackgroundColor,
|
||||
borderTopWidth: 1,
|
||||
borderColor: borderColor,
|
||||
paddingBottom: insets.bottom > 0 ? Math.max(insets.bottom - 12, 0) : 0,
|
||||
}
|
||||
]}>
|
||||
{/* Add Recording Button */}
|
||||
<Pressable
|
||||
style={[
|
||||
styles.iconButton,
|
||||
{
|
||||
backgroundColor: isRecording ? 'transparent' : 'transparent',
|
||||
}
|
||||
]}
|
||||
onPress={handleAddRecordingPress}
|
||||
disabled={isRecording}
|
||||
>
|
||||
<Icon
|
||||
name="mic-outline"
|
||||
size={24}
|
||||
color={isRecording ? disabledIconColor : iconColor}
|
||||
/>
|
||||
</Pressable>
|
||||
|
||||
{/* Edit Button */}
|
||||
<Pressable
|
||||
style={styles.iconButton}
|
||||
onPress={handleEditPress}
|
||||
disabled={isEditLoading}
|
||||
>
|
||||
{isEditLoading ? (
|
||||
<ActivityIndicator size="small" color={iconColor} />
|
||||
) : (
|
||||
<Icon name="create-outline" size={24} color={iconColor} />
|
||||
)}
|
||||
</Pressable>
|
||||
|
||||
{/* Table of Contents */}
|
||||
<View style={styles.iconButton}>
|
||||
<TableOfContentsMenu items={tableOfContentsItems} />
|
||||
</View>
|
||||
|
||||
{/* Share Button */}
|
||||
<Pressable
|
||||
style={styles.iconButton}
|
||||
onPress={handleSharePress}
|
||||
>
|
||||
<Icon name={Platform.OS === 'android' ? 'share-social-outline' : 'share-outline'} size={24} color={iconColor} />
|
||||
</Pressable>
|
||||
|
||||
{/* Memo Menu */}
|
||||
<View style={styles.iconButton}>
|
||||
<MemoMenu
|
||||
isPinned={isPinned}
|
||||
onPin={onPin}
|
||||
onCopyTranscript={onCopyTranscript}
|
||||
onManageSpaces={onManageSpaces}
|
||||
onReplaceWord={onReplaceWord}
|
||||
onLabelSpeakers={onLabelSpeakers}
|
||||
onSearch={onSearch}
|
||||
onTranslate={onTranslate}
|
||||
onAskQuestion={onAskQuestion}
|
||||
onCreateMemory={onCreateMemory}
|
||||
onAddPhoto={onAddPhoto}
|
||||
onReprocess={onReprocess}
|
||||
onDelete={onDelete}
|
||||
hasStructuredTranscript={hasStructuredTranscript}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
position: 'absolute',
|
||||
bottom: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-evenly',
|
||||
paddingTop: 8,
|
||||
paddingHorizontal: 16,
|
||||
},
|
||||
iconButton: {
|
||||
borderRadius: 16,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
width: '20%',
|
||||
height: 48,
|
||||
},
|
||||
});
|
||||
|
||||
export default React.memo(MemoBottomBar);
|
||||
1066
apps/memoro/apps/mobile/components/molecules/MemoList.tsx
Normal file
1066
apps/memoro/apps/mobile/components/molecules/MemoList.tsx
Normal file
File diff suppressed because it is too large
Load diff
119
apps/memoro/apps/mobile/components/molecules/MemoLocation.tsx
Normal file
119
apps/memoro/apps/mobile/components/molecules/MemoLocation.tsx
Normal file
|
|
@ -0,0 +1,119 @@
|
|||
import React from 'react';
|
||||
import { View, StyleSheet, Dimensions } from 'react-native';
|
||||
import Text from '~/components/atoms/Text';
|
||||
import { useTheme } from '~/features/theme/ThemeProvider';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
interface AddressInfo {
|
||||
street?: string;
|
||||
streetNumber?: string;
|
||||
postalCode?: string;
|
||||
city?: string;
|
||||
district?: string;
|
||||
region?: string;
|
||||
country?: string;
|
||||
name?: string;
|
||||
formattedAddress?: string;
|
||||
}
|
||||
|
||||
interface LocationData {
|
||||
latitude?: number;
|
||||
longitude?: number;
|
||||
address?: AddressInfo;
|
||||
memoId?: string; // ID des zugehörigen Memos
|
||||
}
|
||||
|
||||
interface MemoLocationProps {
|
||||
location: LocationData;
|
||||
showLabel?: boolean;
|
||||
fullOpacity?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Komponente zur Anzeige von Standortdaten in einem Memo
|
||||
*/
|
||||
const MemoLocation: React.FC<MemoLocationProps> = ({ location, showLabel = true, fullOpacity = false }) => {
|
||||
const { isDark } = useTheme();
|
||||
const { t } = useTranslation();
|
||||
|
||||
console.log('[MemoLocation] Received location prop:', location);
|
||||
|
||||
if (!location) {
|
||||
console.log('[MemoLocation] No location data, returning null');
|
||||
return null;
|
||||
}
|
||||
|
||||
const { latitude, longitude, address } = location;
|
||||
console.log('[MemoLocation] Extracted data:', { latitude, longitude, address });
|
||||
|
||||
const screenWidth = Dimensions.get('window').width;
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'flex-start',
|
||||
marginTop: 0,
|
||||
paddingVertical: 0,
|
||||
},
|
||||
addressText: {
|
||||
fontSize: 14,
|
||||
color: fullOpacity ? (isDark ? '#FFFFFF' : '#000000') : (isDark ? 'rgba(255, 255, 255, 0.7)' : 'rgba(0, 0, 0, 0.7)'),
|
||||
lineHeight: 20,
|
||||
flex: 1,
|
||||
flexWrap: 'wrap',
|
||||
},
|
||||
});
|
||||
|
||||
// Formatiere die Adresse für die Anzeige
|
||||
const getFormattedAddress = () => {
|
||||
if (!address) return null;
|
||||
|
||||
if (address.formattedAddress) {
|
||||
return address.formattedAddress;
|
||||
}
|
||||
|
||||
const addressParts = [];
|
||||
|
||||
// Straße und Hausnummer
|
||||
if (address.street) {
|
||||
const streetPart = address.streetNumber
|
||||
? `${address.street} ${address.streetNumber}`
|
||||
: address.street;
|
||||
addressParts.push(streetPart);
|
||||
}
|
||||
|
||||
// PLZ und Stadt
|
||||
if (address.city) {
|
||||
const cityPart = address.postalCode ? `${address.postalCode} ${address.city}` : address.city;
|
||||
addressParts.push(cityPart);
|
||||
}
|
||||
|
||||
// Land
|
||||
if (address.country) {
|
||||
addressParts.push(address.country);
|
||||
}
|
||||
|
||||
return addressParts.join(', ');
|
||||
};
|
||||
|
||||
const formattedAddress = getFormattedAddress();
|
||||
console.log('[MemoLocation] Formatted address:', formattedAddress);
|
||||
|
||||
// If no address, show coordinates as fallback
|
||||
const displayText =
|
||||
formattedAddress ||
|
||||
(latitude && longitude ? `${latitude.toFixed(6)}, ${longitude.toFixed(6)}` : null);
|
||||
|
||||
if (!displayText) {
|
||||
console.log('[MemoLocation] No location data to display, returning null');
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<Text style={styles.addressText}>{displayText}</Text>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
export default MemoLocation;
|
||||
1092
apps/memoro/apps/mobile/components/molecules/MemoPreview.tsx
Normal file
1092
apps/memoro/apps/mobile/components/molecules/MemoPreview.tsx
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -0,0 +1,181 @@
|
|||
import React, { useEffect, useRef } from 'react';
|
||||
import { View, Animated } from 'react-native';
|
||||
import { useTheme } from '~/features/theme/ThemeProvider';
|
||||
|
||||
interface MemoPreviewSkeletonProps {
|
||||
showMargins?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Skeleton loader for MemoPreview component
|
||||
* Matches exact MemoPreview structure and dimensions with pulsing animation
|
||||
*/
|
||||
const MemoPreviewSkeleton = ({ showMargins = true }: MemoPreviewSkeletonProps) => {
|
||||
const { colors } = useTheme();
|
||||
const pulseAnim = useRef(new Animated.Value(0.4)).current;
|
||||
|
||||
// Theme-aware colors
|
||||
const skeletonColor = colors.skeleton;
|
||||
const backgroundColor = colors.contentBackground;
|
||||
const borderColor = colors.border;
|
||||
|
||||
// Pulsing animation effect
|
||||
useEffect(() => {
|
||||
const pulse = Animated.loop(
|
||||
Animated.sequence([
|
||||
Animated.timing(pulseAnim, {
|
||||
toValue: 1,
|
||||
duration: 1000,
|
||||
useNativeDriver: true,
|
||||
}),
|
||||
Animated.timing(pulseAnim, {
|
||||
toValue: 0.4,
|
||||
duration: 1000,
|
||||
useNativeDriver: true,
|
||||
}),
|
||||
])
|
||||
);
|
||||
|
||||
pulse.start();
|
||||
|
||||
return () => pulse.stop();
|
||||
}, [pulseAnim]);
|
||||
|
||||
return (
|
||||
<View style={{
|
||||
backgroundColor,
|
||||
borderRadius: 16,
|
||||
borderWidth: 1,
|
||||
borderColor,
|
||||
minHeight: 140,
|
||||
...(showMargins && {
|
||||
marginLeft: 16,
|
||||
marginRight: 16,
|
||||
}),
|
||||
flexShrink: 0,
|
||||
marginBottom: 12,
|
||||
}}>
|
||||
<View style={{ flex: 1, paddingTop: 16, paddingHorizontal: 16 }}>
|
||||
<View style={{
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
}}>
|
||||
<View style={{ flex: 1 }}>
|
||||
<View style={{ flexDirection: 'row', alignItems: 'center' }}>
|
||||
<Animated.View style={{
|
||||
height: 14,
|
||||
width: 60,
|
||||
backgroundColor: skeletonColor,
|
||||
borderRadius: 3,
|
||||
marginRight: 4,
|
||||
opacity: pulseAnim,
|
||||
}} />
|
||||
<Animated.View style={{
|
||||
width: 4,
|
||||
height: 4,
|
||||
backgroundColor: skeletonColor,
|
||||
borderRadius: 2,
|
||||
marginHorizontal: 4,
|
||||
opacity: pulseAnim,
|
||||
}} />
|
||||
<Animated.View style={{
|
||||
height: 14,
|
||||
width: 40,
|
||||
backgroundColor: skeletonColor,
|
||||
borderRadius: 3,
|
||||
marginRight: 4,
|
||||
opacity: pulseAnim,
|
||||
}} />
|
||||
<Animated.View style={{
|
||||
width: 4,
|
||||
height: 4,
|
||||
backgroundColor: skeletonColor,
|
||||
borderRadius: 2,
|
||||
marginHorizontal: 4,
|
||||
opacity: pulseAnim,
|
||||
}} />
|
||||
<Animated.View style={{
|
||||
height: 14,
|
||||
width: 35,
|
||||
backgroundColor: skeletonColor,
|
||||
borderRadius: 3,
|
||||
opacity: pulseAnim,
|
||||
}} />
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<View style={{ maxWidth: 120, flexShrink: 0 }}>
|
||||
<Animated.View style={{
|
||||
height: 20,
|
||||
width: 80,
|
||||
backgroundColor: skeletonColor,
|
||||
borderRadius: 10,
|
||||
opacity: pulseAnim,
|
||||
}} />
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<View style={{ marginTop: 8, marginBottom: 4 }}>
|
||||
<Animated.View style={{
|
||||
height: 18,
|
||||
width: 280,
|
||||
backgroundColor: skeletonColor,
|
||||
borderRadius: 4,
|
||||
marginBottom: 2,
|
||||
opacity: pulseAnim,
|
||||
}} />
|
||||
<Animated.View style={{
|
||||
height: 18,
|
||||
width: 200,
|
||||
backgroundColor: skeletonColor,
|
||||
borderRadius: 4,
|
||||
opacity: pulseAnim,
|
||||
}} />
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<View style={{
|
||||
marginTop: 4,
|
||||
minHeight: 32,
|
||||
paddingBottom: 10,
|
||||
paddingHorizontal: 16,
|
||||
}}>
|
||||
<View style={{
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
flexWrap: 'wrap',
|
||||
}}>
|
||||
<Animated.View style={{
|
||||
height: 24,
|
||||
width: 65,
|
||||
backgroundColor: skeletonColor,
|
||||
borderRadius: 12,
|
||||
marginRight: 8,
|
||||
marginBottom: 4,
|
||||
opacity: pulseAnim,
|
||||
}} />
|
||||
<Animated.View style={{
|
||||
height: 24,
|
||||
width: 85,
|
||||
backgroundColor: skeletonColor,
|
||||
borderRadius: 12,
|
||||
marginRight: 8,
|
||||
marginBottom: 4,
|
||||
opacity: pulseAnim,
|
||||
}} />
|
||||
<Animated.View style={{
|
||||
height: 24,
|
||||
width: 50,
|
||||
backgroundColor: skeletonColor,
|
||||
borderRadius: 12,
|
||||
marginBottom: 4,
|
||||
opacity: pulseAnim,
|
||||
}} />
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
export default MemoPreviewSkeleton;
|
||||
|
|
@ -0,0 +1,59 @@
|
|||
import React, { forwardRef, useEffect, useRef } from 'react';
|
||||
import { ScrollView, ScrollViewProps, Text } from 'react-native';
|
||||
|
||||
/**
|
||||
* Wrapper around ScrollView with forwardRef to ensure ref works properly
|
||||
*/
|
||||
const MemoScrollView = forwardRef<ScrollView, ScrollViewProps>((props, ref) => {
|
||||
const internalRef = useRef<ScrollView>(null);
|
||||
|
||||
console.log('MemoScrollView rendering');
|
||||
console.log('MemoScrollView props:', Object.keys(props));
|
||||
|
||||
// Try syncing in useEffect
|
||||
useEffect(() => {
|
||||
console.log('MemoScrollView useEffect - internalRef.current:', internalRef.current);
|
||||
|
||||
if (internalRef.current) {
|
||||
console.log('Setting forwarded ref from useEffect');
|
||||
if (typeof ref === 'function') {
|
||||
ref(internalRef.current);
|
||||
} else if (ref) {
|
||||
(ref as any).current = internalRef.current;
|
||||
}
|
||||
}
|
||||
}, [internalRef.current]);
|
||||
|
||||
console.log('About to render ScrollView...');
|
||||
|
||||
return (
|
||||
<>
|
||||
<Text style={{ color: 'red', fontSize: 20 }}>DEBUG: ScrollView is rendering</Text>
|
||||
<ScrollView
|
||||
ref={(scrollViewRef) => {
|
||||
console.log('=== MemoScrollView ref callback fired ===');
|
||||
console.log('ref value:', scrollViewRef);
|
||||
console.log('ref is null:', scrollViewRef === null);
|
||||
|
||||
// Set both refs
|
||||
internalRef.current = scrollViewRef;
|
||||
|
||||
// Set the forwarded ref
|
||||
if (scrollViewRef) {
|
||||
if (typeof ref === 'function') {
|
||||
ref(scrollViewRef);
|
||||
} else if (ref) {
|
||||
(ref as any).current = scrollViewRef;
|
||||
}
|
||||
console.log('Forwarded ref set successfully!');
|
||||
}
|
||||
}}
|
||||
{...props}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
});
|
||||
|
||||
MemoScrollView.displayName = 'MemoScrollView';
|
||||
|
||||
export default MemoScrollView;
|
||||
443
apps/memoro/apps/mobile/components/molecules/MemoTitle.tsx
Normal file
443
apps/memoro/apps/mobile/components/molecules/MemoTitle.tsx
Normal file
|
|
@ -0,0 +1,443 @@
|
|||
import { useState } from 'react';
|
||||
import { StyleSheet, View, TextInput, Pressable, Alert, Platform } from 'react-native';
|
||||
import { useTheme } from '~/features/theme/ThemeProvider';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import Text from '~/components/atoms/Text';
|
||||
import HighlightedText from '~/components/atoms/HighlightedText';
|
||||
import MemoLocation from '~/components/molecules/MemoLocation';
|
||||
import { Ionicons } from '@expo/vector-icons';
|
||||
import * as Clipboard from 'expo-clipboard';
|
||||
|
||||
interface MemoTitleProps {
|
||||
title?: string;
|
||||
intro?: string;
|
||||
timestamp?: Date;
|
||||
duration?: number;
|
||||
viewCount?: number;
|
||||
wordCount?: number;
|
||||
location?: any; // Location-Daten für die Adressanzeige
|
||||
language?: string; // Transkript-Sprache (z.B. "de-DE")
|
||||
speakerCount?: number; // Anzahl der Sprecher
|
||||
isEditMode?: boolean;
|
||||
onTitleChange?: (text: string) => void;
|
||||
onIntroChange?: (text: string) => void;
|
||||
onSave?: () => void;
|
||||
onCancel?: () => void;
|
||||
params?: any;
|
||||
memoId?: string;
|
||||
tags?: Array<{ id: string; text: string; color: string }>;
|
||||
selectedTagIds?: string[];
|
||||
onTagPress?: (id: string) => void;
|
||||
onAddTagPress?: () => void;
|
||||
// Search props
|
||||
isSearchMode?: boolean;
|
||||
searchQuery?: string;
|
||||
currentSearchIndex?: number;
|
||||
searchResults?: Array<{
|
||||
id: string;
|
||||
type: string;
|
||||
text: string;
|
||||
index: number;
|
||||
matchIndex: number;
|
||||
}>;
|
||||
isPinned?: boolean;
|
||||
onPinPress?: () => void;
|
||||
}
|
||||
|
||||
export default function MemoTitle({
|
||||
title,
|
||||
intro,
|
||||
timestamp,
|
||||
duration,
|
||||
viewCount = 0,
|
||||
wordCount,
|
||||
location,
|
||||
language,
|
||||
speakerCount,
|
||||
isEditMode = false,
|
||||
onTitleChange,
|
||||
onIntroChange,
|
||||
onSave: _onSave,
|
||||
onCancel: _onCancel,
|
||||
params: _params,
|
||||
memoId: _memoId,
|
||||
tags = [],
|
||||
selectedTagIds = [],
|
||||
onTagPress,
|
||||
onAddTagPress,
|
||||
isSearchMode = false,
|
||||
searchQuery = '',
|
||||
currentSearchIndex = 0,
|
||||
searchResults = [],
|
||||
isPinned = false,
|
||||
onPinPress,
|
||||
}: MemoTitleProps) {
|
||||
const { isDark } = useTheme();
|
||||
const { t, i18n } = useTranslation();
|
||||
const [showAllMetadata, setShowAllMetadata] = useState(false);
|
||||
|
||||
const handleCopyMemoId = async () => {
|
||||
if (_memoId) {
|
||||
await Clipboard.setStringAsync(_memoId);
|
||||
if (Platform.OS === 'ios' || Platform.OS === 'android') {
|
||||
Alert.alert(
|
||||
t('memo.copy_success_title'),
|
||||
t('memo.id_copied', 'Memo ID copied to clipboard')
|
||||
);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Hilfsfunktionen für die Formatierung
|
||||
const formatDate = (date: Date): string => {
|
||||
return date.toLocaleDateString(i18n.language, {
|
||||
weekday: 'long',
|
||||
day: 'numeric',
|
||||
month: 'long',
|
||||
year: 'numeric',
|
||||
});
|
||||
};
|
||||
|
||||
const formatTime = (date: Date): string => {
|
||||
const langCode = i18n.language.split('-')[0];
|
||||
const is12Hour = ['en', 'hi', 'ur', 'tl', 'ms'].includes(langCode);
|
||||
|
||||
return date.toLocaleTimeString(i18n.language, {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
hour12: is12Hour,
|
||||
});
|
||||
};
|
||||
|
||||
const formatDuration = (durationInSeconds: number): string => {
|
||||
if (durationInSeconds < 60) {
|
||||
const seconds = Math.ceil(durationInSeconds);
|
||||
return t('memo.duration_seconds', '{{seconds}} Sek', { seconds });
|
||||
}
|
||||
const minutes = Math.ceil(durationInSeconds / 60);
|
||||
return t('memo.duration_minutes', '{{minutes}} min', { minutes });
|
||||
};
|
||||
|
||||
const formatViewCount = (count: number): string => {
|
||||
return t(count === 1 ? 'memo.view_count' : 'memo.view_count_plural', { count });
|
||||
};
|
||||
|
||||
const formatWordCount = (count?: number): string | null => {
|
||||
if (count === undefined || count === null) return null;
|
||||
return t(count === 1 ? 'memo.word_count' : 'memo.word_count_plural', { count });
|
||||
};
|
||||
|
||||
const formatLanguage = (lang?: string): string | null => {
|
||||
if (!lang) return null;
|
||||
// Konvertiere Sprachcodes zu lesbaren Namen
|
||||
const languageNames: Record<string, string> = {
|
||||
'de-DE': 'Deutsch',
|
||||
'en-US': 'Englisch',
|
||||
'fr-FR': 'Französisch',
|
||||
'es-ES': 'Spanisch',
|
||||
'it-IT': 'Italienisch',
|
||||
'pt-PT': 'Portugiesisch',
|
||||
'nl-NL': 'Niederländisch',
|
||||
'pl-PL': 'Polnisch',
|
||||
'ru-RU': 'Russisch',
|
||||
'ja-JP': 'Japanisch',
|
||||
'ko-KR': 'Koreanisch',
|
||||
'zh-CN': 'Chinesisch',
|
||||
'ar-SA': 'Arabisch',
|
||||
'hi-IN': 'Hindi',
|
||||
'tr-TR': 'Türkisch',
|
||||
};
|
||||
return languageNames[lang] || lang;
|
||||
};
|
||||
|
||||
const formatSpeakerCount = (count?: number): string | null => {
|
||||
if (!count || count === 0) return null;
|
||||
return t(count === 1 ? 'memo.speaker_count' : 'memo.speaker_count_plural', { count });
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
marginTop: 24,
|
||||
marginBottom: 24,
|
||||
paddingHorizontal: 20,
|
||||
},
|
||||
metadataContainer: {
|
||||
alignSelf: 'stretch',
|
||||
marginBottom: 24,
|
||||
},
|
||||
dateContainer: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
flexShrink: 0,
|
||||
marginBottom: 4,
|
||||
},
|
||||
metadataRow: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
paddingVertical: 12,
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: isDark ? 'rgba(255, 255, 255, 0.1)' : 'rgba(0, 0, 0, 0.05)',
|
||||
},
|
||||
metadataLabel: {
|
||||
fontSize: 14,
|
||||
fontWeight: '500',
|
||||
color: showAllMetadata ? (isDark ? '#FFFFFF' : '#000000') : (isDark ? 'rgba(255, 255, 255, 0.7)' : 'rgba(0, 0, 0, 0.7)'),
|
||||
width: 100,
|
||||
marginLeft: 8,
|
||||
},
|
||||
metadataIcon: {
|
||||
marginRight: 0,
|
||||
},
|
||||
metadataValue: {
|
||||
fontSize: 14,
|
||||
color: showAllMetadata ? (isDark ? '#FFFFFF' : '#000000') : (isDark ? 'rgba(255, 255, 255, 0.7)' : 'rgba(0, 0, 0, 0.7)'),
|
||||
flex: 1,
|
||||
},
|
||||
infoText: {
|
||||
fontSize: 14,
|
||||
color: showAllMetadata ? (isDark ? '#FFFFFF' : '#000000') : (isDark ? 'rgba(255, 255, 255, 0.7)' : 'rgba(0, 0, 0, 0.7)'),
|
||||
},
|
||||
separator: {
|
||||
fontSize: 14,
|
||||
color: showAllMetadata ? (isDark ? '#FFFFFF' : '#000000') : (isDark ? 'rgba(255, 255, 255, 0.7)' : 'rgba(0, 0, 0, 0.7)'),
|
||||
marginHorizontal: 6,
|
||||
},
|
||||
pinnedText: {
|
||||
fontSize: 12,
|
||||
fontWeight: '600',
|
||||
},
|
||||
emojiContainer: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'flex-start',
|
||||
alignItems: 'flex-start',
|
||||
marginBottom: 16,
|
||||
marginTop: 8,
|
||||
},
|
||||
headline: {
|
||||
fontSize: 22,
|
||||
fontWeight: 'bold',
|
||||
color: isDark ? '#FFFFFF' : '#000000',
|
||||
lineHeight: 32,
|
||||
marginBottom: intro ? 8 : 16,
|
||||
},
|
||||
intro: {
|
||||
fontSize: 16,
|
||||
color: isDark ? 'rgba(255, 255, 255, 0.9)' : 'rgba(0, 0, 0, 0.8)',
|
||||
lineHeight: 24,
|
||||
marginBottom: 16,
|
||||
},
|
||||
input: {
|
||||
fontSize: 22,
|
||||
fontWeight: 'bold',
|
||||
color: isDark ? '#FFFFFF' : '#000000',
|
||||
lineHeight: 32,
|
||||
marginBottom: 8,
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: isDark ? 'rgba(255, 255, 255, 0.3)' : 'rgba(0, 0, 0, 0.3)',
|
||||
paddingBottom: 4,
|
||||
minHeight: 32,
|
||||
},
|
||||
introInput: {
|
||||
fontSize: 16,
|
||||
color: isDark ? 'rgba(255, 255, 255, 0.9)' : 'rgba(0, 0, 0, 0.8)',
|
||||
lineHeight: 24,
|
||||
marginBottom: 16,
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: isDark ? 'rgba(255, 255, 255, 0.3)' : 'rgba(0, 0, 0, 0.3)',
|
||||
paddingBottom: 4,
|
||||
minHeight: 24,
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
{isEditMode ? (
|
||||
<>
|
||||
<TextInput
|
||||
style={styles.input}
|
||||
value={title}
|
||||
onChangeText={onTitleChange}
|
||||
placeholder={t('memo.title_placeholder', 'Enter title')}
|
||||
placeholderTextColor={isDark ? 'rgba(255, 255, 255, 0.5)' : 'rgba(0, 0, 0, 0.5)'}
|
||||
multiline
|
||||
textAlignVertical="top"
|
||||
scrollEnabled={false}
|
||||
/>
|
||||
<TextInput
|
||||
style={styles.introInput}
|
||||
value={intro}
|
||||
onChangeText={onIntroChange}
|
||||
placeholder={t('memo.intro_placeholder', 'Enter description (optional)')}
|
||||
placeholderTextColor={isDark ? 'rgba(255, 255, 255, 0.5)' : 'rgba(0, 0, 0, 0.5)'}
|
||||
multiline
|
||||
textAlignVertical="top"
|
||||
scrollEnabled={false}
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{/* Metadaten über dem Titel */}
|
||||
{timestamp && (
|
||||
<Pressable onPress={() => setShowAllMetadata(!showAllMetadata)} style={styles.metadataContainer}>
|
||||
<View style={styles.dateContainer}>
|
||||
<Text style={styles.infoText}>{formatDate(timestamp)}</Text>
|
||||
<Text style={styles.separator}>·</Text>
|
||||
<Text style={styles.infoText}>{formatTime(timestamp)}</Text>
|
||||
{duration !== undefined && (
|
||||
<>
|
||||
<Text style={styles.separator}>·</Text>
|
||||
<Text style={styles.infoText}>{formatDuration(duration)}</Text>
|
||||
</>
|
||||
)}
|
||||
{isPinned && (
|
||||
<>
|
||||
<Text style={styles.separator}>·</Text>
|
||||
<Pressable onPress={(e) => {
|
||||
e.stopPropagation();
|
||||
onPinPress?.();
|
||||
}}>
|
||||
<Ionicons name="pin" size={14} color="#FF9500" />
|
||||
</Pressable>
|
||||
</>
|
||||
)}
|
||||
<Ionicons
|
||||
name={showAllMetadata ? 'chevron-down' : 'chevron-back'}
|
||||
size={20}
|
||||
color={isDark ? 'rgba(255, 255, 255, 0.5)' : 'rgba(0, 0, 0, 0.5)'}
|
||||
style={{ marginLeft: 12 }}
|
||||
/>
|
||||
</View>
|
||||
|
||||
{/* Zusätzliche Metadaten nur anzeigen wenn showAllMetadata true ist */}
|
||||
{showAllMetadata && (
|
||||
<>
|
||||
{/* Date */}
|
||||
<View style={[styles.metadataRow, { marginTop: 12, borderTopWidth: 1, borderTopColor: isDark ? 'rgba(255, 255, 255, 0.1)' : 'rgba(0, 0, 0, 0.05)' }]}>
|
||||
<Ionicons name="calendar-outline" size={16} color={isDark ? '#FFFFFF' : '#000000'} style={styles.metadataIcon} />
|
||||
<Text style={styles.metadataLabel}>{t('memo.date', 'Date')}</Text>
|
||||
<Text style={styles.metadataValue}>{formatDate(timestamp)}</Text>
|
||||
</View>
|
||||
|
||||
{/* Time */}
|
||||
<View style={styles.metadataRow}>
|
||||
<Ionicons name="time-outline" size={16} color={isDark ? '#FFFFFF' : '#000000'} style={styles.metadataIcon} />
|
||||
<Text style={styles.metadataLabel}>{t('memo.time', 'Time')}</Text>
|
||||
<Text style={styles.metadataValue}>{formatTime(timestamp)}</Text>
|
||||
</View>
|
||||
|
||||
{/* Duration */}
|
||||
{duration !== undefined && (
|
||||
<View style={styles.metadataRow}>
|
||||
<Ionicons name="timer-outline" size={16} color={isDark ? '#FFFFFF' : '#000000'} style={styles.metadataIcon} />
|
||||
<Text style={styles.metadataLabel}>{t('memo.duration', 'Duration')}</Text>
|
||||
<Text style={styles.metadataValue}>{formatDuration(duration)}</Text>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* Views */}
|
||||
<View style={styles.metadataRow}>
|
||||
<Ionicons name="eye-outline" size={16} color={isDark ? '#FFFFFF' : '#000000'} style={styles.metadataIcon} />
|
||||
<Text style={styles.metadataLabel}>{t('memo.views', 'Views')}</Text>
|
||||
<Text style={styles.metadataValue}>{viewCount}</Text>
|
||||
</View>
|
||||
|
||||
{/* Words */}
|
||||
{wordCount !== undefined && wordCount > 0 && (
|
||||
<View style={styles.metadataRow}>
|
||||
<Ionicons name="text-outline" size={16} color={isDark ? '#FFFFFF' : '#000000'} style={styles.metadataIcon} />
|
||||
<Text style={styles.metadataLabel}>{t('memo.words', 'Words')}</Text>
|
||||
<Text style={styles.metadataValue}>{wordCount.toLocaleString()}</Text>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* Language */}
|
||||
{language && (
|
||||
<View style={styles.metadataRow}>
|
||||
<Ionicons name="language-outline" size={16} color={isDark ? '#FFFFFF' : '#000000'} style={styles.metadataIcon} />
|
||||
<Text style={styles.metadataLabel}>{t('memo.language', 'Language')}</Text>
|
||||
<Text style={styles.metadataValue}>{formatLanguage(language)}</Text>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* Speakers */}
|
||||
{speakerCount && speakerCount > 0 && (
|
||||
<View style={styles.metadataRow}>
|
||||
<Ionicons name="people-outline" size={16} color={isDark ? '#FFFFFF' : '#000000'} style={styles.metadataIcon} />
|
||||
<Text style={styles.metadataLabel}>{t('memo.speakers', 'Speakers')}</Text>
|
||||
<Text style={styles.metadataValue}>{speakerCount}</Text>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* Location */}
|
||||
{location && (
|
||||
<View style={styles.metadataRow}>
|
||||
<Ionicons name="location-outline" size={16} color={isDark ? '#FFFFFF' : '#000000'} style={styles.metadataIcon} />
|
||||
<Text style={styles.metadataLabel}>{t('memo.location', 'Location')}</Text>
|
||||
<View style={{ flex: 1 }}>
|
||||
<MemoLocation location={location} fullOpacity={true} />
|
||||
</View>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* Memo ID */}
|
||||
{_memoId && (
|
||||
<Pressable
|
||||
onPress={(e) => {
|
||||
e.stopPropagation();
|
||||
handleCopyMemoId();
|
||||
}}
|
||||
style={[styles.metadataRow, { borderBottomWidth: 0 }]}
|
||||
>
|
||||
<Ionicons name="key-outline" size={16} color={isDark ? '#FFFFFF' : '#000000'} style={styles.metadataIcon} />
|
||||
<Text style={styles.metadataLabel}>ID</Text>
|
||||
<View style={{ flex: 1, flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between' }}>
|
||||
<Text style={[styles.metadataValue, { fontFamily: 'monospace', fontSize: 13 }]} numberOfLines={1}>{_memoId}</Text>
|
||||
<Ionicons
|
||||
name="copy-outline"
|
||||
size={18}
|
||||
color={isDark ? '#FFFFFF' : '#000000'}
|
||||
/>
|
||||
</View>
|
||||
</Pressable>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</Pressable>
|
||||
)}
|
||||
|
||||
{/* Titel und Intro nach den Metadaten */}
|
||||
{title ? (
|
||||
isSearchMode && searchQuery ? (
|
||||
<HighlightedText
|
||||
text={title}
|
||||
searchQuery={searchQuery}
|
||||
style={styles.headline}
|
||||
numberOfLines={undefined}
|
||||
currentResultIndex={currentSearchIndex}
|
||||
searchResults={searchResults}
|
||||
textType="title"
|
||||
/>
|
||||
) : (
|
||||
<Text style={styles.headline}>{title}</Text>
|
||||
)
|
||||
) : undefined}
|
||||
{intro ? (
|
||||
isSearchMode && searchQuery ? (
|
||||
<HighlightedText
|
||||
text={intro}
|
||||
searchQuery={searchQuery}
|
||||
style={styles.intro}
|
||||
numberOfLines={undefined}
|
||||
currentResultIndex={currentSearchIndex}
|
||||
searchResults={searchResults}
|
||||
textType="intro"
|
||||
/>
|
||||
) : (
|
||||
<Text style={styles.intro}>{intro}</Text>
|
||||
)
|
||||
) : undefined}
|
||||
</>
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,333 @@
|
|||
import React, { useState, useEffect } from 'react';
|
||||
import { View, Pressable, StyleSheet, Platform } from 'react-native';
|
||||
import Text from '~/components/atoms/Text';
|
||||
import { useTheme } from '~/features/theme/ThemeProvider';
|
||||
import BaseModal from '~/components/atoms/BaseModal';
|
||||
import BaseLanguageSelector, { LanguageItem } from '~/components/molecules/BaseLanguageSelector';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import Icon from '~/components/atoms/Icon';
|
||||
|
||||
const tailwindConfig = require('../../tailwind.config.js');
|
||||
|
||||
interface Language {
|
||||
nativeName: string;
|
||||
emoji: string;
|
||||
locale?: string;
|
||||
}
|
||||
|
||||
interface MultiLanguageSelectorProps {
|
||||
isVisible?: boolean;
|
||||
onClose?: () => void;
|
||||
languages: Record<string, Language>;
|
||||
selectedLanguages?: string[];
|
||||
onToggleLanguage: (language: string) => void;
|
||||
title?: string;
|
||||
|
||||
// Eigenschaften für den Button-Modus
|
||||
buttonMode?: boolean;
|
||||
size?: number;
|
||||
onButtonPress?: () => void;
|
||||
}
|
||||
|
||||
const MultiLanguageSelector: React.FC<MultiLanguageSelectorProps> = ({
|
||||
isVisible = false,
|
||||
onClose = () => {},
|
||||
languages,
|
||||
selectedLanguages = [],
|
||||
onToggleLanguage,
|
||||
title,
|
||||
buttonMode = false,
|
||||
size = 40,
|
||||
onButtonPress,
|
||||
}) => {
|
||||
const { isDark, themeVariant } = useTheme();
|
||||
const { t } = useTranslation();
|
||||
const [modalVisible, setModalVisible] = useState(isVisible);
|
||||
|
||||
// Aktualisiere modalVisible, wenn sich isVisible ändert
|
||||
useEffect(() => {
|
||||
setModalVisible(isVisible);
|
||||
}, [isVisible]);
|
||||
|
||||
// Get theme colors
|
||||
const getThemeColors = () => {
|
||||
try {
|
||||
if (tailwindConfig?.theme?.extend?.colors) {
|
||||
const colors = tailwindConfig.theme.extend.colors;
|
||||
|
||||
if (isDark && colors.dark?.[themeVariant]) {
|
||||
return {
|
||||
text: colors.dark[themeVariant].text || '#FFFFFF',
|
||||
primary: colors.dark[themeVariant].primary || '#f8d62b',
|
||||
primaryButton: colors.dark[themeVariant].primaryButton || '#f8d62b',
|
||||
contentBackground: colors.dark[themeVariant].contentBackground || '#1E1E1E',
|
||||
contentBackgroundHover: colors.dark[themeVariant].contentBackgroundHover || '#333333',
|
||||
menuBackground: colors.dark[themeVariant].menuBackground || '#252525',
|
||||
border: colors.dark[themeVariant].borderLight || '#333333',
|
||||
};
|
||||
} else if (colors[themeVariant]) {
|
||||
return {
|
||||
text: colors[themeVariant].text || '#000000',
|
||||
primary: colors[themeVariant].primary || '#f8d62b',
|
||||
primaryButton: colors[themeVariant].primaryButton || '#f8d62b',
|
||||
contentBackground: colors[themeVariant].contentBackground || '#ffffff',
|
||||
contentBackgroundHover: colors[themeVariant].contentBackgroundHover || '#f5f5f5',
|
||||
menuBackground: colors[themeVariant].menuBackground || '#dddddd',
|
||||
border: colors[themeVariant].borderLight || '#f2f2f2',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback colors
|
||||
return {
|
||||
text: isDark ? '#FFFFFF' : '#000000',
|
||||
primary: '#f8d62b',
|
||||
primaryButton: '#f8d62b',
|
||||
contentBackground: isDark ? '#1E1E1E' : '#ffffff',
|
||||
contentBackgroundHover: isDark ? '#333333' : '#f5f5f5',
|
||||
menuBackground: isDark ? '#252525' : '#dddddd',
|
||||
border: isDark ? '#333333' : '#f2f2f2',
|
||||
};
|
||||
} catch (e) {
|
||||
console.debug('Error loading theme colors:', e);
|
||||
return {
|
||||
text: isDark ? '#FFFFFF' : '#000000',
|
||||
primary: '#f8d62b',
|
||||
primaryButton: '#f8d62b',
|
||||
contentBackground: isDark ? '#1E1E1E' : '#ffffff',
|
||||
contentBackgroundHover: isDark ? '#333333' : '#f5f5f5',
|
||||
menuBackground: isDark ? '#252525' : '#dddddd',
|
||||
border: isDark ? '#333333' : '#f2f2f2',
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
const themeColors = getThemeColors();
|
||||
|
||||
// Convert languages to BaseLanguageSelector format
|
||||
const languageItems: Record<string, LanguageItem> = Object.entries(languages).reduce(
|
||||
(acc, [code, lang]) => {
|
||||
acc[code] = {
|
||||
code,
|
||||
...lang,
|
||||
};
|
||||
return acc;
|
||||
},
|
||||
{} as Record<string, LanguageItem>
|
||||
);
|
||||
|
||||
// Handler for selection changes
|
||||
const handleSelectionChange = (newSelection: string[]) => {
|
||||
// Ensure selectedLanguages is always an array - defensive check for undefined/null
|
||||
const safeSelectedLanguages = Array.isArray(selectedLanguages) ? selectedLanguages : [];
|
||||
// Find the difference to determine which language was toggled
|
||||
const added = newSelection.find(lang => !safeSelectedLanguages.includes(lang));
|
||||
const removed = safeSelectedLanguages.find(lang => !newSelection.includes(lang));
|
||||
|
||||
if (added) {
|
||||
onToggleLanguage(added);
|
||||
} else if (removed) {
|
||||
onToggleLanguage(removed);
|
||||
}
|
||||
};
|
||||
|
||||
const handleOpenModal = () => {
|
||||
setModalVisible(true);
|
||||
if (onButtonPress) onButtonPress();
|
||||
};
|
||||
|
||||
const handleCloseModal = () => {
|
||||
setModalVisible(false);
|
||||
if (onClose) onClose();
|
||||
};
|
||||
|
||||
// Bestimme den Titel des Modals
|
||||
const getModalTitle = () => {
|
||||
return title || t('language.select_recording_language', 'Aufnahmesprache auswählen');
|
||||
};
|
||||
|
||||
// Bestimme, welches Icon/Emoji für den Button angezeigt werden soll
|
||||
const renderSelectorContent = () => {
|
||||
// Ensure selectedLanguages is always an array - defensive check for undefined/null
|
||||
const safeSelectedLanguages = Array.isArray(selectedLanguages) ? selectedLanguages : [];
|
||||
const hasSelection = safeSelectedLanguages.length > 0;
|
||||
|
||||
if (safeSelectedLanguages.length === 0) {
|
||||
// Kein Sprachfilter ausgewählt
|
||||
return (
|
||||
<View style={{ opacity: 0.6 }}>
|
||||
<Icon name="language-outline" size={size * 0.6} color={themeColors.text} />
|
||||
</View>
|
||||
);
|
||||
} else if (safeSelectedLanguages.includes('auto')) {
|
||||
// Auto-Modus ausgewählt
|
||||
return (
|
||||
<View style={{
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
}}>
|
||||
<Text style={{
|
||||
fontSize: size * 0.65,
|
||||
lineHeight: size * 0.75,
|
||||
textAlign: 'center',
|
||||
includeFontPadding: false,
|
||||
marginTop: 2,
|
||||
}}>🌐</Text>
|
||||
</View>
|
||||
);
|
||||
} else if (safeSelectedLanguages.length === 1) {
|
||||
// Eine Sprache ausgewählt
|
||||
const language = safeSelectedLanguages[0];
|
||||
const languageData = languages[language];
|
||||
const emoji = languageData?.emoji || '🌐';
|
||||
return (
|
||||
<View style={{
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
}}>
|
||||
<Text style={{
|
||||
fontSize: size * 0.65,
|
||||
lineHeight: size * 0.75,
|
||||
textAlign: 'center',
|
||||
includeFontPadding: false,
|
||||
marginTop: 2,
|
||||
}}>{emoji}</Text>
|
||||
</View>
|
||||
);
|
||||
} else {
|
||||
// Mehrere Sprachen ausgewählt - zeige alle Flaggen vertikal
|
||||
// Größere Flaggen bei 2-3 Sprachen
|
||||
const flagSize = safeSelectedLanguages.length <= 3 ? size * 0.5 : size * 0.35;
|
||||
|
||||
return (
|
||||
<View style={{
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
flexDirection: 'column',
|
||||
}}>
|
||||
{safeSelectedLanguages.map((lang, index) => {
|
||||
const languageData = languages[lang];
|
||||
const emoji = languageData?.emoji || '🌐';
|
||||
return (
|
||||
<Text
|
||||
key={lang}
|
||||
style={{
|
||||
fontSize: flagSize,
|
||||
lineHeight: flagSize,
|
||||
marginTop: index === 0 ? 0 : 4, // Mehr Abstand zwischen Flaggen
|
||||
includeFontPadding: false,
|
||||
}}
|
||||
>
|
||||
{emoji}
|
||||
</Text>
|
||||
);
|
||||
})}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
// Im Button-Modus zeigen wir nur den Button an, der das Modal öffnet
|
||||
if (buttonMode) {
|
||||
const safeSelectedLanguages = Array.isArray(selectedLanguages) ? selectedLanguages : [];
|
||||
const hasSelection = safeSelectedLanguages.length > 0;
|
||||
|
||||
const buttonStyles = StyleSheet.create({
|
||||
buttonContainer: {
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
backgroundColor: 'transparent',
|
||||
},
|
||||
selectorButton: {
|
||||
borderRadius: size / 2,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
backgroundColor: 'transparent',
|
||||
shadowColor: '#000',
|
||||
shadowOffset: { width: 0, height: 1 },
|
||||
shadowOpacity: 0,
|
||||
shadowRadius: 2,
|
||||
...(Platform.OS === 'android' && {
|
||||
elevation: 0,
|
||||
}),
|
||||
},
|
||||
});
|
||||
|
||||
// Dynamische Höhe für mehrere Flaggen
|
||||
const buttonHeight = safeSelectedLanguages.length > 1 && !safeSelectedLanguages.includes('auto')
|
||||
? Math.min(size + (safeSelectedLanguages.length - 1) * size * 0.25, size * 2) // Dynamisch basierend auf Anzahl, max 2x size
|
||||
: size;
|
||||
|
||||
return (
|
||||
<View style={[buttonStyles.buttonContainer, { width: size, height: buttonHeight }]}>
|
||||
<Pressable
|
||||
style={[buttonStyles.selectorButton, { width: size, height: buttonHeight, borderRadius: buttonHeight / 2 }]}
|
||||
onPress={handleOpenModal}
|
||||
android_ripple={{
|
||||
color: isDark ? 'rgba(255, 255, 255, 0.1)' : 'rgba(0, 0, 0, 0.1)',
|
||||
borderless: true,
|
||||
radius: size / 2
|
||||
}}
|
||||
>
|
||||
{renderSelectorContent()}
|
||||
</Pressable>
|
||||
|
||||
<BaseModal
|
||||
isVisible={modalVisible}
|
||||
onClose={handleCloseModal}
|
||||
title={getModalTitle()}
|
||||
animationType="fade"
|
||||
closeOnOverlayPress={true}
|
||||
showCloseButton={true}
|
||||
hideFooter={true}
|
||||
scrollable={false}
|
||||
noPadding={true}
|
||||
size="medium"
|
||||
>
|
||||
<BaseLanguageSelector
|
||||
languages={languageItems}
|
||||
selectedLanguages={selectedLanguages}
|
||||
onSelect={handleSelectionChange}
|
||||
mode="multi"
|
||||
showAutoDetect={true}
|
||||
height={450}
|
||||
priorityLanguages={['de', 'en', 'it', 'fr', 'es']}
|
||||
/>
|
||||
</BaseModal>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
// Standard-Modus: Nur das Modal anzeigen
|
||||
return (
|
||||
<BaseModal
|
||||
isVisible={modalVisible}
|
||||
onClose={handleCloseModal}
|
||||
title={getModalTitle()}
|
||||
animationType="fade"
|
||||
closeOnOverlayPress={true}
|
||||
showCloseButton={true}
|
||||
hideFooter={true}
|
||||
scrollable={false}
|
||||
noPadding={true}
|
||||
size="medium"
|
||||
>
|
||||
<BaseLanguageSelector
|
||||
languages={languageItems}
|
||||
selectedLanguages={selectedLanguages}
|
||||
onSelect={handleSelectionChange}
|
||||
mode="multi"
|
||||
showAutoDetect={true}
|
||||
height={450}
|
||||
priorityLanguages={['de', 'en', 'it', 'fr', 'es']}
|
||||
/>
|
||||
</BaseModal>
|
||||
);
|
||||
};
|
||||
|
||||
export default MultiLanguageSelector;
|
||||
|
|
@ -0,0 +1,136 @@
|
|||
import React from 'react';
|
||||
import { View, Linking, Platform } from 'react-native';
|
||||
import { useTheme } from '~/features/theme/ThemeProvider';
|
||||
import Text from '~/components/atoms/Text';
|
||||
import Button from '~/components/atoms/Button';
|
||||
import BaseModal from '~/components/atoms/BaseModal';
|
||||
import Icon from '~/components/atoms/Icon';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
interface PermissionDeniedModalProps {
|
||||
isVisible: boolean;
|
||||
onClose: () => void;
|
||||
canAskAgain: boolean;
|
||||
onRetry?: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Modal that is displayed when microphone permissions are denied.
|
||||
* Provides guidance to users on how to enable permissions manually in device settings.
|
||||
*/
|
||||
const PermissionDeniedModal: React.FC<PermissionDeniedModalProps> = ({
|
||||
isVisible,
|
||||
onClose,
|
||||
canAskAgain,
|
||||
onRetry,
|
||||
}) => {
|
||||
const { isDark, themeVariant } = useTheme();
|
||||
const { t } = useTranslation();
|
||||
|
||||
const handleOpenSettings = async () => {
|
||||
try {
|
||||
if (Platform.OS === 'ios') {
|
||||
await Linking.openURL('app-settings:');
|
||||
} else {
|
||||
await Linking.openSettings();
|
||||
}
|
||||
} catch (error) {
|
||||
console.debug('Error opening settings:', error);
|
||||
// Fallback: try to open general settings
|
||||
try {
|
||||
await Linking.openSettings();
|
||||
} catch (fallbackError) {
|
||||
console.debug('Error opening fallback settings:', fallbackError);
|
||||
}
|
||||
}
|
||||
onClose();
|
||||
};
|
||||
|
||||
const handleRetry = () => {
|
||||
onClose();
|
||||
// Call the retry callback if provided
|
||||
onRetry?.();
|
||||
};
|
||||
|
||||
const renderFooter = () => (
|
||||
<View style={{ flexDirection: 'row', justifyContent: 'space-between' }}>
|
||||
<Button
|
||||
title={t('common.cancel', 'Abbrechen')}
|
||||
onPress={onClose}
|
||||
variant='secondary'
|
||||
style={{ flex: 1, marginRight: 8 }}
|
||||
/>
|
||||
{canAskAgain ? (
|
||||
<Button
|
||||
title={t('permissions.retry', 'Erneut versuchen')}
|
||||
onPress={handleRetry}
|
||||
variant='primary'
|
||||
iconName='refresh-outline'
|
||||
style={{ flex: 1 }}
|
||||
/>
|
||||
) : (
|
||||
<Button
|
||||
title={t('permissions.open_settings', 'Zu Einstellungen')}
|
||||
onPress={handleOpenSettings}
|
||||
variant='primary'
|
||||
iconName='settings-outline'
|
||||
style={{ flex: 1 }}
|
||||
/>
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
|
||||
return (
|
||||
<BaseModal
|
||||
isVisible={isVisible}
|
||||
onClose={onClose}
|
||||
title={t('permissions.microphone_denied_title', 'Mikrofonberechtigung erforderlich')}
|
||||
animationType='fade'
|
||||
closeOnOverlayPress={true}
|
||||
footerContent={renderFooter()}
|
||||
>
|
||||
<View style={{ alignItems: 'center', paddingVertical: 16 }}>
|
||||
{/* Microphone Icon */}
|
||||
<View style={{ marginBottom: 16 }}>
|
||||
<Icon
|
||||
name="mic-off-outline"
|
||||
size={64}
|
||||
color={isDark ? '#FF6B6B' : '#EF4444'}
|
||||
/>
|
||||
</View>
|
||||
|
||||
{/* Main message */}
|
||||
<Text
|
||||
style={{
|
||||
textAlign: 'center',
|
||||
fontSize: 16,
|
||||
marginBottom: 12,
|
||||
color: isDark ? '#FFFFFF' : '#000000'
|
||||
}}
|
||||
>
|
||||
{canAskAgain
|
||||
? t('permissions.microphone_denied_message', 'Mikrofonzugriff ist erforderlich, um Audioaufnahmen zu erstellen.')
|
||||
: t('permissions.microphone_permanently_denied_message', 'Mikrofonzugriff wurde dauerhaft verweigert.')
|
||||
}
|
||||
</Text>
|
||||
|
||||
{/* Secondary message */}
|
||||
<Text
|
||||
style={{
|
||||
textAlign: 'center',
|
||||
fontSize: 14,
|
||||
opacity: 0.7,
|
||||
color: isDark ? '#FFFFFF' : '#000000'
|
||||
}}
|
||||
>
|
||||
{canAskAgain
|
||||
? t('permissions.microphone_denied_subtitle', 'Bitte gewähren Sie die Berechtigung, um fortzufahren.')
|
||||
: t('permissions.microphone_permanently_denied_subtitle', 'Bitte aktivieren Sie die Mikrofonberechtigung in den Einstellungen Ihres Geräts unter "Memoro" > "Berechtigungen".')
|
||||
}
|
||||
</Text>
|
||||
</View>
|
||||
</BaseModal>
|
||||
);
|
||||
};
|
||||
|
||||
export default PermissionDeniedModal;
|
||||
247
apps/memoro/apps/mobile/components/molecules/PillFilter.tsx
Normal file
247
apps/memoro/apps/mobile/components/molecules/PillFilter.tsx
Normal file
|
|
@ -0,0 +1,247 @@
|
|||
import React, { useMemo } from 'react';
|
||||
import { View, StyleSheet, ScrollView, Pressable } from 'react-native';
|
||||
import { useTheme } from '~/features/theme/ThemeProvider';
|
||||
import { router } from 'expo-router';
|
||||
import Text from '~/components/atoms/Text';
|
||||
import Pill from '~/components/atoms/Pill';
|
||||
import Icon from '~/components/atoms/Icon';
|
||||
import colors from '~/tailwind.config.js';
|
||||
|
||||
interface FilterItem {
|
||||
id: string;
|
||||
label: string;
|
||||
color?: string;
|
||||
}
|
||||
|
||||
interface FilterItem {
|
||||
id: string;
|
||||
label: string;
|
||||
color?: string;
|
||||
isPinned?: boolean;
|
||||
}
|
||||
|
||||
interface PillFilterProps {
|
||||
items: FilterItem[];
|
||||
selectedIds: string[];
|
||||
onSelectItem: (id: string) => void;
|
||||
isLoading?: boolean;
|
||||
error?: string | null;
|
||||
iconName?: string;
|
||||
iconNavigateTo?: string;
|
||||
showAllOption?: boolean;
|
||||
allOptionLabel?: string;
|
||||
iconType?: 'hashtag' | 'plus' | 'text';
|
||||
/**
|
||||
* If true, enables long press context menu for pills
|
||||
*/
|
||||
enableContextMenu?: boolean;
|
||||
/**
|
||||
* Called when the pin option is selected from the context menu
|
||||
*/
|
||||
onTogglePin?: (id: string) => void;
|
||||
/**
|
||||
* Called when the edit option is selected from the context menu
|
||||
*/
|
||||
onEdit?: (id: string) => void;
|
||||
/**
|
||||
* Called when the delete option is selected from the context menu
|
||||
*/
|
||||
onDelete?: (id: string) => void;
|
||||
/**
|
||||
* Label for the edit action in context menu (default: "Bearbeiten")
|
||||
*/
|
||||
editLabel?: string;
|
||||
/**
|
||||
* Bottom inset for safe area (e.g., for iOS devices with home indicator)
|
||||
*/
|
||||
bottomInset?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* PillFilter-Komponente
|
||||
*
|
||||
* Eine horizontal scrollbare Liste von Pill-Elementen, die für verschiedene Filtertypen verwendet werden kann.
|
||||
* Unterstützt Tags, Blueprints und andere Filtertypen mit konsistentem UI.
|
||||
*/
|
||||
const PillFilter: React.FC<PillFilterProps> = ({
|
||||
items,
|
||||
selectedIds,
|
||||
onSelectItem,
|
||||
isLoading = false,
|
||||
error = null,
|
||||
iconName,
|
||||
iconNavigateTo,
|
||||
showAllOption = true,
|
||||
allOptionLabel = 'Alle',
|
||||
iconType = 'hashtag',
|
||||
enableContextMenu = false,
|
||||
onTogglePin,
|
||||
onEdit,
|
||||
onDelete,
|
||||
editLabel,
|
||||
bottomInset = 0
|
||||
}) => {
|
||||
const { isDark, themeVariant, tw } = useTheme();
|
||||
|
||||
// Direkter Zugriff auf die Farben aus der Tailwind-Konfiguration
|
||||
const menuBackgroundColor = useMemo(() => {
|
||||
const themeColors = colors.theme?.extend?.colors as Record<string, any>;
|
||||
return isDark
|
||||
? themeColors?.dark?.[themeVariant]?.menuBackground || '#252525'
|
||||
: themeColors?.[themeVariant]?.menuBackground || '#FFFFFF';
|
||||
}, [isDark, themeVariant]);
|
||||
|
||||
const borderColor = useMemo(() => {
|
||||
const themeColors = colors.theme?.extend?.colors as Record<string, any>;
|
||||
return isDark
|
||||
? themeColors?.dark?.[themeVariant]?.border || '#424242'
|
||||
: themeColors?.[themeVariant]?.border || '#e6e6e6';
|
||||
}, [isDark, themeVariant]);
|
||||
|
||||
const themeColor = useMemo(() => {
|
||||
const themeColors = colors.theme?.extend?.colors as Record<string, any>;
|
||||
return isDark
|
||||
? themeColors?.dark?.[themeVariant]?.primary || '#f8d62b'
|
||||
: themeColors?.[themeVariant]?.primary || '#f8d62b';
|
||||
}, [isDark, themeVariant]);
|
||||
|
||||
const handleIconPress = () => {
|
||||
if (iconNavigateTo) {
|
||||
router.push(iconNavigateTo as any);
|
||||
}
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return <PillFilterSkeleton isDark={isDark} themeVariant={themeVariant} menuBackgroundColor={menuBackgroundColor} />;
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<View style={styles.errorContainer}>
|
||||
<Text style={{ color: isDark ? '#FFFFFF' : '#000000', fontSize: 12 }}>
|
||||
{error}
|
||||
</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
// Removed debug log for items
|
||||
|
||||
if (items.length === 0 && !showAllOption) {
|
||||
// Removed debug log for empty items
|
||||
return null; // Don't show anything if there are no items and no "all" option
|
||||
}
|
||||
|
||||
|
||||
return (
|
||||
<View style={[
|
||||
styles.container,
|
||||
{
|
||||
backgroundColor: menuBackgroundColor,
|
||||
}
|
||||
]}>
|
||||
<ScrollView
|
||||
horizontal
|
||||
showsHorizontalScrollIndicator={false}
|
||||
contentContainerStyle={[
|
||||
styles.scrollContent,
|
||||
{ paddingBottom: bottomInset }
|
||||
]}
|
||||
>
|
||||
{iconName && (
|
||||
<Pressable onPress={handleIconPress} style={styles.iconButton}>
|
||||
<Icon name={iconName} size={24} color={isDark ? '#FFFFFF' : '#000000'} />
|
||||
</Pressable>
|
||||
)}
|
||||
|
||||
{showAllOption && (
|
||||
<Pill
|
||||
label={allOptionLabel}
|
||||
isSelected={selectedIds.length === 0}
|
||||
onPress={() => onSelectItem('all')}
|
||||
style={styles.pill}
|
||||
size="large"
|
||||
/>
|
||||
)}
|
||||
|
||||
{items.length > 0 && (
|
||||
items.map(item => {
|
||||
return (
|
||||
<Pill
|
||||
key={item.id}
|
||||
label={item.label}
|
||||
isSelected={selectedIds.includes(item.id)}
|
||||
onPress={() => onSelectItem(item.id)}
|
||||
style={styles.pill}
|
||||
size="large"
|
||||
{...(selectedIds.includes(item.id) && item.color ? { color: item.color } : {})}
|
||||
enableContextMenu={enableContextMenu}
|
||||
isPinned={item.isPinned}
|
||||
onTogglePin={onTogglePin ? () => onTogglePin(item.id) : undefined}
|
||||
onEdit={onEdit ? () => onEdit(item.id) : undefined}
|
||||
onDelete={onDelete ? () => onDelete(item.id) : undefined}
|
||||
editLabel={editLabel}
|
||||
/>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</ScrollView>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* PillFilterSkeleton-Komponente
|
||||
*
|
||||
* Zeigt einen einfachen Skeleton-Loader für die PillFilter-Komponente an.
|
||||
* Zeigt nur den Hintergrund ohne Platzhalter für Buttons oder Icons.
|
||||
*/
|
||||
const PillFilterSkeleton: React.FC<{ isDark: boolean; themeVariant: string; menuBackgroundColor: string }> = ({ isDark, themeVariant, menuBackgroundColor }) => {
|
||||
const themeColors = colors.theme?.extend?.colors as Record<string, any>;
|
||||
const borderColor = isDark
|
||||
? themeColors?.dark?.[themeVariant]?.border || '#424242'
|
||||
: themeColors?.[themeVariant]?.border || '#e6e6e6';
|
||||
|
||||
return (
|
||||
<View style={[
|
||||
styles.container,
|
||||
{
|
||||
backgroundColor: menuBackgroundColor,
|
||||
minHeight: 50 // Minimale Höhe für den Container
|
||||
}
|
||||
]} />
|
||||
);
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
width: '100%',
|
||||
paddingVertical: 10,
|
||||
},
|
||||
iconButton: {
|
||||
padding: 8,
|
||||
borderRadius: 20,
|
||||
marginRight: 8,
|
||||
marginLeft: 16,
|
||||
},
|
||||
scrollContent: {
|
||||
paddingRight: 16,
|
||||
paddingLeft: 16,
|
||||
},
|
||||
pill: {
|
||||
marginHorizontal: 4,
|
||||
},
|
||||
|
||||
loadingContainer: {
|
||||
height: 50,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
errorContainer: {
|
||||
height: 50,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
});
|
||||
|
||||
export default PillFilter;
|
||||
280
apps/memoro/apps/mobile/components/molecules/PromptBar.tsx
Normal file
280
apps/memoro/apps/mobile/components/molecules/PromptBar.tsx
Normal file
|
|
@ -0,0 +1,280 @@
|
|||
import React, { useState } from 'react';
|
||||
import { View, StyleSheet, Pressable, TextInput, ActivityIndicator } from 'react-native';
|
||||
import { useTheme } from '~/features/theme/ThemeProvider';
|
||||
import Icon from '~/components/atoms/Icon';
|
||||
import Text from '~/components/atoms/Text';
|
||||
import ManaIcon from '~/features/subscription/ManaIcon';
|
||||
import colors from '~/tailwind.config.js';
|
||||
|
||||
interface PromptBarProps {
|
||||
onSubmit: (prompt: string) => void;
|
||||
placeholder?: string;
|
||||
disabled?: boolean;
|
||||
initialValue?: string;
|
||||
autoFocus?: boolean;
|
||||
inputRef?: React.RefObject<TextInput | null>;
|
||||
isLoading?: boolean;
|
||||
loadingText?: string;
|
||||
onClose?: () => void;
|
||||
showCloseButton?: boolean;
|
||||
manaCost?: number;
|
||||
manaCostLabel?: string;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* PromptBar Component
|
||||
*
|
||||
* An input field with a submit button for sending prompts or messages.
|
||||
* Styled similarly to PillFilter for consistent UI.
|
||||
* Positioned sticky at the bottom of the screen.
|
||||
*/
|
||||
const PromptBar: React.FC<PromptBarProps> = ({
|
||||
onSubmit,
|
||||
placeholder = 'Prompt eingeben...',
|
||||
disabled = false,
|
||||
initialValue = '',
|
||||
autoFocus = false,
|
||||
inputRef,
|
||||
isLoading = false,
|
||||
loadingText = 'Antwort wird generiert...',
|
||||
onClose,
|
||||
showCloseButton = false,
|
||||
manaCost,
|
||||
manaCostLabel,
|
||||
}) => {
|
||||
// Erstelle einen internen Ref, wenn keiner von außen übergeben wurde
|
||||
const internalInputRef = React.useRef<TextInput>(null);
|
||||
const textInputRef = inputRef || internalInputRef;
|
||||
const { isDark, themeVariant, tw } = useTheme();
|
||||
const [inputValue, setInputValue] = useState(initialValue);
|
||||
// Direct access to colors from Tailwind config
|
||||
const menuBackgroundColor = React.useMemo(() => {
|
||||
const themeColors = colors.theme?.extend?.colors as Record<string, any>;
|
||||
return isDark
|
||||
? themeColors?.dark?.[themeVariant]?.menuBackground || '#252525'
|
||||
: themeColors?.[themeVariant]?.menuBackground || '#FFFFFF';
|
||||
}, [isDark, themeVariant]);
|
||||
|
||||
// Get border colors from theme
|
||||
const borderColor = React.useMemo(() => {
|
||||
const themeColors = colors.theme?.extend?.colors as Record<string, any>;
|
||||
return isDark
|
||||
? themeColors?.dark?.[themeVariant]?.border || '#424242'
|
||||
: themeColors?.[themeVariant]?.border || '#e6e6e6';
|
||||
}, [isDark, themeVariant]);
|
||||
|
||||
const borderLightColor = React.useMemo(() => {
|
||||
const themeColors = colors.theme?.extend?.colors as Record<string, any>;
|
||||
return isDark
|
||||
? themeColors?.dark?.[themeVariant]?.borderLight || '#333333'
|
||||
: themeColors?.[themeVariant]?.borderLight || '#f2f2f2';
|
||||
}, [isDark, themeVariant]);
|
||||
|
||||
const borderStrongColor = React.useMemo(() => {
|
||||
const themeColors = colors.theme?.extend?.colors as Record<string, any>;
|
||||
return isDark
|
||||
? themeColors?.dark?.[themeVariant]?.borderStrong || '#616161'
|
||||
: themeColors?.[themeVariant]?.borderStrong || '#cccccc';
|
||||
}, [isDark, themeVariant]);
|
||||
|
||||
const handleSubmit = () => {
|
||||
if (inputValue.trim() && !disabled && !isLoading) {
|
||||
onSubmit(inputValue.trim());
|
||||
setInputValue('');
|
||||
}
|
||||
};
|
||||
|
||||
// Fokussiere das Eingabefeld, wenn autoFocus true ist
|
||||
React.useEffect(() => {
|
||||
if (autoFocus && textInputRef.current) {
|
||||
// Verzögerung, um sicherzustellen, dass die Komponente vollständig gerendert ist
|
||||
const timer = setTimeout(() => {
|
||||
textInputRef.current?.focus();
|
||||
}, 300);
|
||||
|
||||
return () => clearTimeout(timer);
|
||||
}
|
||||
}, [autoFocus]);
|
||||
|
||||
return (
|
||||
<View style={[
|
||||
styles.container,
|
||||
{
|
||||
backgroundColor: menuBackgroundColor,
|
||||
borderTopWidth: 1,
|
||||
borderColor: borderColor,
|
||||
shadowColor: '#000',
|
||||
shadowOffset: { width: 0, height: -2 },
|
||||
shadowOpacity: isDark ? 0.3 : 0.1,
|
||||
shadowRadius: 3,
|
||||
elevation: 5,
|
||||
}
|
||||
]}>
|
||||
{/* Mana cost display with close button in same row */}
|
||||
{(manaCost !== undefined || manaCostLabel || (showCloseButton && onClose)) && (
|
||||
<View style={styles.manaCostContainer}>
|
||||
{/* Spacer for left side to center the mana costs */}
|
||||
<View style={styles.spacer} />
|
||||
|
||||
{/* Centered mana cost content */}
|
||||
<View style={styles.manaCostContent}>
|
||||
{manaCost !== undefined && <ManaIcon size={16} color="#0099FF" />}
|
||||
{manaCost !== undefined && (
|
||||
<Text style={[styles.manaCostText, { color: '#0099FF' }]}>
|
||||
{manaCost}
|
||||
</Text>
|
||||
)}
|
||||
{manaCostLabel && (
|
||||
<Text style={[styles.manaCostLabel, { color: isDark ? '#888' : '#666' }]}>
|
||||
{manaCostLabel}
|
||||
</Text>
|
||||
)}
|
||||
</View>
|
||||
|
||||
{/* Close button on the right */}
|
||||
<View style={styles.rightSection}>
|
||||
{showCloseButton && onClose ? (
|
||||
<Pressable
|
||||
onPress={onClose}
|
||||
style={({ pressed }) => [
|
||||
styles.inlineCloseButton,
|
||||
pressed && styles.closeButtonPressed
|
||||
]}
|
||||
>
|
||||
<Icon
|
||||
name="close"
|
||||
size={20}
|
||||
color={isDark ? 'rgba(255, 255, 255, 0.6)' : 'rgba(0, 0, 0, 0.6)'}
|
||||
/>
|
||||
</Pressable>
|
||||
) : (
|
||||
<View style={styles.spacer} />
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
)}
|
||||
|
||||
<View style={[styles.inputContainer, { borderColor: borderLightColor }]}>
|
||||
<TextInput
|
||||
ref={textInputRef}
|
||||
style={[
|
||||
styles.input,
|
||||
{ color: isDark ? '#FFFFFF' : '#000000' }
|
||||
]}
|
||||
value={inputValue}
|
||||
onChangeText={setInputValue}
|
||||
placeholder={placeholder}
|
||||
placeholderTextColor={isDark ? 'rgba(255, 255, 255, 0.5)' : 'rgba(0, 0, 0, 0.5)'}
|
||||
editable={!disabled && !isLoading}
|
||||
multiline={false}
|
||||
returnKeyType="send"
|
||||
onSubmitEditing={handleSubmit}
|
||||
autoFocus={autoFocus}
|
||||
/>
|
||||
|
||||
<Pressable
|
||||
onPress={handleSubmit}
|
||||
style={({ pressed }) => [
|
||||
styles.sendButton,
|
||||
pressed && styles.sendButtonPressed,
|
||||
(disabled || isLoading) && styles.sendButtonDisabled
|
||||
]}
|
||||
disabled={disabled || isLoading || !inputValue.trim()}
|
||||
>
|
||||
{isLoading ? (
|
||||
<ActivityIndicator size={16} color={isDark ? 'rgba(255, 255, 255, 0.6)' : 'rgba(0, 0, 0, 0.6)'} />
|
||||
) : (
|
||||
<Icon
|
||||
name="send"
|
||||
size={20}
|
||||
color={
|
||||
disabled || isLoading || !inputValue.trim()
|
||||
? isDark ? 'rgba(255, 255, 255, 0.3)' : 'rgba(0, 0, 0, 0.3)'
|
||||
: isDark ? '#FFFFFF' : '#000000'
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</Pressable>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
width: '100%',
|
||||
paddingVertical: 12,
|
||||
paddingHorizontal: 16,
|
||||
},
|
||||
manaCostContainer: {
|
||||
width: '100%',
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
marginBottom: 8,
|
||||
paddingVertical: 8,
|
||||
},
|
||||
spacer: {
|
||||
flex: 1,
|
||||
},
|
||||
manaCostContent: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
flex: 1,
|
||||
},
|
||||
rightSection: {
|
||||
flex: 1,
|
||||
alignItems: 'flex-end',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
inlineCloseButton: {
|
||||
padding: 8,
|
||||
borderRadius: 20,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
},
|
||||
manaCostText: {
|
||||
marginLeft: 6,
|
||||
fontSize: 14,
|
||||
fontWeight: '600',
|
||||
},
|
||||
manaCostLabel: {
|
||||
marginLeft: 4,
|
||||
fontSize: 12,
|
||||
},
|
||||
inputContainer: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
backgroundColor: 'rgba(0, 0, 0, 0.05)',
|
||||
borderRadius: 24,
|
||||
paddingHorizontal: 16,
|
||||
paddingVertical: 10,
|
||||
borderWidth: 1,
|
||||
},
|
||||
input: {
|
||||
flex: 1,
|
||||
fontSize: 16,
|
||||
paddingVertical: 4,
|
||||
minHeight: 24,
|
||||
},
|
||||
closeButtonPressed: {
|
||||
opacity: 0.7,
|
||||
},
|
||||
sendButton: {
|
||||
padding: 8,
|
||||
borderRadius: 20,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
marginLeft: 4,
|
||||
},
|
||||
sendButtonPressed: {
|
||||
opacity: 0.7,
|
||||
},
|
||||
sendButtonDisabled: {
|
||||
opacity: 0.5,
|
||||
},
|
||||
});
|
||||
|
||||
export default PromptBar;
|
||||
|
|
@ -0,0 +1,199 @@
|
|||
import React, { useState } from 'react';
|
||||
import { View, StyleSheet, Pressable, TextInput, Platform, KeyboardAvoidingView } from 'react-native';
|
||||
import { useTheme } from '~/features/theme/ThemeProvider';
|
||||
import Icon from '~/components/atoms/Icon';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import colors from '~/tailwind.config.js';
|
||||
|
||||
interface PromptCreationBarProps {
|
||||
onSubmit: (title: string, promptText: string) => void;
|
||||
titlePlaceholder?: string;
|
||||
promptPlaceholder?: string;
|
||||
disabled?: boolean;
|
||||
initialTitle?: string;
|
||||
initialPrompt?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* PromptCreationBar Component
|
||||
*
|
||||
* A dual input field with a submit button for creating new prompts.
|
||||
* Has separate fields for title and prompt text.
|
||||
* Styled similarly to PillFilter for consistent UI.
|
||||
* Positioned sticky at the bottom of the screen.
|
||||
*/
|
||||
const PromptCreationBar: React.FC<PromptCreationBarProps> = ({
|
||||
onSubmit,
|
||||
titlePlaceholder = 'Titel eingeben...',
|
||||
promptPlaceholder = 'Prompt eingeben...',
|
||||
disabled = false,
|
||||
initialTitle = '',
|
||||
initialPrompt = '',
|
||||
}) => {
|
||||
const { isDark, themeVariant } = useTheme();
|
||||
const { t } = useTranslation();
|
||||
const [titleValue, setTitleValue] = useState(initialTitle);
|
||||
const [promptValue, setPromptValue] = useState(initialPrompt);
|
||||
|
||||
// Direct access to colors from Tailwind config
|
||||
const menuBackgroundColor = React.useMemo(() => {
|
||||
const themeColors = colors.theme?.extend?.colors as Record<string, any>;
|
||||
return isDark
|
||||
? themeColors?.dark?.[themeVariant]?.menuBackground || '#252525'
|
||||
: themeColors?.[themeVariant]?.menuBackground || '#FFFFFF';
|
||||
}, [isDark, themeVariant]);
|
||||
|
||||
// Get border colors from theme
|
||||
const borderColor = React.useMemo(() => {
|
||||
const themeColors = colors.theme?.extend?.colors as Record<string, any>;
|
||||
return isDark
|
||||
? themeColors?.dark?.[themeVariant]?.border || '#424242'
|
||||
: themeColors?.[themeVariant]?.border || '#e6e6e6';
|
||||
}, [isDark, themeVariant]);
|
||||
|
||||
const borderLightColor = React.useMemo(() => {
|
||||
const themeColors = colors.theme?.extend?.colors as Record<string, any>;
|
||||
return isDark
|
||||
? themeColors?.dark?.[themeVariant]?.borderLight || '#333333'
|
||||
: themeColors?.[themeVariant]?.borderLight || '#f2f2f2';
|
||||
}, [isDark, themeVariant]);
|
||||
|
||||
const handleSubmit = () => {
|
||||
if (titleValue.trim() && promptValue.trim() && !disabled) {
|
||||
onSubmit(titleValue.trim(), promptValue.trim());
|
||||
setTitleValue('');
|
||||
setPromptValue('');
|
||||
}
|
||||
};
|
||||
|
||||
const isSubmitDisabled = disabled || !titleValue.trim() || !promptValue.trim();
|
||||
|
||||
return (
|
||||
<KeyboardAvoidingView
|
||||
behavior={Platform.OS === 'ios' ? 'padding' : 'height'}
|
||||
keyboardVerticalOffset={Platform.OS === 'ios' ? 10 : 0}
|
||||
style={{
|
||||
width: '100%',
|
||||
}}
|
||||
>
|
||||
<View style={[
|
||||
styles.container,
|
||||
{
|
||||
backgroundColor: menuBackgroundColor,
|
||||
borderTopWidth: 1,
|
||||
borderColor: borderColor,
|
||||
shadowColor: '#000',
|
||||
shadowOffset: { width: 0, height: -2 },
|
||||
shadowOpacity: isDark ? 0.3 : 0.1,
|
||||
shadowRadius: 3,
|
||||
elevation: 5,
|
||||
}
|
||||
]}>
|
||||
<View style={[styles.inputsContainer]}>
|
||||
{/* Title Input */}
|
||||
<View style={[styles.inputContainer, { borderColor: borderLightColor, marginBottom: 8 }]}>
|
||||
<TextInput
|
||||
style={[
|
||||
styles.input,
|
||||
{ color: isDark ? '#FFFFFF' : '#000000' }
|
||||
]}
|
||||
value={titleValue}
|
||||
onChangeText={setTitleValue}
|
||||
placeholder={titlePlaceholder}
|
||||
placeholderTextColor={isDark ? 'rgba(255, 255, 255, 0.5)' : 'rgba(0, 0, 0, 0.5)'}
|
||||
editable={!disabled}
|
||||
multiline={false}
|
||||
returnKeyType="next"
|
||||
/>
|
||||
</View>
|
||||
|
||||
{/* Prompt Input */}
|
||||
<View style={[styles.inputContainer, { borderColor: borderLightColor }]}>
|
||||
<TextInput
|
||||
style={[
|
||||
styles.input,
|
||||
{ color: isDark ? '#FFFFFF' : '#000000' }
|
||||
]}
|
||||
value={promptValue}
|
||||
onChangeText={setPromptValue}
|
||||
placeholder={promptPlaceholder}
|
||||
placeholderTextColor={isDark ? 'rgba(255, 255, 255, 0.5)' : 'rgba(0, 0, 0, 0.5)'}
|
||||
editable={!disabled}
|
||||
multiline={true}
|
||||
numberOfLines={2}
|
||||
textAlignVertical="top"
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* Submit Button */}
|
||||
<Pressable
|
||||
onPress={handleSubmit}
|
||||
style={({ pressed }) => [
|
||||
styles.submitButton,
|
||||
pressed && styles.submitButtonPressed,
|
||||
isSubmitDisabled && styles.submitButtonDisabled
|
||||
]}
|
||||
disabled={isSubmitDisabled}
|
||||
>
|
||||
<Icon
|
||||
name="add-circle-outline"
|
||||
size={24}
|
||||
color={
|
||||
isSubmitDisabled
|
||||
? isDark ? 'rgba(255, 255, 255, 0.3)' : 'rgba(0, 0, 0, 0.3)'
|
||||
: isDark ? '#FFFFFF' : '#000000'
|
||||
}
|
||||
/>
|
||||
</Pressable>
|
||||
</View>
|
||||
</KeyboardAvoidingView>
|
||||
);
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
width: '100%',
|
||||
paddingVertical: 12,
|
||||
paddingHorizontal: 16,
|
||||
flexDirection: 'row',
|
||||
alignItems: 'flex-end',
|
||||
},
|
||||
inputsContainer: {
|
||||
flex: 1,
|
||||
marginRight: 12,
|
||||
},
|
||||
inputContainer: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
backgroundColor: 'rgba(0, 0, 0, 0.05)',
|
||||
borderRadius: 16,
|
||||
paddingHorizontal: 16,
|
||||
paddingVertical: 10,
|
||||
borderWidth: 1,
|
||||
},
|
||||
input: {
|
||||
flex: 1,
|
||||
fontSize: 16,
|
||||
paddingVertical: 4,
|
||||
minHeight: 24,
|
||||
},
|
||||
submitButton: {
|
||||
padding: 12,
|
||||
borderRadius: 24,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
backgroundColor: '#FF9500',
|
||||
width: 48,
|
||||
height: 48,
|
||||
},
|
||||
submitButtonPressed: {
|
||||
opacity: 0.7,
|
||||
},
|
||||
submitButtonDisabled: {
|
||||
opacity: 0.5,
|
||||
backgroundColor: 'rgba(0, 0, 0, 0.1)',
|
||||
},
|
||||
});
|
||||
|
||||
export default PromptCreationBar;
|
||||
261
apps/memoro/apps/mobile/components/molecules/PromptPreview.tsx
Normal file
261
apps/memoro/apps/mobile/components/molecules/PromptPreview.tsx
Normal file
|
|
@ -0,0 +1,261 @@
|
|||
import React from 'react';
|
||||
import { View, Pressable, Platform } from 'react-native';
|
||||
import { useTheme } from '~/features/theme/ThemeProvider';
|
||||
import Text from '~/components/atoms/Text';
|
||||
import Icon from '~/components/atoms/Icon';
|
||||
import colors from '~/tailwind.config.js';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { getTheme } from '~/features/theme/constants';
|
||||
|
||||
// @expo/ui ContextMenu - use appropriate platform import
|
||||
import { ContextMenu as ContextMenuiOS, Button as ButtoniOS, Host as HostiOS } from '@expo/ui/swift-ui';
|
||||
import { ContextMenu as ContextMenuAndroid, Button as ButtonAndroid } from '@expo/ui/jetpack-compose';
|
||||
|
||||
// Select the correct components based on platform
|
||||
const ContextMenu = Platform.OS === 'ios' ? ContextMenuiOS : ContextMenuAndroid;
|
||||
const ExpoButton = Platform.OS === 'ios' ? ButtoniOS : ButtonAndroid;
|
||||
const Host = Platform.OS === 'ios' ? HostiOS : View;
|
||||
|
||||
interface Blueprint {
|
||||
id: string;
|
||||
name: {
|
||||
[key: string]: string;
|
||||
};
|
||||
color?: string;
|
||||
}
|
||||
|
||||
interface PromptModel {
|
||||
id: string;
|
||||
prompt_text: {
|
||||
[key: string]: string;
|
||||
};
|
||||
memory_title: {
|
||||
[key: string]: string;
|
||||
};
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
blueprints?: Blueprint[];
|
||||
is_public?: boolean;
|
||||
}
|
||||
|
||||
interface PromptPreviewProps {
|
||||
prompt: PromptModel;
|
||||
onPress?: () => void;
|
||||
onShare?: () => void;
|
||||
onCopy?: () => void;
|
||||
isLoading?: boolean;
|
||||
isSelected?: boolean;
|
||||
disableContextMenu?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* PromptPreviewSkeleton-Komponente
|
||||
*
|
||||
* Zeigt einen einfachen Skeleton-Loader für die PromptPreview-Komponente an.
|
||||
*/
|
||||
const PromptPreviewSkeleton: React.FC<{ isDark: boolean; themeVariant: string }> = ({ isDark, themeVariant }) => {
|
||||
// Container-Style mit Hintergrundfarbe aus der Tailwind-Konfiguration
|
||||
const getContainerStyle = () => {
|
||||
// Direkter Zugriff auf die Farben aus der Tailwind-Konfiguration
|
||||
const backgroundColor = isDark
|
||||
? ((colors as any).theme?.extend?.colors?.dark)?.[themeVariant]?.contentBackground || '#1E1E1E'
|
||||
: ((colors as any).theme?.extend?.colors)?.[themeVariant]?.contentBackground || '#FFFFFF';
|
||||
|
||||
return {
|
||||
backgroundColor,
|
||||
borderRadius: 12,
|
||||
height: 140,
|
||||
padding: 16,
|
||||
marginLeft: 16,
|
||||
marginRight: 16,
|
||||
flexShrink: 0,
|
||||
};
|
||||
};
|
||||
|
||||
return <View style={getContainerStyle()} />;
|
||||
};
|
||||
|
||||
/**
|
||||
* PromptPreview-Komponente
|
||||
*
|
||||
* Zeigt eine Vorschau eines Prompts mit Titel, Text und verknüpften Blueprints an.
|
||||
*/
|
||||
const PromptPreview: React.FC<PromptPreviewProps> = ({
|
||||
prompt,
|
||||
onPress,
|
||||
onShare,
|
||||
onCopy,
|
||||
isLoading = false,
|
||||
isSelected = false,
|
||||
disableContextMenu = false
|
||||
}) => {
|
||||
const { isDark, themeVariant, colorScheme, colors: themeColors } = useTheme();
|
||||
const theme = getTheme(colorScheme, themeVariant);
|
||||
const { i18n } = useTranslation();
|
||||
const currentLanguage = i18n.language || 'en';
|
||||
|
||||
// Icon-Farbe basierend auf Theme (weiß im Dark Mode, dunkel im Light Mode)
|
||||
const iconColor = '#AEAEB2'; // Light gray icon color for both light and dark mode
|
||||
|
||||
// Wenn das Laden aktiv ist, zeige den Skeleton-Loader an
|
||||
if (isLoading) {
|
||||
return <PromptPreviewSkeleton isDark={isDark} themeVariant={themeVariant} />;
|
||||
}
|
||||
|
||||
// Extrahiere den Titel und Text in der aktuellen Sprache
|
||||
const title = prompt.memory_title?.[currentLanguage] || prompt.memory_title?.en || '';
|
||||
const promptText = prompt.prompt_text?.[currentLanguage] || prompt.prompt_text?.en || '';
|
||||
|
||||
// Formatiere das Datum
|
||||
const formattedDate = new Date(prompt.created_at).toLocaleDateString(currentLanguage, {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
});
|
||||
|
||||
// Container-Style mit Hintergrundfarbe aus der Tailwind-Konfiguration
|
||||
const getContainerStyle = () => {
|
||||
// Direkter Zugriff auf die Farben aus der Tailwind-Konfiguration
|
||||
const backgroundColor = isDark
|
||||
? ((colors as any).theme?.extend?.colors?.dark)?.[themeVariant]?.contentBackground || '#1E1E1E'
|
||||
: ((colors as any).theme?.extend?.colors)?.[themeVariant]?.contentBackground || '#FFFFFF';
|
||||
|
||||
// Keep original background color regardless of selection
|
||||
|
||||
return {
|
||||
backgroundColor,
|
||||
borderRadius: 12,
|
||||
padding: 16,
|
||||
flexShrink: 0,
|
||||
overflow: 'visible',
|
||||
shadowColor: isDark ? 'rgba(0, 0, 0, 0.3)' : 'rgba(0, 0, 0, 0.1)',
|
||||
shadowOffset: { width: 0, height: 2 },
|
||||
shadowOpacity: 0.8,
|
||||
shadowRadius: 2,
|
||||
elevation: 2,
|
||||
};
|
||||
};
|
||||
|
||||
// Text-Style basierend auf dem Theme
|
||||
const getTextStyle = (isTitle = false) => {
|
||||
const textColor = isDark ? '#FFFFFF' : '#000000';
|
||||
|
||||
return {
|
||||
color: textColor,
|
||||
fontSize: isTitle ? 16 : 14,
|
||||
fontWeight: isTitle ? 'bold' : 'normal' as 'bold' | 'normal',
|
||||
marginBottom: isTitle ? 8 : 4,
|
||||
};
|
||||
};
|
||||
|
||||
// Menu items
|
||||
const menuItems = [
|
||||
{
|
||||
key: 'copy',
|
||||
title: 'Kopieren',
|
||||
systemIcon: 'doc.on.doc',
|
||||
onSelect: onCopy,
|
||||
},
|
||||
{
|
||||
key: 'share',
|
||||
title: 'Teilen',
|
||||
systemIcon: 'square.and.arrow.up',
|
||||
onSelect: onShare,
|
||||
},
|
||||
];
|
||||
|
||||
|
||||
// Render-Funktion für die Blueprints-Liste
|
||||
const renderBlueprints = () => {
|
||||
if (!prompt.blueprints || prompt.blueprints.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<View style={{ marginTop: 8 }}>
|
||||
<Text style={{ fontSize: 14, color: isDark ? '#AAAAAA' : '#666666' }}>
|
||||
{prompt.blueprints.map((blueprint) =>
|
||||
blueprint.name?.[currentLanguage] || blueprint.name?.en || ''
|
||||
).join(', ')}
|
||||
</Text>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
// Hauptinhalt der Komponente
|
||||
const content = (
|
||||
<View style={getContainerStyle()}>
|
||||
{/* Checkmark für ausgewählte Elemente */}
|
||||
{isSelected && (
|
||||
<View style={{
|
||||
position: 'absolute',
|
||||
top: -8,
|
||||
right: -8,
|
||||
backgroundColor: theme.colors.primary,
|
||||
borderRadius: 12,
|
||||
width: 24,
|
||||
height: 24,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
zIndex: 1
|
||||
}}>
|
||||
<Icon
|
||||
name="checkmark"
|
||||
size={16}
|
||||
color={theme.colors.textOnPrimary}
|
||||
/>
|
||||
</View>
|
||||
)}
|
||||
|
||||
<View style={{ marginBottom: 4 }}>
|
||||
<Text style={getTextStyle(true)}>{title}</Text>
|
||||
</View>
|
||||
|
||||
<Text
|
||||
style={[getTextStyle(), { marginBottom: 8 }]}
|
||||
numberOfLines={2}
|
||||
>
|
||||
{promptText}
|
||||
</Text>
|
||||
|
||||
{renderBlueprints()}
|
||||
|
||||
</View>
|
||||
);
|
||||
|
||||
// Wenn Kontextmenü deaktiviert ist, verwende einfaches Pressable
|
||||
if (disableContextMenu) {
|
||||
return (
|
||||
<Pressable onPress={onPress}>
|
||||
{content}
|
||||
</Pressable>
|
||||
);
|
||||
}
|
||||
|
||||
// Verwende @expo/ui ContextMenu
|
||||
return (
|
||||
<Host>
|
||||
<ContextMenu>
|
||||
<ContextMenu.Items>
|
||||
{menuItems.map((item) => (
|
||||
<ExpoButton
|
||||
key={item.key}
|
||||
{...(Platform.OS === 'ios' && { systemImage: item.systemIcon })}
|
||||
onPress={item.onSelect}
|
||||
>
|
||||
{item.title}
|
||||
</ExpoButton>
|
||||
))}
|
||||
</ContextMenu.Items>
|
||||
|
||||
<ContextMenu.Trigger>
|
||||
<Pressable onPress={onPress}>
|
||||
{content}
|
||||
</Pressable>
|
||||
</ContextMenu.Trigger>
|
||||
</ContextMenu>
|
||||
</Host>
|
||||
);
|
||||
};
|
||||
|
||||
export default PromptPreview;
|
||||
|
|
@ -0,0 +1,74 @@
|
|||
import React, { useEffect, useRef } from 'react';
|
||||
import { View, Animated, ViewStyle } from 'react-native';
|
||||
import MemoroLogo from '~/components/atoms/MemoroLogo';
|
||||
import { useTheme } from '~/features/theme/ThemeProvider';
|
||||
|
||||
interface PulsingLogoAnimationProps {
|
||||
size?: number;
|
||||
style?: ViewStyle;
|
||||
color?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Einfache pulsierende Logo-Animation
|
||||
* Minimalistisch und elegant - perfekt für Loading-States
|
||||
*/
|
||||
export function PulsingLogoAnimation({
|
||||
size = 80,
|
||||
style,
|
||||
color
|
||||
}: PulsingLogoAnimationProps) {
|
||||
const { colors } = useTheme();
|
||||
const scaleAnim = useRef(new Animated.Value(1)).current;
|
||||
const opacityAnim = useRef(new Animated.Value(1)).current;
|
||||
|
||||
const logoColor = color || colors.primary;
|
||||
|
||||
useEffect(() => {
|
||||
// Kombinierte Scale + Opacity Animation
|
||||
const animation = Animated.loop(
|
||||
Animated.sequence([
|
||||
Animated.parallel([
|
||||
Animated.timing(scaleAnim, {
|
||||
toValue: 1.1,
|
||||
duration: 1000,
|
||||
useNativeDriver: true,
|
||||
}),
|
||||
Animated.timing(opacityAnim, {
|
||||
toValue: 0.6,
|
||||
duration: 1000,
|
||||
useNativeDriver: true,
|
||||
}),
|
||||
]),
|
||||
Animated.parallel([
|
||||
Animated.timing(scaleAnim, {
|
||||
toValue: 1,
|
||||
duration: 1000,
|
||||
useNativeDriver: true,
|
||||
}),
|
||||
Animated.timing(opacityAnim, {
|
||||
toValue: 1,
|
||||
duration: 1000,
|
||||
useNativeDriver: true,
|
||||
}),
|
||||
]),
|
||||
])
|
||||
);
|
||||
|
||||
animation.start();
|
||||
|
||||
return () => animation.stop();
|
||||
}, [scaleAnim, opacityAnim]);
|
||||
|
||||
return (
|
||||
<View style={[{ alignItems: 'center', justifyContent: 'center' }, style]}>
|
||||
<Animated.View
|
||||
style={{
|
||||
transform: [{ scale: scaleAnim }],
|
||||
opacity: opacityAnim,
|
||||
}}>
|
||||
<MemoroLogo size={size} color={logoColor} />
|
||||
</Animated.View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
324
apps/memoro/apps/mobile/components/molecules/RecordingBar.tsx
Normal file
324
apps/memoro/apps/mobile/components/molecules/RecordingBar.tsx
Normal file
|
|
@ -0,0 +1,324 @@
|
|||
import React, { useEffect, useCallback, useRef, useState } from 'react';
|
||||
import { View, StyleSheet, Pressable } from 'react-native';
|
||||
import Animated, { useAnimatedStyle, useSharedValue, withTiming, withSpring, interpolateColor, Easing, cancelAnimation } from 'react-native-reanimated';
|
||||
import * as Haptics from 'expo-haptics';
|
||||
import { useTheme } from '~/features/theme/ThemeProvider';
|
||||
import { RecordingStatus } from '~/features/audioRecordingV2/types';
|
||||
import { useRecordingStore } from '~/features/audioRecordingV2/store/recordingStore';
|
||||
import { useToastActions } from '~/features/toast';
|
||||
import Text from '~/components/atoms/Text';
|
||||
import Icon from '~/components/atoms/Icon';
|
||||
import Alert from '~/components/atoms/Alert';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
interface RecordingBarProps {
|
||||
onRecordingComplete?: (result: string, title?: string, spaceId?: string | null, blueprintId?: string | null, memoId?: string | null) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* RecordingBar-Komponente
|
||||
*
|
||||
* Eine Leiste, die während einer aktiven Aufnahme am unteren Bildschirmrand angezeigt wird.
|
||||
* Ermöglicht das Pausieren, Fortsetzen und Beenden der Aufnahme.
|
||||
*/
|
||||
function RecordingBar({
|
||||
onRecordingComplete,
|
||||
}: RecordingBarProps) {
|
||||
const { isDark } = useTheme();
|
||||
const { showInfo } = useToastActions();
|
||||
const { t } = useTranslation();
|
||||
|
||||
// Verwende den globalen Recording-Store
|
||||
const {
|
||||
status,
|
||||
savedFile,
|
||||
stopRecording,
|
||||
pauseRecording,
|
||||
resumeRecording,
|
||||
isRecording,
|
||||
isPaused,
|
||||
duration,
|
||||
resetRecording
|
||||
} = useRecordingStore();
|
||||
|
||||
// Long-press state for stop button
|
||||
const [isStopPressed, setIsStopPressed] = useState(false);
|
||||
const stopPressTimeout = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const isStopPressedRef = useRef(false);
|
||||
const STOP_PRESS_DURATION = 1000; // 1 second hold
|
||||
|
||||
// Animation values for stop button
|
||||
const stopScaleAnim = useSharedValue(1);
|
||||
const stopRotationAnim = useSharedValue(0);
|
||||
const headerColorAnim = useSharedValue(0); // 0 = normal color, 1 = red color
|
||||
|
||||
// Formatiere die Aufnahmedauer als MM:SS
|
||||
const formatDuration = (milliseconds: number) => {
|
||||
const totalSeconds = Math.floor(milliseconds / 1000);
|
||||
const minutes = Math.floor(totalSeconds / 60);
|
||||
const remainingSeconds = totalSeconds % 60;
|
||||
return `${minutes.toString().padStart(2, '0')}:${remainingSeconds.toString().padStart(2, '0')}`;
|
||||
};
|
||||
|
||||
// Handler für das Pausieren der Aufnahme
|
||||
const handlePauseRecording = () => {
|
||||
if (status === RecordingStatus.RECORDING) {
|
||||
pauseRecording();
|
||||
}
|
||||
};
|
||||
|
||||
// Handler für das Fortsetzen der Aufnahme
|
||||
const handleResumeRecording = () => {
|
||||
if (status === RecordingStatus.PAUSED) {
|
||||
resumeRecording();
|
||||
}
|
||||
};
|
||||
|
||||
// Haptic feedback functions
|
||||
const triggerStopHaptic = useCallback(async () => {
|
||||
try {
|
||||
await Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success);
|
||||
} catch (error) {
|
||||
console.debug('Haptic feedback error:', error);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const triggerPressHaptic = useCallback(async () => {
|
||||
try {
|
||||
await Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Medium);
|
||||
} catch (error) {
|
||||
console.debug('Haptic feedback error:', error);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Handler für das Beenden der Aufnahme
|
||||
const handleCompleteRecording = useCallback(() => {
|
||||
triggerStopHaptic();
|
||||
stopRecording();
|
||||
}, [triggerStopHaptic, stopRecording]);
|
||||
|
||||
// Long-press handlers for stop button
|
||||
const handleStopPressIn = useCallback(() => {
|
||||
setIsStopPressed(true);
|
||||
isStopPressedRef.current = true;
|
||||
|
||||
// Haptic feedback on press start
|
||||
triggerPressHaptic();
|
||||
|
||||
// Visual feedback: scale down and start rotation
|
||||
stopScaleAnim.value = withSpring(0.9, { damping: 15, stiffness: 200 });
|
||||
stopRotationAnim.value = withTiming(360, {
|
||||
duration: STOP_PRESS_DURATION,
|
||||
easing: Easing.linear
|
||||
});
|
||||
headerColorAnim.value = withTiming(1, {
|
||||
duration: 300,
|
||||
easing: Easing.out(Easing.ease)
|
||||
});
|
||||
|
||||
// Timer for completing the stop action
|
||||
stopPressTimeout.current = setTimeout(() => {
|
||||
if (isStopPressedRef.current) {
|
||||
handleCompleteRecording();
|
||||
}
|
||||
}, STOP_PRESS_DURATION);
|
||||
}, [triggerPressHaptic, stopScaleAnim, stopRotationAnim, handleCompleteRecording]);
|
||||
|
||||
const handleStopPressOut = useCallback(() => {
|
||||
const wasInProgress = stopPressTimeout.current !== null && isStopPressedRef.current;
|
||||
|
||||
setIsStopPressed(false);
|
||||
isStopPressedRef.current = false;
|
||||
|
||||
// Clear timeout if stop hasn't completed yet
|
||||
if (stopPressTimeout.current) {
|
||||
clearTimeout(stopPressTimeout.current);
|
||||
stopPressTimeout.current = null;
|
||||
}
|
||||
|
||||
// Visual feedback: return to normal scale and reset rotation
|
||||
stopScaleAnim.value = withSpring(1, { damping: 15, stiffness: 200 });
|
||||
stopRotationAnim.value = withTiming(0, {
|
||||
duration: 300,
|
||||
easing: Easing.out(Easing.ease)
|
||||
});
|
||||
headerColorAnim.value = withTiming(0, {
|
||||
duration: 300,
|
||||
easing: Easing.out(Easing.ease)
|
||||
});
|
||||
|
||||
// Show toast if user released the button before completing the action
|
||||
if (wasInProgress) {
|
||||
showInfo('Lange drücken um Aufnahme zu beenden');
|
||||
}
|
||||
}, [stopScaleAnim, stopRotationAnim, headerColorAnim, showInfo]);
|
||||
|
||||
// Handler für das Abbrechen und Zurücksetzen der Aufnahme
|
||||
const handleCancelRecording = () => {
|
||||
// Native Alert statt Modal
|
||||
Alert.alert(
|
||||
t('recording.cancel_recording_title'),
|
||||
t('recording.cancel_recording_message'),
|
||||
[
|
||||
{
|
||||
text: t('recording.no_continue'),
|
||||
style: 'cancel'
|
||||
},
|
||||
{
|
||||
text: t('recording.yes_cancel'),
|
||||
style: 'destructive',
|
||||
onPress: async () => {
|
||||
try {
|
||||
await resetRecording();
|
||||
|
||||
console.debug('Recording canceled and reset successfully');
|
||||
} catch (error) {
|
||||
|
||||
console.debug('Error resetting recording:', error);
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
);
|
||||
};
|
||||
|
||||
// Cleanup effect
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
// Clear any active timeout
|
||||
if (stopPressTimeout.current) {
|
||||
clearTimeout(stopPressTimeout.current);
|
||||
stopPressTimeout.current = null;
|
||||
}
|
||||
|
||||
// Cancel all animations to prevent memory leaks
|
||||
cancelAnimation(stopScaleAnim);
|
||||
cancelAnimation(stopRotationAnim);
|
||||
cancelAnimation(headerColorAnim);
|
||||
|
||||
// Reset animation values
|
||||
stopScaleAnim.value = 1;
|
||||
stopRotationAnim.value = 0;
|
||||
headerColorAnim.value = 0;
|
||||
|
||||
// Reset state refs
|
||||
isStopPressedRef.current = false;
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Effekt für die Benachrichtigung über abgeschlossene Aufnahmen
|
||||
useEffect(() => {
|
||||
// Wenn die Aufnahme beendet ist und wir eine gespeicherte Datei haben, rufe den Callback auf
|
||||
if (status === RecordingStatus.STOPPED && savedFile) {
|
||||
// Hole alle relevanten Informationen aus dem Recording-Store
|
||||
const { title, spaceId, blueprintId, memoId } = useRecordingStore.getState();
|
||||
onRecordingComplete?.(savedFile.uri, title, spaceId, blueprintId, memoId);
|
||||
}
|
||||
}, [status, savedFile, onRecordingComplete]);
|
||||
|
||||
// Animated styles for stop button
|
||||
const stopButtonScaleStyle = useAnimatedStyle(() => {
|
||||
return {
|
||||
transform: [{ scale: stopScaleAnim.value }],
|
||||
};
|
||||
});
|
||||
|
||||
const stopButtonRotationStyle = useAnimatedStyle(() => {
|
||||
return {
|
||||
transform: [{ rotate: `${stopRotationAnim.value}deg` }],
|
||||
};
|
||||
});
|
||||
|
||||
const headerBackgroundStyle = useAnimatedStyle(() => ({
|
||||
backgroundColor: interpolateColor(
|
||||
headerColorAnim.value,
|
||||
[0, 1],
|
||||
[isDark ? '#1E1E1E' : '#FFFFFF', '#dc2626'] // Normal color to darker red
|
||||
),
|
||||
}));
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
position: 'relative',
|
||||
width: '100%',
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: isDark ? 'rgba(255, 255, 255, 0.1)' : 'rgba(0, 0, 0, 0.1)',
|
||||
padding: 12,
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
shadowColor: '#000',
|
||||
shadowOffset: { width: 0, height: 2 },
|
||||
shadowOpacity: 0.1,
|
||||
shadowRadius: 4,
|
||||
elevation: 5,
|
||||
zIndex: 1000,
|
||||
},
|
||||
recordingInfo: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
},
|
||||
recordingDot: {
|
||||
width: 12,
|
||||
height: 12,
|
||||
borderRadius: 6,
|
||||
backgroundColor: '#FF3B30',
|
||||
marginRight: 8,
|
||||
},
|
||||
recordingText: {
|
||||
fontSize: 14,
|
||||
fontWeight: '500',
|
||||
},
|
||||
duration: {
|
||||
fontSize: 14,
|
||||
marginLeft: 8,
|
||||
},
|
||||
controls: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
},
|
||||
controlButton: {
|
||||
padding: 8,
|
||||
marginLeft: 8,
|
||||
}
|
||||
});
|
||||
|
||||
if (!isRecording) return null;
|
||||
|
||||
return (
|
||||
<Animated.View style={[styles.container, headerBackgroundStyle]}>
|
||||
<View style={styles.recordingInfo}>
|
||||
<View style={styles.recordingDot} />
|
||||
<Text style={styles.recordingText}>Aufnahme</Text>
|
||||
<Text style={styles.duration}>{formatDuration(duration)}</Text>
|
||||
</View>
|
||||
<View style={styles.controls}>
|
||||
{isPaused ? (
|
||||
<Pressable style={styles.controlButton} onPress={handleResumeRecording}>
|
||||
<Icon name="play" size={24} color={isDark ? '#FFFFFF' : '#000000'} />
|
||||
</Pressable>
|
||||
) : (
|
||||
<Pressable style={styles.controlButton} onPress={handlePauseRecording}>
|
||||
<Icon name="pause" size={24} color={isDark ? '#FFFFFF' : '#000000'} />
|
||||
</Pressable>
|
||||
)}
|
||||
<Pressable
|
||||
style={styles.controlButton}
|
||||
onPressIn={handleStopPressIn}
|
||||
onPressOut={handleStopPressOut}
|
||||
>
|
||||
<Animated.View style={stopButtonScaleStyle}>
|
||||
<Animated.View style={stopButtonRotationStyle}>
|
||||
<Icon name="stop" size={24} color="#FF3B30" />
|
||||
</Animated.View>
|
||||
</Animated.View>
|
||||
</Pressable>
|
||||
<Pressable style={styles.controlButton} onPress={handleCancelRecording}>
|
||||
<Icon name="close-outline" size={24} color="#FF3B30" />
|
||||
</Pressable>
|
||||
</View>
|
||||
</Animated.View>
|
||||
);
|
||||
}
|
||||
|
||||
export default RecordingBar;
|
||||
|
|
@ -0,0 +1,123 @@
|
|||
import React, { useState, useEffect } from 'react';
|
||||
import { View } from 'react-native';
|
||||
import { useTheme } from '~/features/theme/ThemeProvider';
|
||||
import Text from '~/components/atoms/Text';
|
||||
import Button from '~/components/atoms/Button';
|
||||
import Input from '~/components/atoms/Input';
|
||||
import BaseModal from '~/components/atoms/BaseModal';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
interface ReplaceWordModalProps {
|
||||
visible: boolean;
|
||||
onClose: () => void;
|
||||
onSubmit: (wordToReplace: string, replacementWord: string) => void;
|
||||
initialWordToReplace?: string;
|
||||
initialReplacementWord?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Modal component for replacing words in text
|
||||
*
|
||||
* Allows input of a word to replace and a replacement word.
|
||||
*/
|
||||
const ReplaceWordModal: React.FC<ReplaceWordModalProps> = ({
|
||||
visible,
|
||||
onClose,
|
||||
onSubmit,
|
||||
initialWordToReplace = '',
|
||||
initialReplacementWord = '',
|
||||
}) => {
|
||||
const { isDark } = useTheme();
|
||||
const { t } = useTranslation();
|
||||
|
||||
// Debug borders (set to true to enable)
|
||||
const DEBUG_BORDERS = false;
|
||||
|
||||
// State for input fields
|
||||
const [wordToReplace, setWordToReplace] = useState(initialWordToReplace);
|
||||
const [replacementWord, setReplacementWord] = useState(initialReplacementWord);
|
||||
|
||||
// Reset input fields when initial values change
|
||||
useEffect(() => {
|
||||
setWordToReplace(initialWordToReplace);
|
||||
setReplacementWord(initialReplacementWord);
|
||||
}, [initialWordToReplace, initialReplacementWord]);
|
||||
|
||||
// Handler for form submission
|
||||
const handleSubmit = () => {
|
||||
onSubmit(wordToReplace, replacementWord);
|
||||
onClose();
|
||||
};
|
||||
|
||||
// Render the form content
|
||||
const renderContent = () => (
|
||||
<View className='w-full'>
|
||||
<View className='mb-4 w-full'>
|
||||
<Text className={`mb-1 ${isDark ? 'text-white/80' : 'text-black/80'}`}>
|
||||
{t('memo.word_to_replace', 'Zu ersetzendes Wort:')}
|
||||
</Text>
|
||||
<Input
|
||||
value={wordToReplace}
|
||||
onChangeText={setWordToReplace}
|
||||
placeholder={t('memo.word_to_replace_placeholder', 'Zu ersetzendes Wort eingeben')}
|
||||
autoFocus
|
||||
/>
|
||||
</View>
|
||||
|
||||
<View className='mb-4 w-full'>
|
||||
<Text className={`mb-1 ${isDark ? 'text-white/80' : 'text-black/80'}`}>
|
||||
{t('memo.new_word', 'Neues Wort:')}
|
||||
</Text>
|
||||
<Input
|
||||
value={replacementWord}
|
||||
onChangeText={setReplacementWord}
|
||||
placeholder={t('memo.new_word_placeholder', 'Neues Wort eingeben')}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
|
||||
// Render the footer with action buttons
|
||||
const renderFooter = () => (
|
||||
<View
|
||||
className='flex-row justify-between w-full'
|
||||
style={DEBUG_BORDERS ? { borderWidth: 2, borderColor: 'yellow' } : {}}
|
||||
>
|
||||
<Button
|
||||
title={t('common.cancel', 'Abbrechen')}
|
||||
onPress={onClose}
|
||||
variant='secondary'
|
||||
style={{
|
||||
flex: 1,
|
||||
marginRight: 8,
|
||||
...(DEBUG_BORDERS ? { borderWidth: 2, borderColor: 'cyan' } : {})
|
||||
}}
|
||||
/>
|
||||
<Button
|
||||
title={t('memo.replace', 'Ersetzen')}
|
||||
onPress={handleSubmit}
|
||||
variant='primary'
|
||||
style={{
|
||||
flex: 1,
|
||||
...(DEBUG_BORDERS ? { borderWidth: 2, borderColor: 'magenta' } : {})
|
||||
}}
|
||||
disabled={!wordToReplace.trim() || !replacementWord.trim()}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
|
||||
return (
|
||||
<BaseModal
|
||||
isVisible={visible}
|
||||
onClose={onClose}
|
||||
title={t('memo.replace_word', 'Wort ersetzen')}
|
||||
animationType='fade'
|
||||
closeOnOverlayPress={true}
|
||||
footerContent={renderFooter()}
|
||||
>
|
||||
{renderContent()}
|
||||
</BaseModal>
|
||||
);
|
||||
};
|
||||
|
||||
export default ReplaceWordModal;
|
||||
679
apps/memoro/apps/mobile/components/molecules/ReprocessModal.tsx
Normal file
679
apps/memoro/apps/mobile/components/molecules/ReprocessModal.tsx
Normal file
|
|
@ -0,0 +1,679 @@
|
|||
import React, { useState } from 'react';
|
||||
import { View, Pressable, ActivityIndicator, ScrollView, Platform } from 'react-native';
|
||||
import DateTimePicker from '@react-native-community/datetimepicker';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useTheme } from '~/features/theme/ThemeProvider';
|
||||
import BaseModal from '~/components/atoms/BaseModal';
|
||||
import BaseLanguageSelector, { LanguageItem } from '~/components/molecules/BaseLanguageSelector';
|
||||
import Text from '~/components/atoms/Text';
|
||||
import Icon from '~/components/atoms/Icon';
|
||||
import Button from '~/components/atoms/Button';
|
||||
import {
|
||||
useRecordingLanguage,
|
||||
} from '~/features/audioRecordingV2';
|
||||
import { createClient } from '@supabase/supabase-js';
|
||||
import colors from '~/tailwind.config.js';
|
||||
|
||||
interface ReprocessModalProps {
|
||||
isVisible: boolean;
|
||||
onClose: () => void;
|
||||
onReprocess: (language?: string, blueprint?: any, recordingDate?: Date) => Promise<void>;
|
||||
currentLanguage: string;
|
||||
isProcessing?: boolean;
|
||||
memo: any; // The memo object to reprocess
|
||||
}
|
||||
|
||||
const ReprocessModal: React.FC<ReprocessModalProps> = ({
|
||||
isVisible,
|
||||
onClose,
|
||||
onReprocess,
|
||||
currentLanguage,
|
||||
isProcessing = false,
|
||||
memo,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const { isDark, themeVariant } = useTheme();
|
||||
const { recordingLanguages, toggleRecordingLanguage, supportedAzureLanguages } =
|
||||
useRecordingLanguage();
|
||||
|
||||
const [modalMode, setModalMode] = useState<
|
||||
'reprocess' | 'language' | 'blueprint' | 'date' | 'time'
|
||||
>('reprocess');
|
||||
const [blueprints, setBlueprints] = useState<any[]>([]);
|
||||
const [blueprintsLoading, setBlueprintsLoading] = useState(false);
|
||||
const [selectedBlueprintForReprocess, setSelectedBlueprintForReprocess] = useState<any>(null);
|
||||
const [selectedLanguageForReprocess, setSelectedLanguageForReprocess] = useState<string>('auto');
|
||||
const [selectedDateTime, setSelectedDateTime] = useState<Date>(new Date());
|
||||
const [showDatePicker, setShowDatePicker] = useState(false);
|
||||
const [showTimePicker, setShowTimePicker] = useState(false);
|
||||
const [hasCustomTime, setHasCustomTime] = useState(false);
|
||||
|
||||
// Initialize values from memo when modal opens
|
||||
React.useEffect(() => {
|
||||
if (isVisible && memo) {
|
||||
// Set initial language from memo metadata
|
||||
const memoLanguage = memo.metadata?.language || 'auto';
|
||||
setSelectedLanguageForReprocess(memoLanguage);
|
||||
|
||||
// Set initial date/time from memo
|
||||
const recordingDate = memo.metadata?.recordingStartedAt
|
||||
? new Date(memo.metadata.recordingStartedAt)
|
||||
: new Date(memo.created_at);
|
||||
setSelectedDateTime(recordingDate);
|
||||
|
||||
// Check if memo has a custom time set
|
||||
const memoCreatedAt = new Date(memo.created_at);
|
||||
const timeDiff = Math.abs(recordingDate.getTime() - memoCreatedAt.getTime());
|
||||
setHasCustomTime(timeDiff > 60000); // More than 1 minute difference
|
||||
|
||||
// Reset blueprint selection
|
||||
setSelectedBlueprintForReprocess(null);
|
||||
setModalMode('reprocess');
|
||||
}
|
||||
}, [isVisible, memo]);
|
||||
|
||||
// Get theme colors
|
||||
const getThemeColors = () => {
|
||||
const themeColors = isDark
|
||||
? colors.theme.extend.colors.dark[themeVariant]
|
||||
: colors.theme.extend.colors[themeVariant];
|
||||
|
||||
return {
|
||||
text: themeColors?.text || (isDark ? '#FFFFFF' : '#000000'),
|
||||
contentBackground: themeColors?.contentBackground || (isDark ? '#1E1E1E' : '#FFFFFF'),
|
||||
contentBackgroundHover:
|
||||
themeColors?.contentBackgroundHover || (isDark ? '#333333' : '#F5F5F5'),
|
||||
border: themeColors?.border || (isDark ? '#374151' : '#E5E7EB'),
|
||||
primary: themeColors?.primary || '#f8d62b',
|
||||
};
|
||||
};
|
||||
|
||||
const themeColors = getThemeColors();
|
||||
|
||||
const handleReprocess = async () => {
|
||||
if (isProcessing) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await onReprocess(
|
||||
selectedLanguageForReprocess,
|
||||
selectedBlueprintForReprocess,
|
||||
selectedDateTime
|
||||
);
|
||||
// Only close modal if reprocess was successful
|
||||
handleModalClose();
|
||||
} catch (error) {
|
||||
console.error('Reprocess failed:', error);
|
||||
// Error handling is done in the action handler and global interceptor
|
||||
// 402 errors are handled globally, other errors show alerts in memoActions.ts
|
||||
}
|
||||
};
|
||||
|
||||
const handleLanguageSelect = () => {
|
||||
setModalMode('language');
|
||||
};
|
||||
|
||||
const handleBlueprintSelect = () => {
|
||||
setModalMode('blueprint');
|
||||
loadBlueprints();
|
||||
};
|
||||
|
||||
const handleDateSelect = () => {
|
||||
if (Platform.OS === 'ios') {
|
||||
setModalMode('date');
|
||||
} else {
|
||||
setShowDatePicker(true);
|
||||
}
|
||||
};
|
||||
|
||||
const handleTimeSelect = () => {
|
||||
if (Platform.OS === 'ios') {
|
||||
setModalMode('time');
|
||||
} else {
|
||||
setShowTimePicker(true);
|
||||
}
|
||||
};
|
||||
|
||||
const onDateChange = (event: any, selectedDate?: Date) => {
|
||||
if (Platform.OS === 'android') {
|
||||
setShowDatePicker(false);
|
||||
}
|
||||
if (selectedDate) {
|
||||
const newDateTime = new Date(selectedDateTime);
|
||||
newDateTime.setFullYear(selectedDate.getFullYear());
|
||||
newDateTime.setMonth(selectedDate.getMonth());
|
||||
newDateTime.setDate(selectedDate.getDate());
|
||||
setSelectedDateTime(newDateTime);
|
||||
}
|
||||
};
|
||||
|
||||
const onTimeChange = (event: any, selectedTime?: Date) => {
|
||||
if (Platform.OS === 'android') {
|
||||
setShowTimePicker(false);
|
||||
}
|
||||
if (selectedTime) {
|
||||
const newDateTime = new Date(selectedDateTime);
|
||||
newDateTime.setHours(selectedTime.getHours());
|
||||
newDateTime.setMinutes(selectedTime.getMinutes());
|
||||
setSelectedDateTime(newDateTime);
|
||||
setHasCustomTime(true);
|
||||
}
|
||||
};
|
||||
|
||||
// Load blueprints
|
||||
const loadBlueprints = async () => {
|
||||
setBlueprintsLoading(true);
|
||||
try {
|
||||
const directSupabase = createClient(
|
||||
'https://npgifbrwhftlbrbaglmi.supabase.co',
|
||||
'sb_publishable_HlAZpB4BxXaMcfOCNx6VJA_-64NTxu4'
|
||||
);
|
||||
|
||||
const { data: blueprintData, error: blueprintError } = await directSupabase
|
||||
.from('blueprints')
|
||||
.select('*')
|
||||
.eq('is_public', true)
|
||||
.order('created_at', { ascending: false });
|
||||
|
||||
if (blueprintError) {
|
||||
console.error('Error loading blueprints:', blueprintError);
|
||||
setBlueprints([]);
|
||||
} else {
|
||||
setBlueprints(blueprintData || []);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error loading blueprints:', error);
|
||||
setBlueprints([]);
|
||||
} finally {
|
||||
setBlueprintsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const getSelectedLanguageDisplay = () => {
|
||||
if (selectedLanguageForReprocess === 'auto') {
|
||||
return t('upload.auto_detect', 'Automatisch erkennen');
|
||||
} else {
|
||||
return (
|
||||
supportedAzureLanguages[selectedLanguageForReprocess]?.nativeName ||
|
||||
selectedLanguageForReprocess
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const getSelectedBlueprintDisplay = () => {
|
||||
if (selectedBlueprintForReprocess) {
|
||||
const lang = currentLanguage.startsWith('de') ? 'de' : 'en';
|
||||
return (
|
||||
selectedBlueprintForReprocess.name?.[lang] ||
|
||||
selectedBlueprintForReprocess.name?.en ||
|
||||
selectedBlueprintForReprocess.name?.de ||
|
||||
'Blueprint'
|
||||
);
|
||||
}
|
||||
return t('reprocess.no_blueprint_selected', 'Kein Blueprint ausgewählt');
|
||||
};
|
||||
|
||||
const getSelectedDateDisplay = () => {
|
||||
return selectedDateTime.toLocaleDateString(
|
||||
currentLanguage.startsWith('de') ? 'de-DE' : 'en-US',
|
||||
{
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric',
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
const getSelectedTimeDisplay = () => {
|
||||
return selectedDateTime.toLocaleTimeString(
|
||||
currentLanguage.startsWith('de') ? 'de-DE' : 'en-US',
|
||||
{
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
const renderActionButton = (
|
||||
iconName: string,
|
||||
title: string,
|
||||
subtitle: string,
|
||||
onPress: () => void,
|
||||
showChevron: boolean = true,
|
||||
isActive: boolean = false
|
||||
) => (
|
||||
<Pressable
|
||||
style={{
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
padding: 16,
|
||||
backgroundColor: themeColors.contentBackgroundHover,
|
||||
borderWidth: 1,
|
||||
borderColor: themeColors.border,
|
||||
borderRadius: 12,
|
||||
marginBottom: 12,
|
||||
shadowColor: '#000',
|
||||
shadowOffset: { width: 0, height: 1 },
|
||||
shadowOpacity: isDark ? 0.3 : 0.1,
|
||||
shadowRadius: 2,
|
||||
elevation: 2,
|
||||
}}
|
||||
onPress={onPress}
|
||||
android_ripple={{
|
||||
color: isDark ? 'rgba(255, 255, 255, 0.1)' : 'rgba(0, 0, 0, 0.1)',
|
||||
borderless: false,
|
||||
}}>
|
||||
<View
|
||||
style={{
|
||||
width: 40,
|
||||
height: 40,
|
||||
borderRadius: 20,
|
||||
backgroundColor: isActive ? themeColors.primary : themeColors.contentBackgroundHover,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
marginRight: 16,
|
||||
borderWidth: isActive ? 0 : 1,
|
||||
borderColor: themeColors.border,
|
||||
}}>
|
||||
<Icon name={iconName} size={20} color={isActive ? '#000000' : themeColors.text} />
|
||||
</View>
|
||||
|
||||
<View style={{ flex: 1 }}>
|
||||
<Text
|
||||
style={{
|
||||
fontSize: 16,
|
||||
fontWeight: '600',
|
||||
color: themeColors.text,
|
||||
marginBottom: 2,
|
||||
}}>
|
||||
{title}
|
||||
</Text>
|
||||
<Text
|
||||
style={{
|
||||
fontSize: 14,
|
||||
color: isDark ? '#D1D5DB' : '#6B7280',
|
||||
}}>
|
||||
{subtitle}
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
{showChevron && (
|
||||
<Icon name="chevron-forward" size={20} color={isDark ? '#9CA3AF' : '#6B7280'} />
|
||||
)}
|
||||
</Pressable>
|
||||
);
|
||||
|
||||
const getModalTitle = () => {
|
||||
switch (modalMode) {
|
||||
case 'language':
|
||||
return t('reprocess.select_language', 'Sprache auswählen');
|
||||
case 'blueprint':
|
||||
return t('reprocess.select_blueprint', 'Blueprint auswählen');
|
||||
case 'date':
|
||||
return t('reprocess.select_date', 'Datum auswählen');
|
||||
case 'time':
|
||||
return t('reprocess.select_time', 'Uhrzeit auswählen');
|
||||
default:
|
||||
return t('reprocess.title', 'Memo erneut verarbeiten');
|
||||
}
|
||||
};
|
||||
|
||||
const renderModalContent = () => {
|
||||
switch (modalMode) {
|
||||
case 'language':
|
||||
// Convert languages to BaseLanguageSelector format
|
||||
const languageItems: Record<string, LanguageItem> = Object.entries(
|
||||
supportedAzureLanguages
|
||||
).reduce(
|
||||
(acc, [code, lang]) => {
|
||||
acc[code] = {
|
||||
code,
|
||||
...lang,
|
||||
};
|
||||
return acc;
|
||||
},
|
||||
{} as Record<string, LanguageItem>
|
||||
);
|
||||
|
||||
return (
|
||||
<View style={{ height: 400 }}>
|
||||
<BaseLanguageSelector
|
||||
languages={languageItems}
|
||||
selectedLanguages={selectedLanguageForReprocess ? [selectedLanguageForReprocess] : []}
|
||||
onSelect={(languages) => {
|
||||
if (languages.length > 0) {
|
||||
setSelectedLanguageForReprocess(languages[0]);
|
||||
setModalMode('reprocess');
|
||||
}
|
||||
}}
|
||||
mode="single"
|
||||
showAutoDetect={true}
|
||||
height={400}
|
||||
autoSelectOnSingle={false}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
|
||||
case 'blueprint':
|
||||
return (
|
||||
<ScrollView style={{ maxHeight: 400 }} showsVerticalScrollIndicator={true}>
|
||||
{blueprintsLoading ? (
|
||||
<View style={{ padding: 20, alignItems: 'center' }}>
|
||||
<ActivityIndicator size="large" color={themeColors.primary} />
|
||||
<Text style={{ color: themeColors.text, marginTop: 10 }}>
|
||||
{t('reprocess.loading_blueprints', 'Blueprints werden geladen...')}
|
||||
</Text>
|
||||
</View>
|
||||
) : blueprints.length === 0 ? (
|
||||
<View style={{ padding: 20, alignItems: 'center' }}>
|
||||
<Text style={{ color: themeColors.text }}>
|
||||
{t('reprocess.no_blueprints_available', 'Keine Blueprints verfügbar')}
|
||||
</Text>
|
||||
</View>
|
||||
) : (
|
||||
blueprints.map((blueprint) => {
|
||||
const isSelected = selectedBlueprintForReprocess?.id === blueprint.id;
|
||||
const lang = currentLanguage.startsWith('de') ? 'de' : 'en';
|
||||
const displayName =
|
||||
blueprint.name?.[lang] || blueprint.name?.en || blueprint.name?.de || 'Blueprint';
|
||||
const displayDescription =
|
||||
blueprint.description?.[lang] ||
|
||||
blueprint.description?.en ||
|
||||
blueprint.description?.de ||
|
||||
'';
|
||||
|
||||
return (
|
||||
<Pressable
|
||||
key={blueprint.id}
|
||||
style={{
|
||||
padding: 16,
|
||||
backgroundColor: isSelected
|
||||
? themeColors.contentBackgroundHover
|
||||
: 'transparent',
|
||||
borderWidth: 1,
|
||||
borderColor: isSelected ? themeColors.primary : themeColors.border,
|
||||
borderRadius: 12,
|
||||
marginBottom: 8,
|
||||
}}
|
||||
onPress={() => {
|
||||
setSelectedBlueprintForReprocess(blueprint);
|
||||
setModalMode('reprocess');
|
||||
}}>
|
||||
<View style={{ flexDirection: 'row', alignItems: 'center' }}>
|
||||
<View
|
||||
style={{
|
||||
width: 40,
|
||||
height: 40,
|
||||
borderRadius: 20,
|
||||
backgroundColor: themeColors.primary,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
marginRight: 12,
|
||||
}}>
|
||||
<Icon name="document-text" size={20} color="#000000" />
|
||||
</View>
|
||||
|
||||
<View style={{ flex: 1 }}>
|
||||
<Text
|
||||
style={{
|
||||
fontSize: 16,
|
||||
fontWeight: isSelected ? '600' : '500',
|
||||
color: themeColors.text,
|
||||
marginBottom: 2,
|
||||
}}>
|
||||
{displayName}
|
||||
</Text>
|
||||
{displayDescription ? (
|
||||
<Text
|
||||
style={{
|
||||
fontSize: 14,
|
||||
color: isDark ? '#D1D5DB' : '#6B7280',
|
||||
lineHeight: 18,
|
||||
}}
|
||||
numberOfLines={2}
|
||||
ellipsizeMode="tail">
|
||||
{displayDescription}
|
||||
</Text>
|
||||
) : null}
|
||||
</View>
|
||||
|
||||
{isSelected && (
|
||||
<View
|
||||
style={{
|
||||
width: 24,
|
||||
height: 24,
|
||||
borderRadius: 12,
|
||||
backgroundColor: themeColors.primary,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
}}>
|
||||
<Icon name="checkmark" size={16} color="#000000" />
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
</Pressable>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</ScrollView>
|
||||
);
|
||||
|
||||
case 'date':
|
||||
return (
|
||||
<View style={{ padding: 20, alignItems: 'center' }}>
|
||||
<DateTimePicker
|
||||
value={selectedDateTime}
|
||||
mode="date"
|
||||
display={Platform.OS === 'ios' ? 'spinner' : 'default'}
|
||||
onChange={onDateChange}
|
||||
style={{
|
||||
backgroundColor: themeColors.contentBackground,
|
||||
}}
|
||||
themeVariant={isDark ? 'dark' : 'light'}
|
||||
/>
|
||||
{Platform.OS === 'ios' && (
|
||||
<Button
|
||||
title={t('common.done', 'Fertig')}
|
||||
onPress={() => setModalMode('reprocess')}
|
||||
variant="primary"
|
||||
style={{ marginTop: 20, minWidth: 120 }}
|
||||
/>
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
|
||||
case 'time':
|
||||
return (
|
||||
<View style={{ padding: 20, alignItems: 'center' }}>
|
||||
<DateTimePicker
|
||||
value={selectedDateTime}
|
||||
mode="time"
|
||||
display={Platform.OS === 'ios' ? 'spinner' : 'default'}
|
||||
onChange={onTimeChange}
|
||||
style={{
|
||||
backgroundColor: themeColors.contentBackground,
|
||||
}}
|
||||
themeVariant={isDark ? 'dark' : 'light'}
|
||||
/>
|
||||
{Platform.OS === 'ios' && (
|
||||
<Button
|
||||
title={t('common.done', 'Fertig')}
|
||||
onPress={() => setModalMode('reprocess')}
|
||||
variant="primary"
|
||||
style={{ marginTop: 20, minWidth: 120 }}
|
||||
/>
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
|
||||
default:
|
||||
return (
|
||||
<View style={{ width: '100%' }}>
|
||||
{isProcessing ? (
|
||||
<View style={{ alignItems: 'center', paddingVertical: 40 }}>
|
||||
<ActivityIndicator size="large" color={themeColors.primary} />
|
||||
<Text
|
||||
style={{
|
||||
fontSize: 16,
|
||||
fontWeight: '600',
|
||||
color: themeColors.text,
|
||||
marginTop: 16,
|
||||
marginBottom: 8,
|
||||
}}>
|
||||
{t('reprocess.processing', 'Memo wird verarbeitet...')}
|
||||
</Text>
|
||||
<Text
|
||||
style={{
|
||||
fontSize: 14,
|
||||
color: isDark ? '#D1D5DB' : '#6B7280',
|
||||
textAlign: 'center',
|
||||
lineHeight: 20,
|
||||
}}>
|
||||
{memo?.title ||
|
||||
t(
|
||||
'reprocess.processing_memo',
|
||||
'Das Memo wird mit den neuen Einstellungen verarbeitet.'
|
||||
)}
|
||||
</Text>
|
||||
</View>
|
||||
) : (
|
||||
<>
|
||||
<Text
|
||||
style={{
|
||||
fontSize: 14,
|
||||
color: isDark ? '#D1D5DB' : '#6B7280',
|
||||
marginBottom: 24,
|
||||
lineHeight: 20,
|
||||
}}>
|
||||
{t(
|
||||
'reprocess.description',
|
||||
'Konfigurieren Sie die Einstellungen für die erneute Verarbeitung des Memos.'
|
||||
)}
|
||||
</Text>
|
||||
|
||||
{renderActionButton(
|
||||
'language-outline',
|
||||
t('reprocess.select_language', 'Sprache auswählen'),
|
||||
getSelectedLanguageDisplay(),
|
||||
handleLanguageSelect,
|
||||
true,
|
||||
selectedLanguageForReprocess !== 'auto'
|
||||
)}
|
||||
|
||||
{renderActionButton(
|
||||
'calendar-outline',
|
||||
t('reprocess.select_date', 'Datum auswählen'),
|
||||
getSelectedDateDisplay(),
|
||||
handleDateSelect,
|
||||
true,
|
||||
selectedDateTime.toDateString() !== new Date().toDateString()
|
||||
)}
|
||||
|
||||
{renderActionButton(
|
||||
'time-outline',
|
||||
t('reprocess.select_time', 'Uhrzeit auswählen'),
|
||||
getSelectedTimeDisplay(),
|
||||
handleTimeSelect,
|
||||
true,
|
||||
hasCustomTime
|
||||
)}
|
||||
|
||||
{renderActionButton(
|
||||
'document-text-outline',
|
||||
t('reprocess.select_blueprint', 'Blueprint auswählen'),
|
||||
getSelectedBlueprintDisplay(),
|
||||
handleBlueprintSelect,
|
||||
true,
|
||||
!!selectedBlueprintForReprocess
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const handleModalClose = () => {
|
||||
if (isProcessing) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (modalMode === 'reprocess') {
|
||||
setModalMode('reprocess');
|
||||
onClose();
|
||||
} else {
|
||||
setModalMode('reprocess');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<BaseModal
|
||||
isVisible={isVisible}
|
||||
onClose={handleModalClose}
|
||||
title={getModalTitle()}
|
||||
animationType="fade"
|
||||
closeOnOverlayPress={!isProcessing}
|
||||
hideFooter={modalMode !== 'reprocess' || isProcessing}
|
||||
noPadding={modalMode === 'language'}
|
||||
size="medium"
|
||||
footerContent={
|
||||
modalMode === 'reprocess' && !isProcessing ? (
|
||||
<View
|
||||
style={{
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
paddingHorizontal: 20,
|
||||
paddingBottom: 20,
|
||||
gap: 12,
|
||||
}}>
|
||||
<Button
|
||||
title={t('common.cancel', 'Abbrechen')}
|
||||
onPress={handleModalClose}
|
||||
variant="secondary"
|
||||
style={{ flex: 1 }}
|
||||
disabled={isProcessing}
|
||||
/>
|
||||
<Button
|
||||
title={
|
||||
isProcessing
|
||||
? t('reprocess.processing', 'Verarbeitung...')
|
||||
: t('reprocess.reprocess', 'Erneut verarbeiten')
|
||||
}
|
||||
onPress={handleReprocess}
|
||||
variant="primary"
|
||||
style={{ flex: 1 }}
|
||||
disabled={isProcessing}
|
||||
loading={isProcessing}
|
||||
/>
|
||||
</View>
|
||||
) : undefined
|
||||
}>
|
||||
{renderModalContent()}
|
||||
</BaseModal>
|
||||
|
||||
{/* Android Date Picker */}
|
||||
{Platform.OS === 'android' && showDatePicker && (
|
||||
<DateTimePicker
|
||||
value={selectedDateTime}
|
||||
mode="date"
|
||||
display="default"
|
||||
onChange={onDateChange}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Android Time Picker */}
|
||||
{Platform.OS === 'android' && showTimePicker && (
|
||||
<DateTimePicker
|
||||
value={selectedDateTime}
|
||||
mode="time"
|
||||
display="default"
|
||||
onChange={onTimeChange}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default ReprocessModal;
|
||||
241
apps/memoro/apps/mobile/components/molecules/SearchBar.tsx
Normal file
241
apps/memoro/apps/mobile/components/molecules/SearchBar.tsx
Normal file
|
|
@ -0,0 +1,241 @@
|
|||
import React, { useState } from 'react';
|
||||
import { View, StyleSheet, Pressable, TextInput, Platform } from 'react-native';
|
||||
import { useTheme } from '~/features/theme/ThemeProvider';
|
||||
import Icon from '~/components/atoms/Icon';
|
||||
import Text from '~/components/atoms/Text';
|
||||
import colors from '~/tailwind.config.js';
|
||||
|
||||
interface SearchBarProps {
|
||||
onSearch: (query: string) => void;
|
||||
onClose: () => void;
|
||||
placeholder?: string;
|
||||
autoFocus?: boolean;
|
||||
inputRef?: React.RefObject<TextInput | null>;
|
||||
currentIndex?: number;
|
||||
totalResults?: number;
|
||||
onNext?: () => void;
|
||||
onPrevious?: () => void;
|
||||
onChange?: (text: string) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* SearchBar Component
|
||||
*
|
||||
* A dedicated search input field with navigation controls.
|
||||
* Positioned at the top of the screen for search functionality.
|
||||
*/
|
||||
const SearchBar: React.FC<SearchBarProps> = ({
|
||||
onSearch,
|
||||
onClose,
|
||||
placeholder = 'Suche...',
|
||||
autoFocus = false,
|
||||
inputRef,
|
||||
currentIndex = 0,
|
||||
totalResults = 0,
|
||||
onNext,
|
||||
onPrevious,
|
||||
onChange,
|
||||
}) => {
|
||||
const internalInputRef = React.useRef<TextInput>(null);
|
||||
const textInputRef = inputRef || internalInputRef;
|
||||
const { isDark, themeVariant } = useTheme();
|
||||
const [inputValue, setInputValue] = useState('');
|
||||
|
||||
// Direct access to colors from Tailwind config
|
||||
const menuBackgroundColor = React.useMemo(() => {
|
||||
const themeColors = colors.theme?.extend?.colors as Record<string, any>;
|
||||
return isDark
|
||||
? themeColors?.dark?.[themeVariant]?.menuBackground || '#252525'
|
||||
: themeColors?.[themeVariant]?.menuBackground || '#FFFFFF';
|
||||
}, [isDark, themeVariant]);
|
||||
|
||||
const borderColor = React.useMemo(() => {
|
||||
const themeColors = colors.theme?.extend?.colors as Record<string, any>;
|
||||
return isDark
|
||||
? themeColors?.dark?.[themeVariant]?.border || '#424242'
|
||||
: themeColors?.[themeVariant]?.border || '#e6e6e6';
|
||||
}, [isDark, themeVariant]);
|
||||
|
||||
const borderLightColor = React.useMemo(() => {
|
||||
const themeColors = colors.theme?.extend?.colors as Record<string, any>;
|
||||
return isDark
|
||||
? themeColors?.dark?.[themeVariant]?.borderLight || '#333333'
|
||||
: themeColors?.[themeVariant]?.borderLight || '#f2f2f2';
|
||||
}, [isDark, themeVariant]);
|
||||
|
||||
const handleSearch = () => {
|
||||
if (inputValue.trim()) {
|
||||
onSearch(inputValue.trim());
|
||||
}
|
||||
};
|
||||
|
||||
const handleTextChange = (text: string) => {
|
||||
setInputValue(text);
|
||||
onChange?.(text);
|
||||
// Automatically search as user types (with debouncing handled by parent)
|
||||
if (text.trim()) {
|
||||
onSearch(text.trim());
|
||||
}
|
||||
};
|
||||
|
||||
// Auto-focus when component mounts
|
||||
React.useEffect(() => {
|
||||
if (autoFocus && textInputRef.current) {
|
||||
const timer = setTimeout(() => {
|
||||
textInputRef.current?.focus();
|
||||
}, 100);
|
||||
|
||||
return () => clearTimeout(timer);
|
||||
}
|
||||
}, [autoFocus]);
|
||||
|
||||
return (
|
||||
<View style={[
|
||||
styles.container,
|
||||
{
|
||||
backgroundColor: menuBackgroundColor,
|
||||
borderTopWidth: 1,
|
||||
borderColor: borderColor,
|
||||
shadowColor: '#000',
|
||||
shadowOffset: { width: 0, height: -2 },
|
||||
shadowOpacity: isDark ? 0.3 : 0.1,
|
||||
shadowRadius: 3,
|
||||
elevation: 5,
|
||||
}
|
||||
]}>
|
||||
{/* Search input container */}
|
||||
<View style={[styles.inputContainer, { borderColor: borderLightColor }]}>
|
||||
{/* Search icon */}
|
||||
<View style={styles.searchIconContainer}>
|
||||
<Icon name="search-outline" size={20} color={isDark ? 'rgba(255, 255, 255, 0.7)' : 'rgba(0, 0, 0, 0.7)'} />
|
||||
</View>
|
||||
|
||||
<TextInput
|
||||
ref={textInputRef}
|
||||
style={[
|
||||
styles.input,
|
||||
{
|
||||
color: isDark ? '#FFFFFF' : '#000000',
|
||||
}
|
||||
]}
|
||||
value={inputValue}
|
||||
onChangeText={handleTextChange}
|
||||
placeholder={placeholder}
|
||||
placeholderTextColor={isDark ? 'rgba(255, 255, 255, 0.5)' : 'rgba(0, 0, 0, 0.5)'}
|
||||
multiline={false}
|
||||
returnKeyType="search"
|
||||
onSubmitEditing={handleSearch}
|
||||
autoFocus={autoFocus}
|
||||
/>
|
||||
|
||||
{/* Close button */}
|
||||
<Pressable onPress={onClose} style={styles.closeButton}>
|
||||
<Icon name="close" size={20} color={isDark ? 'rgba(255, 255, 255, 0.5)' : 'rgba(0, 0, 0, 0.5)'} />
|
||||
</Pressable>
|
||||
</View>
|
||||
|
||||
{/* Navigation controls - show when there are results */}
|
||||
{totalResults > 0 && (
|
||||
<View style={styles.navigationContainer}>
|
||||
<Text style={[styles.resultCount, { color: isDark ? '#FFFFFF' : '#000000' }]}>
|
||||
{currentIndex} von {totalResults}
|
||||
</Text>
|
||||
<View style={styles.navigationButtons}>
|
||||
<Pressable
|
||||
onPress={onPrevious}
|
||||
style={[styles.navButton, { backgroundColor: isDark ? 'rgba(255, 255, 255, 0.1)' : 'rgba(0, 0, 0, 0.05)' }]}
|
||||
>
|
||||
<Icon name="chevron-up" size={20} color={isDark ? '#FFFFFF' : '#000000'} />
|
||||
</Pressable>
|
||||
<Pressable
|
||||
onPress={onNext}
|
||||
style={[styles.navButton, { backgroundColor: isDark ? 'rgba(255, 255, 255, 0.1)' : 'rgba(0, 0, 0, 0.05)' }]}
|
||||
>
|
||||
<Icon name="chevron-down" size={20} color={isDark ? '#FFFFFF' : '#000000'} />
|
||||
</Pressable>
|
||||
</View>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
width: '100%',
|
||||
paddingVertical: 12,
|
||||
paddingHorizontal: 16,
|
||||
},
|
||||
inputContainer: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
backgroundColor: 'rgba(0, 0, 0, 0.05)',
|
||||
borderRadius: 24,
|
||||
paddingHorizontal: 12,
|
||||
paddingVertical: Platform.OS === 'ios' ? 8 : 10,
|
||||
borderWidth: 1,
|
||||
height: Platform.OS === 'ios' ? 44 : 48, // Standard heights for touch targets
|
||||
},
|
||||
searchIconContainer: {
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
marginRight: 8,
|
||||
paddingHorizontal: 4,
|
||||
},
|
||||
closeButton: {
|
||||
padding: 6,
|
||||
borderRadius: 20,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
marginLeft: 8,
|
||||
},
|
||||
input: {
|
||||
flex: 1,
|
||||
fontSize: 16,
|
||||
paddingVertical: 0,
|
||||
paddingHorizontal: 0,
|
||||
margin: 0,
|
||||
textAlignVertical: 'center', // Android specific
|
||||
includeFontPadding: false, // Android specific - removes extra padding
|
||||
...Platform.select({
|
||||
ios: {
|
||||
lineHeight: 20, // Explicit line height for iOS
|
||||
},
|
||||
android: {
|
||||
paddingVertical: 2,
|
||||
},
|
||||
}),
|
||||
},
|
||||
searchButton: {
|
||||
padding: 8,
|
||||
borderRadius: 20,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
marginLeft: 4,
|
||||
},
|
||||
searchButtonPressed: {
|
||||
opacity: 0.7,
|
||||
},
|
||||
navigationContainer: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
paddingHorizontal: 16,
|
||||
},
|
||||
resultCount: {
|
||||
fontSize: 14,
|
||||
fontWeight: '500',
|
||||
},
|
||||
navigationButtons: {
|
||||
flexDirection: 'row',
|
||||
gap: 8,
|
||||
},
|
||||
navButton: {
|
||||
padding: 8,
|
||||
borderRadius: 20,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
},
|
||||
});
|
||||
|
||||
export default SearchBar;
|
||||
157
apps/memoro/apps/mobile/components/molecules/SearchOverlay.tsx
Normal file
157
apps/memoro/apps/mobile/components/molecules/SearchOverlay.tsx
Normal file
|
|
@ -0,0 +1,157 @@
|
|||
import React, { useState, useEffect } from 'react';
|
||||
import { View, Keyboard, KeyboardEvent, Platform } from 'react-native';
|
||||
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||
import { useTheme } from '~/features/theme/ThemeProvider';
|
||||
import SearchBar from './SearchBar';
|
||||
import colors from '~/tailwind.config.js';
|
||||
import { usePathname } from 'expo-router';
|
||||
|
||||
interface SearchOverlayProps {
|
||||
isVisible: boolean;
|
||||
onSearch: (query: string) => void;
|
||||
onClose: () => void;
|
||||
placeholder?: string;
|
||||
currentIndex?: number;
|
||||
totalResults?: number;
|
||||
onNext?: () => void;
|
||||
onPrevious?: () => void;
|
||||
onChange?: (text: string) => void;
|
||||
showBackdrop?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* SearchOverlay Component
|
||||
*
|
||||
* A unified search overlay that handles keyboard positioning and optional backdrop.
|
||||
* Used consistently across the app for search functionality.
|
||||
*/
|
||||
const SearchOverlay: React.FC<SearchOverlayProps> = ({
|
||||
isVisible,
|
||||
onSearch,
|
||||
onClose,
|
||||
placeholder = 'Search...',
|
||||
currentIndex = 0,
|
||||
totalResults = 0,
|
||||
onNext,
|
||||
onPrevious,
|
||||
onChange,
|
||||
showBackdrop = false,
|
||||
}) => {
|
||||
const { isDark, themeVariant } = useTheme();
|
||||
const insets = useSafeAreaInsets();
|
||||
const [keyboardHeight, setKeyboardHeight] = useState(0);
|
||||
const pathname = usePathname();
|
||||
|
||||
// Check if we're on a tab page
|
||||
// Note: The pathname might be simplified to just '/memos' even though it's in the tabs layout
|
||||
const isTabPage = pathname?.includes('/(tabs)/') ||
|
||||
pathname === '/memos' ||
|
||||
pathname === '/spaces' ||
|
||||
pathname === '/';
|
||||
|
||||
// Check which page we're on for positioning
|
||||
const isBlueprintsPage = pathname?.includes('blueprints');
|
||||
const isTagsPage = pathname?.includes('tags');
|
||||
|
||||
|
||||
// Get theme colors
|
||||
const pageBackgroundColor = React.useMemo(() => {
|
||||
const themeColors = colors.theme?.extend?.colors as Record<string, any>;
|
||||
return isDark
|
||||
? themeColors?.dark?.[themeVariant]?.pageBackground || '#121212'
|
||||
: themeColors?.[themeVariant]?.pageBackground || '#FFFFFF';
|
||||
}, [isDark, themeVariant]);
|
||||
|
||||
// Keyboard height tracking
|
||||
useEffect(() => {
|
||||
const keyboardWillShowListener = Keyboard.addListener(
|
||||
Platform.OS === 'ios' ? 'keyboardWillShow' : 'keyboardDidShow',
|
||||
(e: KeyboardEvent) => {
|
||||
let adjustedHeight = e.endCoordinates.height;
|
||||
|
||||
// ONLY adjust for tab pages, not for regular pages like tags
|
||||
if (isTabPage && Platform.OS === 'ios') {
|
||||
// When keyboard shows on tab pages, the tab bar is hidden
|
||||
// but its space is included in the keyboard height
|
||||
const TAB_BAR_HEIGHT = 49;
|
||||
const hasHomeIndicator = insets.bottom > 20;
|
||||
const HOME_INDICATOR_EXTRA = hasHomeIndicator ? 34 : 0;
|
||||
const FINE_ADJUSTMENT = 5; // Small extra adjustment for perfect alignment
|
||||
|
||||
adjustedHeight = e.endCoordinates.height - TAB_BAR_HEIGHT - HOME_INDICATOR_EXTRA - FINE_ADJUSTMENT;
|
||||
}
|
||||
|
||||
setKeyboardHeight(adjustedHeight);
|
||||
}
|
||||
);
|
||||
|
||||
const keyboardWillHideListener = Keyboard.addListener(
|
||||
Platform.OS === 'ios' ? 'keyboardWillHide' : 'keyboardDidHide',
|
||||
() => {
|
||||
setKeyboardHeight(0);
|
||||
}
|
||||
);
|
||||
|
||||
return () => {
|
||||
keyboardWillShowListener.remove();
|
||||
keyboardWillHideListener.remove();
|
||||
};
|
||||
}, [isTabPage, insets.bottom]);
|
||||
|
||||
if (!isVisible) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Optional semi-transparent backdrop */}
|
||||
{showBackdrop && (
|
||||
<View
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
backgroundColor: isDark ? 'rgba(0, 0, 0, 0.5)' : 'rgba(0, 0, 0, 0.2)',
|
||||
zIndex: 999,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
|
||||
|
||||
{/* Search bar positioned on keyboard */}
|
||||
<View
|
||||
style={{
|
||||
position: 'absolute',
|
||||
// Position based on page context when keyboard is hidden
|
||||
bottom: keyboardHeight > 0
|
||||
? keyboardHeight
|
||||
: isBlueprintsPage
|
||||
? insets.bottom + 60 // Slightly lower on blueprints page
|
||||
: isTagsPage
|
||||
? insets.bottom + 12 // Same as tags page button
|
||||
: insets.bottom + 20, // Default for other pages
|
||||
left: 0,
|
||||
right: 0,
|
||||
width: '100%',
|
||||
zIndex: 1000,
|
||||
}}>
|
||||
<SearchBar
|
||||
onSearch={onSearch}
|
||||
onClose={onClose}
|
||||
placeholder={placeholder}
|
||||
autoFocus={true}
|
||||
currentIndex={currentIndex}
|
||||
totalResults={totalResults}
|
||||
onNext={onNext}
|
||||
onPrevious={onPrevious}
|
||||
onChange={onChange}
|
||||
/>
|
||||
</View>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default SearchOverlay;
|
||||
|
|
@ -0,0 +1,222 @@
|
|||
import React, { useState, useCallback } from 'react';
|
||||
import { View, StyleSheet, Pressable, Animated } from 'react-native';
|
||||
import MemoList, { MemoModel } from './MemoList';
|
||||
import SearchBar from './SearchBar';
|
||||
import Icon from '~/components/atoms/Icon';
|
||||
import Text from '~/components/atoms/Text';
|
||||
import { useTheme } from '~/features/theme/ThemeProvider';
|
||||
import { useMemoListSearch } from '~/features/memos/hooks/useMemoListSearch';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
interface SearchableMemoListProps {
|
||||
showArchived?: boolean | null;
|
||||
spaceId?: string;
|
||||
tagIds?: string[];
|
||||
selectionMode?: boolean;
|
||||
selectedMemoIds?: string[];
|
||||
onMemoSelection?: (memoId: string, selected: boolean) => void;
|
||||
refreshTrigger?: number;
|
||||
onShare?: (memo: MemoModel) => void;
|
||||
onMemosLoaded?: (memos: MemoModel[]) => void;
|
||||
}
|
||||
|
||||
const SearchableMemoList: React.FC<SearchableMemoListProps> = ({
|
||||
showArchived,
|
||||
spaceId,
|
||||
tagIds,
|
||||
selectionMode,
|
||||
selectedMemoIds,
|
||||
onMemoSelection,
|
||||
refreshTrigger,
|
||||
onShare,
|
||||
onMemosLoaded,
|
||||
}) => {
|
||||
const { isDark, themeVariant } = useTheme();
|
||||
const { t } = useTranslation();
|
||||
const [isSearchMode, setIsSearchMode] = useState(false);
|
||||
const [allMemos, setAllMemos] = useState<MemoModel[]>([]);
|
||||
const searchAnimation = useState(new Animated.Value(0))[0];
|
||||
|
||||
// Use the search hook
|
||||
const { searchQuery, setSearchQuery, filteredMemos, searchCount, isSearching, clearSearch } =
|
||||
useMemoListSearch(allMemos, {
|
||||
searchFields: ['title', 'intro', 'transcript', 'tags'],
|
||||
minSearchLength: 2,
|
||||
includeArchived: showArchived || false,
|
||||
});
|
||||
|
||||
// Callback when memos are loaded from MemoList
|
||||
const handleMemosLoaded = useCallback(
|
||||
(memos: MemoModel[]) => {
|
||||
setAllMemos(memos);
|
||||
onMemosLoaded?.(memos);
|
||||
},
|
||||
[onMemosLoaded]
|
||||
);
|
||||
|
||||
// Toggle search mode with animation
|
||||
const toggleSearchMode = useCallback(() => {
|
||||
const newMode = !isSearchMode;
|
||||
setIsSearchMode(newMode);
|
||||
|
||||
Animated.timing(searchAnimation, {
|
||||
toValue: newMode ? 1 : 0,
|
||||
duration: 200,
|
||||
useNativeDriver: true,
|
||||
}).start();
|
||||
|
||||
if (!newMode) {
|
||||
clearSearch();
|
||||
}
|
||||
}, [isSearchMode, searchAnimation, clearSearch]);
|
||||
|
||||
// Handle search close
|
||||
const handleSearchClose = useCallback(() => {
|
||||
setIsSearchMode(false);
|
||||
clearSearch();
|
||||
|
||||
Animated.timing(searchAnimation, {
|
||||
toValue: 0,
|
||||
duration: 200,
|
||||
useNativeDriver: true,
|
||||
}).start();
|
||||
}, [searchAnimation, clearSearch]);
|
||||
|
||||
// Determine which memos to display
|
||||
const memosToDisplay = isSearching ? filteredMemos : allMemos;
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
{/* Search Bar with animation */}
|
||||
{isSearchMode && (
|
||||
<Animated.View
|
||||
style={[
|
||||
styles.searchBarContainer,
|
||||
{
|
||||
opacity: searchAnimation,
|
||||
transform: [
|
||||
{
|
||||
translateY: searchAnimation.interpolate({
|
||||
inputRange: [0, 1],
|
||||
outputRange: [-50, 0],
|
||||
}),
|
||||
},
|
||||
],
|
||||
},
|
||||
]}>
|
||||
<SearchBar
|
||||
onSearch={setSearchQuery}
|
||||
onClose={handleSearchClose}
|
||||
placeholder={t('memo.searchPlaceholder', 'Suche in Titel, Inhalt, Tags...')}
|
||||
autoFocus={true}
|
||||
totalResults={isSearching ? searchCount : 0}
|
||||
currentIndex={isSearching && searchCount > 0 ? 1 : 0}
|
||||
/>
|
||||
</Animated.View>
|
||||
)}
|
||||
|
||||
{/* Search Button (floating) */}
|
||||
{!isSearchMode && !selectionMode && (
|
||||
<Pressable
|
||||
style={[
|
||||
styles.searchButton,
|
||||
{
|
||||
backgroundColor: isDark ? 'rgba(255, 255, 255, 0.1)' : 'rgba(0, 0, 0, 0.05)',
|
||||
borderColor: isDark ? 'rgba(255, 255, 255, 0.2)' : 'rgba(0, 0, 0, 0.1)',
|
||||
},
|
||||
]}
|
||||
onPress={toggleSearchMode}>
|
||||
<Icon name="search-outline" size={20} color={isDark ? '#FFFFFF' : '#000000'} />
|
||||
<Text style={[styles.searchButtonText, { color: isDark ? '#FFFFFF' : '#000000' }]}>
|
||||
{t('common.search', 'Suche')}
|
||||
</Text>
|
||||
</Pressable>
|
||||
)}
|
||||
|
||||
{/* Memo List */}
|
||||
<MemoList
|
||||
showArchived={showArchived}
|
||||
spaceId={spaceId}
|
||||
memos={memosToDisplay}
|
||||
tagIds={tagIds}
|
||||
selectionMode={selectionMode}
|
||||
selectedMemoIds={selectedMemoIds}
|
||||
onMemoSelection={onMemoSelection}
|
||||
refreshTrigger={refreshTrigger}
|
||||
onShare={onShare}
|
||||
onMemosLoaded={handleMemosLoaded}
|
||||
/>
|
||||
|
||||
{/* No Results Message */}
|
||||
{isSearching && searchCount === 0 && searchQuery.length >= 2 && (
|
||||
<View style={styles.noResultsContainer}>
|
||||
<Icon
|
||||
name="search-outline"
|
||||
size={48}
|
||||
color={isDark ? 'rgba(255, 255, 255, 0.3)' : 'rgba(0, 0, 0, 0.3)'}
|
||||
/>
|
||||
<Text
|
||||
style={[
|
||||
styles.noResultsText,
|
||||
{ color: isDark ? 'rgba(255, 255, 255, 0.5)' : 'rgba(0, 0, 0, 0.5)' },
|
||||
]}>
|
||||
{t('memo.noSearchResults', 'Keine Memos für')} "{searchQuery}"{' '}
|
||||
{t('memo.found', 'gefunden')}
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
position: 'relative',
|
||||
},
|
||||
searchBarContainer: {
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
zIndex: 100,
|
||||
},
|
||||
searchButton: {
|
||||
position: 'absolute',
|
||||
top: 16,
|
||||
right: 16,
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
paddingHorizontal: 16,
|
||||
paddingVertical: 8,
|
||||
borderRadius: 20,
|
||||
borderWidth: 1,
|
||||
gap: 8,
|
||||
zIndex: 90,
|
||||
elevation: 5,
|
||||
shadowColor: '#000',
|
||||
shadowOffset: { width: 0, height: 2 },
|
||||
shadowOpacity: 0.1,
|
||||
shadowRadius: 4,
|
||||
},
|
||||
searchButtonText: {
|
||||
fontSize: 14,
|
||||
fontWeight: '500',
|
||||
},
|
||||
noResultsContainer: {
|
||||
position: 'absolute',
|
||||
top: '40%',
|
||||
left: 0,
|
||||
right: 0,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
paddingHorizontal: 32,
|
||||
},
|
||||
noResultsText: {
|
||||
fontSize: 16,
|
||||
textAlign: 'center',
|
||||
marginTop: 16,
|
||||
},
|
||||
});
|
||||
|
||||
export default SearchableMemoList;
|
||||
390
apps/memoro/apps/mobile/components/molecules/ShareModal.tsx
Normal file
390
apps/memoro/apps/mobile/components/molecules/ShareModal.tsx
Normal file
|
|
@ -0,0 +1,390 @@
|
|||
import React, { useState, useEffect } from 'react';
|
||||
import { View, TextInput, StyleSheet, Share, ScrollView } from 'react-native';
|
||||
import SegmentedControl from '@react-native-segmented-control/segmented-control';
|
||||
import { useTheme } from '~/features/theme/ThemeProvider';
|
||||
import Button from '~/components/atoms/Button';
|
||||
import SelectableItem from '~/components/atoms/SelectableItem';
|
||||
import BaseModal from '~/components/atoms/BaseModal';
|
||||
import * as Clipboard from 'expo-clipboard';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useToast } from '~/features/toast';
|
||||
|
||||
interface MemoryItem {
|
||||
id: string;
|
||||
title: string;
|
||||
content: string;
|
||||
isSelected: boolean;
|
||||
}
|
||||
|
||||
interface ShareModalProps {
|
||||
visible: boolean;
|
||||
onClose: () => void;
|
||||
title: string;
|
||||
intro?: string;
|
||||
memories: Array<{
|
||||
id: string;
|
||||
title: string;
|
||||
content: string;
|
||||
}>;
|
||||
transcript?: string;
|
||||
audioUrl?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Ein Modal zum Teilen von Memo-Inhalten.
|
||||
* Ermöglicht die Auswahl von Memories, Transkript und Audio-URL.
|
||||
*/
|
||||
const ShareModal = ({
|
||||
visible,
|
||||
onClose,
|
||||
title: initialTitle,
|
||||
intro: initialIntro,
|
||||
memories,
|
||||
transcript,
|
||||
audioUrl,
|
||||
}: ShareModalProps): React.ReactElement => {
|
||||
|
||||
const { isDark, colors } = useTheme();
|
||||
const { t } = useTranslation();
|
||||
const { showSuccess } = useToast();
|
||||
|
||||
// State für den editierbaren Titel und Intro
|
||||
const [shareTitle, setShareTitle] = useState(initialTitle);
|
||||
const [shareIntro, setShareIntro] = useState(initialIntro || '');
|
||||
const [isIntroSelected, setIsIntroSelected] = useState(!!initialIntro);
|
||||
|
||||
// State für die auswählbaren Memories
|
||||
const [memoryItems, setMemoryItems] = useState<MemoryItem[]>([]);
|
||||
|
||||
// State für die Auswahl von Transkript und Audio
|
||||
const [isTranscriptSelected, setIsTranscriptSelected] = useState(false);
|
||||
const [isAudioSelected, setIsAudioSelected] = useState(false);
|
||||
|
||||
// State für das Export-Format
|
||||
const [exportFormat, setExportFormat] = useState<'plain' | 'markdown'>('plain');
|
||||
|
||||
// Hilfsfunktion zum Entfernen von Markdown-Formatierungen
|
||||
const stripMarkdown = (text: string): string => {
|
||||
return text
|
||||
// Entferne Bold-Markierungen
|
||||
.replace(/\*\*(.*?)\*\*/g, '$1')
|
||||
// Entferne Italic-Markierungen
|
||||
.replace(/\*(.*?)\*/g, '$1')
|
||||
// Entferne Inline-Code
|
||||
.replace(/`(.*?)`/g, '$1')
|
||||
// Entferne Links, behalte nur den Text
|
||||
.replace(/\[([^\]]+)\]\([^)]+\)/g, '$1')
|
||||
// Entferne Überschriften-Markierungen
|
||||
.replace(/^#{1,6}\s+/gm, '')
|
||||
// Entferne Blockquotes
|
||||
.replace(/^>\s+/gm, '')
|
||||
// Entferne horizontale Linien
|
||||
.replace(/^---+$/gm, '')
|
||||
// Entferne mehrfache Leerzeilen
|
||||
.replace(/\n{3,}/g, '\n\n')
|
||||
.trim();
|
||||
};
|
||||
|
||||
// Aktualisiere die Memories, wenn sich die Props ändern
|
||||
useEffect(() => {
|
||||
setMemoryItems(
|
||||
memories.map(memory => ({
|
||||
...memory,
|
||||
isSelected: true, // Standardmäßig alle ausgewählt
|
||||
}))
|
||||
);
|
||||
|
||||
// Setze den Titel und Intro zurück, wenn sich die Props ändern
|
||||
setShareTitle(initialTitle);
|
||||
setShareIntro(initialIntro || '');
|
||||
setIsIntroSelected(!!initialIntro);
|
||||
|
||||
// Setze die Auswahl zurück
|
||||
setIsTranscriptSelected(false);
|
||||
setIsAudioSelected(false); // Audio ist standardmäßig nicht ausgewählt
|
||||
}, [memories, initialTitle, initialIntro]);
|
||||
|
||||
// Toggle die Auswahl eines Memory-Items
|
||||
const toggleMemorySelection = (id: string) => {
|
||||
setMemoryItems(
|
||||
memoryItems.map(item =>
|
||||
item.id === id
|
||||
? { ...item, isSelected: !item.isSelected }
|
||||
: item
|
||||
)
|
||||
);
|
||||
};
|
||||
|
||||
// Erstelle den zu teilenden Text basierend auf der Auswahl
|
||||
const createShareText = () => {
|
||||
if (exportFormat === 'markdown') {
|
||||
// Markdown-Format
|
||||
let text = `# ${shareTitle}\n\n`;
|
||||
|
||||
// Füge Intro hinzu, wenn ausgewählt
|
||||
if (isIntroSelected && shareIntro) {
|
||||
text += `${shareIntro}\n\n`;
|
||||
}
|
||||
|
||||
// Füge ausgewählte Memories hinzu
|
||||
const selectedMemories = memoryItems.filter(item => item.isSelected);
|
||||
if (selectedMemories.length > 0) {
|
||||
selectedMemories.forEach(memory => {
|
||||
text += `## ${memory.title}\n\n${memory.content}\n\n`;
|
||||
});
|
||||
}
|
||||
|
||||
// Füge Transkript hinzu, wenn ausgewählt
|
||||
if (isTranscriptSelected && transcript) {
|
||||
text += `## ${t('memo.transcript', 'Transkript')}\n\n${transcript}\n\n`;
|
||||
}
|
||||
|
||||
// Füge Audio-URL hinzu, wenn ausgewählt
|
||||
if (isAudioSelected && audioUrl) {
|
||||
text += `**${t('memo.audio', 'Audio')}:** [${t('memo.audio_link', 'Audio-Link')}](${audioUrl})\n\n`;
|
||||
}
|
||||
|
||||
return text.trim();
|
||||
} else {
|
||||
// Plain-Text-Format (bestehend)
|
||||
// Entferne Markdown-Formatierungen aus dem Titel
|
||||
let text = `${stripMarkdown(shareTitle)}\n\n`;
|
||||
|
||||
// Füge Intro hinzu, wenn ausgewählt
|
||||
if (isIntroSelected && shareIntro) {
|
||||
text += `${stripMarkdown(shareIntro)}\n\n`;
|
||||
}
|
||||
|
||||
// Füge ausgewählte Memories hinzu
|
||||
const selectedMemories = memoryItems.filter(item => item.isSelected);
|
||||
if (selectedMemories.length > 0) {
|
||||
selectedMemories.forEach(memory => {
|
||||
// Entferne Markdown-Formatierungen aus dem Inhalt
|
||||
const cleanTitle = stripMarkdown(memory.title);
|
||||
const cleanContent = stripMarkdown(memory.content);
|
||||
text += `${cleanTitle}\n${cleanContent}\n\n`;
|
||||
});
|
||||
}
|
||||
|
||||
// Füge Transkript hinzu, wenn ausgewählt
|
||||
if (isTranscriptSelected && transcript) {
|
||||
text += `${t('memo.transcript', 'Transkript')}:\n${transcript}\n\n`;
|
||||
}
|
||||
|
||||
// Füge Audio-URL hinzu, wenn ausgewählt
|
||||
if (isAudioSelected && audioUrl) {
|
||||
text += `${t('memo.audio', 'Audio')}: ${audioUrl}\n\n`;
|
||||
}
|
||||
|
||||
return text.trim();
|
||||
}
|
||||
};
|
||||
|
||||
// Kopiere den Text in die Zwischenablage
|
||||
const handleCopy = async () => {
|
||||
const text = createShareText();
|
||||
await Clipboard.setStringAsync(text);
|
||||
showSuccess(
|
||||
t('memo.copy_success_title', 'Kopiert!'),
|
||||
t('memo.copy_success_message', 'Inhalt wurde in die Zwischenablage kopiert')
|
||||
);
|
||||
onClose();
|
||||
};
|
||||
|
||||
// Teile den Text über das System-Share-Sheet
|
||||
const handleShare = async () => {
|
||||
const text = createShareText();
|
||||
try {
|
||||
await Share.share({
|
||||
message: text,
|
||||
});
|
||||
} catch (error) {
|
||||
console.debug('Error sharing:', error);
|
||||
}
|
||||
onClose();
|
||||
};
|
||||
|
||||
// Dynamische Styles basierend auf dem Theme
|
||||
const styles = StyleSheet.create({
|
||||
contentWrapper: {
|
||||
flex: 1,
|
||||
paddingHorizontal: 20,
|
||||
paddingTop: 12,
|
||||
paddingBottom: 0,
|
||||
},
|
||||
titleInput: {
|
||||
fontSize: 20,
|
||||
fontWeight: 'bold',
|
||||
color: isDark ? '#FFFFFF' : '#000000',
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: isDark ? 'rgba(255, 255, 255, 0.2)' : 'rgba(0, 0, 0, 0.1)',
|
||||
paddingTop: -4,
|
||||
paddingBottom: 12,
|
||||
marginBottom: 0,
|
||||
minHeight: 50,
|
||||
textAlignVertical: 'top',
|
||||
lineHeight: 28,
|
||||
},
|
||||
scrollView: {
|
||||
flex: 1,
|
||||
},
|
||||
scrollContent: {
|
||||
paddingTop: 16,
|
||||
paddingBottom: 20, // Erhöht von 0 auf 20 für mehr Abstand am Ende
|
||||
flexGrow: 1,
|
||||
},
|
||||
itemsContainer: {
|
||||
flexDirection: 'column',
|
||||
},
|
||||
itemContainer: {
|
||||
marginBottom: 4,
|
||||
width: '100%',
|
||||
},
|
||||
copyButton: {
|
||||
marginTop: 16,
|
||||
marginBottom: 8,
|
||||
},
|
||||
segmentedControl: {
|
||||
height: 36,
|
||||
borderRadius: 8, // Abgerundete Ecken passend zu den Buttons
|
||||
},
|
||||
});
|
||||
|
||||
// Render the footer with format selection and action buttons
|
||||
const renderFooter = () => (
|
||||
<>
|
||||
{/* Format-Auswahl */}
|
||||
<SegmentedControl
|
||||
values={[
|
||||
t('share.plain_text', 'Text'),
|
||||
t('share.markdown', 'Markdown')
|
||||
]}
|
||||
selectedIndex={exportFormat === 'plain' ? 0 : 1}
|
||||
onChange={(event) => {
|
||||
setExportFormat(event.nativeEvent.selectedSegmentIndex === 0 ? 'plain' : 'markdown');
|
||||
}}
|
||||
style={[styles.segmentedControl, { marginBottom: 16, borderRadius: 18 }]}
|
||||
tintColor={colors.primaryButton}
|
||||
backgroundColor={colors.secondaryButton}
|
||||
fontStyle={{
|
||||
color: colors.text,
|
||||
fontSize: 14,
|
||||
}}
|
||||
activeFontStyle={{
|
||||
color: colors.primaryButtonText,
|
||||
fontSize: 14,
|
||||
fontWeight: '600',
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Action Buttons */}
|
||||
<View style={{ flexDirection: 'row', justifyContent: 'space-between' }}>
|
||||
<Button
|
||||
title={t('memo.copy', 'Kopieren')}
|
||||
variant='secondary'
|
||||
iconName='copy-outline'
|
||||
onPress={handleCopy}
|
||||
style={{ flex: 1, marginRight: 8 }}
|
||||
/>
|
||||
<Button
|
||||
title={t('common.share', 'Teilen')}
|
||||
onPress={handleShare}
|
||||
variant='primary'
|
||||
iconName='share-outline'
|
||||
style={{ flex: 1, marginLeft: 8 }}
|
||||
/>
|
||||
</View>
|
||||
</>
|
||||
);
|
||||
|
||||
return (
|
||||
<BaseModal
|
||||
isVisible={visible}
|
||||
onClose={onClose}
|
||||
title={t('memo.share_memo', 'Memo teilen')}
|
||||
animationType='fade'
|
||||
closeOnOverlayPress={true}
|
||||
footerContent={renderFooter()}
|
||||
noPadding={true}
|
||||
>
|
||||
<View style={styles.contentWrapper}>
|
||||
{/* Editierbarer Titel */}
|
||||
<TextInput
|
||||
style={styles.titleInput}
|
||||
value={shareTitle}
|
||||
onChangeText={setShareTitle}
|
||||
placeholder={t('memo.title_placeholder', 'Titel eingeben')}
|
||||
placeholderTextColor={isDark ? 'rgba(255, 255, 255, 0.5)' : 'rgba(0, 0, 0, 0.3)'}
|
||||
multiline
|
||||
/>
|
||||
|
||||
<ScrollView style={styles.scrollView} contentContainerStyle={styles.scrollContent}>
|
||||
<View style={styles.itemsContainer}>
|
||||
{/* Intro-Sektion */}
|
||||
{shareIntro && (
|
||||
<View style={styles.itemContainer}>
|
||||
<SelectableItem
|
||||
title={t('memo.intro', 'Einleitung')}
|
||||
isSelected={isIntroSelected}
|
||||
onToggle={() => setIsIntroSelected(!isIntroSelected)}
|
||||
/>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* Memories-Sektion */}
|
||||
{memoryItems.length > 0 && (
|
||||
memoryItems.map((item, index) => {
|
||||
const isLastMemory = index === memoryItems.length - 1;
|
||||
const hasMoreSections = transcript || audioUrl;
|
||||
const isLastItem = isLastMemory && !hasMoreSections;
|
||||
|
||||
return (
|
||||
<View
|
||||
key={item.id}
|
||||
style={[
|
||||
styles.itemContainer,
|
||||
isLastItem && { marginBottom: 0 }
|
||||
]}
|
||||
>
|
||||
<SelectableItem
|
||||
title={item.title}
|
||||
isSelected={item.isSelected}
|
||||
onToggle={() => toggleMemorySelection(item.id)}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
})
|
||||
)}
|
||||
|
||||
{/* Transkript-Sektion */}
|
||||
{transcript && (
|
||||
<View style={[
|
||||
styles.itemContainer,
|
||||
!audioUrl && { marginBottom: 0 }
|
||||
]}>
|
||||
<SelectableItem
|
||||
title={t('memo.transcript', 'Transkript')}
|
||||
isSelected={isTranscriptSelected}
|
||||
onToggle={() => setIsTranscriptSelected(!isTranscriptSelected)}
|
||||
/>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* Audio-Sektion */}
|
||||
{audioUrl && (
|
||||
<View style={[styles.itemContainer, { marginBottom: 0 }]}>
|
||||
<SelectableItem
|
||||
title={t('memo.audio_file', 'Audio-Datei')}
|
||||
isSelected={isAudioSelected}
|
||||
onToggle={() => setIsAudioSelected(!isAudioSelected)}
|
||||
/>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
</ScrollView>
|
||||
</View>
|
||||
</BaseModal>
|
||||
);
|
||||
};
|
||||
|
||||
export default ShareModal;
|
||||
|
|
@ -0,0 +1,87 @@
|
|||
import React from 'react';
|
||||
import { View, ScrollView, Pressable } from 'react-native';
|
||||
import { useTheme } from '~/features/theme/ThemeProvider';
|
||||
import Icon from '~/components/atoms/Icon';
|
||||
import BaseModal from '~/components/atoms/BaseModal';
|
||||
import Text from '~/components/atoms/Text';
|
||||
import { Space } from '~/features/spaces';
|
||||
|
||||
interface SpaceSelectorModalProps {
|
||||
visible: boolean;
|
||||
onClose: () => void;
|
||||
spaces: Space[];
|
||||
onSelectSpace: (spaceId: string) => void;
|
||||
selectedSpaceId: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Eine Komponente, die einen Modal für die Auswahl eines Spaces anzeigt.
|
||||
*
|
||||
* Beispiel:
|
||||
* ```tsx
|
||||
* <SpaceSelectorModal
|
||||
* visible={isSpaceSelectorVisible}
|
||||
* onClose={() => setIsSpaceSelectorVisible(false)}
|
||||
* spaces={spaces}
|
||||
* onSelectSpace={handleSelectSpace}
|
||||
* selectedSpaceId={selectedSpaceId}
|
||||
* />
|
||||
* ```
|
||||
*/
|
||||
const SpaceSelectorModal = ({
|
||||
visible,
|
||||
onClose,
|
||||
spaces,
|
||||
onSelectSpace,
|
||||
selectedSpaceId,
|
||||
}: SpaceSelectorModalProps) => {
|
||||
const { isDark } = useTheme();
|
||||
|
||||
// Dynamische Klassen basierend auf dem Theme
|
||||
const headerTextClass = isDark ? 'text-white' : 'text-black';
|
||||
const emptyTextClass = isDark ? 'text-gray-400' : 'text-gray-500';
|
||||
|
||||
return (
|
||||
<BaseModal
|
||||
isVisible={visible}
|
||||
onClose={onClose}
|
||||
title="Space auswählen"
|
||||
animationType="fade"
|
||||
closeOnOverlayPress={true}
|
||||
>
|
||||
{/* Space Liste */}
|
||||
{spaces.length === 0 ? (
|
||||
<View className="items-center justify-center py-8">
|
||||
<Text className={emptyTextClass}>Keine Spaces gefunden.</Text>
|
||||
</View>
|
||||
) : (
|
||||
<ScrollView>
|
||||
<View className="gap-2">
|
||||
{spaces.map((space) => (
|
||||
<Pressable
|
||||
key={space.id}
|
||||
className="flex-row items-center p-3 rounded-lg border-l-4"
|
||||
style={{ borderLeftColor: space.color || '#4CAF50' }}
|
||||
onPress={() => onSelectSpace(space.id)}
|
||||
>
|
||||
<View className="flex-1">
|
||||
<Text className={`font-medium ${headerTextClass}`}>{space.name}</Text>
|
||||
{space.description && (
|
||||
<Text className={`text-sm ${isDark ? 'text-gray-300' : 'text-gray-600'}`}>
|
||||
{space.description}
|
||||
</Text>
|
||||
)}
|
||||
</View>
|
||||
{selectedSpaceId === space.id && (
|
||||
<Icon name="checkmark-circle" size={24} color="#4CAF50" />
|
||||
)}
|
||||
</Pressable>
|
||||
))}
|
||||
</View>
|
||||
</ScrollView>
|
||||
)}
|
||||
</BaseModal>
|
||||
);
|
||||
};
|
||||
|
||||
export default SpaceSelectorModal;
|
||||
|
|
@ -0,0 +1,183 @@
|
|||
import React, { useState, useEffect } from 'react';
|
||||
import { View, ScrollView } from 'react-native';
|
||||
import { useTheme } from '~/features/theme/ThemeProvider';
|
||||
import Text from '~/components/atoms/Text';
|
||||
import Button from '~/components/atoms/Button';
|
||||
import Input from '~/components/atoms/Input';
|
||||
import BaseModal from '~/components/atoms/BaseModal';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
interface SpeakerMapping {
|
||||
id: string;
|
||||
label: string;
|
||||
}
|
||||
|
||||
interface SpeakerLabelModalProps {
|
||||
visible: boolean;
|
||||
onClose: () => void;
|
||||
onSubmit: (speakerMappings: SpeakerMapping[]) => void;
|
||||
speakers: string[];
|
||||
initialMappings?: Record<string, string>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Modal-Komponente zur Benennung von Sprechern im Transkript
|
||||
*
|
||||
* Ermöglicht die Zuweisung von Namen zu Sprechern, die im Transkript identifiziert wurden.
|
||||
*/
|
||||
const SpeakerLabelModal: React.FC<SpeakerLabelModalProps> = ({
|
||||
visible,
|
||||
onClose,
|
||||
onSubmit,
|
||||
speakers,
|
||||
initialMappings = {},
|
||||
}) => {
|
||||
const { isDark } = useTheme();
|
||||
const { t } = useTranslation();
|
||||
|
||||
// State für die Sprecher-Mappings
|
||||
const [speakerMappings, setSpeakerMappings] = useState<SpeakerMapping[]>([]);
|
||||
const [hasInitialized, setHasInitialized] = useState(false);
|
||||
|
||||
// Initialisiere die Sprecher-Mappings nur beim ersten Öffnen des Modals
|
||||
useEffect(() => {
|
||||
if (visible && !hasInitialized) {
|
||||
// Wenn keine Sprecher vorhanden sind, erstelle Standardsprecher
|
||||
if (speakers.length === 0) {
|
||||
// Erstelle zwei Standardsprecher, wenn keine vorhanden sind
|
||||
const defaultSpeakers = [
|
||||
{
|
||||
id: 'speaker1',
|
||||
label:
|
||||
initialMappings['speaker1'] ||
|
||||
t('memo.speaker_default', 'Sprecher {{number}}', { number: 1 }),
|
||||
},
|
||||
{
|
||||
id: 'speaker2',
|
||||
label:
|
||||
initialMappings['speaker2'] ||
|
||||
t('memo.speaker_default', 'Sprecher {{number}}', { number: 2 }),
|
||||
},
|
||||
];
|
||||
setSpeakerMappings(defaultSpeakers);
|
||||
} else {
|
||||
const initializedMappings = speakers.map((speakerId) => ({
|
||||
id: speakerId,
|
||||
label:
|
||||
initialMappings[speakerId] ||
|
||||
t('memo.speaker_default', 'Sprecher {{number}}', {
|
||||
number: speakerId.replace('speaker', ''),
|
||||
}),
|
||||
}));
|
||||
setSpeakerMappings(initializedMappings);
|
||||
}
|
||||
setHasInitialized(true);
|
||||
}
|
||||
|
||||
// Reset initialization flag when modal is closed
|
||||
if (!visible && hasInitialized) {
|
||||
setHasInitialized(false);
|
||||
}
|
||||
}, [visible, speakers, initialMappings, hasInitialized]);
|
||||
|
||||
// Handler für die Änderung eines Sprecher-Labels
|
||||
const handleLabelChange = (speakerId: string, newLabel: string) => {
|
||||
setSpeakerMappings((prevMappings) =>
|
||||
prevMappings.map((mapping) =>
|
||||
mapping.id === speakerId ? { ...mapping, label: newLabel } : mapping
|
||||
)
|
||||
);
|
||||
};
|
||||
|
||||
// Handler für das Absenden des Formulars
|
||||
const handleSubmit = () => {
|
||||
onSubmit(speakerMappings);
|
||||
};
|
||||
|
||||
// Helper function to format speaker display label
|
||||
const getSpeakerLabel = (speakerId: string): string => {
|
||||
// Handle prefixed speaker IDs from additional recordings (e.g., rec0_speaker1)
|
||||
const prefixedMatch = speakerId.match(/^rec(\d+)_speaker(\d+)$/i);
|
||||
if (prefixedMatch) {
|
||||
const recordingIndex = parseInt(prefixedMatch[1], 10);
|
||||
const speakerNumber = prefixedMatch[2];
|
||||
// Format as "Recording X - Speaker Y"
|
||||
return t(
|
||||
'memo.recording_speaker',
|
||||
'Aufnahme {{recordingNumber}} - Sprecher {{speakerNumber}}',
|
||||
{
|
||||
recordingNumber: recordingIndex + 1,
|
||||
speakerNumber: speakerNumber,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
// Handle regular speaker IDs
|
||||
const match = speakerId.match(/speaker(\d+)/i);
|
||||
if (match) {
|
||||
const number = match[1];
|
||||
return t('memo.speaker_default', 'Sprecher {{number}}', { number });
|
||||
}
|
||||
|
||||
// Fallback
|
||||
return speakerId;
|
||||
};
|
||||
|
||||
// Render the content of the modal
|
||||
const renderContent = () => (
|
||||
<View className="w-full">
|
||||
<Text className={`mb-6 text-center text-base ${isDark ? 'text-white/80' : 'text-black/80'}`}>
|
||||
{t('memo.name_speakers_description', 'Ordne jedem Sprecher im Transkript einen Namen zu')}
|
||||
</Text>
|
||||
|
||||
<ScrollView className="max-h-[400px] w-full">
|
||||
{speakerMappings.map((mapping) => (
|
||||
<View key={mapping.id} className="mb-4 w-full">
|
||||
<Text className={`mb-1 ${isDark ? 'text-white/80' : 'text-black/80'}`}>
|
||||
{getSpeakerLabel(mapping.id)}:
|
||||
</Text>
|
||||
<Input
|
||||
value={mapping.label}
|
||||
onChangeText={(text) => handleLabelChange(mapping.id, text)}
|
||||
placeholder={t('memo.speaker_name_placeholder', 'Namen eingeben', {
|
||||
id: mapping.id.replace('speaker', ''),
|
||||
})}
|
||||
/>
|
||||
</View>
|
||||
))}
|
||||
</ScrollView>
|
||||
</View>
|
||||
);
|
||||
|
||||
// Render the footer with action buttons
|
||||
const renderFooter = () => (
|
||||
<View className="w-full flex-row justify-between">
|
||||
<Button
|
||||
title={t('common.cancel', 'Abbrechen')}
|
||||
onPress={onClose}
|
||||
variant="secondary"
|
||||
style={{ flex: 1, marginRight: 8 }}
|
||||
/>
|
||||
<Button
|
||||
title={t('common.save', 'Speichern')}
|
||||
onPress={handleSubmit}
|
||||
variant="primary"
|
||||
style={{ flex: 1 }}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
|
||||
return (
|
||||
<BaseModal
|
||||
isVisible={visible}
|
||||
onClose={onClose}
|
||||
title={t('memo.name_speakers', 'Sprecher benennen')}
|
||||
animationType="fade"
|
||||
closeOnOverlayPress={true}
|
||||
footerContent={renderFooter()}>
|
||||
{renderContent()}
|
||||
</BaseModal>
|
||||
);
|
||||
};
|
||||
|
||||
export default SpeakerLabelModal;
|
||||
|
|
@ -0,0 +1,62 @@
|
|||
import React, { useEffect, useRef } from 'react';
|
||||
import { View, Animated, ViewStyle } from 'react-native';
|
||||
import { useTheme } from '~/features/theme/ThemeProvider';
|
||||
|
||||
interface SpinnerAnimationProps {
|
||||
size?: number;
|
||||
style?: ViewStyle;
|
||||
color?: string;
|
||||
thickness?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Minimalistischer rotierender Spinner
|
||||
* Clean und modern - inspiriert von iOS/Material Design
|
||||
*/
|
||||
export function SpinnerAnimation({
|
||||
size = 60,
|
||||
style,
|
||||
color,
|
||||
thickness = 4
|
||||
}: SpinnerAnimationProps) {
|
||||
const { colors, isDark } = useTheme();
|
||||
const rotateAnim = useRef(new Animated.Value(0)).current;
|
||||
|
||||
const spinnerColor = color || colors.primary;
|
||||
|
||||
useEffect(() => {
|
||||
const animation = Animated.loop(
|
||||
Animated.timing(rotateAnim, {
|
||||
toValue: 1,
|
||||
duration: 1000,
|
||||
useNativeDriver: true,
|
||||
})
|
||||
);
|
||||
|
||||
animation.start();
|
||||
|
||||
return () => animation.stop();
|
||||
}, [rotateAnim]);
|
||||
|
||||
const rotate = rotateAnim.interpolate({
|
||||
inputRange: [0, 1],
|
||||
outputRange: ['0deg', '360deg'],
|
||||
});
|
||||
|
||||
return (
|
||||
<View style={[{ alignItems: 'center', justifyContent: 'center' }, style]}>
|
||||
<Animated.View
|
||||
style={{
|
||||
width: size,
|
||||
height: size,
|
||||
borderRadius: size / 2,
|
||||
borderWidth: thickness,
|
||||
borderColor: 'transparent',
|
||||
borderTopColor: spinnerColor,
|
||||
borderRightColor: spinnerColor,
|
||||
transform: [{ rotate }],
|
||||
}}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
107
apps/memoro/apps/mobile/components/molecules/StableMemoTitle.tsx
Normal file
107
apps/memoro/apps/mobile/components/molecules/StableMemoTitle.tsx
Normal file
|
|
@ -0,0 +1,107 @@
|
|||
import React, { useEffect, useState, useRef } from 'react';
|
||||
import { Text, TextProps, Animated } from 'react-native';
|
||||
import { useMemoProcessing } from '~/features/memos/contexts/MemoProcessingContext';
|
||||
|
||||
interface StableMemoTitleProps extends TextProps {
|
||||
memoId: string;
|
||||
titleClasses?: string;
|
||||
numberOfLines?: number;
|
||||
ellipsizeMode?: 'head' | 'middle' | 'tail' | 'clip';
|
||||
}
|
||||
|
||||
/**
|
||||
* A specialized component that renders ONLY the memo title
|
||||
* and updates it without causing parent re-renders
|
||||
*/
|
||||
const StableMemoTitle: React.FC<StableMemoTitleProps> = ({
|
||||
memoId,
|
||||
titleClasses,
|
||||
numberOfLines = 2,
|
||||
ellipsizeMode = 'tail',
|
||||
style,
|
||||
...rest
|
||||
}) => {
|
||||
// Get the display title from context
|
||||
const { getDisplayTitle, registerForUpdates } = useMemoProcessing();
|
||||
|
||||
// Local state for the title (will only update this component)
|
||||
const [title, setTitle] = useState(() => getDisplayTitle(memoId) || 'New Recording');
|
||||
|
||||
// Animation value for smooth transitions
|
||||
const fadeAnim = useRef(new Animated.Value(1)).current;
|
||||
|
||||
// Direct polling for title updates to ensure we don't miss any
|
||||
useEffect(() => {
|
||||
// Poll for title updates every 500ms
|
||||
const pollInterval = setInterval(() => {
|
||||
const newTitle = getDisplayTitle(memoId);
|
||||
if (newTitle && newTitle !== title) {
|
||||
console.debug(`🔄 StableMemoTitle: Updating title from "${title}" to "${newTitle}"`);
|
||||
|
||||
// Fade out
|
||||
Animated.timing(fadeAnim, {
|
||||
toValue: 0,
|
||||
duration: 100,
|
||||
useNativeDriver: true,
|
||||
}).start(() => {
|
||||
// Update title
|
||||
setTitle(newTitle);
|
||||
|
||||
// Fade in
|
||||
Animated.timing(fadeAnim, {
|
||||
toValue: 1,
|
||||
duration: 100,
|
||||
useNativeDriver: true,
|
||||
}).start();
|
||||
});
|
||||
}
|
||||
}, 500);
|
||||
|
||||
// Also register for immediate updates from the context
|
||||
const unregister = registerForUpdates(() => {
|
||||
const newTitle = getDisplayTitle(memoId);
|
||||
if (newTitle && newTitle !== title) {
|
||||
console.debug(`🔔 StableMemoTitle: Context update - title from "${title}" to "${newTitle}"`);
|
||||
|
||||
// Fade out
|
||||
Animated.timing(fadeAnim, {
|
||||
toValue: 0,
|
||||
duration: 100,
|
||||
useNativeDriver: true,
|
||||
}).start(() => {
|
||||
// Update title
|
||||
setTitle(newTitle);
|
||||
|
||||
// Fade in
|
||||
Animated.timing(fadeAnim, {
|
||||
toValue: 1,
|
||||
duration: 100,
|
||||
useNativeDriver: true,
|
||||
}).start();
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Cleanup
|
||||
return () => {
|
||||
clearInterval(pollInterval);
|
||||
unregister();
|
||||
};
|
||||
}, [memoId, getDisplayTitle, registerForUpdates, title, fadeAnim]);
|
||||
|
||||
return (
|
||||
<Animated.View style={{ opacity: fadeAnim }}>
|
||||
<Text
|
||||
className={titleClasses}
|
||||
numberOfLines={numberOfLines}
|
||||
ellipsizeMode={ellipsizeMode}
|
||||
style={style}
|
||||
{...rest}
|
||||
>
|
||||
{title}
|
||||
</Text>
|
||||
</Animated.View>
|
||||
);
|
||||
};
|
||||
|
||||
export default React.memo(StableMemoTitle);
|
||||
|
|
@ -0,0 +1,120 @@
|
|||
import React, { useMemo } from 'react';
|
||||
import { Platform, View } from 'react-native';
|
||||
import Icon from '~/components/atoms/Icon';
|
||||
import { useTheme } from '~/features/theme/ThemeProvider';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
// @expo/ui ContextMenu - use appropriate platform import
|
||||
import { ContextMenu as ContextMenuiOS, Button as ButtoniOS, Host as HostiOS } from '@expo/ui/swift-ui';
|
||||
import { ContextMenu as ContextMenuAndroid, Button as ButtonAndroid } from '@expo/ui/jetpack-compose';
|
||||
|
||||
// Select the correct components based on platform
|
||||
const ContextMenu = Platform.OS === 'ios' ? ContextMenuiOS : ContextMenuAndroid;
|
||||
const ExpoButton = Platform.OS === 'ios' ? ButtoniOS : ButtonAndroid;
|
||||
const Host = Platform.OS === 'ios' ? HostiOS : View;
|
||||
|
||||
interface TableOfContentsItem {
|
||||
id: string;
|
||||
title: string;
|
||||
icon: string;
|
||||
onPress: () => void;
|
||||
}
|
||||
|
||||
interface TableOfContentsMenuProps {
|
||||
items: TableOfContentsItem[];
|
||||
}
|
||||
|
||||
// Maximum number of items to prevent performance issues
|
||||
const MAX_MENU_ITEMS = 50;
|
||||
|
||||
/**
|
||||
* TableOfContentsMenu - Ein natives Dropdown-Menü für die Navigation innerhalb eines Memos
|
||||
*
|
||||
* Zeigt verschiedene Abschnitte wie Überschrift, Memory-Titel, Audiodatei, Transkript an
|
||||
* und ermöglicht das Scrollen zu diesen Abschnitten.
|
||||
* Nutzt @expo/ui ContextMenu für native Menus auf iOS und Android.
|
||||
*/
|
||||
const TableOfContentsMenu: React.FC<TableOfContentsMenuProps> = ({
|
||||
items,
|
||||
}) => {
|
||||
const { isDark } = useTheme();
|
||||
const { t } = useTranslation();
|
||||
|
||||
const iconColor = isDark ? '#FFFFFF' : '#000000';
|
||||
|
||||
const getSystemIconForIcon = (iconName: string) => {
|
||||
switch (iconName) {
|
||||
case 'text-outline':
|
||||
return 'textformat';
|
||||
case 'document-text-outline':
|
||||
return 'doc.text';
|
||||
case 'reader-outline':
|
||||
return 'book';
|
||||
case 'play-outline':
|
||||
return 'play.circle';
|
||||
case 'document-outline':
|
||||
return 'doc.plaintext';
|
||||
default:
|
||||
return 'doc.text';
|
||||
}
|
||||
};
|
||||
|
||||
// Centralized sorting function - memoized to prevent redundant calculations
|
||||
const sortedItems = useMemo(() => {
|
||||
const order = {
|
||||
'document-outline': 0, // Transcript (first in array, appears at top)
|
||||
'play-outline': 1, // Audio
|
||||
'document-text-outline': 2, // Old Memories icon
|
||||
'reader-outline': 2, // New Memories/Summary icon
|
||||
'text-outline': 3, // Title (last in array, appears at bottom)
|
||||
};
|
||||
// Limit items to prevent performance issues
|
||||
const limitedItems = items.slice(0, MAX_MENU_ITEMS);
|
||||
return [...limitedItems].sort((a, b) => {
|
||||
return (order[a.icon as keyof typeof order] ?? 999) - (order[b.icon as keyof typeof order] ?? 999);
|
||||
});
|
||||
}, [items]);
|
||||
|
||||
// Menu items
|
||||
const menuItems = useMemo(() => {
|
||||
return sortedItems.map((item) => ({
|
||||
key: item.id,
|
||||
title: item.title,
|
||||
systemIcon: getSystemIconForIcon(item.icon),
|
||||
onSelect: item.onPress,
|
||||
}));
|
||||
}, [sortedItems]);
|
||||
|
||||
return (
|
||||
<Host>
|
||||
<ContextMenu>
|
||||
<ContextMenu.Items>
|
||||
{menuItems.map((item) => (
|
||||
<ExpoButton
|
||||
key={item.key}
|
||||
{...(Platform.OS === 'ios' && { systemImage: item.systemIcon })}
|
||||
onPress={item.onSelect}
|
||||
>
|
||||
{item.title}
|
||||
</ExpoButton>
|
||||
))}
|
||||
</ContextMenu.Items>
|
||||
|
||||
<ContextMenu.Trigger>
|
||||
<View
|
||||
style={{
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
}}
|
||||
>
|
||||
<Icon name="reader-outline" size={24} color={iconColor} />
|
||||
</View>
|
||||
</ContextMenu.Trigger>
|
||||
</ContextMenu>
|
||||
</Host>
|
||||
);
|
||||
};
|
||||
|
||||
export default TableOfContentsMenu;
|
||||
170
apps/memoro/apps/mobile/components/molecules/TagList.tsx
Normal file
170
apps/memoro/apps/mobile/components/molecules/TagList.tsx
Normal file
|
|
@ -0,0 +1,170 @@
|
|||
import React, { useState } from 'react';
|
||||
import {
|
||||
ScrollView, View, StyleSheet, Pressable,
|
||||
} from 'react-native';
|
||||
import Pill from '~/components/atoms/Pill';
|
||||
import Icon from '~/components/atoms/Icon';
|
||||
import Text from '~/components/atoms/Text';
|
||||
import { useTheme } from '~/features/theme/ThemeProvider';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import colors from '~/tailwind.config.js';
|
||||
|
||||
interface TagItem {
|
||||
id: string;
|
||||
text: string;
|
||||
color?: string;
|
||||
big?: boolean;
|
||||
selected?: boolean;
|
||||
}
|
||||
|
||||
interface TagListProps {
|
||||
tags: TagItem[];
|
||||
horizontal?: boolean;
|
||||
showAddButton?: boolean;
|
||||
showAddButtonText?: boolean;
|
||||
onTagPress?: (id: string) => void;
|
||||
onAddPress?: () => void;
|
||||
}
|
||||
|
||||
function TagList({
|
||||
tags,
|
||||
horizontal = false,
|
||||
showAddButton = true,
|
||||
showAddButtonText = true,
|
||||
onTagPress,
|
||||
onAddPress,
|
||||
}: TagListProps) {
|
||||
const { isDark, themeVariant } = useTheme();
|
||||
const { t } = useTranslation();
|
||||
const [isHovered, setIsHovered] = useState(false);
|
||||
|
||||
// Farben aus der Tailwind-Konfiguration
|
||||
const themeColors = (colors as any).theme?.extend?.colors;
|
||||
|
||||
// Verwende secondaryButton aus der Tailwind-Konfiguration
|
||||
const backgroundColor = isDark
|
||||
? themeColors?.dark?.[themeVariant]?.secondaryButton || '#1E1E1E'
|
||||
: themeColors?.[themeVariant]?.secondaryButton || '#EDE89B';
|
||||
|
||||
const hoverBackgroundColor = isDark
|
||||
? themeColors?.dark?.[themeVariant]?.menuBackgroundHover || '#333333'
|
||||
: themeColors?.[themeVariant]?.menuBackgroundHover || '#cccccc';
|
||||
|
||||
const textColor = isDark ? '#FFFFFF' : '#000000';
|
||||
const borderColor = isDark
|
||||
? themeColors?.dark?.[themeVariant]?.border || '#424242'
|
||||
: themeColors?.[themeVariant]?.border || '#e6e6e6';
|
||||
const primaryColor = isDark
|
||||
? themeColors?.dark?.[themeVariant]?.primary || '#f8d62b'
|
||||
: themeColors?.[themeVariant]?.primary || '#f8d62b';
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
position: 'relative',
|
||||
width: '100%',
|
||||
minHeight: 30,
|
||||
},
|
||||
scrollView: {
|
||||
flexGrow: 0,
|
||||
width: '100%',
|
||||
},
|
||||
tagWrapper: {
|
||||
flexDirection: 'row',
|
||||
flexWrap: 'nowrap',
|
||||
alignItems: 'center',
|
||||
gap: 6,
|
||||
minHeight: 30,
|
||||
paddingHorizontal: 20,
|
||||
},
|
||||
addButton: {
|
||||
paddingHorizontal: 14, // Match Pill's horizontal padding
|
||||
paddingVertical: 6, // Match Pill's vertical padding
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
alignSelf: 'flex-start', // Match Pill's alignSelf
|
||||
backgroundColor,
|
||||
borderWidth: 1,
|
||||
borderColor,
|
||||
borderRadius: 999, // Maximale Rundung für immer runden Button
|
||||
flexDirection: 'row',
|
||||
gap: 2,
|
||||
minWidth: showAddButtonText ? 40 : 28, // Match Pill's minWidth for consistency
|
||||
marginRight: 2,
|
||||
// Remove explicit height to let content determine size naturally
|
||||
},
|
||||
addButtonText: {
|
||||
color: textColor,
|
||||
fontWeight: '500',
|
||||
textAlign: 'center', // Ensure text is centered
|
||||
// fontSize and lineHeight handled by Text component's "small" variant
|
||||
},
|
||||
addButtonHovered: {
|
||||
backgroundColor: hoverBackgroundColor,
|
||||
borderColor: primaryColor,
|
||||
},
|
||||
});
|
||||
|
||||
const handleTagPress = (tagId: string) => {
|
||||
if (onTagPress) {
|
||||
onTagPress(tagId);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<ScrollView
|
||||
horizontal={horizontal}
|
||||
showsHorizontalScrollIndicator={false}
|
||||
showsVerticalScrollIndicator={false}
|
||||
style={styles.scrollView}
|
||||
contentContainerStyle={styles.tagWrapper}
|
||||
>
|
||||
{showAddButton && (
|
||||
<Pressable
|
||||
style={[
|
||||
styles.addButton,
|
||||
isHovered && styles.addButtonHovered
|
||||
]}
|
||||
onPress={onAddPress}
|
||||
onPressIn={() => setIsHovered(true)}
|
||||
onPressOut={() => setIsHovered(false)}
|
||||
hitSlop={8}
|
||||
>
|
||||
<Icon
|
||||
name="add-outline"
|
||||
size={18}
|
||||
color={textColor}
|
||||
/>
|
||||
{showAddButtonText && (
|
||||
<Text
|
||||
variant="small"
|
||||
style={styles.addButtonText}
|
||||
numberOfLines={1}
|
||||
ellipsizeMode="tail"
|
||||
>
|
||||
{t('tags.add_tag', 'Add Tag')}
|
||||
</Text>
|
||||
)}
|
||||
</Pressable>
|
||||
)}
|
||||
{tags.filter(tag => tag.id).map((tag) => (
|
||||
<Pill
|
||||
key={tag.id}
|
||||
label={tag.text}
|
||||
color={tag.color || primaryColor}
|
||||
onPress={() => handleTagPress(tag.id)}
|
||||
isSelected={tag.selected}
|
||||
size="small"
|
||||
maxLength={15}
|
||||
style={{
|
||||
backgroundColor: `${tag.color || primaryColor}33`,
|
||||
borderColor: `${tag.color || primaryColor}77`,
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</ScrollView>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
export default TagList;
|
||||
221
apps/memoro/apps/mobile/components/molecules/TagSelector.tsx
Normal file
221
apps/memoro/apps/mobile/components/molecules/TagSelector.tsx
Normal file
|
|
@ -0,0 +1,221 @@
|
|||
import React, { useState, useEffect, useCallback } from 'react';
|
||||
import { View, StyleSheet, Pressable, Modal, ScrollView, ActivityIndicator } from 'react-native';
|
||||
import { useTheme } from '~/features/theme/ThemeProvider';
|
||||
import Text from '~/components/atoms/Text';
|
||||
import Pill from '~/components/atoms/Pill';
|
||||
import Icon from '~/components/atoms/Icon';
|
||||
import { getAuthenticatedClient } from '~/features/auth/lib/supabaseClient';
|
||||
|
||||
// Tag-Schnittstelle für die interne Verwendung
|
||||
interface TagItem {
|
||||
id: string;
|
||||
text: string;
|
||||
color: string;
|
||||
}
|
||||
|
||||
interface TagSelectorProps {
|
||||
visible: boolean;
|
||||
onClose: () => void;
|
||||
onTagSelect: (tagId: string) => void;
|
||||
selectedTagIds?: string[];
|
||||
memoId?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Eine Komponente, die ein Modal zur Auswahl von Tags anzeigt.
|
||||
* Lädt Tags aus der Datenbank und ermöglicht die Auswahl.
|
||||
*/
|
||||
const TagSelector: React.FC<TagSelectorProps> = ({
|
||||
visible,
|
||||
onClose,
|
||||
onTagSelect,
|
||||
selectedTagIds = [],
|
||||
memoId
|
||||
}) => {
|
||||
const { isDark } = useTheme();
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [tagItems, setTagItems] = useState<TagItem[]>([]);
|
||||
|
||||
// Hilfsfunktion zum Konvertieren von Tags aus der Datenbank in TagItems
|
||||
const convertTagToTagItem = (tag: any): TagItem => {
|
||||
// Verwende die Farbe direkt aus dem style-Objekt oder einen Standardwert
|
||||
const tagColor = tag.style?.color || '#4FC3F7';
|
||||
|
||||
return {
|
||||
id: tag.id,
|
||||
text: tag.name,
|
||||
color: tagColor,
|
||||
};
|
||||
};
|
||||
|
||||
// Lade alle verfügbaren Tags
|
||||
const loadTags = useCallback(async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
// Authentifizierten Client holen
|
||||
const supabase = await getAuthenticatedClient();
|
||||
|
||||
const { data, error } = await supabase
|
||||
.from('tags')
|
||||
.select('*')
|
||||
.order('is_pinned', { ascending: false })
|
||||
.order('sort_order', { ascending: true })
|
||||
.order('created_at', { ascending: false });
|
||||
|
||||
if (error) {
|
||||
console.debug('Fehler beim Laden der Tags:', error);
|
||||
throw error;
|
||||
}
|
||||
|
||||
// Tags in TagItems konvertieren
|
||||
const items = (data || []).map(convertTagToTagItem);
|
||||
setTagItems(items);
|
||||
} catch (error) {
|
||||
console.debug('Fehler beim Laden der Tags:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Tags beim ersten Laden abrufen
|
||||
useEffect(() => {
|
||||
if (visible) {
|
||||
loadTags();
|
||||
}
|
||||
}, [visible, loadTags]);
|
||||
|
||||
// Farben für das Modal
|
||||
const overlayColor = isDark ? 'rgba(0, 0, 0, 0.6)' : 'rgba(0, 0, 0, 0.4)';
|
||||
const backgroundColor = isDark ? '#1E1E1E' : '#FFFFFF';
|
||||
const borderColor = isDark ? '#333333' : '#E0E0E0';
|
||||
const textColor = isDark ? '#FFFFFF' : '#000000';
|
||||
const headerBgColor = isDark ? '#2D2D2D' : '#F5F5F5';
|
||||
|
||||
return (
|
||||
<Modal
|
||||
visible={visible}
|
||||
transparent
|
||||
animationType="fade"
|
||||
onRequestClose={onClose}
|
||||
>
|
||||
<Pressable
|
||||
style={[styles.modalContainer, { backgroundColor: overlayColor }]}
|
||||
onPress={onClose} // Schließen des Modals beim Klick auf den Hintergrund
|
||||
>
|
||||
<Pressable
|
||||
style={[styles.modalContent, { backgroundColor, borderColor }]}
|
||||
onPress={(e) => e.stopPropagation()} // Verhindert, dass Klicks auf den Inhalt das Modal schließen
|
||||
>
|
||||
{/* Header */}
|
||||
<View style={[styles.header, { backgroundColor: headerBgColor }]}>
|
||||
<Text style={[styles.title, { color: textColor }]}>Tags auswählen</Text>
|
||||
<Pressable style={styles.closeButton} onPress={onClose}>
|
||||
<Icon name="close-outline" size={24} color={textColor} />
|
||||
</Pressable>
|
||||
</View>
|
||||
|
||||
{/* Tags */}
|
||||
{loading ? (
|
||||
<View style={styles.loadingContainer}>
|
||||
<ActivityIndicator size="large" color={isDark ? '#FFFFFF' : '#000000'} />
|
||||
<Text style={[styles.loadingText, { color: textColor }]}>Lade Tags...</Text>
|
||||
</View>
|
||||
) : tagItems.length === 0 ? (
|
||||
<View style={styles.emptyContainer}>
|
||||
<Text style={[styles.emptyText, { color: isDark ? '#AAAAAA' : '#666666' }]}>Keine Tags gefunden.</Text>
|
||||
</View>
|
||||
) : (
|
||||
<ScrollView style={styles.scrollContainer}>
|
||||
<View style={styles.tagsContainer}>
|
||||
{tagItems.map((tagItem) => {
|
||||
// Stelle sicher, dass die Farbe einen gültigen Wert hat
|
||||
const tagColor = tagItem.color || '#4FC3F7';
|
||||
const isSelected = selectedTagIds.includes(tagItem.id);
|
||||
|
||||
return (
|
||||
<View key={tagItem.id} style={styles.tagWrapper}>
|
||||
<Pill
|
||||
label={tagItem.text}
|
||||
color={tagColor}
|
||||
isSelected={isSelected}
|
||||
onPress={() => onTagSelect(tagItem.id)}
|
||||
size="small"
|
||||
maxLength={20}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
})}
|
||||
</View>
|
||||
</ScrollView>
|
||||
)}
|
||||
</Pressable>
|
||||
</Pressable>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
modalContainer: {
|
||||
flex: 1,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
},
|
||||
modalContent: {
|
||||
width: '90%',
|
||||
maxHeight: '80%',
|
||||
borderRadius: 12,
|
||||
overflow: 'hidden',
|
||||
borderWidth: 1,
|
||||
shadowColor: '#000',
|
||||
shadowOffset: { width: 0, height: 2 },
|
||||
shadowOpacity: 0.25,
|
||||
shadowRadius: 3.84,
|
||||
elevation: 5,
|
||||
},
|
||||
header: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
padding: 16,
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: 'rgba(0, 0, 0, 0.1)',
|
||||
},
|
||||
title: {
|
||||
fontSize: 18,
|
||||
fontWeight: 'bold',
|
||||
},
|
||||
closeButton: {
|
||||
padding: 8,
|
||||
},
|
||||
scrollContainer: {
|
||||
maxHeight: '70%',
|
||||
},
|
||||
tagsContainer: {
|
||||
flexDirection: 'row',
|
||||
flexWrap: 'wrap',
|
||||
padding: 16,
|
||||
gap: 12,
|
||||
},
|
||||
tagWrapper: {
|
||||
marginBottom: 8,
|
||||
},
|
||||
loadingContainer: {
|
||||
padding: 24,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
loadingText: {
|
||||
marginTop: 12,
|
||||
fontSize: 16,
|
||||
},
|
||||
emptyContainer: {
|
||||
padding: 24,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
emptyText: {
|
||||
fontSize: 16,
|
||||
},
|
||||
});
|
||||
|
||||
export default TagSelector;
|
||||
244
apps/memoro/apps/mobile/components/molecules/Toolbar.tsx
Normal file
244
apps/memoro/apps/mobile/components/molecules/Toolbar.tsx
Normal file
|
|
@ -0,0 +1,244 @@
|
|||
import React from 'react';
|
||||
import { View, Text, ScrollView } from 'react-native';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useTheme } from '~/features/theme/ThemeProvider';
|
||||
import Button from '~/components/atoms/Button';
|
||||
|
||||
interface ToolbarAction {
|
||||
icon: string;
|
||||
label: string;
|
||||
onPress: () => void;
|
||||
color?: string;
|
||||
variant?: 'primary' | 'secondary' | 'danger';
|
||||
}
|
||||
|
||||
interface ToolbarProps {
|
||||
// Position options
|
||||
position: 'top' | 'bottom';
|
||||
|
||||
// Layout options
|
||||
layout?: 'row' | 'column';
|
||||
|
||||
// Content options
|
||||
title?: string;
|
||||
primaryActions?: ToolbarAction[];
|
||||
secondaryActions?: ToolbarAction[];
|
||||
additionalActions?: ToolbarAction[];
|
||||
|
||||
// For selection toolbar specific
|
||||
selectedCount?: number;
|
||||
|
||||
// For edit toolbar specific
|
||||
onSave?: () => void;
|
||||
onCancel?: () => void;
|
||||
|
||||
// Style options
|
||||
scrollable?: boolean;
|
||||
className?: string;
|
||||
backgroundColor?: string;
|
||||
disableAbsolutePosition?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* A unified toolbar component that can be used for various purposes like
|
||||
* editing, selection, or other actions. It can be positioned at the top or bottom
|
||||
* of the screen and can have different layouts.
|
||||
*/
|
||||
const Toolbar = ({
|
||||
position = 'top',
|
||||
layout = 'row',
|
||||
title,
|
||||
primaryActions = [],
|
||||
secondaryActions = [],
|
||||
additionalActions = [],
|
||||
selectedCount,
|
||||
onSave,
|
||||
onCancel,
|
||||
scrollable = false,
|
||||
className = '',
|
||||
backgroundColor,
|
||||
disableAbsolutePosition = false,
|
||||
}: ToolbarProps): React.ReactElement => {
|
||||
const { isDark } = useTheme();
|
||||
const { t } = useTranslation();
|
||||
|
||||
// Dynamic classes based on theme
|
||||
const containerBgClass = isDark ? 'bg-gray-800' : 'bg-gray-200';
|
||||
const textColorClass = isDark ? 'text-white' : 'text-black';
|
||||
|
||||
// Use custom background color if provided
|
||||
const finalBackgroundColor = backgroundColor || (isDark ? '#1F1F1F' : '#E5E5EA');
|
||||
|
||||
// Position classes
|
||||
const positionClass = position === 'top'
|
||||
? 'top-0 left-0 right-0 z-50'
|
||||
: 'bottom-0 left-0 right-0 z-50';
|
||||
|
||||
// Layout classes
|
||||
const layoutClass = layout === 'row'
|
||||
? 'flex-row justify-between'
|
||||
: 'flex-col justify-center';
|
||||
|
||||
// Prepare actions
|
||||
const allActions = [
|
||||
...(onCancel ? [{
|
||||
icon: 'close-outline',
|
||||
label: t('common.cancel', 'Cancel'),
|
||||
onPress: onCancel,
|
||||
variant: 'secondary' as const,
|
||||
}] : []),
|
||||
...primaryActions,
|
||||
...additionalActions,
|
||||
...(onSave ? [{
|
||||
icon: 'checkmark-outline',
|
||||
label: t('common.save', 'Save'),
|
||||
onPress: onSave,
|
||||
variant: 'primary' as const,
|
||||
}] : []),
|
||||
...secondaryActions,
|
||||
];
|
||||
|
||||
// Render the content
|
||||
const renderContent = () => {
|
||||
// If we have a selection count, show title, actions, and count
|
||||
if (selectedCount !== undefined) {
|
||||
return (
|
||||
<>
|
||||
<Text className={`text-base font-medium mb-3 ml-3 ${textColorClass}`}>
|
||||
{selectedCount === 1
|
||||
? t('memo.process_selected_singular', 'Ausgewähltes {{count}} Memo verarbeiten:', { count: selectedCount })
|
||||
: t('memo.process_selected_plural', 'Ausgewählte {{count}} Memos verarbeiten:', { count: selectedCount })
|
||||
}
|
||||
</Text>
|
||||
|
||||
{renderActions()}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// If we have a title, show it
|
||||
if (title) {
|
||||
return (
|
||||
<>
|
||||
<Text className={`text-base font-medium mb-3 ml-3 ${textColorClass}`}>
|
||||
{title}
|
||||
</Text>
|
||||
|
||||
{renderActions()}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// Otherwise just show the actions
|
||||
return renderActions();
|
||||
};
|
||||
|
||||
// Render the actions
|
||||
const renderActions = () => {
|
||||
if (scrollable) {
|
||||
return (
|
||||
<ScrollView
|
||||
horizontal
|
||||
showsHorizontalScrollIndicator={false}
|
||||
contentContainerStyle={{
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: 8,
|
||||
paddingLeft: 12,
|
||||
paddingRight: 12
|
||||
}}
|
||||
style={{
|
||||
width: '100%',
|
||||
flexGrow: 1
|
||||
}}
|
||||
>
|
||||
{allActions.map((action, index) => renderAction(action, index))}
|
||||
</ScrollView>
|
||||
);
|
||||
}
|
||||
|
||||
if (layout === 'row') {
|
||||
return (
|
||||
<>
|
||||
{/* Left side */}
|
||||
{onCancel && (
|
||||
<Button
|
||||
title={t('common.cancel', 'Cancel')}
|
||||
variant="secondary"
|
||||
iconName="close-outline"
|
||||
onPress={onCancel}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Middle section - Additional actions */}
|
||||
<View className="flex-row gap-2">
|
||||
{additionalActions.map((action, index) => (
|
||||
<Button
|
||||
key={`action-${index}`}
|
||||
title={action.label}
|
||||
iconName={action.icon}
|
||||
style={{ backgroundColor: action.color || '#007AFF' }}
|
||||
onPress={action.onPress}
|
||||
/>
|
||||
))}
|
||||
</View>
|
||||
|
||||
{/* Right side */}
|
||||
{onSave && (
|
||||
<Button
|
||||
title={t('common.save', 'Save')}
|
||||
variant="primary"
|
||||
iconName="checkmark-outline"
|
||||
onPress={onSave}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<View className="flex-row flex-wrap gap-2 px-3">
|
||||
{allActions.map((action, index) => renderAction(action, index))}
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
// Render a single action
|
||||
const renderAction = (action: ToolbarAction, index: number) => {
|
||||
// Always use the Button component for consistency
|
||||
return (
|
||||
<Button
|
||||
key={`action-${index}`}
|
||||
title={action.label}
|
||||
iconName={action.icon}
|
||||
variant={action.variant || 'primary'}
|
||||
style={action.color ? { backgroundColor: action.color } : undefined}
|
||||
onPress={action.onPress}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
if (disableAbsolutePosition) {
|
||||
return (
|
||||
<View
|
||||
className={`${layoutClass} items-center px-0 py-4 w-full`}
|
||||
style={{ backgroundColor: finalBackgroundColor }}
|
||||
>
|
||||
{renderContent()}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<View className={`absolute ${positionClass} px-4 py-2 ${className}`}>
|
||||
<View
|
||||
className={`${layoutClass} items-center px-4 py-3 rounded-xl w-full ${containerBgClass} shadow-md`}
|
||||
style={{ elevation: 5 }}
|
||||
>
|
||||
{renderContent()}
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
export default Toolbar;
|
||||
|
|
@ -0,0 +1,99 @@
|
|||
import React, { useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import BaseModal from '~/components/atoms/BaseModal';
|
||||
import BaseLanguageSelector, { LanguageItem } from '~/components/molecules/BaseLanguageSelector';
|
||||
import { ALL_TRANSLATION_LANGUAGES, isOfficiallySupported } from '~/config/translationLanguages';
|
||||
|
||||
interface TranslateLanguageModalProps {
|
||||
isVisible: boolean;
|
||||
onClose: () => void;
|
||||
onConfirm: (targetLanguage: string) => Promise<boolean>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Modal zur Auswahl der Zielsprache für die Memo-Übersetzung
|
||||
* Unterstützt alle Gemini 2.0 Flash Sprachen (38 offiziell + zusätzliche)
|
||||
*/
|
||||
const TranslateLanguageModal: React.FC<TranslateLanguageModalProps> = ({
|
||||
isVisible,
|
||||
onClose,
|
||||
onConfirm,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const [selectedLanguage, setSelectedLanguage] = useState<string | null>(null);
|
||||
const [isTranslating, setIsTranslating] = useState(false);
|
||||
|
||||
// Convert translation languages to BaseLanguageSelector format
|
||||
const languagesWithMetadata: Record<string, LanguageItem> = Object.entries(
|
||||
ALL_TRANSLATION_LANGUAGES
|
||||
).reduce(
|
||||
(acc, [code, lang]) => {
|
||||
acc[code] = {
|
||||
code,
|
||||
...lang,
|
||||
isExperimental: !isOfficiallySupported(code),
|
||||
};
|
||||
return acc;
|
||||
},
|
||||
{} as Record<string, LanguageItem>
|
||||
);
|
||||
|
||||
// Handler für die Auswahl einer Sprache (nur State aktualisieren)
|
||||
const handleLanguageSelect = (languages: string[]) => {
|
||||
if (languages.length === 0) {
|
||||
setSelectedLanguage(null);
|
||||
return;
|
||||
}
|
||||
setSelectedLanguage(languages[0]); // Single selection mode
|
||||
};
|
||||
|
||||
// Handler für die Bestätigung der Übersetzung
|
||||
const handleConfirm = async () => {
|
||||
if (!selectedLanguage) return;
|
||||
|
||||
setIsTranslating(true);
|
||||
try {
|
||||
await onConfirm(selectedLanguage);
|
||||
} finally {
|
||||
setIsTranslating(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Handler für das Schließen des Modals
|
||||
const handleClose = () => {
|
||||
setSelectedLanguage(null);
|
||||
setIsTranslating(false);
|
||||
onClose();
|
||||
};
|
||||
|
||||
return (
|
||||
<BaseModal
|
||||
isVisible={isVisible}
|
||||
onClose={handleClose}
|
||||
title={t('memo.translate_select_language', 'Zielsprache für Übersetzung auswählen')}
|
||||
animationType="fade"
|
||||
closeOnOverlayPress={true}
|
||||
noPadding={true}
|
||||
size="medium"
|
||||
primaryButtonText={isTranslating ? t('memo.translating', 'Übersetze...') : t('memo.translate', 'Übersetzen')}
|
||||
primaryButtonDisabled={!selectedLanguage || isTranslating}
|
||||
primaryButtonLoading={isTranslating}
|
||||
onPrimaryButtonPress={handleConfirm}
|
||||
secondaryButtonText={t('common.cancel', 'Abbrechen')}
|
||||
onSecondaryButtonPress={handleClose}>
|
||||
<BaseLanguageSelector
|
||||
languages={languagesWithMetadata}
|
||||
selectedLanguages={selectedLanguage ? [selectedLanguage] : []}
|
||||
onSelect={handleLanguageSelect}
|
||||
mode="single"
|
||||
showAutoDetect={false}
|
||||
showExperimentalWarning={true}
|
||||
loading={false}
|
||||
height={450}
|
||||
autoSelectOnSingle={false}
|
||||
/>
|
||||
</BaseModal>
|
||||
);
|
||||
};
|
||||
|
||||
export default TranslateLanguageModal;
|
||||
|
|
@ -0,0 +1,309 @@
|
|||
import React, { useState, useEffect } from 'react';
|
||||
import { View, StyleSheet, Pressable, ActivityIndicator } from 'react-native';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useTheme } from '~/features/theme/ThemeProvider';
|
||||
import { useRouter } from 'expo-router';
|
||||
import Text from '~/components/atoms/Text';
|
||||
import Icon from '~/components/atoms/Icon';
|
||||
import { getAuthenticatedClient } from '~/features/auth/lib/supabaseClient';
|
||||
import colors from '~/tailwind.config.js';
|
||||
|
||||
interface TranslationLinksProps {
|
||||
memoId: string;
|
||||
memoMetadata?: any;
|
||||
}
|
||||
|
||||
interface TranslationInfo {
|
||||
memo_id: string;
|
||||
target_language: string;
|
||||
translated_at: string;
|
||||
translator_model?: string;
|
||||
}
|
||||
|
||||
interface OriginalInfo {
|
||||
source_memo_id: string;
|
||||
source_language: string;
|
||||
target_language: string;
|
||||
translated_at: string;
|
||||
}
|
||||
|
||||
// Sprach-Mapping für bessere Anzeige
|
||||
const LANGUAGE_NAMES: Record<string, string> = {
|
||||
'de': 'Deutsch',
|
||||
'en': 'English',
|
||||
'es': 'Español',
|
||||
'fr': 'Français',
|
||||
'it': 'Italiano',
|
||||
'pt': 'Português',
|
||||
'nl': 'Nederlands',
|
||||
'pl': 'Polski',
|
||||
'ru': 'Русский',
|
||||
'ja': '日本語',
|
||||
'ko': '한국어',
|
||||
'zh': '中文',
|
||||
'ar': 'العربية',
|
||||
'hi': 'हिन्दी',
|
||||
'tr': 'Türkçe',
|
||||
'sv': 'Svenska',
|
||||
'da': 'Dansk',
|
||||
'no': 'Norsk',
|
||||
'fi': 'Suomi',
|
||||
'cs': 'Čeština',
|
||||
'sk': 'Slovenčina',
|
||||
'hu': 'Magyar',
|
||||
'ro': 'Română',
|
||||
'bg': 'Български',
|
||||
'hr': 'Hrvatski',
|
||||
'sr': 'Српски',
|
||||
'sl': 'Slovenščina',
|
||||
'et': 'Eesti',
|
||||
'lv': 'Latviešu',
|
||||
'lt': 'Lietuvių',
|
||||
'mt': 'Malti',
|
||||
'ga': 'Gaeilge',
|
||||
'el': 'Ελληνικά',
|
||||
'uk': 'Українська',
|
||||
'bn': 'বাংলা',
|
||||
'ur': 'اردو',
|
||||
'fa': 'فارسی',
|
||||
'vi': 'Tiếng Việt',
|
||||
'id': 'Bahasa Indonesia'
|
||||
};
|
||||
|
||||
const LANGUAGE_FLAGS: Record<string, string> = {
|
||||
'de': '🇩🇪',
|
||||
'en': '🇺🇸',
|
||||
'es': '🇪🇸',
|
||||
'fr': '🇫🇷',
|
||||
'it': '🇮🇹',
|
||||
'pt': '🇵🇹',
|
||||
'nl': '🇳🇱',
|
||||
'pl': '🇵🇱',
|
||||
'ru': '🇷🇺',
|
||||
'ja': '🇯🇵',
|
||||
'ko': '🇰🇷',
|
||||
'zh': '🇨🇳',
|
||||
'ar': '🇸🇦',
|
||||
'hi': '🇮🇳',
|
||||
'tr': '🇹🇷',
|
||||
'sv': '🇸🇪',
|
||||
'da': '🇩🇰',
|
||||
'no': '🇳🇴',
|
||||
'fi': '🇫🇮',
|
||||
'cs': '🇨🇿',
|
||||
'sk': '🇸🇰',
|
||||
'hu': '🇭🇺',
|
||||
'ro': '🇷🇴',
|
||||
'bg': '🇧🇬',
|
||||
'hr': '🇭🇷',
|
||||
'sr': '🇷🇸',
|
||||
'sl': '🇸🇮',
|
||||
'et': '🇪🇪',
|
||||
'lv': '🇱🇻',
|
||||
'lt': '🇱🇹',
|
||||
'mt': '🇲🇹',
|
||||
'ga': '🇮🇪',
|
||||
'el': '🇬🇷',
|
||||
'uk': '🇺🇦',
|
||||
'bn': '🇧🇩',
|
||||
'ur': '🇵🇰',
|
||||
'fa': '🇮🇷',
|
||||
'vi': '🇻🇳',
|
||||
'id': '🇮🇩'
|
||||
};
|
||||
|
||||
/**
|
||||
* Komponente zur Anzeige von Übersetzungsverknüpfungen
|
||||
* Zeigt Links zum Original-Memo und zu verfügbaren Übersetzungen
|
||||
*/
|
||||
const TranslationLinks: React.FC<TranslationLinksProps> = ({
|
||||
memoId,
|
||||
memoMetadata
|
||||
}) => {
|
||||
const { isDark, themeVariant } = useTheme();
|
||||
const { t } = useTranslation();
|
||||
const router = useRouter();
|
||||
|
||||
const [originalMemoTitle, setOriginalMemoTitle] = useState<string>('');
|
||||
const [loadingOriginal, setLoadingOriginal] = useState(false);
|
||||
|
||||
// Prüfe ob das aktuelle Memo eine Übersetzung ist
|
||||
const translationInfo: OriginalInfo | null = memoMetadata?.translation || null;
|
||||
const isTranslation = !!translationInfo;
|
||||
|
||||
// Hole verfügbare Übersetzungen (wenn das aktuelle Memo das Original ist)
|
||||
const availableTranslations: TranslationInfo[] = memoMetadata?.translations || [];
|
||||
|
||||
// Lade den Titel des Original-Memos
|
||||
useEffect(() => {
|
||||
const loadOriginalTitle = async () => {
|
||||
if (!isTranslation || !translationInfo?.source_memo_id) return;
|
||||
|
||||
setLoadingOriginal(true);
|
||||
try {
|
||||
const supabase = await getAuthenticatedClient();
|
||||
const { data: originalMemo, error } = await supabase
|
||||
.from('memos')
|
||||
.select('title')
|
||||
.eq('id', translationInfo.source_memo_id)
|
||||
.single();
|
||||
|
||||
if (!error && originalMemo) {
|
||||
setOriginalMemoTitle(originalMemo.title || t('memo.untitled', 'Ohne Titel'));
|
||||
}
|
||||
} catch (error) {
|
||||
console.debug('Error loading original memo title:', error);
|
||||
} finally {
|
||||
setLoadingOriginal(false);
|
||||
}
|
||||
};
|
||||
|
||||
loadOriginalTitle();
|
||||
}, [isTranslation, translationInfo?.source_memo_id, t]);
|
||||
|
||||
// Helper function to get language display name
|
||||
const getLanguageDisplay = (langCode: string) => {
|
||||
const flag = LANGUAGE_FLAGS[langCode] || '🌐';
|
||||
const name = LANGUAGE_NAMES[langCode] || langCode;
|
||||
return `${flag} ${name}`;
|
||||
};
|
||||
|
||||
// Handler für Navigation zum Original
|
||||
const handleNavigateToOriginal = () => {
|
||||
if (translationInfo?.source_memo_id) {
|
||||
router.push(`/(protected)/(memo)/${translationInfo.source_memo_id}`);
|
||||
}
|
||||
};
|
||||
|
||||
// Handler für Navigation zu Übersetzung
|
||||
const handleNavigateToTranslation = (translationMemoId: string) => {
|
||||
router.push(`/(protected)/(memo)/${translationMemoId}`);
|
||||
};
|
||||
|
||||
// Primärfarbe für Links
|
||||
const primaryColor = isDark
|
||||
? ((colors as any).theme?.extend?.colors?.dark)?.[themeVariant]?.primary || '#f8d62b'
|
||||
: ((colors as any).theme?.extend?.colors)?.[themeVariant]?.primary || '#f8d62b';
|
||||
|
||||
// Styles
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
backgroundColor: isDark ? 'rgba(255, 255, 255, 0.05)' : 'rgba(0, 0, 0, 0.05)',
|
||||
borderRadius: 12,
|
||||
padding: 16,
|
||||
marginBottom: 16,
|
||||
},
|
||||
header: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
marginBottom: 12,
|
||||
},
|
||||
headerText: {
|
||||
fontSize: 16,
|
||||
fontWeight: '600',
|
||||
color: isDark ? '#FFFFFF' : '#000000',
|
||||
},
|
||||
linkContainer: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
paddingVertical: 8,
|
||||
paddingHorizontal: 12,
|
||||
backgroundColor: isDark ? 'rgba(255, 255, 255, 0.1)' : 'rgba(0, 0, 0, 0.05)',
|
||||
borderRadius: 8,
|
||||
marginBottom: 8,
|
||||
},
|
||||
linkText: {
|
||||
flex: 1,
|
||||
fontSize: 14,
|
||||
color: isDark ? '#FFFFFF' : '#000000',
|
||||
},
|
||||
linkSubtext: {
|
||||
fontSize: 12,
|
||||
color: isDark ? 'rgba(255, 255, 255, 0.6)' : 'rgba(0, 0, 0, 0.6)',
|
||||
marginTop: 2,
|
||||
},
|
||||
chevronIcon: {
|
||||
marginLeft: 8,
|
||||
},
|
||||
divider: {
|
||||
height: 1,
|
||||
backgroundColor: isDark ? 'rgba(255, 255, 255, 0.1)' : 'rgba(0, 0, 0, 0.1)',
|
||||
marginVertical: 8,
|
||||
},
|
||||
});
|
||||
|
||||
// Wenn weder Original noch Übersetzungen vorhanden sind, nichts anzeigen
|
||||
if (!isTranslation && availableTranslations.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
{/* Header */}
|
||||
<View style={styles.header}>
|
||||
<Icon
|
||||
name="globe-outline"
|
||||
size={20}
|
||||
color={isDark ? '#FFFFFF' : '#000000'}
|
||||
/>
|
||||
<View style={{ width: 12 }} />
|
||||
<Text style={styles.headerText}>
|
||||
{t('memo.translations', 'Übersetzungen')}
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
{/* Link zum Original (wenn das aktuelle Memo eine Übersetzung ist) */}
|
||||
{isTranslation && translationInfo && (
|
||||
<Pressable
|
||||
style={styles.linkContainer}
|
||||
onPress={handleNavigateToOriginal}
|
||||
disabled={loadingOriginal}
|
||||
>
|
||||
<View style={{ flex: 1, flexDirection: 'row', alignItems: 'center' }}>
|
||||
<Text style={styles.linkText}>
|
||||
{loadingOriginal ? (
|
||||
<ActivityIndicator size="small" color={primaryColor} />
|
||||
) : (
|
||||
`${t('memo.original_memo', 'Original')}: ${LANGUAGE_FLAGS[translationInfo.source_language] || '🌐'} ${originalMemoTitle}`
|
||||
)}
|
||||
</Text>
|
||||
</View>
|
||||
<Icon
|
||||
name="chevron-forward"
|
||||
size={16}
|
||||
color={isDark ? 'rgba(255, 255, 255, 0.6)' : 'rgba(0, 0, 0, 0.6)'}
|
||||
style={styles.chevronIcon}
|
||||
/>
|
||||
</Pressable>
|
||||
)}
|
||||
|
||||
{/* Trennlinie wenn beide Bereiche vorhanden sind */}
|
||||
{isTranslation && availableTranslations.length > 0 && (
|
||||
<View style={styles.divider} />
|
||||
)}
|
||||
|
||||
{/* Links zu verfügbaren Übersetzungen */}
|
||||
{availableTranslations.map((translation, index) => (
|
||||
<Pressable
|
||||
key={`${translation.memo_id}-${index}`}
|
||||
style={styles.linkContainer}
|
||||
onPress={() => handleNavigateToTranslation(translation.memo_id)}
|
||||
>
|
||||
<View style={{ flex: 1, flexDirection: 'row', alignItems: 'center' }}>
|
||||
<Text style={styles.linkText}>
|
||||
{t('memo.translation_to', 'Übersetzung in')} {LANGUAGE_FLAGS[translation.target_language] || '🌐'} {LANGUAGE_NAMES[translation.target_language] || translation.target_language}
|
||||
</Text>
|
||||
</View>
|
||||
<Icon
|
||||
name="chevron-forward"
|
||||
size={16}
|
||||
color={isDark ? 'rgba(255, 255, 255, 0.6)' : 'rgba(0, 0, 0, 0.6)'}
|
||||
style={styles.chevronIcon}
|
||||
/>
|
||||
</Pressable>
|
||||
))}
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
export default TranslationLinks;
|
||||
818
apps/memoro/apps/mobile/components/molecules/UploadModal.tsx
Normal file
818
apps/memoro/apps/mobile/components/molecules/UploadModal.tsx
Normal file
|
|
@ -0,0 +1,818 @@
|
|||
import React, { useState } from 'react';
|
||||
import { View, Pressable, ActivityIndicator, ScrollView, Platform, Alert } from 'react-native';
|
||||
import * as DocumentPicker from 'expo-document-picker';
|
||||
import DateTimePicker from '@react-native-community/datetimepicker';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useTheme } from '~/features/theme/ThemeProvider';
|
||||
import BaseModal from '~/components/atoms/BaseModal';
|
||||
import Text from '~/components/atoms/Text';
|
||||
import Icon from '~/components/atoms/Icon';
|
||||
import Button from '~/components/atoms/Button';
|
||||
import BlueprintModal from '~/components/organisms/BlueprintModal';
|
||||
import { useRecordingLanguage } from '~/features/audioRecordingV2';
|
||||
import { createClient } from '@supabase/supabase-js';
|
||||
import colors from '~/tailwind.config.js';
|
||||
import { useInsufficientCreditsStore } from '~/features/credits/store/insufficientCreditsStore';
|
||||
import { extractMediaDurationWithFormat, isSupportedMediaFile } from '~/utils/mediaUtils';
|
||||
|
||||
interface UploadModalProps {
|
||||
isVisible: boolean;
|
||||
onClose: () => void;
|
||||
onFileUpload: (file: any, language?: string, blueprint?: any, recordingDate?: Date, duration?: number) => Promise<void>;
|
||||
currentLanguage: string;
|
||||
isUploading?: boolean;
|
||||
}
|
||||
|
||||
const UploadModal: React.FC<UploadModalProps> = ({
|
||||
isVisible,
|
||||
onClose,
|
||||
onFileUpload,
|
||||
currentLanguage,
|
||||
isUploading = false
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const { isDark, themeVariant } = useTheme();
|
||||
const { recordingLanguages, toggleRecordingLanguage, supportedAzureLanguages } = useRecordingLanguage();
|
||||
const isInsufficientCreditsModalVisible = useInsufficientCreditsStore((state) => state.isModalVisible);
|
||||
|
||||
const [isLanguageSelectorVisible, setIsLanguageSelectorVisible] = useState(false);
|
||||
const [isBlueprintModalVisible, setIsBlueprintModalVisible] = useState(false);
|
||||
const [selectedBlueprint, setSelectedBlueprint] = useState<any>(null);
|
||||
const [modalMode, setModalMode] = useState<'upload' | 'language' | 'blueprint' | 'date' | 'time'>('upload');
|
||||
const [blueprints, setBlueprints] = useState<any[]>([]);
|
||||
const [blueprintsLoading, setBlueprintsLoading] = useState(false);
|
||||
const [selectedBlueprintForUpload, setSelectedBlueprintForUpload] = useState<any>(null);
|
||||
const [selectedLanguageForUpload, setSelectedLanguageForUpload] = useState<string>('auto');
|
||||
const [selectedFile, setSelectedFile] = useState<any>(null);
|
||||
const [selectedDateTime, setSelectedDateTime] = useState<Date>(new Date());
|
||||
const [showDatePicker, setShowDatePicker] = useState(false);
|
||||
const [showTimePicker, setShowTimePicker] = useState(false);
|
||||
const [hasCustomTime, setHasCustomTime] = useState(false);
|
||||
const [fileDuration, setFileDuration] = useState<{ milliseconds: number; formatted: string } | null>(null);
|
||||
const [isExtractingDuration, setIsExtractingDuration] = useState(false);
|
||||
|
||||
// Get theme colors
|
||||
const getThemeColors = () => {
|
||||
const themeColors = isDark
|
||||
? colors.theme.extend.colors.dark[themeVariant]
|
||||
: colors.theme.extend.colors[themeVariant];
|
||||
|
||||
return {
|
||||
text: themeColors?.text || (isDark ? '#FFFFFF' : '#000000'),
|
||||
contentBackground: themeColors?.contentBackground || (isDark ? '#1E1E1E' : '#FFFFFF'),
|
||||
contentBackgroundHover: themeColors?.contentBackgroundHover || (isDark ? '#333333' : '#F5F5F5'),
|
||||
border: themeColors?.border || (isDark ? '#374151' : '#E5E7EB'),
|
||||
primary: themeColors?.primary || '#f8d62b'
|
||||
};
|
||||
};
|
||||
|
||||
const themeColors = getThemeColors();
|
||||
|
||||
const handleFileSelect = async () => {
|
||||
try {
|
||||
const result = await DocumentPicker.getDocumentAsync({
|
||||
type: [
|
||||
// Audio formats
|
||||
'audio/mpeg', 'audio/mp4', 'audio/x-m4a', 'audio/m4a',
|
||||
// Video formats
|
||||
'video/mp4', 'video/quicktime', 'video/x-m4v', 'video/*'
|
||||
],
|
||||
copyToCacheDirectory: true,
|
||||
});
|
||||
|
||||
if (!result.canceled) {
|
||||
const file = result.assets[0];
|
||||
setSelectedFile(file);
|
||||
|
||||
// Extract duration from the selected file
|
||||
setIsExtractingDuration(true);
|
||||
setFileDuration(null);
|
||||
|
||||
try {
|
||||
if (!isSupportedMediaFile(file)) {
|
||||
console.warn('File may not be a supported media format, attempting extraction anyway');
|
||||
}
|
||||
|
||||
const durationResult = await extractMediaDurationWithFormat(file);
|
||||
setFileDuration({
|
||||
milliseconds: durationResult.durationMillis,
|
||||
formatted: durationResult.formatted
|
||||
});
|
||||
console.log('Duration extracted successfully:', durationResult.formatted);
|
||||
} catch (error) {
|
||||
console.error('Failed to extract duration:', error);
|
||||
Alert.alert(
|
||||
t('upload.error_title', 'Error'),
|
||||
t('upload.duration_extraction_failed', 'Could not extract media duration. The file may be corrupted or in an unsupported format.'),
|
||||
[
|
||||
{
|
||||
text: t('common.cancel', 'Cancel'),
|
||||
style: 'cancel',
|
||||
onPress: () => {
|
||||
setSelectedFile(null);
|
||||
setFileDuration(null);
|
||||
}
|
||||
},
|
||||
{
|
||||
text: t('common.try_another', 'Try Another File'),
|
||||
onPress: () => handleFileSelect()
|
||||
}
|
||||
]
|
||||
);
|
||||
} finally {
|
||||
setIsExtractingDuration(false);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error selecting file:', error);
|
||||
Alert.alert(
|
||||
t('upload.error_title', 'Error'),
|
||||
t('upload.file_selection_failed', 'Failed to select file. Please try again.')
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const handleUpload = async () => {
|
||||
console.log('handleUpload called');
|
||||
if (!selectedFile || isUploading) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Ensure we have duration before uploading
|
||||
if (!fileDuration) {
|
||||
Alert.alert(
|
||||
t('upload.error_title', 'Error'),
|
||||
t('upload.no_duration', 'Media duration could not be determined. Please select a different file.')
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
console.log('Calling onFileUpload with params:', selectedFile, selectedLanguageForUpload, selectedBlueprintForUpload, selectedDateTime, fileDuration.milliseconds);
|
||||
await onFileUpload(selectedFile, selectedLanguageForUpload, selectedBlueprintForUpload, selectedDateTime, fileDuration.milliseconds);
|
||||
// Modal wird nur geschlossen, wenn Upload erfolgreich war
|
||||
handleModalClose();
|
||||
} catch (error) {
|
||||
console.error('Upload failed:', error);
|
||||
// Modal bleibt offen bei Fehlern
|
||||
}
|
||||
};
|
||||
|
||||
const handleLanguageSelect = () => {
|
||||
console.log('Language select clicked');
|
||||
console.log('Current modalMode:', modalMode);
|
||||
setModalMode('language');
|
||||
console.log('Setting modalMode to language');
|
||||
};
|
||||
|
||||
const handleBlueprintSelect = () => {
|
||||
console.log('Blueprint select clicked');
|
||||
setModalMode('blueprint');
|
||||
loadBlueprints();
|
||||
};
|
||||
|
||||
const handleDateSelect = () => {
|
||||
console.log('Date select clicked');
|
||||
if (Platform.OS === 'ios') {
|
||||
setModalMode('date');
|
||||
} else {
|
||||
setShowDatePicker(true);
|
||||
}
|
||||
};
|
||||
|
||||
const handleTimeSelect = () => {
|
||||
console.log('Time select clicked');
|
||||
if (Platform.OS === 'ios') {
|
||||
setModalMode('time');
|
||||
} else {
|
||||
setShowTimePicker(true);
|
||||
}
|
||||
};
|
||||
|
||||
const onDateChange = (event: any, selectedDate?: Date) => {
|
||||
if (Platform.OS === 'android') {
|
||||
setShowDatePicker(false);
|
||||
}
|
||||
if (selectedDate) {
|
||||
// Preserve the existing time when changing date
|
||||
const newDateTime = new Date(selectedDateTime);
|
||||
newDateTime.setFullYear(selectedDate.getFullYear());
|
||||
newDateTime.setMonth(selectedDate.getMonth());
|
||||
newDateTime.setDate(selectedDate.getDate());
|
||||
setSelectedDateTime(newDateTime);
|
||||
// On iOS, don't automatically close - let user press "Done" button
|
||||
}
|
||||
};
|
||||
|
||||
const onTimeChange = (event: any, selectedTime?: Date) => {
|
||||
if (Platform.OS === 'android') {
|
||||
setShowTimePicker(false);
|
||||
}
|
||||
if (selectedTime) {
|
||||
// Preserve the existing date when changing time
|
||||
const newDateTime = new Date(selectedDateTime);
|
||||
newDateTime.setHours(selectedTime.getHours());
|
||||
newDateTime.setMinutes(selectedTime.getMinutes());
|
||||
setSelectedDateTime(newDateTime);
|
||||
setHasCustomTime(true);
|
||||
// On iOS, don't automatically close - let user press "Done" button
|
||||
}
|
||||
};
|
||||
|
||||
// Lade Blueprints
|
||||
const loadBlueprints = async () => {
|
||||
setBlueprintsLoading(true);
|
||||
try {
|
||||
// Verwende den direkten Supabase-Zugriff wie in der BlueprintModal
|
||||
const directSupabase = createClient(
|
||||
'https://npgifbrwhftlbrbaglmi.supabase.co',
|
||||
'sb_publishable_HlAZpB4BxXaMcfOCNx6VJA_-64NTxu4'
|
||||
);
|
||||
|
||||
const { data: blueprintData, error: blueprintError } = await directSupabase
|
||||
.from('blueprints')
|
||||
.select('*')
|
||||
.eq('is_public', true)
|
||||
.order('created_at', { ascending: false });
|
||||
|
||||
if (blueprintError) {
|
||||
console.error('Error loading blueprints:', blueprintError);
|
||||
setBlueprints([]);
|
||||
} else {
|
||||
console.log('Loaded blueprints:', blueprintData);
|
||||
setBlueprints(blueprintData || []);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error loading blueprints:', error);
|
||||
setBlueprints([]);
|
||||
} finally {
|
||||
setBlueprintsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const getSelectedLanguageDisplay = () => {
|
||||
if (selectedLanguageForUpload === 'auto') {
|
||||
return t('upload.auto_detect', 'Automatisch erkennen');
|
||||
} else {
|
||||
return supportedAzureLanguages[selectedLanguageForUpload]?.nativeName || selectedLanguageForUpload;
|
||||
}
|
||||
};
|
||||
|
||||
const getSelectedBlueprintDisplay = () => {
|
||||
if (selectedBlueprintForUpload) {
|
||||
const lang = currentLanguage.startsWith('de') ? 'de' : 'en';
|
||||
return selectedBlueprintForUpload.name?.[lang] || selectedBlueprintForUpload.name?.en || selectedBlueprintForUpload.name?.de || t('blueprints.title', 'Blueprints');
|
||||
}
|
||||
return t('upload.no_blueprint_selected', 'No blueprint selected');
|
||||
};
|
||||
|
||||
const getSelectedDateDisplay = () => {
|
||||
return selectedDateTime.toLocaleDateString(currentLanguage.startsWith('de') ? 'de-DE' : 'en-US', {
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric'
|
||||
});
|
||||
};
|
||||
|
||||
const getSelectedTimeDisplay = () => {
|
||||
return selectedDateTime.toLocaleTimeString(currentLanguage.startsWith('de') ? 'de-DE' : 'en-US', {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
});
|
||||
};
|
||||
|
||||
const renderActionButton = (
|
||||
iconName: string,
|
||||
title: string,
|
||||
subtitle: string,
|
||||
onPress: () => void,
|
||||
showChevron: boolean = true,
|
||||
isActive: boolean = false
|
||||
) => (
|
||||
<Pressable
|
||||
style={{
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
padding: 16,
|
||||
backgroundColor: themeColors.contentBackgroundHover,
|
||||
borderWidth: 1,
|
||||
borderColor: themeColors.border,
|
||||
borderRadius: 12,
|
||||
marginBottom: 12,
|
||||
shadowColor: '#000',
|
||||
shadowOffset: { width: 0, height: 1 },
|
||||
shadowOpacity: isDark ? 0.3 : 0.1,
|
||||
shadowRadius: 2,
|
||||
elevation: 2,
|
||||
}}
|
||||
onPress={onPress}
|
||||
android_ripple={{
|
||||
color: isDark ? 'rgba(255, 255, 255, 0.1)' : 'rgba(0, 0, 0, 0.1)',
|
||||
borderless: false
|
||||
}}
|
||||
>
|
||||
<View
|
||||
style={{
|
||||
width: 40,
|
||||
height: 40,
|
||||
borderRadius: 20,
|
||||
backgroundColor: isActive ? themeColors.primary : themeColors.contentBackgroundHover,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
marginRight: 16,
|
||||
borderWidth: isActive ? 0 : 1,
|
||||
borderColor: themeColors.border,
|
||||
}}
|
||||
>
|
||||
<Icon name={iconName} size={20} color={isActive ? '#000000' : themeColors.text} />
|
||||
</View>
|
||||
|
||||
<View style={{ flex: 1 }}>
|
||||
<Text
|
||||
style={{
|
||||
fontSize: 16,
|
||||
fontWeight: '600',
|
||||
color: themeColors.text,
|
||||
marginBottom: 2,
|
||||
}}
|
||||
>
|
||||
{title}
|
||||
</Text>
|
||||
<Text
|
||||
style={{
|
||||
fontSize: 14,
|
||||
color: isDark ? '#D1D5DB' : '#6B7280',
|
||||
}}
|
||||
>
|
||||
{subtitle}
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
{showChevron && (
|
||||
<Icon
|
||||
name="chevron-forward"
|
||||
size={20}
|
||||
color={isDark ? '#9CA3AF' : '#6B7280'}
|
||||
/>
|
||||
)}
|
||||
</Pressable>
|
||||
);
|
||||
|
||||
// Bestimme den Modal-Titel basierend auf dem Modus
|
||||
const getModalTitle = () => {
|
||||
console.log('getModalTitle called with modalMode:', modalMode);
|
||||
switch (modalMode) {
|
||||
case 'language':
|
||||
return t('upload.select_recording_language', 'Aufnahmesprache auswählen');
|
||||
case 'blueprint':
|
||||
return t('upload.select_blueprint', 'Blueprint auswählen');
|
||||
case 'date':
|
||||
return t('upload.select_date', 'Datum auswählen');
|
||||
case 'time':
|
||||
return t('upload.select_time', 'Uhrzeit auswählen');
|
||||
default:
|
||||
return t('upload.title', 'Audio/Video hochladen');
|
||||
}
|
||||
};
|
||||
|
||||
// Bestimme den Modal-Inhalt basierend auf dem Modus
|
||||
const renderModalContent = () => {
|
||||
console.log('renderModalContent called with modalMode:', modalMode);
|
||||
switch (modalMode) {
|
||||
case 'language':
|
||||
return (
|
||||
<ScrollView style={{ maxHeight: 400 }} showsVerticalScrollIndicator={true}>
|
||||
{Object.entries(supportedAzureLanguages)
|
||||
.sort(([a], [b]) => {
|
||||
if (a === 'auto') return -1;
|
||||
if (b === 'auto') return 1;
|
||||
// Ausgewählte Sprache zuerst, dann alphabetisch
|
||||
const aSelected = selectedLanguageForUpload === a;
|
||||
const bSelected = selectedLanguageForUpload === b;
|
||||
if (aSelected && !bSelected) return -1;
|
||||
if (!aSelected && bSelected) return 1;
|
||||
return supportedAzureLanguages[a].nativeName.localeCompare(supportedAzureLanguages[b].nativeName);
|
||||
})
|
||||
.map(([code, language]) => {
|
||||
const isSelected = selectedLanguageForUpload === code;
|
||||
return (
|
||||
<Pressable
|
||||
key={code}
|
||||
style={{
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
padding: 16,
|
||||
backgroundColor: isSelected ? themeColors.contentBackgroundHover : 'transparent',
|
||||
borderWidth: 1,
|
||||
borderColor: isSelected ? themeColors.primary : themeColors.border,
|
||||
borderRadius: 12,
|
||||
marginBottom: 8,
|
||||
}}
|
||||
onPress={() => {
|
||||
console.log('Language selected:', code);
|
||||
setSelectedLanguageForUpload(code); // Setze nur eine einzelne Sprache
|
||||
setModalMode('upload'); // Zurück zum Upload-Modal
|
||||
}}
|
||||
>
|
||||
<Text style={{ fontSize: 20, marginRight: 16 }}>
|
||||
{language.emoji}
|
||||
</Text>
|
||||
<View style={{ flex: 1 }}>
|
||||
<Text
|
||||
style={{
|
||||
fontSize: 16,
|
||||
fontWeight: isSelected ? '600' : '400',
|
||||
color: themeColors.text,
|
||||
}}
|
||||
>
|
||||
{language.nativeName}
|
||||
</Text>
|
||||
</View>
|
||||
{isSelected && (
|
||||
<View
|
||||
style={{
|
||||
width: 24,
|
||||
height: 24,
|
||||
borderRadius: 12,
|
||||
backgroundColor: themeColors.primary,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
}}
|
||||
>
|
||||
<Icon name="checkmark" size={16} color={isDark ? '#000000' : '#000000'} />
|
||||
</View>
|
||||
)}
|
||||
</Pressable>
|
||||
);
|
||||
})}
|
||||
</ScrollView>
|
||||
);
|
||||
|
||||
case 'blueprint':
|
||||
return (
|
||||
<ScrollView style={{ maxHeight: 400 }} showsVerticalScrollIndicator={true}>
|
||||
{blueprintsLoading ? (
|
||||
<View style={{ padding: 20, alignItems: 'center' }}>
|
||||
<ActivityIndicator size="large" color={themeColors.primary} />
|
||||
<Text style={{ color: themeColors.text, marginTop: 10 }}>
|
||||
{t('upload.loading_blueprints', 'Blueprints werden geladen...')}
|
||||
</Text>
|
||||
</View>
|
||||
) : blueprints.length === 0 ? (
|
||||
<View style={{ padding: 20, alignItems: 'center' }}>
|
||||
<Text style={{ color: themeColors.text }}>
|
||||
{t('upload.no_blueprints_available', 'Keine Blueprints verfügbar')}
|
||||
</Text>
|
||||
</View>
|
||||
) : (
|
||||
blueprints.map((blueprint) => {
|
||||
const isSelected = selectedBlueprintForUpload?.id === blueprint.id;
|
||||
const lang = currentLanguage.startsWith('de') ? 'de' : 'en';
|
||||
const displayName = blueprint.name?.[lang] || blueprint.name?.en || blueprint.name?.de || 'Blueprint';
|
||||
const displayDescription = blueprint.description?.[lang] || blueprint.description?.en || blueprint.description?.de || '';
|
||||
|
||||
return (
|
||||
<Pressable
|
||||
key={blueprint.id}
|
||||
style={{
|
||||
padding: 16,
|
||||
backgroundColor: isSelected ? themeColors.contentBackgroundHover : 'transparent',
|
||||
borderWidth: 1,
|
||||
borderColor: isSelected ? themeColors.primary : themeColors.border,
|
||||
borderRadius: 12,
|
||||
marginBottom: 8,
|
||||
}}
|
||||
onPress={() => {
|
||||
console.log('Blueprint selected:', blueprint.id);
|
||||
setSelectedBlueprintForUpload(blueprint);
|
||||
setModalMode('upload'); // Zurück zum Upload-Modal
|
||||
}}
|
||||
>
|
||||
<View style={{ flexDirection: 'row', alignItems: 'center' }}>
|
||||
<View
|
||||
style={{
|
||||
width: 40,
|
||||
height: 40,
|
||||
borderRadius: 20,
|
||||
backgroundColor: themeColors.primary,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
marginRight: 12,
|
||||
}}
|
||||
>
|
||||
<Icon name="document-text" size={20} color={isDark ? '#000000' : '#000000'} />
|
||||
</View>
|
||||
|
||||
<View style={{ flex: 1 }}>
|
||||
<Text
|
||||
style={{
|
||||
fontSize: 16,
|
||||
fontWeight: isSelected ? '600' : '500',
|
||||
color: themeColors.text,
|
||||
marginBottom: 2,
|
||||
}}
|
||||
>
|
||||
{displayName}
|
||||
</Text>
|
||||
{displayDescription ? (
|
||||
<Text
|
||||
style={{
|
||||
fontSize: 14,
|
||||
color: isDark ? '#D1D5DB' : '#6B7280',
|
||||
lineHeight: 18,
|
||||
}}
|
||||
numberOfLines={2}
|
||||
ellipsizeMode="tail"
|
||||
>
|
||||
{displayDescription}
|
||||
</Text>
|
||||
) : null}
|
||||
</View>
|
||||
|
||||
{isSelected && (
|
||||
<View
|
||||
style={{
|
||||
width: 24,
|
||||
height: 24,
|
||||
borderRadius: 12,
|
||||
backgroundColor: themeColors.primary,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
}}
|
||||
>
|
||||
<Icon name="checkmark" size={16} color={isDark ? '#000000' : '#000000'} />
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
</Pressable>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</ScrollView>
|
||||
);
|
||||
|
||||
case 'date':
|
||||
return (
|
||||
<View style={{ padding: 20, alignItems: 'center' }}>
|
||||
<DateTimePicker
|
||||
value={selectedDateTime}
|
||||
mode="date"
|
||||
display={Platform.OS === 'ios' ? 'spinner' : 'default'}
|
||||
onChange={onDateChange}
|
||||
style={{
|
||||
backgroundColor: themeColors.contentBackground,
|
||||
}}
|
||||
themeVariant={isDark ? 'dark' : 'light'}
|
||||
/>
|
||||
{Platform.OS === 'ios' && (
|
||||
<Button
|
||||
title={t('common.done', 'Fertig')}
|
||||
onPress={() => setModalMode('upload')}
|
||||
variant="primary"
|
||||
style={{ marginTop: 20, minWidth: 120 }}
|
||||
/>
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
|
||||
case 'time':
|
||||
return (
|
||||
<View style={{ padding: 20, alignItems: 'center' }}>
|
||||
<DateTimePicker
|
||||
value={selectedDateTime}
|
||||
mode="time"
|
||||
display={Platform.OS === 'ios' ? 'spinner' : 'default'}
|
||||
onChange={onTimeChange}
|
||||
style={{
|
||||
backgroundColor: themeColors.contentBackground,
|
||||
}}
|
||||
themeVariant={isDark ? 'dark' : 'light'}
|
||||
/>
|
||||
{Platform.OS === 'ios' && (
|
||||
<Button
|
||||
title={t('common.done', 'Fertig')}
|
||||
onPress={() => setModalMode('upload')}
|
||||
variant="primary"
|
||||
style={{ marginTop: 20, minWidth: 120 }}
|
||||
/>
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
|
||||
default:
|
||||
return (
|
||||
<View style={{ width: '100%' }}>
|
||||
{isUploading ? (
|
||||
// Upload-Progress UI
|
||||
<View style={{ alignItems: 'center', paddingVertical: 40 }}>
|
||||
<ActivityIndicator size="large" color={themeColors.primary} />
|
||||
<Text
|
||||
style={{
|
||||
fontSize: 16,
|
||||
fontWeight: '600',
|
||||
color: themeColors.text,
|
||||
marginTop: 16,
|
||||
marginBottom: 8,
|
||||
}}
|
||||
>
|
||||
{t('upload.uploading', 'Datei wird hochgeladen...')}
|
||||
</Text>
|
||||
<Text
|
||||
style={{
|
||||
fontSize: 14,
|
||||
color: isDark ? '#D1D5DB' : '#6B7280',
|
||||
textAlign: 'center',
|
||||
lineHeight: 20,
|
||||
}}
|
||||
>
|
||||
{selectedFile?.name}
|
||||
</Text>
|
||||
<Text
|
||||
style={{
|
||||
fontSize: 12,
|
||||
color: isDark ? '#9CA3AF' : '#9CA3AF',
|
||||
textAlign: 'center',
|
||||
marginTop: 8,
|
||||
}}
|
||||
>
|
||||
{t('upload.processing_info', 'Die Datei wird verarbeitet und transkribiert. Dies kann einen Moment dauern.')}
|
||||
</Text>
|
||||
</View>
|
||||
) : (
|
||||
// Normale Upload-Konfiguration UI
|
||||
<>
|
||||
<Text
|
||||
style={{
|
||||
fontSize: 14,
|
||||
color: isDark ? '#D1D5DB' : '#6B7280',
|
||||
marginBottom: 24,
|
||||
lineHeight: 20,
|
||||
}}
|
||||
>
|
||||
{t('upload.description', 'Select an audio or video file and configure the recording settings. Video files will be automatically converted to audio for transcription.')}
|
||||
</Text>
|
||||
|
||||
{renderActionButton(
|
||||
'cloud-upload-outline',
|
||||
t('upload.select_file', 'Datei auswählen'),
|
||||
selectedFile ?
|
||||
(isExtractingDuration ?
|
||||
t('upload.extracting_duration', 'Extracting duration...') :
|
||||
(fileDuration ?
|
||||
`${selectedFile.name} (${fileDuration.formatted})` :
|
||||
selectedFile.name
|
||||
)
|
||||
) :
|
||||
t('upload.select_file_description', 'Audio- oder Videodatei hochladen'),
|
||||
handleFileSelect,
|
||||
false,
|
||||
!!selectedFile && !!fileDuration
|
||||
)}
|
||||
|
||||
{renderActionButton(
|
||||
'language-outline',
|
||||
t('upload.select_language', 'Sprache auswählen'),
|
||||
getSelectedLanguageDisplay(),
|
||||
handleLanguageSelect,
|
||||
true,
|
||||
selectedLanguageForUpload !== 'auto'
|
||||
)}
|
||||
|
||||
{renderActionButton(
|
||||
'calendar-outline',
|
||||
t('upload.select_date', 'Datum auswählen'),
|
||||
getSelectedDateDisplay(),
|
||||
handleDateSelect,
|
||||
true,
|
||||
selectedDateTime.toDateString() !== new Date().toDateString()
|
||||
)}
|
||||
|
||||
{renderActionButton(
|
||||
'time-outline',
|
||||
t('upload.select_time', 'Uhrzeit auswählen'),
|
||||
getSelectedTimeDisplay(),
|
||||
handleTimeSelect,
|
||||
true,
|
||||
hasCustomTime
|
||||
)}
|
||||
|
||||
{renderActionButton(
|
||||
'document-text-outline',
|
||||
t('upload.select_blueprint', 'Select blueprint'),
|
||||
getSelectedBlueprintDisplay(),
|
||||
handleBlueprintSelect,
|
||||
true,
|
||||
!!selectedBlueprintForUpload
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const handleModalClose = () => {
|
||||
// Verhindere das Schließen während des Uploads
|
||||
if (isUploading) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (modalMode === 'upload') {
|
||||
// Reset state when modal is closed
|
||||
setSelectedFile(null);
|
||||
setSelectedLanguageForUpload('auto');
|
||||
setSelectedBlueprintForUpload(null);
|
||||
setSelectedDateTime(new Date());
|
||||
setHasCustomTime(false);
|
||||
setFileDuration(null);
|
||||
setIsExtractingDuration(false);
|
||||
setModalMode('upload');
|
||||
onClose();
|
||||
} else {
|
||||
setModalMode('upload'); // Zurück zum Upload-Modal
|
||||
}
|
||||
};
|
||||
|
||||
console.log('UploadModal render - modalMode:', modalMode, 'isVisible:', isVisible);
|
||||
|
||||
// Don't show this modal if the insufficient credits modal is already visible
|
||||
if (isInsufficientCreditsModalVisible && isVisible) {
|
||||
console.log('[UploadModal] Prevented from showing - insufficient credits modal is already visible');
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<BaseModal
|
||||
isVisible={isVisible}
|
||||
onClose={handleModalClose}
|
||||
title={getModalTitle()}
|
||||
animationType="fade"
|
||||
closeOnOverlayPress={!isUploading}
|
||||
hideFooter={modalMode !== 'upload' || isUploading}
|
||||
footerContent={modalMode === 'upload' && !isUploading ? (
|
||||
<View style={{
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
paddingHorizontal: 20,
|
||||
paddingBottom: 20,
|
||||
gap: 12
|
||||
}}>
|
||||
<Button
|
||||
title={t('common.cancel', 'Abbrechen')}
|
||||
onPress={handleModalClose}
|
||||
variant="secondary"
|
||||
style={{ flex: 1 }}
|
||||
disabled={isUploading}
|
||||
/>
|
||||
<Button
|
||||
title={isUploading ? t('upload.uploading', 'Hochladen...') : t('upload.upload', 'Hochladen')}
|
||||
onPress={handleUpload}
|
||||
variant="primary"
|
||||
style={{ flex: 1 }}
|
||||
disabled={!selectedFile || !fileDuration || isUploading || isExtractingDuration}
|
||||
loading={isUploading}
|
||||
/>
|
||||
</View>
|
||||
) : undefined}
|
||||
>
|
||||
{renderModalContent()}
|
||||
</BaseModal>
|
||||
|
||||
{/* Blueprint Modal */}
|
||||
{selectedBlueprint && (
|
||||
<BlueprintModal
|
||||
visible={isBlueprintModalVisible}
|
||||
onClose={() => {
|
||||
setIsBlueprintModalVisible(false);
|
||||
setSelectedBlueprint(null);
|
||||
}}
|
||||
blueprint={selectedBlueprint}
|
||||
currentLanguage={currentLanguage}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Android Date Picker */}
|
||||
{Platform.OS === 'android' && showDatePicker && (
|
||||
<DateTimePicker
|
||||
value={selectedDateTime}
|
||||
mode="date"
|
||||
display="default"
|
||||
onChange={onDateChange}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Android Time Picker */}
|
||||
{Platform.OS === 'android' && showTimePicker && (
|
||||
<DateTimePicker
|
||||
value={selectedDateTime}
|
||||
mode="time"
|
||||
display="default"
|
||||
onChange={onTimeChange}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default UploadModal;
|
||||
165
apps/memoro/apps/mobile/components/molecules/WebContextMenu.tsx
Normal file
165
apps/memoro/apps/mobile/components/molecules/WebContextMenu.tsx
Normal file
|
|
@ -0,0 +1,165 @@
|
|||
import React, { useEffect, useRef } from 'react';
|
||||
import { View, Pressable, Platform } from 'react-native';
|
||||
import ReactDOM from 'react-dom';
|
||||
import { useTheme } from '~/features/theme/ThemeProvider';
|
||||
import Text from '~/components/atoms/Text';
|
||||
import Icon from '~/components/atoms/Icon';
|
||||
import colors from '~/tailwind.config.js';
|
||||
|
||||
interface WebContextMenuItem {
|
||||
title: string;
|
||||
icon?: string;
|
||||
destructive?: boolean;
|
||||
onPress: () => void;
|
||||
}
|
||||
|
||||
interface WebContextMenuProps {
|
||||
isVisible: boolean;
|
||||
position: { x: number; y: number };
|
||||
items: WebContextMenuItem[];
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
const WebContextMenu: React.FC<WebContextMenuProps> = ({
|
||||
isVisible,
|
||||
position,
|
||||
items,
|
||||
onClose,
|
||||
}) => {
|
||||
const { isDark, themeVariant } = useTheme();
|
||||
const menuRef = useRef<any>(null);
|
||||
|
||||
// Get theme colors
|
||||
const backgroundColor = isDark
|
||||
? ((colors as any).theme?.extend?.colors?.dark)?.[themeVariant]?.menuBackground || '#252525'
|
||||
: ((colors as any).theme?.extend?.colors)?.[themeVariant]?.menuBackground || '#FFFFFF';
|
||||
|
||||
const textColor = isDark
|
||||
? ((colors as any).theme?.extend?.colors?.dark)?.[themeVariant]?.text || '#FFFFFF'
|
||||
: ((colors as any).theme?.extend?.colors)?.[themeVariant]?.text || '#000000';
|
||||
|
||||
const borderColor = isDark
|
||||
? ((colors as any).theme?.extend?.colors?.dark)?.[themeVariant]?.border || '#424242'
|
||||
: ((colors as any).theme?.extend?.colors)?.[themeVariant]?.border || '#e6e6e6';
|
||||
|
||||
const hoverColor = isDark
|
||||
? ((colors as any).theme?.extend?.colors?.dark)?.[themeVariant]?.contentBackgroundHover || '#333333'
|
||||
: ((colors as any).theme?.extend?.colors)?.[themeVariant]?.contentBackgroundHover || '#f5f5f5';
|
||||
|
||||
// Handle click outside to close menu
|
||||
useEffect(() => {
|
||||
if (!isVisible) return;
|
||||
|
||||
const handleClickOutside = (event: MouseEvent) => {
|
||||
if (menuRef.current && !menuRef.current.contains(event.target)) {
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
|
||||
const handleEscape = (event: KeyboardEvent) => {
|
||||
if (event.key === 'Escape') {
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
|
||||
// Add event listeners
|
||||
document.addEventListener('mousedown', handleClickOutside);
|
||||
document.addEventListener('keydown', handleEscape);
|
||||
|
||||
// Cleanup
|
||||
return () => {
|
||||
document.removeEventListener('mousedown', handleClickOutside);
|
||||
document.removeEventListener('keydown', handleEscape);
|
||||
};
|
||||
}, [isVisible, onClose]);
|
||||
|
||||
if (!isVisible || Platform.OS !== 'web') return null;
|
||||
|
||||
// Calculate menu position to keep it within viewport
|
||||
const menuStyle: React.CSSProperties = {
|
||||
position: 'fixed',
|
||||
top: position.y,
|
||||
left: position.x,
|
||||
zIndex: 9999,
|
||||
backgroundColor,
|
||||
borderWidth: 1,
|
||||
borderColor,
|
||||
borderRadius: 12,
|
||||
minWidth: 200,
|
||||
boxShadow: isDark
|
||||
? '0 4px 24px rgba(0, 0, 0, 0.4)'
|
||||
: '0 4px 24px rgba(0, 0, 0, 0.1)',
|
||||
overflow: 'hidden',
|
||||
};
|
||||
|
||||
// Adjust position if menu would go off screen
|
||||
if (typeof window !== 'undefined') {
|
||||
const menuWidth = 200;
|
||||
const menuHeight = items.length * 44 + 8; // Approximate height
|
||||
|
||||
if (position.x + menuWidth > window.innerWidth) {
|
||||
menuStyle.left = position.x - menuWidth;
|
||||
}
|
||||
|
||||
if (position.y + menuHeight > window.innerHeight) {
|
||||
menuStyle.top = position.y - menuHeight;
|
||||
}
|
||||
}
|
||||
|
||||
const menuContent = (
|
||||
<div ref={menuRef} style={menuStyle}>
|
||||
<View style={{ padding: 8 }}>
|
||||
{items.map((item, index) => {
|
||||
const isDestructive = item.destructive;
|
||||
const itemTextColor = isDestructive ? '#FF3B30' : textColor;
|
||||
|
||||
return (
|
||||
<Pressable
|
||||
key={index}
|
||||
onPress={() => {
|
||||
item.onPress();
|
||||
onClose();
|
||||
}}
|
||||
style={({ hovered }) => ({
|
||||
padding: 12,
|
||||
borderRadius: 8,
|
||||
backgroundColor: hovered ? hoverColor : 'transparent',
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
cursor: 'pointer',
|
||||
})}
|
||||
>
|
||||
{item.icon && (
|
||||
<View style={{ marginRight: 12 }}>
|
||||
<Icon
|
||||
name={item.icon}
|
||||
size={20}
|
||||
color={itemTextColor}
|
||||
/>
|
||||
</View>
|
||||
)}
|
||||
<Text
|
||||
style={{
|
||||
color: itemTextColor,
|
||||
fontSize: 14,
|
||||
fontWeight: '500',
|
||||
}}
|
||||
>
|
||||
{item.title}
|
||||
</Text>
|
||||
</Pressable>
|
||||
);
|
||||
})}
|
||||
</View>
|
||||
</div>
|
||||
);
|
||||
|
||||
// Use portal to render menu at document root
|
||||
if (typeof document !== 'undefined') {
|
||||
return ReactDOM.createPortal(menuContent, document.body);
|
||||
}
|
||||
|
||||
return menuContent;
|
||||
};
|
||||
|
||||
export default WebContextMenu;
|
||||
|
|
@ -0,0 +1,617 @@
|
|||
/**
|
||||
* Integration Tests for UploadModal.tsx
|
||||
* Tests the complete video/audio upload flow
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { render, fireEvent, waitFor, act } from '@testing-library/react-native';
|
||||
import UploadModal from '../UploadModal';
|
||||
import * as DocumentPicker from 'expo-document-picker';
|
||||
import { extractMediaDurationWithFormat } from '~/utils/mediaUtils';
|
||||
|
||||
// Mock dependencies
|
||||
jest.mock('expo-document-picker');
|
||||
jest.mock('~/utils/mediaUtils');
|
||||
jest.mock('react-i18next', () => ({
|
||||
useTranslation: () => ({
|
||||
t: (key: string) => key,
|
||||
i18n: { language: 'en' }
|
||||
})
|
||||
}));
|
||||
jest.mock('~/features/theme/ThemeProvider', () => ({
|
||||
useTheme: () => ({
|
||||
isDark: false,
|
||||
themeVariant: 'default'
|
||||
})
|
||||
}));
|
||||
jest.mock('~/features/audioRecordingV2', () => ({
|
||||
useRecordingLanguage: () => ({
|
||||
recordingLanguages: ['en-US'],
|
||||
toggleRecordingLanguage: jest.fn(),
|
||||
supportedAzureLanguages: {
|
||||
'en-US': { nativeName: 'English', emoji: '🇺🇸', locale: 'en-US' },
|
||||
'auto': { nativeName: 'Auto Detect', emoji: '🌍', locale: 'auto' }
|
||||
}
|
||||
})
|
||||
}));
|
||||
|
||||
describe('UploadModal Integration Tests', () => {
|
||||
const mockOnClose = jest.fn();
|
||||
const mockOnFileUpload = jest.fn();
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('Video file upload flow', () => {
|
||||
it('should handle complete video upload workflow', async () => {
|
||||
const mockVideoFile = {
|
||||
uri: 'file:///path/to/video.mp4',
|
||||
name: 'test-video.mp4',
|
||||
size: 10240000,
|
||||
mimeType: 'video/mp4',
|
||||
type: 'success',
|
||||
canceled: false,
|
||||
assets: [{
|
||||
uri: 'file:///path/to/video.mp4',
|
||||
name: 'test-video.mp4',
|
||||
size: 10240000,
|
||||
mimeType: 'video/mp4',
|
||||
}]
|
||||
};
|
||||
|
||||
// Mock DocumentPicker to return video file
|
||||
(DocumentPicker.getDocumentAsync as jest.Mock).mockResolvedValue(mockVideoFile);
|
||||
|
||||
// Mock duration extraction
|
||||
(extractMediaDurationWithFormat as jest.Mock).mockResolvedValue({
|
||||
durationMillis: 180000,
|
||||
durationSeconds: 180,
|
||||
formatted: '3:00'
|
||||
});
|
||||
|
||||
const { getByText, findByText } = render(
|
||||
<UploadModal
|
||||
isVisible={true}
|
||||
onClose={mockOnClose}
|
||||
onFileUpload={mockOnFileUpload}
|
||||
currentLanguage="en"
|
||||
isUploading={false}
|
||||
/>
|
||||
);
|
||||
|
||||
// Step 1: Click select file button
|
||||
const selectFileButton = getByText('upload.select_file');
|
||||
await act(async () => {
|
||||
fireEvent.press(selectFileButton);
|
||||
});
|
||||
|
||||
// Wait for file selection and duration extraction
|
||||
await waitFor(() => {
|
||||
expect(DocumentPicker.getDocumentAsync).toHaveBeenCalledWith({
|
||||
type: expect.arrayContaining(['video/mp4', 'audio/mpeg']),
|
||||
copyToCacheDirectory: true
|
||||
});
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(extractMediaDurationWithFormat).toHaveBeenCalledWith(mockVideoFile.assets[0]);
|
||||
});
|
||||
|
||||
// Step 2: Verify file is displayed with duration
|
||||
await findByText(/test-video.mp4.*3:00/);
|
||||
|
||||
// Step 3: Trigger upload
|
||||
const uploadButton = getByText('upload.upload');
|
||||
await act(async () => {
|
||||
fireEvent.press(uploadButton);
|
||||
});
|
||||
|
||||
// Verify upload was called with correct parameters
|
||||
await waitFor(() => {
|
||||
expect(mockOnFileUpload).toHaveBeenCalledWith(
|
||||
mockVideoFile.assets[0],
|
||||
expect.any(String), // language
|
||||
expect.anything(), // blueprint
|
||||
expect.any(Date), // date
|
||||
180000 // duration in milliseconds
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle MOV video files', async () => {
|
||||
const mockMovFile = {
|
||||
uri: 'file:///path/to/video.mov',
|
||||
name: 'iphone-video.mov',
|
||||
size: 15360000,
|
||||
mimeType: 'video/quicktime',
|
||||
type: 'success',
|
||||
canceled: false,
|
||||
assets: [{
|
||||
uri: 'file:///path/to/video.mov',
|
||||
name: 'iphone-video.mov',
|
||||
size: 15360000,
|
||||
mimeType: 'video/quicktime',
|
||||
}]
|
||||
};
|
||||
|
||||
(DocumentPicker.getDocumentAsync as jest.Mock).mockResolvedValue(mockMovFile);
|
||||
(extractMediaDurationWithFormat as jest.Mock).mockResolvedValue({
|
||||
durationMillis: 240000,
|
||||
durationSeconds: 240,
|
||||
formatted: '4:00'
|
||||
});
|
||||
|
||||
const { getByText, findByText } = render(
|
||||
<UploadModal
|
||||
isVisible={true}
|
||||
onClose={mockOnClose}
|
||||
onFileUpload={mockOnFileUpload}
|
||||
currentLanguage="en"
|
||||
/>
|
||||
);
|
||||
|
||||
const selectFileButton = getByText('upload.select_file');
|
||||
await act(async () => {
|
||||
fireEvent.press(selectFileButton);
|
||||
});
|
||||
|
||||
await findByText(/iphone-video.mov.*4:00/);
|
||||
|
||||
expect(extractMediaDurationWithFormat).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should handle AVI video files', async () => {
|
||||
const mockAviFile = {
|
||||
uri: 'file:///path/to/video.avi',
|
||||
name: 'screen-recording.avi',
|
||||
size: 20480000,
|
||||
mimeType: 'video/x-msvideo',
|
||||
type: 'success',
|
||||
canceled: false,
|
||||
assets: [{
|
||||
uri: 'file:///path/to/video.avi',
|
||||
name: 'screen-recording.avi',
|
||||
size: 20480000,
|
||||
mimeType: 'video/x-msvideo',
|
||||
}]
|
||||
};
|
||||
|
||||
(DocumentPicker.getDocumentAsync as jest.Mock).mockResolvedValue(mockAviFile);
|
||||
(extractMediaDurationWithFormat as jest.Mock).mockResolvedValue({
|
||||
durationMillis: 300000,
|
||||
durationSeconds: 300,
|
||||
formatted: '5:00'
|
||||
});
|
||||
|
||||
const { getByText } = render(
|
||||
<UploadModal
|
||||
isVisible={true}
|
||||
onClose={mockOnClose}
|
||||
onFileUpload={mockOnFileUpload}
|
||||
currentLanguage="en"
|
||||
/>
|
||||
);
|
||||
|
||||
const selectFileButton = getByText('upload.select_file');
|
||||
await act(async () => {
|
||||
fireEvent.press(selectFileButton);
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(extractMediaDurationWithFormat).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Audio file upload flow', () => {
|
||||
it('should handle MP3 audio files', async () => {
|
||||
const mockMp3File = {
|
||||
uri: 'file:///path/to/audio.mp3',
|
||||
name: 'recording.mp3',
|
||||
size: 5120000,
|
||||
mimeType: 'audio/mpeg',
|
||||
type: 'success',
|
||||
canceled: false,
|
||||
assets: [{
|
||||
uri: 'file:///path/to/audio.mp3',
|
||||
name: 'recording.mp3',
|
||||
size: 5120000,
|
||||
mimeType: 'audio/mpeg',
|
||||
}]
|
||||
};
|
||||
|
||||
(DocumentPicker.getDocumentAsync as jest.Mock).mockResolvedValue(mockMp3File);
|
||||
(extractMediaDurationWithFormat as jest.Mock).mockResolvedValue({
|
||||
durationMillis: 120000,
|
||||
durationSeconds: 120,
|
||||
formatted: '2:00'
|
||||
});
|
||||
|
||||
const { getByText, findByText } = render(
|
||||
<UploadModal
|
||||
isVisible={true}
|
||||
onClose={mockOnClose}
|
||||
onFileUpload={mockOnFileUpload}
|
||||
currentLanguage="en"
|
||||
/>
|
||||
);
|
||||
|
||||
const selectFileButton = getByText('upload.select_file');
|
||||
await act(async () => {
|
||||
fireEvent.press(selectFileButton);
|
||||
});
|
||||
|
||||
await findByText(/recording.mp3.*2:00/);
|
||||
});
|
||||
|
||||
it('should handle M4A audio files', async () => {
|
||||
const mockM4aFile = {
|
||||
uri: 'file:///path/to/audio.m4a',
|
||||
name: 'voice-memo.m4a',
|
||||
size: 3072000,
|
||||
mimeType: 'audio/x-m4a',
|
||||
type: 'success',
|
||||
canceled: false,
|
||||
assets: [{
|
||||
uri: 'file:///path/to/audio.m4a',
|
||||
name: 'voice-memo.m4a',
|
||||
size: 3072000,
|
||||
mimeType: 'audio/x-m4a',
|
||||
}]
|
||||
};
|
||||
|
||||
(DocumentPicker.getDocumentAsync as jest.Mock).mockResolvedValue(mockM4aFile);
|
||||
(extractMediaDurationWithFormat as jest.Mock).mockResolvedValue({
|
||||
durationMillis: 90000,
|
||||
durationSeconds: 90,
|
||||
formatted: '1:30'
|
||||
});
|
||||
|
||||
const { getByText, findByText } = render(
|
||||
<UploadModal
|
||||
isVisible={true}
|
||||
onClose={mockOnClose}
|
||||
onFileUpload={mockOnFileUpload}
|
||||
currentLanguage="en"
|
||||
/>
|
||||
);
|
||||
|
||||
const selectFileButton = getByText('upload.select_file');
|
||||
await act(async () => {
|
||||
fireEvent.press(selectFileButton);
|
||||
});
|
||||
|
||||
await findByText(/voice-memo.m4a.*1:30/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Error scenarios', () => {
|
||||
it('should handle duration extraction failure', async () => {
|
||||
const mockFile = {
|
||||
uri: 'file:///path/to/corrupted.mp4',
|
||||
name: 'corrupted.mp4',
|
||||
size: 5120000,
|
||||
mimeType: 'video/mp4',
|
||||
type: 'success',
|
||||
canceled: false,
|
||||
assets: [{
|
||||
uri: 'file:///path/to/corrupted.mp4',
|
||||
name: 'corrupted.mp4',
|
||||
size: 5120000,
|
||||
mimeType: 'video/mp4',
|
||||
}]
|
||||
};
|
||||
|
||||
(DocumentPicker.getDocumentAsync as jest.Mock).mockResolvedValue(mockFile);
|
||||
(extractMediaDurationWithFormat as jest.Mock).mockRejectedValue(
|
||||
new Error('Could not extract media duration')
|
||||
);
|
||||
|
||||
const { getByText } = render(
|
||||
<UploadModal
|
||||
isVisible={true}
|
||||
onClose={mockOnClose}
|
||||
onFileUpload={mockOnFileUpload}
|
||||
currentLanguage="en"
|
||||
/>
|
||||
);
|
||||
|
||||
const selectFileButton = getByText('upload.select_file');
|
||||
await act(async () => {
|
||||
fireEvent.press(selectFileButton);
|
||||
});
|
||||
|
||||
// Wait for error alert
|
||||
await waitFor(() => {
|
||||
// Alert.alert is called with error message
|
||||
expect(extractMediaDurationWithFormat).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// Upload button should remain disabled
|
||||
const uploadButton = getByText('upload.upload');
|
||||
expect(uploadButton.props.accessibilityState?.disabled).toBe(true);
|
||||
});
|
||||
|
||||
it('should prevent upload when no file is selected', () => {
|
||||
const { getByText } = render(
|
||||
<UploadModal
|
||||
isVisible={true}
|
||||
onClose={mockOnClose}
|
||||
onFileUpload={mockOnFileUpload}
|
||||
currentLanguage="en"
|
||||
/>
|
||||
);
|
||||
|
||||
const uploadButton = getByText('upload.upload');
|
||||
expect(uploadButton.props.accessibilityState?.disabled).toBe(true);
|
||||
});
|
||||
|
||||
it('should prevent upload when duration extraction is in progress', async () => {
|
||||
const mockFile = {
|
||||
uri: 'file:///path/to/large-video.mp4',
|
||||
name: 'large-video.mp4',
|
||||
size: 50000000,
|
||||
mimeType: 'video/mp4',
|
||||
type: 'success',
|
||||
canceled: false,
|
||||
assets: [{
|
||||
uri: 'file:///path/to/large-video.mp4',
|
||||
name: 'large-video.mp4',
|
||||
size: 50000000,
|
||||
mimeType: 'video/mp4',
|
||||
}]
|
||||
};
|
||||
|
||||
(DocumentPicker.getDocumentAsync as jest.Mock).mockResolvedValue(mockFile);
|
||||
|
||||
// Simulate long-running duration extraction
|
||||
(extractMediaDurationWithFormat as jest.Mock).mockImplementation(
|
||||
() => new Promise(resolve => setTimeout(() => resolve({
|
||||
durationMillis: 600000,
|
||||
durationSeconds: 600,
|
||||
formatted: '10:00'
|
||||
}), 5000))
|
||||
);
|
||||
|
||||
const { getByText, findByText } = render(
|
||||
<UploadModal
|
||||
isVisible={true}
|
||||
onClose={mockOnClose}
|
||||
onFileUpload={mockOnFileUpload}
|
||||
currentLanguage="en"
|
||||
/>
|
||||
);
|
||||
|
||||
const selectFileButton = getByText('upload.select_file');
|
||||
await act(async () => {
|
||||
fireEvent.press(selectFileButton);
|
||||
});
|
||||
|
||||
// While extracting, upload should be disabled
|
||||
await findByText('upload.extracting_duration');
|
||||
|
||||
const uploadButton = getByText('upload.upload');
|
||||
expect(uploadButton.props.accessibilityState?.disabled).toBe(true);
|
||||
});
|
||||
|
||||
it('should handle file selection cancellation', async () => {
|
||||
(DocumentPicker.getDocumentAsync as jest.Mock).mockResolvedValue({
|
||||
type: 'cancel',
|
||||
canceled: true
|
||||
});
|
||||
|
||||
const { getByText } = render(
|
||||
<UploadModal
|
||||
isVisible={true}
|
||||
onClose={mockOnClose}
|
||||
onFileUpload={mockOnFileUpload}
|
||||
currentLanguage="en"
|
||||
/>
|
||||
);
|
||||
|
||||
const selectFileButton = getByText('upload.select_file');
|
||||
await act(async () => {
|
||||
fireEvent.press(selectFileButton);
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(DocumentPicker.getDocumentAsync).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// No file should be selected
|
||||
expect(extractMediaDurationWithFormat).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should handle upload failure gracefully', async () => {
|
||||
const mockFile = {
|
||||
uri: 'file:///path/to/video.mp4',
|
||||
name: 'test-video.mp4',
|
||||
size: 10240000,
|
||||
mimeType: 'video/mp4',
|
||||
type: 'success',
|
||||
canceled: false,
|
||||
assets: [{
|
||||
uri: 'file:///path/to/video.mp4',
|
||||
name: 'test-video.mp4',
|
||||
size: 10240000,
|
||||
mimeType: 'video/mp4',
|
||||
}]
|
||||
};
|
||||
|
||||
(DocumentPicker.getDocumentAsync as jest.Mock).mockResolvedValue(mockFile);
|
||||
(extractMediaDurationWithFormat as jest.Mock).mockResolvedValue({
|
||||
durationMillis: 180000,
|
||||
durationSeconds: 180,
|
||||
formatted: '3:00'
|
||||
});
|
||||
|
||||
// Mock upload failure
|
||||
mockOnFileUpload.mockRejectedValue(new Error('Network error'));
|
||||
|
||||
const { getByText, findByText } = render(
|
||||
<UploadModal
|
||||
isVisible={true}
|
||||
onClose={mockOnClose}
|
||||
onFileUpload={mockOnFileUpload}
|
||||
currentLanguage="en"
|
||||
/>
|
||||
);
|
||||
|
||||
const selectFileButton = getByText('upload.select_file');
|
||||
await act(async () => {
|
||||
fireEvent.press(selectFileButton);
|
||||
});
|
||||
|
||||
await findByText(/test-video.mp4.*3:00/);
|
||||
|
||||
const uploadButton = getByText('upload.upload');
|
||||
await act(async () => {
|
||||
fireEvent.press(uploadButton);
|
||||
});
|
||||
|
||||
// Modal should remain open on error
|
||||
await waitFor(() => {
|
||||
expect(mockOnFileUpload).toHaveBeenCalled();
|
||||
expect(mockOnClose).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Language selection', () => {
|
||||
it('should allow language selection for video upload', async () => {
|
||||
const mockFile = {
|
||||
uri: 'file:///path/to/video.mp4',
|
||||
name: 'test-video.mp4',
|
||||
size: 10240000,
|
||||
mimeType: 'video/mp4',
|
||||
type: 'success',
|
||||
canceled: false,
|
||||
assets: [{
|
||||
uri: 'file:///path/to/video.mp4',
|
||||
name: 'test-video.mp4',
|
||||
size: 10240000,
|
||||
mimeType: 'video/mp4',
|
||||
}]
|
||||
};
|
||||
|
||||
(DocumentPicker.getDocumentAsync as jest.Mock).mockResolvedValue(mockFile);
|
||||
(extractMediaDurationWithFormat as jest.Mock).mockResolvedValue({
|
||||
durationMillis: 180000,
|
||||
durationSeconds: 180,
|
||||
formatted: '3:00'
|
||||
});
|
||||
|
||||
const { getByText, findByText } = render(
|
||||
<UploadModal
|
||||
isVisible={true}
|
||||
onClose={mockOnClose}
|
||||
onFileUpload={mockOnFileUpload}
|
||||
currentLanguage="en"
|
||||
/>
|
||||
);
|
||||
|
||||
// Select file
|
||||
const selectFileButton = getByText('upload.select_file');
|
||||
await act(async () => {
|
||||
fireEvent.press(selectFileButton);
|
||||
});
|
||||
|
||||
await findByText(/test-video.mp4/);
|
||||
|
||||
// Open language selector
|
||||
const languageButton = getByText('upload.select_language');
|
||||
await act(async () => {
|
||||
fireEvent.press(languageButton);
|
||||
});
|
||||
|
||||
// Verify language options are displayed
|
||||
await findByText('English');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Upload state management', () => {
|
||||
it('should disable interactions during upload', async () => {
|
||||
const { getByText, rerender } = render(
|
||||
<UploadModal
|
||||
isVisible={true}
|
||||
onClose={mockOnClose}
|
||||
onFileUpload={mockOnFileUpload}
|
||||
currentLanguage="en"
|
||||
isUploading={false}
|
||||
/>
|
||||
);
|
||||
|
||||
// Rerender with isUploading=true
|
||||
rerender(
|
||||
<UploadModal
|
||||
isVisible={true}
|
||||
onClose={mockOnClose}
|
||||
onFileUpload={mockOnFileUpload}
|
||||
currentLanguage="en"
|
||||
isUploading={true}
|
||||
/>
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(getByText('upload.uploading')).toBeTruthy();
|
||||
});
|
||||
|
||||
// Close button should be disabled
|
||||
const cancelButton = getByText('common.cancel');
|
||||
expect(cancelButton.props.accessibilityState?.disabled).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Date and time selection', () => {
|
||||
it('should allow custom date selection for video upload', async () => {
|
||||
const mockFile = {
|
||||
uri: 'file:///path/to/video.mp4',
|
||||
name: 'test-video.mp4',
|
||||
size: 10240000,
|
||||
mimeType: 'video/mp4',
|
||||
type: 'success',
|
||||
canceled: false,
|
||||
assets: [{
|
||||
uri: 'file:///path/to/video.mp4',
|
||||
name: 'test-video.mp4',
|
||||
size: 10240000,
|
||||
mimeType: 'video/mp4',
|
||||
}]
|
||||
};
|
||||
|
||||
(DocumentPicker.getDocumentAsync as jest.Mock).mockResolvedValue(mockFile);
|
||||
(extractMediaDurationWithFormat as jest.Mock).mockResolvedValue({
|
||||
durationMillis: 180000,
|
||||
durationSeconds: 180,
|
||||
formatted: '3:00'
|
||||
});
|
||||
|
||||
const { getByText, findByText } = render(
|
||||
<UploadModal
|
||||
isVisible={true}
|
||||
onClose={mockOnClose}
|
||||
onFileUpload={mockOnFileUpload}
|
||||
currentLanguage="en"
|
||||
/>
|
||||
);
|
||||
|
||||
// Select file
|
||||
const selectFileButton = getByText('upload.select_file');
|
||||
await act(async () => {
|
||||
fireEvent.press(selectFileButton);
|
||||
});
|
||||
|
||||
await findByText(/test-video.mp4/);
|
||||
|
||||
// Open date selector
|
||||
const dateButton = getByText('upload.select_date');
|
||||
fireEvent.press(dateButton);
|
||||
|
||||
// Verify date picker is displayed
|
||||
await waitFor(() => {
|
||||
expect(getByText('upload.select_date')).toBeTruthy();
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,11 @@
|
|||
/**
|
||||
* Custom Loading Animations für Memoro
|
||||
*
|
||||
* Alle Animationen nutzen die native React Native Animated API
|
||||
* und sind vollständig theme-aware.
|
||||
*/
|
||||
|
||||
export { PulsingLogoAnimation } from '../PulsingLogoAnimation';
|
||||
export { SpinnerAnimation } from '../SpinnerAnimation';
|
||||
export { DotsAnimation } from '../DotsAnimation';
|
||||
export { LogoSpinnerAnimation } from '../LogoSpinnerAnimation';
|
||||
371
apps/memoro/apps/mobile/components/organisms/AudioPlayer.tsx
Normal file
371
apps/memoro/apps/mobile/components/organisms/AudioPlayer.tsx
Normal file
|
|
@ -0,0 +1,371 @@
|
|||
import React, { useState, useRef, useEffect } from 'react';
|
||||
import {
|
||||
View,
|
||||
StyleSheet,
|
||||
TouchableOpacity,
|
||||
Pressable,
|
||||
LayoutChangeEvent,
|
||||
Share,
|
||||
Alert,
|
||||
Clipboard,
|
||||
Platform,
|
||||
} from 'react-native';
|
||||
import { useTheme } from '~/features/theme/ThemeProvider';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useToast } from '~/features/toast/contexts/ToastContext';
|
||||
import Icon from '~/components/atoms/Icon';
|
||||
import Text from '~/components/atoms/Text';
|
||||
import { useAudioPlayer } from '~/features/audioPlayer/useAudioPlayer';
|
||||
|
||||
interface AudioPlayerProps {
|
||||
audioUri: string;
|
||||
headlineText: string;
|
||||
dateText: string;
|
||||
durationText?: string;
|
||||
fileSizeBytes?: number;
|
||||
onDelete?: () => void;
|
||||
onPlayStatusChange?: (isPlaying: boolean) => void;
|
||||
showCopyButton?: boolean; // New prop to control copy button visibility
|
||||
}
|
||||
|
||||
function AudioPlayer({
|
||||
audioUri,
|
||||
headlineText,
|
||||
dateText,
|
||||
durationText,
|
||||
fileSizeBytes,
|
||||
onDelete,
|
||||
onPlayStatusChange,
|
||||
showCopyButton = true, // Default to true to maintain existing behavior
|
||||
}: AudioPlayerProps) {
|
||||
const { isDark, themeVariant } = useTheme();
|
||||
const { t } = useTranslation();
|
||||
const { showSuccess, showError } = useToast();
|
||||
const [timelineWidth, setTimelineWidth] = useState(0);
|
||||
const [containerWidth, setContainerWidth] = useState(0);
|
||||
const timelineRef = useRef<View>(null);
|
||||
|
||||
// Audio-Player-Hook für die Wiedergabe
|
||||
const {
|
||||
isPlaying,
|
||||
duration,
|
||||
currentTime,
|
||||
status,
|
||||
error,
|
||||
loadError,
|
||||
loadSound,
|
||||
playPause,
|
||||
stop,
|
||||
seek,
|
||||
formattedPosition,
|
||||
formattedDuration,
|
||||
percentComplete,
|
||||
} = useAudioPlayer();
|
||||
|
||||
// Lade den Sound, wenn sich die URI ändert
|
||||
useEffect(() => {
|
||||
if (audioUri) {
|
||||
loadSound(audioUri);
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [audioUri]);
|
||||
|
||||
// Benachrichtige über Wiedergabestatus, wenn sich isPlaying ändert
|
||||
// Verwende einen Ref, um zu verhindern, dass der Callback bei jedem Render aufgerufen wird
|
||||
const prevIsPlayingRef = useRef(isPlaying);
|
||||
|
||||
useEffect(() => {
|
||||
// Nur aufrufen, wenn sich der Status tatsächlich geändert hat
|
||||
if (prevIsPlayingRef.current !== isPlaying) {
|
||||
prevIsPlayingRef.current = isPlaying;
|
||||
onPlayStatusChange?.(isPlaying);
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [isPlaying]);
|
||||
|
||||
const handleTimelinePress = (event: any) => {
|
||||
if (loadError || timelineWidth <= 0 || duration <= 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const locationX = event.nativeEvent.locationX;
|
||||
const percentage = Math.max(0, Math.min(1, locationX / timelineWidth));
|
||||
const newPosition = Math.round(percentage * duration * 1000);
|
||||
|
||||
seek(newPosition);
|
||||
};
|
||||
|
||||
const handleTimelineLayout = (event: LayoutChangeEvent) => {
|
||||
const newWidth = event.nativeEvent.layout.width;
|
||||
setTimelineWidth(newWidth);
|
||||
};
|
||||
|
||||
const handleContainerLayout = (event: LayoutChangeEvent) => {
|
||||
const newWidth = event.nativeEvent.layout.width;
|
||||
setContainerWidth(newWidth);
|
||||
};
|
||||
|
||||
const handleShare = async () => {
|
||||
try {
|
||||
await Share.share({
|
||||
url: audioUri,
|
||||
message: `${headlineText} - ${dateText}`,
|
||||
});
|
||||
console.debug('Share opened successfully');
|
||||
} catch (error) {
|
||||
console.debug('Error sharing:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCopyLink = async () => {
|
||||
try {
|
||||
await Clipboard.setString(audioUri);
|
||||
console.debug('Link copied to clipboard:', audioUri);
|
||||
showSuccess(t('memo.audio_link_copied', 'Link copied!'));
|
||||
} catch (error) {
|
||||
console.debug('Error copying link:', error);
|
||||
showError(t('memo.audio_link_copy_error', 'Link could not be copied.'));
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeletePress = () => {
|
||||
Alert.alert(
|
||||
t('memo.delete_audio_title', 'Delete Audio Recording'),
|
||||
t(
|
||||
'memo.delete_audio_confirmation',
|
||||
'Do you really want to delete this audio recording? This action cannot be undone.'
|
||||
),
|
||||
[
|
||||
{
|
||||
text: t('common.cancel', 'Cancel'),
|
||||
style: 'cancel',
|
||||
},
|
||||
{
|
||||
text: t('common.delete', 'Delete'),
|
||||
onPress: () => {
|
||||
if (onDelete) {
|
||||
onDelete();
|
||||
}
|
||||
},
|
||||
style: 'destructive',
|
||||
},
|
||||
],
|
||||
{ cancelable: true }
|
||||
);
|
||||
};
|
||||
|
||||
const handleShowFile = () => {
|
||||
console.debug('Show file:', audioUri);
|
||||
};
|
||||
|
||||
// Formatiere Dateigröße in menschenlesbares Format
|
||||
const formatFileSize = (bytes?: number): string => {
|
||||
if (!bytes) return '';
|
||||
|
||||
const units = ['B', 'KB', 'MB', 'GB'];
|
||||
let size = bytes;
|
||||
let unitIndex = 0;
|
||||
|
||||
while (size >= 1024 && unitIndex < units.length - 1) {
|
||||
size /= 1024;
|
||||
unitIndex++;
|
||||
}
|
||||
|
||||
return `${size.toFixed(unitIndex > 0 ? 1 : 0)} ${units[unitIndex]}`;
|
||||
};
|
||||
|
||||
// Import colors for consistent theme access
|
||||
const colors = require('~/tailwind.config.js').theme.extend.colors;
|
||||
|
||||
// Get colors from the theme system (same as MemoPreview)
|
||||
const textColor = isDark
|
||||
? colors.dark?.[themeVariant]?.text || '#FFFFFF'
|
||||
: colors[themeVariant]?.text || '#000000';
|
||||
|
||||
// Container background (like MemoPreview contentBackground)
|
||||
const backgroundColor = isDark
|
||||
? colors.dark?.[themeVariant]?.contentBackground
|
||||
: colors[themeVariant]?.contentBackground;
|
||||
|
||||
// Border color (same as MemoPreview)
|
||||
const borderColor = isDark
|
||||
? colors.dark?.[themeVariant]?.border || '#424242'
|
||||
: colors[themeVariant]?.border || '#e6e6e6';
|
||||
|
||||
// Inner container background (secondary button color)
|
||||
const innerBackgroundColor = isDark
|
||||
? colors.dark?.[themeVariant]?.secondaryButton
|
||||
: colors[themeVariant]?.secondaryButton;
|
||||
|
||||
// Inner border color
|
||||
const innerBorderColor = isDark
|
||||
? colors.dark?.[themeVariant]?.border || '#424242'
|
||||
: colors[themeVariant]?.border || '#e6e6e6';
|
||||
|
||||
const primaryColor = isDark
|
||||
? colors.dark?.[themeVariant]?.primary
|
||||
: colors[themeVariant]?.primary;
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
backgroundColor,
|
||||
borderColor,
|
||||
borderWidth: 1,
|
||||
borderRadius: 16, // Same as MemoPreview
|
||||
padding: 16,
|
||||
},
|
||||
headerContainer: {
|
||||
marginBottom: 16,
|
||||
paddingRight: 80,
|
||||
},
|
||||
headlineContainer: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
},
|
||||
headlineText: {
|
||||
fontSize: 16,
|
||||
fontWeight: 'bold',
|
||||
marginLeft: 8,
|
||||
color: textColor,
|
||||
flex: 1,
|
||||
},
|
||||
dateText: {
|
||||
fontSize: 14,
|
||||
fontWeight: '500',
|
||||
color: isDark ? 'rgba(255, 255, 255, 0.8)' : 'rgba(0, 0, 0, 0.8)',
|
||||
marginTop: 12,
|
||||
marginLeft: 32,
|
||||
},
|
||||
metaText: {
|
||||
fontSize: 14,
|
||||
fontWeight: '500',
|
||||
color: isDark ? 'rgba(255, 255, 255, 0.8)' : 'rgba(0, 0, 0, 0.8)',
|
||||
marginTop: 4,
|
||||
marginLeft: 32,
|
||||
},
|
||||
timelineContainer: {
|
||||
marginBottom: 12,
|
||||
},
|
||||
timeline: {
|
||||
height: 8,
|
||||
borderRadius: 4,
|
||||
backgroundColor: isDark ? 'rgba(255, 255, 255, 0.1)' : 'rgba(0, 0, 0, 0.1)',
|
||||
overflow: 'hidden',
|
||||
},
|
||||
progress: {
|
||||
height: '100%',
|
||||
backgroundColor: primaryColor,
|
||||
},
|
||||
controls: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
marginTop: 8,
|
||||
},
|
||||
scrubber: {
|
||||
position: 'absolute',
|
||||
top: -6,
|
||||
width: 20,
|
||||
height: 20,
|
||||
borderRadius: 10,
|
||||
backgroundColor: primaryColor,
|
||||
borderWidth: 3,
|
||||
borderColor,
|
||||
transform: [{ translateX: -10 }], // Zentriere den Scrubber
|
||||
},
|
||||
controlButton: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
},
|
||||
timestamp: {
|
||||
fontSize: 16,
|
||||
fontWeight: 'bold',
|
||||
color: textColor,
|
||||
},
|
||||
contentContainer: {
|
||||
backgroundColor: isDark ? 'rgba(20, 20, 20, 0.8)' : 'rgba(235, 235, 235, 0.8)',
|
||||
borderColor: innerBorderColor,
|
||||
borderWidth: 1,
|
||||
borderRadius: 8,
|
||||
padding: 12,
|
||||
marginTop: 8,
|
||||
},
|
||||
shareButtonContainer: {
|
||||
position: 'absolute',
|
||||
top: 8,
|
||||
right: 8,
|
||||
zIndex: 1,
|
||||
flexDirection: 'row',
|
||||
backgroundColor: isDark ? 'rgba(0, 0, 0, 0.3)' : 'rgba(255, 255, 255, 0.3)',
|
||||
borderRadius: 8,
|
||||
},
|
||||
actionButton: {
|
||||
padding: 8,
|
||||
marginLeft: 4,
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<View style={styles.container} onLayout={handleContainerLayout}>
|
||||
<View style={styles.shareButtonContainer}>
|
||||
<Pressable style={styles.actionButton} onPress={handleShare}>
|
||||
<Icon
|
||||
name={Platform.OS === 'android' ? 'share-social-outline' : 'share-outline'}
|
||||
size={20}
|
||||
color={textColor}
|
||||
/>
|
||||
</Pressable>
|
||||
{showCopyButton && (
|
||||
<Pressable style={styles.actionButton} onPress={handleCopyLink}>
|
||||
<Icon name="copy-outline" size={20} color={textColor} />
|
||||
</Pressable>
|
||||
)}
|
||||
{onDelete && (
|
||||
<Pressable style={styles.actionButton} onPress={handleDeletePress}>
|
||||
<Icon name="trash-outline" size={20} color={textColor} />
|
||||
</Pressable>
|
||||
)}
|
||||
</View>
|
||||
|
||||
<View style={styles.headerContainer}>
|
||||
<View style={styles.headlineContainer}>
|
||||
<Icon name="mic-outline" size={24} color={textColor} />
|
||||
<Text style={styles.headlineText}>{headlineText}</Text>
|
||||
</View>
|
||||
{dateText && <Text style={styles.dateText}>{dateText}</Text>}
|
||||
{(durationText || fileSizeBytes) && (
|
||||
<Text style={styles.metaText}>
|
||||
{[durationText, formatFileSize(fileSizeBytes)].filter(Boolean).join(' • ')}
|
||||
</Text>
|
||||
)}
|
||||
</View>
|
||||
|
||||
<View style={styles.contentContainer}>
|
||||
<View style={styles.timelineContainer}>
|
||||
<TouchableOpacity
|
||||
ref={timelineRef}
|
||||
style={[styles.timeline, loadError && { opacity: 0.5 }]}
|
||||
onPress={handleTimelinePress}
|
||||
onLayout={handleTimelineLayout}
|
||||
activeOpacity={0.8}
|
||||
disabled={loadError}>
|
||||
<View style={[styles.progress, { width: `${percentComplete}%` }]} />
|
||||
<View style={[styles.scrubber, { left: `${percentComplete}%` }]} />
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
<View style={styles.controls}>
|
||||
<Text style={styles.timestamp}>{loadError ? '--:--' : formattedPosition}</Text>
|
||||
<TouchableOpacity
|
||||
onPress={playPause}
|
||||
style={[styles.controlButton, loadError && { opacity: 0.5 }]}
|
||||
disabled={loadError}>
|
||||
<Icon name={isPlaying ? 'pause-outline' : 'play-outline'} size={32} color={textColor} />
|
||||
</TouchableOpacity>
|
||||
<Text style={styles.timestamp}>{loadError ? '--:--' : formattedDuration}</Text>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
export default AudioPlayer;
|
||||
184
apps/memoro/apps/mobile/components/organisms/AudioRecorder.tsx
Normal file
184
apps/memoro/apps/mobile/components/organisms/AudioRecorder.tsx
Normal file
|
|
@ -0,0 +1,184 @@
|
|||
import React, { useMemo, useEffect, useState } from 'react';
|
||||
import { View, Text, Pressable, StyleSheet, Platform } from 'react-native';
|
||||
|
||||
import { RecordingStatus } from '~/features/audioRecordingV2';
|
||||
|
||||
/**
|
||||
* Props für die AudioRecorder-Komponente
|
||||
*/
|
||||
interface AudioRecorderProps {
|
||||
status: RecordingStatus;
|
||||
duration: number;
|
||||
error: string | null;
|
||||
metering: number | null;
|
||||
onStartRecording: () => void;
|
||||
onStopRecording: () => void;
|
||||
onPauseRecording?: () => void;
|
||||
onResumeRecording?: () => void;
|
||||
onReset: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Komponente für die Aufnahmesteuerung und Timer-Anzeige
|
||||
* Web-kompatible Version
|
||||
*/
|
||||
export const AudioRecorder: React.FC<AudioRecorderProps> = ({
|
||||
status,
|
||||
duration,
|
||||
error,
|
||||
metering,
|
||||
onStartRecording,
|
||||
onStopRecording,
|
||||
onPauseRecording,
|
||||
onResumeRecording,
|
||||
onReset,
|
||||
}) => {
|
||||
// Formatiere die Dauer als MM:SS
|
||||
const formattedDuration = useMemo(() => {
|
||||
const totalSeconds = Math.floor(duration / 1000);
|
||||
const minutes = Math.floor(totalSeconds / 60);
|
||||
const seconds = totalSeconds % 60;
|
||||
return `${minutes.toString().padStart(2, '0')}:${seconds.toString().padStart(2, '0')}`;
|
||||
}, [duration]);
|
||||
|
||||
// Status für blinkenden Indikator (nur für Aufnahme)
|
||||
const [visible, setVisible] = useState(true);
|
||||
const isRecording = status === RecordingStatus.RECORDING;
|
||||
const isPaused = status === RecordingStatus.PAUSED;
|
||||
const isStopped = status === RecordingStatus.STOPPED;
|
||||
const hasError = status === RecordingStatus.ERROR;
|
||||
const isWeb = Platform.OS === 'web';
|
||||
|
||||
// Prüfen, ob Pause/Resume unterstützt wird
|
||||
const supportsPauseResume = !!onPauseRecording && !!onResumeRecording;
|
||||
|
||||
// Für blinkenden Aufnahme-Indikator
|
||||
useEffect(() => {
|
||||
let interval: ReturnType<typeof setInterval> | number;
|
||||
|
||||
if (isRecording) {
|
||||
interval = setInterval(() => {
|
||||
setVisible((prev) => !prev);
|
||||
}, 800);
|
||||
} else {
|
||||
setVisible(true);
|
||||
}
|
||||
|
||||
return () => {
|
||||
if (interval) clearInterval(interval as ReturnType<typeof setInterval>);
|
||||
};
|
||||
}, [isRecording]);
|
||||
|
||||
return (
|
||||
<View className="w-full rounded-lg bg-gray-50 p-6 border border-gray-200">
|
||||
{/* Timer mit optionalem blinkenden Aufnahme-Indikator */}
|
||||
<View className="mb-4 flex items-center justify-center">
|
||||
{(isRecording || isPaused) && (
|
||||
<View className="mb-2 flex-row items-center justify-center">
|
||||
<View style={[
|
||||
styles.recordingDot,
|
||||
(!visible || isPaused) && styles.recordingDotInvisible
|
||||
]} />
|
||||
<Text className={`ml-2 font-medium ${isPaused ? 'text-amber-600' : 'text-red-600'}`}>
|
||||
{isPaused ? 'PAUSE' : 'REC'}
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* Audio-Pegel Anzeige - nur auf Mobile zeigen */}
|
||||
{!isWeb && (isRecording || isPaused) && metering !== null && (
|
||||
<View className="mb-2 w-full max-w-xs">
|
||||
<View className="h-2 w-full bg-gray-200 rounded-full overflow-hidden">
|
||||
<View
|
||||
className="h-full bg-blue-500 rounded-full"
|
||||
style={{
|
||||
width: `${Math.min(100, Math.max(0, (metering + 60) / 60 * 100))}%`,
|
||||
opacity: isPaused ? 0.5 : 1
|
||||
}}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
)}
|
||||
|
||||
<Text className="text-4xl font-bold text-gray-900">{formattedDuration}</Text>
|
||||
<Text className="mt-1 text-sm text-gray-600">
|
||||
{isRecording
|
||||
? 'Aufnahme läuft... Tippe auf Stop zum Beenden'
|
||||
: isPaused
|
||||
? 'Aufnahme pausiert... Tippe auf Fortsetzen'
|
||||
: isStopped
|
||||
? duration > 0
|
||||
? `Aufnahme beendet (${Math.floor(duration / 1000)}s)`
|
||||
: 'Aufnahme beendet'
|
||||
: 'Bereit für Aufnahme'}
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
{/* Fehlermeldung */}
|
||||
{hasError && error && (
|
||||
<View className="mb-4 rounded bg-red-50 p-2 border border-red-100">
|
||||
<Text className="text-center text-red-600">{error}</Text>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* Steuerungselemente mit Universal-Buttons */}
|
||||
<View className="flex-row items-center justify-center space-x-6">
|
||||
{/* Zurücksetzen-Button (nur anzeigen, wenn gestoppt oder Fehler) */}
|
||||
{(isStopped || hasError) && (
|
||||
<Pressable
|
||||
onPress={onReset}
|
||||
className="h-12 w-12 items-center justify-center rounded-full bg-gray-200 shadow-sm">
|
||||
<Text className="text-lg text-gray-700">↺</Text>
|
||||
</Pressable>
|
||||
)}
|
||||
|
||||
{/* Pause/Resume-Button (nur anzeigen, wenn Aufnahme läuft oder pausiert und unterstützt wird) */}
|
||||
{supportsPauseResume && (isRecording || isPaused) && (
|
||||
<Pressable
|
||||
onPress={isPaused ? onResumeRecording : onPauseRecording}
|
||||
className="h-12 w-12 items-center justify-center rounded-full bg-amber-500 shadow-sm">
|
||||
<Text className="text-lg text-gray-800">{isPaused ? '▶' : '❚❚'}</Text>
|
||||
</Pressable>
|
||||
)}
|
||||
|
||||
{/* Aufnahme-Button mit universellen Symbolen */}
|
||||
<Pressable
|
||||
onPress={isRecording || isPaused ? onStopRecording : onStartRecording}
|
||||
className={`h-16 w-16 items-center justify-center rounded-full ${
|
||||
isRecording ? 'bg-red-600' : isPaused ? 'bg-amber-600' : 'bg-blue-600'
|
||||
}`}>
|
||||
<Text className="text-xl font-bold text-gray-100">
|
||||
{isRecording || isPaused ? '■' : '●'}
|
||||
</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
|
||||
{/* Hilfstext */}
|
||||
<Text className="mt-4 text-center text-xs text-gray-600">
|
||||
{isRecording
|
||||
? supportsPauseResume
|
||||
? 'Tippe auf Pause zum Pausieren oder Stopp zum Beenden'
|
||||
: 'Tippe auf Stopp zum Beenden'
|
||||
: isPaused
|
||||
? 'Tippe auf Fortsetzen oder Stopp zum Beenden'
|
||||
: isStopped
|
||||
? 'Aufnahme gespeichert'
|
||||
: 'Tippe auf Start zum Aufnehmen'}
|
||||
</Text>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
recordingDot: {
|
||||
width: 10,
|
||||
height: 10,
|
||||
borderRadius: 5,
|
||||
backgroundColor: '#B91C1C', // Even darker red for better contrast on white
|
||||
},
|
||||
recordingDotInvisible: {
|
||||
opacity: 0,
|
||||
},
|
||||
});
|
||||
|
||||
export default AudioRecorder;
|
||||
285
apps/memoro/apps/mobile/components/organisms/BlueprintCard.tsx
Normal file
285
apps/memoro/apps/mobile/components/organisms/BlueprintCard.tsx
Normal file
|
|
@ -0,0 +1,285 @@
|
|||
import React, { useState, useEffect } from 'react';
|
||||
import { Pressable, View, StyleSheet, ActivityIndicator } from 'react-native';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useTheme } from '~/features/theme/ThemeProvider';
|
||||
import Text from '~/components/atoms/Text';
|
||||
import Icon from '~/components/atoms/Icon';
|
||||
import { isActiveBlueprintForUser, toggleBlueprintActive } from '~/features/blueprints/lib/activeBlueprintService';
|
||||
import tagEvents from '~/features/tags/tagEvents';
|
||||
import colors from '~/tailwind.config.js';
|
||||
import { STANDARD_BLUEPRINT_ID } from '~/features/blueprints/constants';
|
||||
|
||||
interface BlueprintCardProps {
|
||||
id: string;
|
||||
name: {
|
||||
de?: string;
|
||||
en?: string;
|
||||
};
|
||||
description?: {
|
||||
de?: string;
|
||||
en?: string;
|
||||
};
|
||||
category?: {
|
||||
id: string;
|
||||
name: {
|
||||
de?: string;
|
||||
en?: string;
|
||||
} | string;
|
||||
style?: { color?: string; [key: string]: any } | string;
|
||||
};
|
||||
isPublic: boolean;
|
||||
createdAt: string;
|
||||
onPress: (id: string) => void;
|
||||
onActiveStatusChange?: (id: string, isActive: boolean) => void;
|
||||
showCategory?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* BlueprintCard displays a blueprint item with name, description, and metadata
|
||||
* in a styled card format with proper localization support
|
||||
*/
|
||||
const BlueprintCard = ({
|
||||
id,
|
||||
name,
|
||||
description,
|
||||
category,
|
||||
isPublic,
|
||||
createdAt,
|
||||
onPress,
|
||||
onActiveStatusChange,
|
||||
showCategory = false
|
||||
}: BlueprintCardProps) => {
|
||||
const { i18n } = useTranslation();
|
||||
const { isDark, themeVariant } = useTheme();
|
||||
|
||||
const [isActive, setIsActive] = useState<boolean>(false);
|
||||
const [isLoading, setIsLoading] = useState<boolean>(false);
|
||||
|
||||
const currentLanguage = i18n.language.startsWith('de') ? 'de' : 'en';
|
||||
const displayName = name?.[currentLanguage] || name?.en || name?.de || 'Unnamed Blueprint';
|
||||
const displayDescription = description?.[currentLanguage] || description?.en || description?.de || '';
|
||||
|
||||
// Get category display name and color
|
||||
let categoryName = '';
|
||||
let categoryColor = '#808080';
|
||||
|
||||
if (category) {
|
||||
// Parse category name
|
||||
if (category.name) {
|
||||
if (typeof category.name === 'string') {
|
||||
try {
|
||||
const nameObj = JSON.parse(category.name);
|
||||
categoryName = nameObj[currentLanguage] || nameObj.en || nameObj.de || '';
|
||||
} catch (e) {
|
||||
categoryName = category.name;
|
||||
}
|
||||
} else {
|
||||
categoryName = category.name[currentLanguage] || category.name.en || category.name.de || '';
|
||||
}
|
||||
}
|
||||
|
||||
// Parse category color
|
||||
if (category.style) {
|
||||
if (typeof category.style === 'string') {
|
||||
try {
|
||||
const styleObj = JSON.parse(category.style);
|
||||
categoryColor = styleObj.color || '#808080';
|
||||
} catch (e) {
|
||||
categoryColor = '#808080';
|
||||
}
|
||||
} else {
|
||||
categoryColor = category.style.color || '#808080';
|
||||
}
|
||||
}
|
||||
|
||||
// Validate color format (should be hex)
|
||||
if (!categoryColor.startsWith('#')) {
|
||||
categoryColor = '#808080';
|
||||
}
|
||||
}
|
||||
|
||||
// Lade den Aktivierungsstatus beim Mounten der Komponente
|
||||
useEffect(() => {
|
||||
const loadActiveStatus = async () => {
|
||||
try {
|
||||
const active = await isActiveBlueprintForUser(id);
|
||||
setIsActive(active);
|
||||
} catch (error) {
|
||||
console.debug('Fehler beim Laden des Aktivierungsstatus:', error);
|
||||
}
|
||||
};
|
||||
|
||||
loadActiveStatus();
|
||||
}, [id]);
|
||||
|
||||
// Funktion zum Umschalten des Aktivierungsstatus
|
||||
const handleToggleActive = async (event: any) => {
|
||||
// Verhindere, dass das Event zum Parent-Element propagiert
|
||||
event.stopPropagation();
|
||||
|
||||
try {
|
||||
setIsLoading(true);
|
||||
const newStatus = !isActive;
|
||||
const success = await toggleBlueprintActive(id, newStatus);
|
||||
|
||||
if (success) {
|
||||
setIsActive(newStatus);
|
||||
if (onActiveStatusChange) {
|
||||
onActiveStatusChange(id, newStatus);
|
||||
}
|
||||
|
||||
// Event emittieren für andere Komponenten
|
||||
tagEvents.emitBlueprintPinned(id, newStatus);
|
||||
}
|
||||
} catch (error) {
|
||||
console.debug('Fehler beim Umschalten des Aktivierungsstatus:', error);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Get border color from theme
|
||||
const borderColor = isDark
|
||||
? ((colors as any).theme?.extend?.colors?.dark)?.[themeVariant]?.border || '#424242'
|
||||
: ((colors as any).theme?.extend?.colors)?.[themeVariant]?.border || '#e6e6e6';
|
||||
|
||||
// Get background color from theme
|
||||
const backgroundColor = isDark
|
||||
? ((colors as any).theme?.extend?.colors?.dark)?.[themeVariant]?.contentBackground
|
||||
: ((colors as any).theme?.extend?.colors)?.[themeVariant]?.contentBackground;
|
||||
|
||||
return (
|
||||
<Pressable
|
||||
style={[
|
||||
styles.container,
|
||||
{
|
||||
backgroundColor,
|
||||
borderWidth: 1,
|
||||
borderColor
|
||||
}
|
||||
]}
|
||||
onPress={() => onPress(id)}
|
||||
>
|
||||
<View style={styles.header}>
|
||||
<Text
|
||||
style={[styles.name, { color: isDark ? '#FFFFFF' : '#000000' }]}
|
||||
numberOfLines={1}
|
||||
ellipsizeMode="tail"
|
||||
>
|
||||
{displayName}
|
||||
</Text>
|
||||
<View style={styles.iconContainer}>
|
||||
{/* {isPublic && (
|
||||
<Icon name="globe-outline" size={16} color={isDark ? '#AAAAAA' : '#666666'} />
|
||||
)} */}
|
||||
<Pressable
|
||||
onPress={handleToggleActive}
|
||||
style={[
|
||||
styles.activeButton,
|
||||
{
|
||||
backgroundColor: isActive
|
||||
? 'rgba(255, 149, 0, 0.15)'
|
||||
: isDark ? 'rgba(255, 255, 255, 0.1)' : 'rgba(0, 0, 0, 0.05)',
|
||||
borderRadius: 8,
|
||||
padding: 8,
|
||||
opacity: id === STANDARD_BLUEPRINT_ID ? 0.5 : 1,
|
||||
}
|
||||
]}
|
||||
disabled={isLoading || id === STANDARD_BLUEPRINT_ID}
|
||||
>
|
||||
{isLoading ? (
|
||||
<ActivityIndicator size="small" color={isDark ? '#AAAAAA' : '#666666'} />
|
||||
) : (
|
||||
<Icon
|
||||
name={isActive ? 'pin' : 'pin-outline'}
|
||||
size={20}
|
||||
color={isActive ? '#FF9500' : isDark ? '#AAAAAA' : '#666666'}
|
||||
/>
|
||||
)}
|
||||
</Pressable>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{displayDescription ? (
|
||||
<Text
|
||||
style={[styles.description, { color: isDark ? '#CCCCCC' : '#666666' }]}
|
||||
numberOfLines={2}
|
||||
>
|
||||
{displayDescription}
|
||||
</Text>
|
||||
) : undefined}
|
||||
|
||||
{showCategory && category && categoryName ? (
|
||||
<View style={styles.categoryContainer}>
|
||||
<View
|
||||
style={[
|
||||
styles.categoryTag,
|
||||
{
|
||||
backgroundColor: `${categoryColor}33`, // 20% opacity in hex
|
||||
borderColor: categoryColor,
|
||||
}
|
||||
]}
|
||||
>
|
||||
<Text
|
||||
style={[
|
||||
styles.categoryText,
|
||||
{ color: categoryColor }
|
||||
]}
|
||||
numberOfLines={1}
|
||||
>
|
||||
{categoryName}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
) : undefined}
|
||||
</Pressable>
|
||||
);
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
borderRadius: 16,
|
||||
padding: 16,
|
||||
marginBottom: 12,
|
||||
},
|
||||
header: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
marginBottom: 8,
|
||||
},
|
||||
name: {
|
||||
fontSize: 18,
|
||||
fontWeight: 'bold',
|
||||
flex: 1,
|
||||
marginRight: 8,
|
||||
},
|
||||
description: {
|
||||
fontSize: 14,
|
||||
marginBottom: 8,
|
||||
},
|
||||
iconContainer: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: 12,
|
||||
},
|
||||
activeButton: {
|
||||
// padding moved inline for better visual feedback
|
||||
},
|
||||
categoryContainer: {
|
||||
flexDirection: 'row',
|
||||
marginTop: 4,
|
||||
},
|
||||
categoryTag: {
|
||||
paddingHorizontal: 10,
|
||||
paddingVertical: 4,
|
||||
borderRadius: 8,
|
||||
borderWidth: 1,
|
||||
},
|
||||
categoryText: {
|
||||
fontSize: 11,
|
||||
fontWeight: '600',
|
||||
},
|
||||
});
|
||||
|
||||
export default BlueprintCard;
|
||||
|
|
@ -0,0 +1,106 @@
|
|||
import React from 'react';
|
||||
import { View, StyleSheet } from 'react-native';
|
||||
import { useTheme } from '~/features/theme/ThemeProvider';
|
||||
import colors from '~/tailwind.config.js';
|
||||
|
||||
/**
|
||||
* Skeleton loader for BlueprintCard component
|
||||
* Matches exact BlueprintCard structure and dimensions
|
||||
*/
|
||||
const BlueprintCardSkeleton = () => {
|
||||
const { isDark, themeVariant } = useTheme();
|
||||
|
||||
// Get theme colors
|
||||
const borderColor = isDark
|
||||
? ((colors as any).theme?.extend?.colors?.dark)?.[themeVariant]?.border || '#424242'
|
||||
: ((colors as any).theme?.extend?.colors)?.[themeVariant]?.border || '#e6e6e6';
|
||||
|
||||
const backgroundColor = isDark
|
||||
? ((colors as any).theme?.extend?.colors?.dark)?.[themeVariant]?.contentBackground
|
||||
: ((colors as any).theme?.extend?.colors)?.[themeVariant]?.contentBackground;
|
||||
|
||||
const skeletonColor = isDark ? '#3a3a3a' : '#e0e0e0';
|
||||
|
||||
return (
|
||||
<View
|
||||
style={[
|
||||
styles.container,
|
||||
{
|
||||
backgroundColor,
|
||||
borderWidth: 1,
|
||||
borderColor
|
||||
}
|
||||
]}
|
||||
>
|
||||
<View style={styles.header}>
|
||||
{/* Title skeleton */}
|
||||
<View
|
||||
style={[
|
||||
styles.titleSkeleton,
|
||||
{ backgroundColor: skeletonColor }
|
||||
]}
|
||||
/>
|
||||
|
||||
{/* Pin icon skeleton */}
|
||||
<View
|
||||
style={[
|
||||
styles.iconSkeleton,
|
||||
{
|
||||
backgroundColor: isDark ? 'rgba(255, 255, 255, 0.1)' : 'rgba(0, 0, 0, 0.05)'
|
||||
}
|
||||
]}
|
||||
/>
|
||||
</View>
|
||||
|
||||
{/* Description skeleton - two lines */}
|
||||
<View>
|
||||
<View
|
||||
style={[
|
||||
styles.descriptionLine,
|
||||
{ backgroundColor: skeletonColor, width: '100%' }
|
||||
]}
|
||||
/>
|
||||
<View
|
||||
style={[
|
||||
styles.descriptionLine,
|
||||
{ backgroundColor: skeletonColor, width: '75%' }
|
||||
]}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
borderRadius: 16,
|
||||
padding: 16,
|
||||
marginBottom: 12,
|
||||
},
|
||||
header: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
marginBottom: 8,
|
||||
},
|
||||
titleSkeleton: {
|
||||
height: 20,
|
||||
width: 180,
|
||||
borderRadius: 4,
|
||||
opacity: 0.6,
|
||||
},
|
||||
iconSkeleton: {
|
||||
width: 36,
|
||||
height: 36,
|
||||
borderRadius: 8,
|
||||
opacity: 0.6,
|
||||
},
|
||||
descriptionLine: {
|
||||
height: 14,
|
||||
borderRadius: 3,
|
||||
marginBottom: 6,
|
||||
opacity: 0.5,
|
||||
},
|
||||
});
|
||||
|
||||
export default BlueprintCardSkeleton;
|
||||
413
apps/memoro/apps/mobile/components/organisms/BlueprintModal.tsx
Normal file
413
apps/memoro/apps/mobile/components/organisms/BlueprintModal.tsx
Normal file
|
|
@ -0,0 +1,413 @@
|
|||
import React, { useState, useEffect, useCallback } from 'react';
|
||||
import { View, Dimensions } from 'react-native';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useTheme } from '~/features/theme/ThemeProvider';
|
||||
import { router } from 'expo-router';
|
||||
import Text from '~/components/atoms/Text';
|
||||
import Button from '~/components/atoms/Button';
|
||||
import BaseModal from '~/components/atoms/BaseModal';
|
||||
import { isActiveBlueprintForUser, toggleBlueprintActive } from '~/features/blueprints/lib/activeBlueprintService';
|
||||
import { createClient, SupabaseClient } from '@supabase/supabase-js';
|
||||
import AsyncStorage from '@react-native-async-storage/async-storage';
|
||||
import colors from '~/tailwind.config.js';
|
||||
import { STANDARD_BLUEPRINT_ID } from '~/features/blueprints/constants';
|
||||
|
||||
const { width, height } = Dimensions.get('window');
|
||||
|
||||
interface Prompt {
|
||||
id: string;
|
||||
memory_title: {
|
||||
de?: string;
|
||||
en?: string;
|
||||
};
|
||||
prompt_text: {
|
||||
de?: string;
|
||||
en?: string;
|
||||
};
|
||||
sort_order?: number;
|
||||
created_at?: string;
|
||||
}
|
||||
|
||||
interface BlueprintModalProps {
|
||||
visible: boolean;
|
||||
onClose: () => void;
|
||||
blueprint: {
|
||||
id: string;
|
||||
name: {
|
||||
de?: string;
|
||||
en?: string;
|
||||
};
|
||||
description?: {
|
||||
de?: string;
|
||||
en?: string;
|
||||
};
|
||||
prompts?: Prompt[];
|
||||
} | null;
|
||||
currentLanguage: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Modal zur Anzeige von Blueprint-Details
|
||||
*
|
||||
* Zeigt Titel, Beschreibung und die zugehörigen Prompts eines Blueprints an
|
||||
*/
|
||||
const BlueprintModal: React.FC<BlueprintModalProps> = ({
|
||||
visible,
|
||||
onClose,
|
||||
blueprint,
|
||||
currentLanguage
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const { isDark, themeVariant } = useTheme();
|
||||
const [isActive, setIsActive] = useState<boolean>(false);
|
||||
const [isActivating, setIsActivating] = useState<boolean>(false);
|
||||
const [prompts, setPrompts] = useState<Prompt[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// Direkter Zugriff auf die Datenbank mit dem anon key für öffentliche Daten
|
||||
// Definiere den Typ für den Supabase-Client
|
||||
const directSupabase: SupabaseClient = createClient(
|
||||
'https://npgifbrwhftlbrbaglmi.supabase.co',
|
||||
'sb_publishable_HlAZpB4BxXaMcfOCNx6VJA_-64NTxu4'
|
||||
);
|
||||
|
||||
// Lade Prompts für den Blueprint, wenn das Modal geöffnet wird
|
||||
useEffect(() => {
|
||||
if (visible && blueprint) {
|
||||
loadPrompts();
|
||||
}
|
||||
}, [visible, blueprint?.id]);
|
||||
|
||||
// Lade Prompts für den aktuellen Blueprint
|
||||
const loadPrompts = async () => {
|
||||
if (!blueprint) return;
|
||||
|
||||
try {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
// Zuerst die Prompt-IDs abrufen
|
||||
const { data: promptLinks, error: promptLinksError } = await directSupabase
|
||||
.from('prompt_blueprints')
|
||||
.select('prompt_id')
|
||||
.eq('blueprint_id', blueprint.id);
|
||||
|
||||
if (promptLinksError) {
|
||||
setError(t('blueprints.error_loading_prompts', 'Fehler beim Laden der Prompts'));
|
||||
setPrompts([]);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!promptLinks || promptLinks.length === 0) {
|
||||
setPrompts([]);
|
||||
return;
|
||||
}
|
||||
|
||||
// Prompt-IDs extrahieren
|
||||
const promptIds = promptLinks.map((link: { prompt_id: string }) => link.prompt_id);
|
||||
|
||||
// Versuche die RPC-Funktion für die Prompts
|
||||
const { data: promptsData, error: promptsError } = await directSupabase
|
||||
.rpc('get_prompts_by_ids', { prompt_ids: promptIds });
|
||||
|
||||
// Wenn die RPC-Funktion nicht existiert, versuche eine alternative Abfrage
|
||||
if (promptsError) {
|
||||
// Einzelne Abfragen für jeden Prompt-ID
|
||||
const prompts = [];
|
||||
for (const id of promptIds) {
|
||||
const { data, error } = await directSupabase
|
||||
.from('prompts')
|
||||
.select('*')
|
||||
.eq('id', id);
|
||||
|
||||
if (data && data.length > 0) {
|
||||
prompts.push(data[0]);
|
||||
}
|
||||
}
|
||||
|
||||
// Sort prompts using the same logic as memories: first by sort_order ascending, then by created_at descending
|
||||
const sortedPrompts = prompts.sort((a, b) => {
|
||||
// First sort by sort_order (ascending)
|
||||
if (a.sort_order !== undefined && b.sort_order !== undefined) {
|
||||
if (a.sort_order !== b.sort_order) {
|
||||
return a.sort_order - b.sort_order;
|
||||
}
|
||||
} else if (a.sort_order !== undefined) {
|
||||
return -1; // a has sort_order, b doesn't, so a comes first
|
||||
} else if (b.sort_order !== undefined) {
|
||||
return 1; // b has sort_order, a doesn't, so b comes first
|
||||
}
|
||||
|
||||
// Then sort by created_at (descending - newest first)
|
||||
if (a.created_at && b.created_at) {
|
||||
return new Date(b.created_at).getTime() - new Date(a.created_at).getTime();
|
||||
}
|
||||
|
||||
return 0;
|
||||
});
|
||||
|
||||
// Verwende die manuell gesammelten und sortierten Prompts
|
||||
if (sortedPrompts.length > 0) {
|
||||
return setPrompts(sortedPrompts);
|
||||
}
|
||||
}
|
||||
|
||||
if (promptsError) {
|
||||
setError(t('blueprints.error_loading_prompts', 'Fehler beim Laden der Prompts'));
|
||||
setPrompts([]);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!promptsData || promptsData.length === 0) {
|
||||
setPrompts([]);
|
||||
return;
|
||||
}
|
||||
|
||||
// Sort prompts using the same logic as memories: first by sort_order ascending, then by created_at descending
|
||||
const sortedPromptsData = [...promptsData].sort((a, b) => {
|
||||
// First sort by sort_order (ascending)
|
||||
if (a.sort_order !== undefined && b.sort_order !== undefined) {
|
||||
if (a.sort_order !== b.sort_order) {
|
||||
return a.sort_order - b.sort_order;
|
||||
}
|
||||
} else if (a.sort_order !== undefined) {
|
||||
return -1; // a has sort_order, b doesn't, so a comes first
|
||||
} else if (b.sort_order !== undefined) {
|
||||
return 1; // b has sort_order, a doesn't, so b comes first
|
||||
}
|
||||
|
||||
// Then sort by created_at (descending - newest first)
|
||||
if (a.created_at && b.created_at) {
|
||||
return new Date(b.created_at).getTime() - new Date(a.created_at).getTime();
|
||||
}
|
||||
|
||||
return 0;
|
||||
});
|
||||
|
||||
setPrompts(sortedPromptsData);
|
||||
} catch (err: any) {
|
||||
setError(t('common.unexpected_error', 'Ein unerwarteter Fehler ist aufgetreten'));
|
||||
setPrompts([]);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Lade den Aktivierungsstatus, wenn das Modal geöffnet wird
|
||||
useEffect(() => {
|
||||
if (visible && blueprint?.id) {
|
||||
const loadActiveStatus = async () => {
|
||||
try {
|
||||
const active = await isActiveBlueprintForUser(blueprint.id);
|
||||
setIsActive(active);
|
||||
} catch (error) {
|
||||
console.debug('Fehler beim Laden des Aktivierungsstatus:', error);
|
||||
}
|
||||
};
|
||||
|
||||
loadActiveStatus();
|
||||
}
|
||||
}, [visible, blueprint?.id]);
|
||||
|
||||
// Funktion zum Umschalten des Aktivierungsstatus
|
||||
const handleToggleActive = useCallback(async () => {
|
||||
if (!blueprint?.id) return;
|
||||
|
||||
try {
|
||||
setIsActivating(true);
|
||||
const newStatus = !isActive;
|
||||
const success = await toggleBlueprintActive(blueprint.id, newStatus);
|
||||
|
||||
if (success) {
|
||||
setIsActive(newStatus);
|
||||
}
|
||||
} catch (error) {
|
||||
console.debug('Fehler beim Umschalten des Aktivierungsstatus:', error);
|
||||
} finally {
|
||||
setIsActivating(false);
|
||||
}
|
||||
}, [blueprint?.id, isActive]);
|
||||
|
||||
// Handler für den Aufnehmen-Button
|
||||
const handleStartRecording = useCallback(async () => {
|
||||
if (!blueprint?.id) return;
|
||||
|
||||
try {
|
||||
// Speichere die Blueprint-ID im AsyncStorage für die Homepage
|
||||
await AsyncStorage.setItem('selectedBlueprintId', blueprint.id);
|
||||
|
||||
// Schließe das Modal
|
||||
onClose();
|
||||
|
||||
// Navigiere zur Homepage
|
||||
router.push('/(protected)/(tabs)/');
|
||||
|
||||
console.debug('Navigiere zur Homepage mit Blueprint:', blueprint.id);
|
||||
} catch (error) {
|
||||
console.debug('Fehler beim Speichern der Blueprint-ID:', error);
|
||||
}
|
||||
}, [blueprint?.id, onClose]);
|
||||
|
||||
// Fallback auf Englisch, wenn die aktuelle Sprache nicht verfügbar ist
|
||||
const lang = currentLanguage.startsWith('de') ? 'de' : 'en';
|
||||
|
||||
// Extrahiere die lokalisierten Werte
|
||||
const displayName = blueprint?.name?.[lang] || blueprint?.name?.en || blueprint?.name?.de || '';
|
||||
const displayDescription = blueprint?.description?.[lang] || blueprint?.description?.en || blueprint?.description?.de || '';
|
||||
|
||||
// Hintergrundfarben basierend auf dem Theme
|
||||
const backgroundColor = isDark ? '#1A1A1A' : '#F5F5F5';
|
||||
const textColor = isDark ? '#FFFFFF' : '#000000';
|
||||
const secondaryTextColor = isDark ? '#CCCCCC' : '#666666';
|
||||
|
||||
if (!blueprint) return null;
|
||||
|
||||
// Benutzerdefinierter Footer für das Modal
|
||||
const customFooter = (
|
||||
<View className='w-full'>
|
||||
<Button
|
||||
title={t('blueprints.record', 'Aufnehmen')}
|
||||
iconName="mic-outline"
|
||||
variant="primary"
|
||||
onPress={handleStartRecording}
|
||||
style={{ width: '100%' }}
|
||||
/>
|
||||
<View className='h-3' />
|
||||
<Button
|
||||
title={isActive ? t('blueprints.unpin', 'Entpinnen') : t('blueprints.pin', 'Anpinnen')}
|
||||
iconName={isActive ? 'pin' : 'pin-outline'}
|
||||
variant={isActive ? 'primary' : 'secondary'}
|
||||
onPress={handleToggleActive}
|
||||
disabled={isActivating || blueprint?.id === STANDARD_BLUEPRINT_ID}
|
||||
loading={isActivating}
|
||||
style={{ width: '100%', opacity: blueprint?.id === STANDARD_BLUEPRINT_ID ? 0.5 : 1 }}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
|
||||
return (
|
||||
<BaseModal
|
||||
isVisible={visible}
|
||||
onClose={onClose}
|
||||
title={`${t('blueprints.template_prefix', 'Vorlage:')} ${displayName}`}
|
||||
animationType='fade'
|
||||
footerContent={customFooter}
|
||||
>
|
||||
<View className='w-full'>
|
||||
<View className='mb-4'>
|
||||
<Text className={`text-base ${isDark ? 'text-gray-400' : 'text-gray-600'}`}>
|
||||
{displayDescription}
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
<View className='w-full'>
|
||||
<Text className={`text-lg font-bold mb-3 ${isDark ? 'text-white' : 'text-gray-900'}`}>
|
||||
{t('blueprints.prompts', 'Prompts')}
|
||||
</Text>
|
||||
|
||||
<View style={{ minHeight: 200 }}>
|
||||
{loading ? (
|
||||
<View className='space-y-3'>
|
||||
{/* Skeleton Loader für 3 Prompts */}
|
||||
{[1, 2, 3].map((index) => (
|
||||
<View
|
||||
key={index}
|
||||
className={`p-3 rounded-lg ${isDark ? 'bg-gray-800' : 'bg-gray-100'}`}
|
||||
>
|
||||
{/* Skeleton für Titel */}
|
||||
<View
|
||||
className={`h-4 rounded mb-2 ${isDark ? 'bg-gray-700' : 'bg-gray-300'}`}
|
||||
style={{ width: `${60 + (index * 15)}%` }}
|
||||
/>
|
||||
{/* Skeleton für Text - 2 Zeilen */}
|
||||
<View
|
||||
className={`h-3 rounded mb-1 ${isDark ? 'bg-gray-700' : 'bg-gray-300'}`}
|
||||
style={{ width: '100%' }}
|
||||
/>
|
||||
<View
|
||||
className={`h-3 rounded ${isDark ? 'bg-gray-700' : 'bg-gray-300'}`}
|
||||
style={{ width: `${70 + (index * 10)}%` }}
|
||||
/>
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
) : error ? (
|
||||
<View className='py-4'>
|
||||
<Text className='text-red-500'>
|
||||
{error}
|
||||
</Text>
|
||||
</View>
|
||||
) : prompts.length === 0 ? (
|
||||
<View className='py-4'>
|
||||
<Text className={`${isDark ? 'text-gray-400' : 'text-gray-600'}`}>
|
||||
{t('blueprints.no_prompts_available', 'Keine Prompts für diesen Blueprint verfügbar.')}
|
||||
</Text>
|
||||
</View>
|
||||
) : (
|
||||
<View>
|
||||
{prompts.map((prompt, index) => {
|
||||
// Get theme colors
|
||||
const backgroundColor = isDark
|
||||
? ((colors as any).theme?.extend?.colors?.dark)?.[themeVariant]?.contentBackgroundHover || '#2D2D2D'
|
||||
: ((colors as any).theme?.extend?.colors)?.[themeVariant]?.contentBackgroundHover || '#F8F9FA';
|
||||
|
||||
const borderColor = isDark
|
||||
? ((colors as any).theme?.extend?.colors?.dark)?.[themeVariant]?.border || '#374151'
|
||||
: ((colors as any).theme?.extend?.colors)?.[themeVariant]?.border || '#E5E7EB';
|
||||
|
||||
return (
|
||||
<View
|
||||
key={prompt.id}
|
||||
style={{
|
||||
backgroundColor,
|
||||
borderWidth: 1,
|
||||
borderColor,
|
||||
borderRadius: 12,
|
||||
marginBottom: 12,
|
||||
padding: 16,
|
||||
shadowColor: '#000',
|
||||
shadowOffset: { width: 0, height: 1 },
|
||||
shadowOpacity: isDark ? 0.3 : 0.1,
|
||||
shadowRadius: 2,
|
||||
elevation: 2,
|
||||
}}
|
||||
>
|
||||
{/* Content */}
|
||||
<View style={{ flex: 1 }}>
|
||||
<Text
|
||||
style={{
|
||||
fontSize: 16,
|
||||
fontWeight: '600',
|
||||
color: isDark ? '#FFFFFF' : '#111827',
|
||||
marginBottom: 4,
|
||||
lineHeight: 20,
|
||||
}}
|
||||
>
|
||||
{prompt.memory_title?.[lang] || prompt.memory_title?.en || prompt.memory_title?.de || t('blueprints.unnamed_prompt', 'Unbenannter Prompt')}
|
||||
</Text>
|
||||
<Text
|
||||
style={{
|
||||
fontSize: 14,
|
||||
color: isDark ? '#D1D5DB' : '#6B7280',
|
||||
lineHeight: 18,
|
||||
}}
|
||||
numberOfLines={2}
|
||||
ellipsizeMode="tail"
|
||||
>
|
||||
{prompt.prompt_text?.[lang] || prompt.prompt_text?.en || prompt.prompt_text?.de || ''}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
})}
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
</BaseModal>
|
||||
);
|
||||
};
|
||||
|
||||
export default BlueprintModal;
|
||||
146
apps/memoro/apps/mobile/components/organisms/EasterEggModal.tsx
Normal file
146
apps/memoro/apps/mobile/components/organisms/EasterEggModal.tsx
Normal file
|
|
@ -0,0 +1,146 @@
|
|||
import React, { useEffect, useRef } from 'react';
|
||||
import {
|
||||
Modal,
|
||||
View,
|
||||
StyleSheet,
|
||||
TouchableOpacity,
|
||||
Animated,
|
||||
Dimensions,
|
||||
Pressable,
|
||||
} from 'react-native';
|
||||
import Text from '~/components/atoms/Text';
|
||||
import MemoroLogo from '~/components/atoms/MemoroLogo';
|
||||
import { useTheme } from '~/features/theme/ThemeProvider';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { MaterialIcons } from '@expo/vector-icons';
|
||||
import colors from '~/tailwind.config.js';
|
||||
|
||||
interface EasterEggModalProps {
|
||||
isVisible: boolean;
|
||||
onClose: () => void;
|
||||
onLongPress: () => void;
|
||||
}
|
||||
|
||||
const { width, height } = Dimensions.get('window');
|
||||
|
||||
export default function EasterEggModal({ isVisible, onClose, onLongPress }: EasterEggModalProps) {
|
||||
const { isDark, themeVariant } = useTheme();
|
||||
const { t } = useTranslation();
|
||||
const rotateAnim = useRef(new Animated.Value(0)).current;
|
||||
|
||||
useEffect(() => {
|
||||
if (isVisible) {
|
||||
Animated.loop(
|
||||
Animated.timing(rotateAnim, {
|
||||
toValue: 1,
|
||||
duration: 3000,
|
||||
useNativeDriver: true,
|
||||
})
|
||||
).start();
|
||||
} else {
|
||||
rotateAnim.setValue(0);
|
||||
}
|
||||
}, [isVisible]);
|
||||
|
||||
const themeColors = (colors as any).theme?.extend?.colors;
|
||||
const contentBgColor = isDark
|
||||
? themeColors?.dark?.[themeVariant]?.contentBackground || '#1E1E1E'
|
||||
: themeColors?.[themeVariant]?.contentBackground || '#FFFFFF';
|
||||
const textColor = isDark ? '#FFFFFF' : '#000000';
|
||||
const subtextColor = isDark ? 'rgba(255, 255, 255, 0.7)' : 'rgba(0, 0, 0, 0.7)';
|
||||
|
||||
const spin = rotateAnim.interpolate({
|
||||
inputRange: [0, 1],
|
||||
outputRange: ['0deg', '360deg'],
|
||||
});
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
modalOverlay: {
|
||||
flex: 1,
|
||||
backgroundColor: 'rgba(0, 0, 0, 0.5)',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
},
|
||||
modalContent: {
|
||||
backgroundColor: contentBgColor,
|
||||
borderRadius: 20,
|
||||
padding: 32,
|
||||
alignItems: 'center',
|
||||
width: width * 0.85,
|
||||
maxWidth: 400,
|
||||
shadowColor: '#000',
|
||||
shadowOffset: {
|
||||
width: 0,
|
||||
height: 2,
|
||||
},
|
||||
shadowOpacity: 0.25,
|
||||
shadowRadius: 3.84,
|
||||
elevation: 5,
|
||||
},
|
||||
closeButton: {
|
||||
position: 'absolute',
|
||||
top: 16,
|
||||
right: 16,
|
||||
padding: 8,
|
||||
},
|
||||
logoContainer: {
|
||||
width: 120,
|
||||
height: 120,
|
||||
marginBottom: 24,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
},
|
||||
title: {
|
||||
fontSize: 24,
|
||||
fontWeight: 'bold',
|
||||
color: textColor,
|
||||
marginBottom: 16,
|
||||
textAlign: 'center',
|
||||
},
|
||||
message: {
|
||||
fontSize: 16,
|
||||
color: subtextColor,
|
||||
textAlign: 'center',
|
||||
lineHeight: 24,
|
||||
marginBottom: 8,
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<Modal animationType="fade" transparent={true} visible={isVisible} onRequestClose={onClose}>
|
||||
<TouchableOpacity style={styles.modalOverlay} activeOpacity={1} onPress={onClose}>
|
||||
<Pressable
|
||||
style={styles.modalContent}
|
||||
onPress={(e) => e.stopPropagation()}
|
||||
onLongPress={onLongPress}
|
||||
delayLongPress={1000}>
|
||||
<TouchableOpacity onPress={onClose} style={styles.closeButton}>
|
||||
<MaterialIcons name="close" size={24} color={subtextColor} />
|
||||
</TouchableOpacity>
|
||||
|
||||
<View style={styles.logoContainer}>
|
||||
<Animated.View
|
||||
style={{
|
||||
transform: [{ rotate: spin }],
|
||||
}}>
|
||||
<MemoroLogo size={100} color={isDark ? '#F7D44C' : '#F7D44C'} />
|
||||
</Animated.View>
|
||||
</View>
|
||||
|
||||
<Text style={styles.title}>Memoro</Text>
|
||||
|
||||
<Text style={styles.message}>
|
||||
{t('settings.easter_egg_message', 'Du hast das geheime Memoro Easter Egg gefunden!')}
|
||||
</Text>
|
||||
|
||||
<Text style={styles.message}>
|
||||
{t(
|
||||
'settings.easter_egg_submessage',
|
||||
'Wir arbeiten hart daran, deine Gedanken und Ideen zu bewahren.'
|
||||
)}
|
||||
</Text>
|
||||
</Pressable>
|
||||
</TouchableOpacity>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,182 @@
|
|||
import React, { useEffect, useRef } from 'react';
|
||||
import { View, Animated, StyleSheet } from 'react-native';
|
||||
import { useTheme } from '~/features/theme/ThemeProvider';
|
||||
import MemoPreviewSkeleton from '~/components/molecules/MemoPreviewSkeleton';
|
||||
import MemoroLogo from '~/components/atoms/MemoroLogo';
|
||||
|
||||
interface HomePageSkeletonProps {
|
||||
recordingButtonSize: number;
|
||||
showBlueprints?: boolean;
|
||||
showLanguageButton?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Vollständiger Skeleton Loader für die Homepage
|
||||
* Zeigt Platzhalter für alle Elemente während des initialen Ladens
|
||||
*/
|
||||
export default function HomePageSkeleton({
|
||||
recordingButtonSize,
|
||||
showBlueprints = true,
|
||||
showLanguageButton = true
|
||||
}: HomePageSkeletonProps) {
|
||||
const { colors, isDark } = useTheme();
|
||||
const pulseAnim = useRef(new Animated.Value(0.4)).current;
|
||||
|
||||
const skeletonColor = colors.skeleton;
|
||||
const backgroundColor = colors.contentBackground;
|
||||
|
||||
// Pulsing animation
|
||||
useEffect(() => {
|
||||
const pulse = Animated.loop(
|
||||
Animated.sequence([
|
||||
Animated.timing(pulseAnim, {
|
||||
toValue: 1,
|
||||
duration: 1000,
|
||||
useNativeDriver: true,
|
||||
}),
|
||||
Animated.timing(pulseAnim, {
|
||||
toValue: 0.4,
|
||||
duration: 1000,
|
||||
useNativeDriver: true,
|
||||
}),
|
||||
])
|
||||
);
|
||||
|
||||
pulse.start();
|
||||
|
||||
return () => pulse.stop();
|
||||
}, [pulseAnim]);
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
{/* MemoPreview Skeleton - oben */}
|
||||
<View style={styles.memoPreviewContainer}>
|
||||
<MemoPreviewSkeleton showMargins={false} />
|
||||
</View>
|
||||
|
||||
{/* Recording Button Skeleton - zentriert */}
|
||||
<View style={[styles.recordingButtonContainer, {
|
||||
transform: [{ translateY: -(recordingButtonSize / 2 + 10) }]
|
||||
}]}>
|
||||
<Animated.View
|
||||
style={[
|
||||
styles.recordingButtonCircle,
|
||||
{
|
||||
width: recordingButtonSize,
|
||||
height: recordingButtonSize,
|
||||
borderRadius: recordingButtonSize / 2,
|
||||
backgroundColor: skeletonColor,
|
||||
opacity: pulseAnim,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
},
|
||||
]}
|
||||
>
|
||||
{/* Memoro Logo inside button - 42% of button size */}
|
||||
<MemoroLogo
|
||||
size={recordingButtonSize * 0.42}
|
||||
color={skeletonColor}
|
||||
/>
|
||||
</Animated.View>
|
||||
|
||||
{/* Language Button Skeleton - links neben Recording Button */}
|
||||
{showLanguageButton && (
|
||||
<View style={styles.languageButtonSkeleton}>
|
||||
<Animated.View
|
||||
style={[
|
||||
styles.languageCircle,
|
||||
{
|
||||
backgroundColor: skeletonColor,
|
||||
opacity: pulseAnim,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
|
||||
{/* Blueprint Pills Skeleton - unten */}
|
||||
{showBlueprints && (
|
||||
<View style={styles.blueprintSelectorContainer}>
|
||||
<View style={styles.pillsContainer}>
|
||||
{[80, 100, 90, 110, 95].map((width, index) => (
|
||||
<Animated.View
|
||||
key={index}
|
||||
style={[
|
||||
styles.pillSkeleton,
|
||||
{
|
||||
width,
|
||||
backgroundColor: skeletonColor,
|
||||
opacity: pulseAnim,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
))}
|
||||
</View>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
position: 'relative',
|
||||
},
|
||||
memoPreviewContainer: {
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
zIndex: 20,
|
||||
minHeight: 180,
|
||||
},
|
||||
recordingButtonContainer: {
|
||||
position: 'absolute',
|
||||
top: '50%',
|
||||
left: 0,
|
||||
right: 0,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
zIndex: 30,
|
||||
},
|
||||
recordingButtonCircle: {
|
||||
shadowColor: '#000',
|
||||
shadowOffset: { width: 0, height: 4 },
|
||||
shadowOpacity: 0.2,
|
||||
shadowRadius: 8,
|
||||
elevation: 5,
|
||||
},
|
||||
languageButtonSkeleton: {
|
||||
position: 'absolute',
|
||||
left: -64,
|
||||
top: '50%',
|
||||
transform: [{ translateY: -26 }],
|
||||
},
|
||||
languageCircle: {
|
||||
width: 52,
|
||||
height: 52,
|
||||
borderRadius: 26,
|
||||
},
|
||||
blueprintSelectorContainer: {
|
||||
position: 'absolute',
|
||||
bottom: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
zIndex: 10,
|
||||
paddingVertical: 16,
|
||||
paddingHorizontal: 16,
|
||||
minHeight: 60,
|
||||
},
|
||||
pillsContainer: {
|
||||
flexDirection: 'row',
|
||||
gap: 8,
|
||||
flexWrap: 'nowrap',
|
||||
paddingHorizontal: 8,
|
||||
},
|
||||
pillSkeleton: {
|
||||
height: 36,
|
||||
borderRadius: 18,
|
||||
},
|
||||
});
|
||||
464
apps/memoro/apps/mobile/components/organisms/Memory.tsx
Normal file
464
apps/memoro/apps/mobile/components/organisms/Memory.tsx
Normal file
|
|
@ -0,0 +1,464 @@
|
|||
import React, { useState, useCallback, useMemo, useImperativeHandle, forwardRef } from 'react';
|
||||
import {
|
||||
View,
|
||||
Pressable,
|
||||
Platform,
|
||||
StyleSheet,
|
||||
TextInput,
|
||||
} from 'react-native';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useTheme } from '~/features/theme/ThemeProvider';
|
||||
import Text from '~/components/atoms/Text';
|
||||
import Icon from '~/components/atoms/Icon';
|
||||
import HighlightedText from '~/components/atoms/HighlightedText';
|
||||
import Markdown from 'react-native-markdown-display';
|
||||
import { createMarkdownStyles, createCompactMarkdownStyles } from '~/features/theme/markdownStyles';
|
||||
|
||||
// TODO: Replace with @expo/ui ContextMenu when implementing
|
||||
// import { MenuView } from '@react-native-menu/menu';
|
||||
|
||||
interface MemoryProps {
|
||||
title: string;
|
||||
content: string;
|
||||
defaultExpanded?: boolean;
|
||||
isEditing?: boolean;
|
||||
onContentChange?: (newContent: string) => void;
|
||||
onTitleChange?: (newTitle: string) => void;
|
||||
onShare?: () => void;
|
||||
onCopy?: () => void;
|
||||
onEdit?: () => void;
|
||||
onDelete?: () => void;
|
||||
createdAt?: string;
|
||||
onMemoPress?: () => void;
|
||||
// Search highlighting props
|
||||
searchQuery?: string;
|
||||
isSearchMode?: boolean;
|
||||
currentResultIndex?: number;
|
||||
searchResults?: Array<{id: string; type: string; text: string; index: number; matchIndex: number}>;
|
||||
memoryId?: string;
|
||||
}
|
||||
|
||||
export interface MemoryHandle {
|
||||
saveContent: () => void;
|
||||
}
|
||||
|
||||
const Memory = forwardRef<MemoryHandle, MemoryProps>((props, ref) => {
|
||||
// Safely extract props without destructuring
|
||||
const safeProps = props || {};
|
||||
const title = safeProps.title || '';
|
||||
const content = safeProps.content || '';
|
||||
const defaultExpanded = safeProps.defaultExpanded !== undefined ? safeProps.defaultExpanded : true;
|
||||
const isEditing = safeProps.isEditing || false;
|
||||
const onContentChange = safeProps.onContentChange;
|
||||
const onTitleChange = safeProps.onTitleChange;
|
||||
const onShare = safeProps.onShare;
|
||||
const onCopy = safeProps.onCopy;
|
||||
const onEdit = safeProps.onEdit;
|
||||
const onDelete = safeProps.onDelete;
|
||||
const createdAt = safeProps.createdAt;
|
||||
const searchQuery = safeProps.searchQuery || '';
|
||||
const isSearchMode = safeProps.isSearchMode || false;
|
||||
const currentResultIndex = safeProps.currentResultIndex;
|
||||
const searchResults = safeProps.searchResults || [];
|
||||
|
||||
// Get theme and translation values safely
|
||||
let isDark = false;
|
||||
let themeVariant = 'lume';
|
||||
let t = (key: string, fallback: string) => fallback;
|
||||
|
||||
try {
|
||||
const theme = useTheme();
|
||||
isDark = theme.isDark;
|
||||
themeVariant = theme.themeVariant;
|
||||
} catch (error) {
|
||||
console.warn('Theme hook failed in Memory component, using defaults');
|
||||
}
|
||||
|
||||
try {
|
||||
const translation = useTranslation();
|
||||
t = translation.t;
|
||||
} catch (error) {
|
||||
console.warn('Translation hook failed in Memory component, using fallback');
|
||||
}
|
||||
|
||||
// State for expanding/collapsing
|
||||
const [isExpanded, setIsExpanded] = useState(defaultExpanded);
|
||||
const [isHovered, setIsHovered] = useState(false);
|
||||
|
||||
// Toggle function
|
||||
const toggleExpanded = useCallback(() => {
|
||||
setIsExpanded(prev => !prev);
|
||||
}, []);
|
||||
|
||||
// Implement the ref methods
|
||||
useImperativeHandle(ref, () => ({
|
||||
saveContent: () => {
|
||||
console.log('Save content called');
|
||||
}
|
||||
}));
|
||||
|
||||
// Menu items for Zeego DropdownMenu
|
||||
const menuItems = useMemo(() => {
|
||||
return [
|
||||
{
|
||||
key: 'edit',
|
||||
title: t('common.edit', 'Edit'),
|
||||
systemIcon: 'pencil',
|
||||
onSelect: onEdit,
|
||||
},
|
||||
{
|
||||
key: 'copy',
|
||||
title: t('memo.copy', 'Copy'),
|
||||
systemIcon: 'doc.on.doc',
|
||||
onSelect: onCopy,
|
||||
},
|
||||
{
|
||||
key: 'share',
|
||||
title: t('common.share', 'Share'),
|
||||
systemIcon: 'square.and.arrow.up',
|
||||
onSelect: onShare,
|
||||
},
|
||||
{
|
||||
key: 'delete',
|
||||
title: t('common.delete', 'Delete'),
|
||||
systemIcon: 'trash',
|
||||
destructive: true,
|
||||
onSelect: onDelete,
|
||||
},
|
||||
];
|
||||
}, [t, onEdit, onCopy, onShare, onDelete]);
|
||||
|
||||
|
||||
// Theme colors and tw function
|
||||
const { tw, colors } = useTheme();
|
||||
const textColor = isDark ? '#FFFFFF' : '#000000';
|
||||
const secondaryTextColor = isDark ? 'rgba(255, 255, 255, 0.7)' : 'rgba(0, 0, 0, 0.7)';
|
||||
// Icon-Farbe basierend auf Theme (weiß im Dark Mode, dunkel im Light Mode)
|
||||
const iconColor = '#AEAEB2'; // Light gray icon color for both light and dark mode
|
||||
|
||||
// Import Tailwind config colors
|
||||
const tailwindConfig = require('~/tailwind.config.js');
|
||||
|
||||
// Get contentBackground color from tailwind config (for content cards)
|
||||
const getContentBackgroundColor = () => {
|
||||
try {
|
||||
const colors = tailwindConfig.theme.extend.colors;
|
||||
|
||||
if (isDark) {
|
||||
return colors.dark[themeVariant].contentBackground;
|
||||
} else {
|
||||
return colors[themeVariant].contentBackground;
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('Failed to get contentBackground from tailwind config, using fallback');
|
||||
return isDark ? '#1E1E1E' : '#FFFFFF';
|
||||
}
|
||||
};
|
||||
|
||||
const backgroundColor = getContentBackgroundColor();
|
||||
|
||||
// Markdown detection function
|
||||
const hasMarkdownSyntax = useCallback((text: string): boolean => {
|
||||
if (!text) return false;
|
||||
|
||||
const markdownPatterns = [
|
||||
/^#{1,6}\s/m, // Headers
|
||||
/\*\*.*\*\*/, // Bold
|
||||
/\*[^*]+\*/, // Italic (improved pattern)
|
||||
/\[.*\]\(.*\)/, // Links
|
||||
/```[\s\S]*```/, // Code blocks
|
||||
/`[^`]+`/, // Inline code
|
||||
/^\s*[-*+]\s/m, // Unordered lists
|
||||
/^\s*\d+\.\s/m, // Ordered lists
|
||||
/^\s*>\s/m, // Blockquotes
|
||||
/^\s*---\s*$/m, // Horizontal rules
|
||||
];
|
||||
|
||||
return markdownPatterns.some(pattern => pattern.test(text));
|
||||
}, []);
|
||||
|
||||
// Custom markdown rules for better blockquote rendering
|
||||
const markdownRules = useMemo(() => ({
|
||||
blockquote: (node: any, children: any, parent: any, styles: any) => (
|
||||
<View key={node.key} style={{
|
||||
backgroundColor: isDark ? 'rgba(255, 255, 255, 0.02)' : 'rgba(0, 0, 0, 0.02)',
|
||||
marginVertical: 12,
|
||||
borderRadius: 4,
|
||||
position: 'relative',
|
||||
}}>
|
||||
<View style={{
|
||||
position: 'absolute',
|
||||
left: 0,
|
||||
top: 0,
|
||||
bottom: 0,
|
||||
width: 3,
|
||||
backgroundColor: isDark ? 'rgba(255, 255, 255, 0.3)' : 'rgba(0, 0, 0, 0.3)',
|
||||
}} />
|
||||
<View style={{ paddingLeft: 20, paddingVertical: 16, paddingRight: 16 }}>
|
||||
{children}
|
||||
</View>
|
||||
</View>
|
||||
),
|
||||
text: (node: any, children: any, parent: any, styles: any) => {
|
||||
// Special handling for text inside blockquotes
|
||||
if (parent && parent.type === 'blockquote') {
|
||||
return (
|
||||
<Text key={node.key} style={{
|
||||
fontStyle: 'italic',
|
||||
color: secondaryTextColor,
|
||||
fontSize: 16,
|
||||
lineHeight: 28,
|
||||
}}>
|
||||
{node.content}
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
// Default text rendering
|
||||
return (
|
||||
<Text key={node.key} style={styles.text}>
|
||||
{node.content}
|
||||
</Text>
|
||||
);
|
||||
},
|
||||
}), [isDark, secondaryTextColor]);
|
||||
|
||||
// Markdown styles using central configuration
|
||||
const markdownStyles = useMemo(() =>
|
||||
createMarkdownStyles({
|
||||
isDark,
|
||||
textColor,
|
||||
secondaryTextColor,
|
||||
backgroundColor: 'transparent',
|
||||
}), [isDark, textColor, secondaryTextColor]
|
||||
);
|
||||
|
||||
// Markdown styles for title (more compact)
|
||||
const markdownTitleStyles = useMemo(() =>
|
||||
createCompactMarkdownStyles(markdownStyles), [markdownStyles]
|
||||
);
|
||||
|
||||
// Clean styles with content background for better visual separation
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
backgroundColor: 'transparent',
|
||||
width: '100%',
|
||||
maxWidth: 720,
|
||||
alignSelf: 'center',
|
||||
marginVertical: 8,
|
||||
paddingHorizontal: 0, // No horizontal padding - parent container handles spacing
|
||||
borderRadius: 16, // Add rounded corners like MemoPreview
|
||||
},
|
||||
header: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
paddingVertical: 12,
|
||||
paddingHorizontal: 0,
|
||||
backgroundColor: 'transparent',
|
||||
borderWidth: 0, // Remove border since container has its own styling
|
||||
borderRadius: 0, // Remove border radius since container handles it
|
||||
},
|
||||
headerContent: {
|
||||
flex: 1,
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
},
|
||||
title: {
|
||||
fontSize: 16,
|
||||
fontWeight: 'bold',
|
||||
color: textColor,
|
||||
flex: 1,
|
||||
opacity: isExpanded ? 0.5 : 1,
|
||||
},
|
||||
titleInput: {
|
||||
fontSize: 16,
|
||||
fontWeight: 'bold',
|
||||
color: textColor,
|
||||
flex: 1,
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: isDark ? 'rgba(255, 255, 255, 0.3)' : 'rgba(0, 0, 0, 0.3)',
|
||||
paddingBottom: 2,
|
||||
minHeight: 20,
|
||||
},
|
||||
bulletPoint: {
|
||||
fontSize: 16,
|
||||
color: textColor,
|
||||
marginRight: 8,
|
||||
transform: [{ scale: 1.5 }],
|
||||
alignSelf: 'flex-start',
|
||||
marginTop: -2,
|
||||
},
|
||||
chevron: {
|
||||
marginLeft: 8,
|
||||
transform: [{ rotate: isExpanded ? '0deg' : '90deg' }],
|
||||
},
|
||||
content: {
|
||||
paddingTop: 0,
|
||||
paddingBottom: 12,
|
||||
backgroundColor: 'transparent',
|
||||
width: '100%',
|
||||
},
|
||||
contentVisible: {
|
||||
opacity: 1,
|
||||
},
|
||||
contentText: {
|
||||
fontSize: 16,
|
||||
lineHeight: 24,
|
||||
color: textColor,
|
||||
},
|
||||
contentInput: {
|
||||
fontSize: 16,
|
||||
lineHeight: 24,
|
||||
color: textColor,
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: isDark ? 'rgba(255, 255, 255, 0.3)' : 'rgba(0, 0, 0, 0.3)',
|
||||
paddingBottom: 4,
|
||||
minHeight: 24,
|
||||
textAlignVertical: 'top',
|
||||
},
|
||||
timestamp: {
|
||||
fontSize: 12,
|
||||
color: secondaryTextColor,
|
||||
marginTop: 8,
|
||||
},
|
||||
});
|
||||
|
||||
// Return content directly - MenuView wrapped around entire component interferes with toggle
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<Pressable
|
||||
style={styles.header}
|
||||
onPress={isEditing ? undefined : toggleExpanded}
|
||||
onHoverIn={() => Platform.OS === 'web' && !isExpanded && setIsHovered(true)}
|
||||
onHoverOut={() => Platform.OS === 'web' && setIsHovered(false)}
|
||||
disabled={isEditing}
|
||||
>
|
||||
<View style={styles.headerContent}>
|
||||
{!isEditing && (
|
||||
<Text style={[styles.bulletPoint, { opacity: isExpanded ? 0.5 : 1 }]}>•</Text>
|
||||
)}
|
||||
{isEditing ? (
|
||||
<TextInput
|
||||
style={styles.titleInput}
|
||||
value={title}
|
||||
onChangeText={onTitleChange}
|
||||
placeholder="Memory Titel eingeben"
|
||||
placeholderTextColor={isDark ? 'rgba(255, 255, 255, 0.5)' : 'rgba(0, 0, 0, 0.5)'}
|
||||
multiline
|
||||
scrollEnabled={true}
|
||||
textAlignVertical="top"
|
||||
/>
|
||||
) : isSearchMode && searchQuery ? (
|
||||
<HighlightedText
|
||||
text={title}
|
||||
searchQuery={searchQuery}
|
||||
style={styles.title}
|
||||
numberOfLines={2}
|
||||
ellipsizeMode="tail"
|
||||
currentResultIndex={currentResultIndex}
|
||||
searchResults={searchResults}
|
||||
textType="memory-title"
|
||||
/>
|
||||
) : hasMarkdownSyntax(title) ? (
|
||||
<View style={{ flex: 1, opacity: isExpanded ? 0.5 : 1 }}>
|
||||
<Markdown style={markdownTitleStyles} rules={markdownRules}>
|
||||
{title}
|
||||
</Markdown>
|
||||
</View>
|
||||
) : (
|
||||
<Text style={styles.title}>{title}</Text>
|
||||
)}
|
||||
|
||||
{!isEditing && (
|
||||
<View style={[styles.chevron, { opacity: 0.5 }]}>
|
||||
<Icon
|
||||
name="chevron-down"
|
||||
size={16}
|
||||
color={isDark ? '#FFFFFF' : '#000000'}
|
||||
/>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
</Pressable>
|
||||
|
||||
{(isExpanded || isEditing) && (
|
||||
Platform.OS === 'web' ? (
|
||||
<View style={[styles.content, styles.contentVisible]}>
|
||||
{isEditing ? (
|
||||
<TextInput
|
||||
style={styles.contentInput}
|
||||
value={content}
|
||||
onChangeText={onContentChange}
|
||||
placeholder="Memory Inhalt eingeben"
|
||||
placeholderTextColor={isDark ? 'rgba(255, 255, 255, 0.5)' : 'rgba(0, 0, 0, 0.5)'}
|
||||
multiline
|
||||
scrollEnabled={false}
|
||||
textAlignVertical="top"
|
||||
/>
|
||||
) : isSearchMode && searchQuery ? (
|
||||
<HighlightedText
|
||||
text={content}
|
||||
searchQuery={searchQuery}
|
||||
style={styles.contentText}
|
||||
currentResultIndex={currentResultIndex}
|
||||
searchResults={searchResults}
|
||||
textType="memory-content"
|
||||
/>
|
||||
) : hasMarkdownSyntax(content) ? (
|
||||
<Markdown style={markdownStyles} rules={markdownRules}>
|
||||
{content}
|
||||
</Markdown>
|
||||
) : (
|
||||
<Text style={styles.contentText}>{content}</Text>
|
||||
)}
|
||||
{createdAt && (
|
||||
<Text style={styles.timestamp}>
|
||||
{new Date(createdAt).toLocaleDateString()}
|
||||
</Text>
|
||||
)}
|
||||
</View>
|
||||
) : (
|
||||
// TODO: Replace with @expo/ui ContextMenu for long-press menu
|
||||
<View style={[styles.content, styles.contentVisible]}>
|
||||
{isEditing ? (
|
||||
<TextInput
|
||||
style={styles.contentInput}
|
||||
value={content}
|
||||
onChangeText={onContentChange}
|
||||
placeholder="Memory Inhalt eingeben"
|
||||
placeholderTextColor={isDark ? 'rgba(255, 255, 255, 0.5)' : 'rgba(0, 0, 0, 0.5)'}
|
||||
multiline
|
||||
scrollEnabled={false}
|
||||
textAlignVertical="top"
|
||||
/>
|
||||
) : isSearchMode && searchQuery ? (
|
||||
<HighlightedText
|
||||
text={content}
|
||||
searchQuery={searchQuery}
|
||||
style={styles.contentText}
|
||||
currentResultIndex={currentResultIndex}
|
||||
searchResults={searchResults}
|
||||
textType="memory-content"
|
||||
/>
|
||||
) : hasMarkdownSyntax(content) ? (
|
||||
<Markdown style={markdownStyles} rules={markdownRules}>
|
||||
{content}
|
||||
</Markdown>
|
||||
) : (
|
||||
<Text style={styles.contentText}>{content}</Text>
|
||||
)}
|
||||
{createdAt && (
|
||||
<Text style={styles.timestamp}>
|
||||
{new Date(createdAt).toLocaleDateString()}
|
||||
</Text>
|
||||
)}
|
||||
</View>
|
||||
)
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
});
|
||||
|
||||
Memory.displayName = 'Memory';
|
||||
|
||||
export default Memory;
|
||||
771
apps/memoro/apps/mobile/components/organisms/PhotoGallery.tsx
Normal file
771
apps/memoro/apps/mobile/components/organisms/PhotoGallery.tsx
Normal file
|
|
@ -0,0 +1,771 @@
|
|||
import React, { useState, useEffect, useRef, useCallback } from 'react';
|
||||
import {
|
||||
View,
|
||||
ScrollView,
|
||||
Image,
|
||||
TouchableOpacity,
|
||||
StyleSheet,
|
||||
Modal,
|
||||
Alert,
|
||||
SafeAreaView,
|
||||
Dimensions,
|
||||
Platform,
|
||||
Animated as RNAnimated,
|
||||
} from 'react-native';
|
||||
import * as Haptics from 'expo-haptics';
|
||||
|
||||
// Import zoom toolkit for native platforms
|
||||
let Gallery: any = null;
|
||||
let ResumableZoom: any = null;
|
||||
let GestureHandlerRootView: any = null;
|
||||
|
||||
if (Platform.OS !== 'web') {
|
||||
try {
|
||||
const zoomToolkit = require('react-native-zoom-toolkit');
|
||||
Gallery = zoomToolkit.Gallery;
|
||||
ResumableZoom = zoomToolkit.ResumableZoom;
|
||||
|
||||
// Import GestureHandlerRootView
|
||||
const gestureHandler = require('react-native-gesture-handler');
|
||||
GestureHandlerRootView = gestureHandler.GestureHandlerRootView;
|
||||
} catch (error) {
|
||||
console.warn('react-native-zoom-toolkit not available:', error);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
const { width: screenWidth, height: screenHeight } = Dimensions.get('window');
|
||||
import { useTheme } from '~/features/theme/ThemeProvider';
|
||||
import { MemoPhoto } from '~/features/storage/storage.types';
|
||||
import Text from '~/components/atoms/Text';
|
||||
import Icon from '~/components/atoms/Icon';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
interface PhotoGalleryProps {
|
||||
memoId: string;
|
||||
photos: MemoPhoto[];
|
||||
onPhotosChange?: (photos: MemoPhoto[]) => void;
|
||||
onPhotoDelete?: (photoPath: string) => void;
|
||||
onAddPhotoPress?: () => void;
|
||||
editable?: boolean;
|
||||
showAddButton?: boolean;
|
||||
loading?: boolean;
|
||||
}
|
||||
|
||||
interface PhotoViewerModalProps {
|
||||
visible: boolean;
|
||||
photos: MemoPhoto[];
|
||||
initialIndex: number;
|
||||
onClose: () => void;
|
||||
onDelete?: (photoPath: string) => void;
|
||||
editable?: boolean;
|
||||
}
|
||||
|
||||
function PhotoViewerModal({
|
||||
visible,
|
||||
photos,
|
||||
initialIndex,
|
||||
onClose,
|
||||
onDelete,
|
||||
editable,
|
||||
}: PhotoViewerModalProps) {
|
||||
const { isDark } = useTheme();
|
||||
const { t } = useTranslation();
|
||||
const [currentIndex, setCurrentIndex] = useState(initialIndex);
|
||||
const [showMenuElements, setShowMenuElements] = useState(true);
|
||||
const [autoHideEnabled, setAutoHideEnabled] = useState(true);
|
||||
const fadeAnim = useRef(new RNAnimated.Value(1)).current;
|
||||
const hideMenuTimer = useRef<NodeJS.Timeout | null>(null);
|
||||
|
||||
// Reset current index when initialIndex changes
|
||||
useEffect(() => {
|
||||
setCurrentIndex(initialIndex);
|
||||
}, [initialIndex]);
|
||||
|
||||
// Auto-hide menu elements after 3 seconds
|
||||
useEffect(() => {
|
||||
if (visible && autoHideEnabled) {
|
||||
setShowMenuElements(true);
|
||||
|
||||
// Clear any existing timer
|
||||
if (hideMenuTimer.current) {
|
||||
clearTimeout(hideMenuTimer.current);
|
||||
}
|
||||
|
||||
// Set new timer
|
||||
hideMenuTimer.current = setTimeout(() => {
|
||||
setShowMenuElements(false);
|
||||
}, 3000);
|
||||
|
||||
return () => {
|
||||
if (hideMenuTimer.current) {
|
||||
clearTimeout(hideMenuTimer.current);
|
||||
}
|
||||
};
|
||||
}
|
||||
}, [visible, autoHideEnabled]);
|
||||
|
||||
const toggleMenuElements = useCallback(() => {
|
||||
const newShowState = !showMenuElements;
|
||||
setShowMenuElements(newShowState);
|
||||
setAutoHideEnabled(false); // Disable auto-hide after manual toggle
|
||||
|
||||
// Clear any existing timer
|
||||
if (hideMenuTimer.current) {
|
||||
clearTimeout(hideMenuTimer.current);
|
||||
}
|
||||
|
||||
RNAnimated.timing(fadeAnim, {
|
||||
toValue: newShowState ? 1 : 0,
|
||||
duration: 300,
|
||||
useNativeDriver: true,
|
||||
}).start();
|
||||
}, [showMenuElements, fadeAnim]);
|
||||
|
||||
// Animate menu elements visibility
|
||||
useEffect(() => {
|
||||
RNAnimated.timing(fadeAnim, {
|
||||
toValue: showMenuElements ? 1 : 0,
|
||||
duration: 300,
|
||||
useNativeDriver: true,
|
||||
}).start();
|
||||
}, [showMenuElements, fadeAnim]);
|
||||
|
||||
const currentPhoto = photos[currentIndex] || null;
|
||||
|
||||
const handleDelete = () => {
|
||||
if (!currentPhoto || !onDelete) return;
|
||||
|
||||
Alert.alert(
|
||||
t('memo.delete_photo_title', 'Foto löschen'),
|
||||
t('memo.delete_photo_message', 'Möchten Sie dieses Foto wirklich löschen?'),
|
||||
[
|
||||
{
|
||||
text: t('common.cancel', 'Abbrechen'),
|
||||
style: 'cancel',
|
||||
},
|
||||
{
|
||||
text: t('common.delete', 'Löschen'),
|
||||
style: 'destructive',
|
||||
onPress: () => {
|
||||
onDelete(currentPhoto.path);
|
||||
if (photos.length <= 1) {
|
||||
onClose();
|
||||
} else if (currentIndex >= photos.length - 1) {
|
||||
setCurrentIndex(Math.max(0, currentIndex - 1));
|
||||
}
|
||||
},
|
||||
},
|
||||
]
|
||||
);
|
||||
};
|
||||
|
||||
if (!currentPhoto || photos.length === 0) return null;
|
||||
|
||||
// Use Gallery component for native platforms
|
||||
if (Platform.OS !== 'web' && Gallery && ResumableZoom) {
|
||||
// Debug logging
|
||||
console.log('Gallery photos:', photos);
|
||||
console.log('Gallery initialIndex:', initialIndex);
|
||||
console.log('Current photo:', currentPhoto);
|
||||
|
||||
return (
|
||||
<Modal visible={visible} transparent animationType="fade" onRequestClose={onClose}>
|
||||
<GestureHandlerRootView style={{ flex: 1 }}>
|
||||
<View style={[styles.modalOverlay, { backgroundColor: 'rgba(0, 0, 0, 0.95)' }]}>
|
||||
<SafeAreaView style={styles.modalSafeArea}>
|
||||
{/* Header */}
|
||||
<RNAnimated.View
|
||||
style={[
|
||||
styles.modalHeader,
|
||||
{
|
||||
opacity: fadeAnim,
|
||||
transform: [
|
||||
{
|
||||
translateY: fadeAnim.interpolate({
|
||||
inputRange: [0, 1],
|
||||
outputRange: [-60, 0],
|
||||
}),
|
||||
},
|
||||
],
|
||||
},
|
||||
]}
|
||||
pointerEvents={showMenuElements ? 'auto' : 'none'}>
|
||||
<Text style={[styles.modalTitle, { color: '#FFFFFF' }]}>
|
||||
{currentPhoto.filename} ({currentIndex + 1}/{photos.length})
|
||||
</Text>
|
||||
<View style={styles.modalHeaderActions}>
|
||||
{editable && onDelete && (
|
||||
<TouchableOpacity style={styles.modalHeaderButton} onPress={handleDelete}>
|
||||
<Icon name="trash-outline" size={24} color="#FFFFFF" />
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
<TouchableOpacity style={styles.modalHeaderButton} onPress={onClose}>
|
||||
<Icon name="close" size={24} color="#FFFFFF" />
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</RNAnimated.View>
|
||||
|
||||
{/* Gallery Component */}
|
||||
<View style={styles.galleryContainer}>
|
||||
<Gallery
|
||||
data={photos}
|
||||
keyExtractor={(item, index) => item?.path || index.toString()}
|
||||
initialIndex={initialIndex}
|
||||
onIndexChange={(index) => {
|
||||
console.log('Gallery index changed to:', index);
|
||||
setCurrentIndex(index);
|
||||
}}
|
||||
renderItem={(photo) => {
|
||||
console.log('Gallery renderItem called with photo:', photo);
|
||||
|
||||
// Safety check for photo
|
||||
if (!photo || !photo.signedUrl) {
|
||||
console.log('No photo or signedUrl, showing placeholder');
|
||||
return (
|
||||
<View
|
||||
style={[
|
||||
styles.placeholderContainer,
|
||||
{
|
||||
backgroundColor: isDark ? '#2A2A2A' : '#F5F5F5',
|
||||
width: screenWidth,
|
||||
height: screenHeight - 160,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
},
|
||||
]}>
|
||||
<Icon
|
||||
name="image-outline"
|
||||
size={48}
|
||||
color={isDark ? '#666666' : '#CCCCCC'}
|
||||
/>
|
||||
<Text style={{ color: isDark ? '#666666' : '#CCCCCC', marginTop: 8 }}>
|
||||
{t('memo.image_not_available', 'Bild nicht verfügbar')}
|
||||
</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
console.log('Rendering image with URL:', photo.signedUrl);
|
||||
return (
|
||||
<ResumableZoom maxScale={5} minScale={1}>
|
||||
<Image
|
||||
source={{ uri: photo.signedUrl }}
|
||||
style={{
|
||||
width: screenWidth,
|
||||
height: screenHeight - 160, // Account for header and footer
|
||||
}}
|
||||
resizeMode="contain"
|
||||
onLoadStart={() => console.log('Loading image:', photo.path)}
|
||||
onLoad={() => console.log('Image loaded:', photo.path)}
|
||||
onError={(e) => console.error('Image error:', photo.path, e)}
|
||||
/>
|
||||
</ResumableZoom>
|
||||
);
|
||||
}}
|
||||
onTap={toggleMenuElements}
|
||||
onDoubleTap={() => {}} // Handled by ResumableZoom
|
||||
pinchCenteringMode="sync"
|
||||
style={{ flex: 1 }}
|
||||
/>
|
||||
</View>
|
||||
|
||||
{/* Footer */}
|
||||
<RNAnimated.View
|
||||
style={[
|
||||
styles.modalFooter,
|
||||
{
|
||||
opacity: fadeAnim,
|
||||
transform: [
|
||||
{
|
||||
translateY: fadeAnim.interpolate({
|
||||
inputRange: [0, 1],
|
||||
outputRange: [60, 0],
|
||||
}),
|
||||
},
|
||||
],
|
||||
},
|
||||
]}
|
||||
pointerEvents={showMenuElements ? 'auto' : 'none'}>
|
||||
<Text style={[styles.imageInfo, { color: '#FFFFFF' }]}>
|
||||
{t('memo.uploaded_at', 'Hochgeladen am: {{date}}', {
|
||||
date: new Date(currentPhoto.uploadedAt).toLocaleDateString(),
|
||||
})}
|
||||
</Text>
|
||||
{currentPhoto.fileSize && (
|
||||
<Text style={[styles.imageInfo, { color: '#FFFFFF' }]}>
|
||||
{t('memo.file_size', 'Größe: {{size}}', {
|
||||
size: (currentPhoto.fileSize / 1024 / 1024).toFixed(2) + ' MB',
|
||||
})}
|
||||
</Text>
|
||||
)}
|
||||
</RNAnimated.View>
|
||||
</SafeAreaView>
|
||||
</View>
|
||||
</GestureHandlerRootView>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
// Fallback for web - simple image viewer
|
||||
return (
|
||||
<Modal visible={visible} transparent animationType="fade" onRequestClose={onClose}>
|
||||
<View style={[styles.modalOverlay, { backgroundColor: 'rgba(0, 0, 0, 0.9)' }]}>
|
||||
<SafeAreaView style={styles.modalSafeArea}>
|
||||
{/* Header */}
|
||||
<View style={styles.modalHeader}>
|
||||
<Text style={[styles.modalTitle, { color: '#FFFFFF' }]}>
|
||||
{currentPhoto.filename} ({currentIndex + 1}/{photos.length})
|
||||
</Text>
|
||||
<View style={styles.modalHeaderActions}>
|
||||
{editable && onDelete && (
|
||||
<TouchableOpacity style={styles.modalHeaderButton} onPress={handleDelete}>
|
||||
<Icon name="trash-outline" size={24} color="#FFFFFF" />
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
<TouchableOpacity style={styles.modalHeaderButton} onPress={onClose}>
|
||||
<Icon name="close" size={24} color="#FFFFFF" />
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* Simple Image Display for Web */}
|
||||
<View style={styles.modalImageContainer}>
|
||||
<ScrollView
|
||||
horizontal
|
||||
pagingEnabled
|
||||
showsHorizontalScrollIndicator={false}
|
||||
onMomentumScrollEnd={(event) => {
|
||||
const newIndex = Math.round(event.nativeEvent.contentOffset.x / screenWidth);
|
||||
setCurrentIndex(newIndex);
|
||||
}}
|
||||
contentOffset={{ x: initialIndex * screenWidth, y: 0 }}>
|
||||
{photos.map((photo, index) => (
|
||||
<View key={photo.path} style={{ width: screenWidth, height: screenHeight - 200 }}>
|
||||
{photo.signedUrl ? (
|
||||
<Image
|
||||
source={{ uri: photo.signedUrl }}
|
||||
style={styles.webImage}
|
||||
resizeMode="contain"
|
||||
/>
|
||||
) : (
|
||||
<View
|
||||
style={[
|
||||
styles.placeholderContainer,
|
||||
{ backgroundColor: isDark ? '#2A2A2A' : '#F5F5F5' },
|
||||
]}>
|
||||
<Icon name="image-outline" size={48} color={isDark ? '#666666' : '#CCCCCC'} />
|
||||
<Text style={{ color: isDark ? '#666666' : '#CCCCCC', marginTop: 8 }}>
|
||||
{t('memo.image_not_available', 'Bild nicht verfügbar')}
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
))}
|
||||
</ScrollView>
|
||||
</View>
|
||||
|
||||
{/* Footer */}
|
||||
<View style={styles.modalFooter}>
|
||||
<Text style={[styles.imageInfo, { color: '#FFFFFF' }]}>
|
||||
{t('memo.uploaded_at', 'Hochgeladen am: {{date}}', {
|
||||
date: new Date(currentPhoto.uploadedAt).toLocaleDateString(),
|
||||
})}
|
||||
</Text>
|
||||
{currentPhoto.fileSize && (
|
||||
<Text style={[styles.imageInfo, { color: '#FFFFFF' }]}>
|
||||
{t('memo.file_size', 'Größe: {{size}}', {
|
||||
size: (currentPhoto.fileSize / 1024 / 1024).toFixed(2) + ' MB',
|
||||
})}
|
||||
</Text>
|
||||
)}
|
||||
</View>
|
||||
</SafeAreaView>
|
||||
</View>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
export default function PhotoGallery({
|
||||
memoId,
|
||||
photos,
|
||||
onPhotosChange,
|
||||
onPhotoDelete,
|
||||
onAddPhotoPress,
|
||||
editable = true,
|
||||
showAddButton = false,
|
||||
loading = false,
|
||||
}: PhotoGalleryProps) {
|
||||
const { isDark, themeVariant } = useTheme();
|
||||
const { t } = useTranslation();
|
||||
const [viewerVisible, setViewerVisible] = useState(false);
|
||||
const [initialPhotoIndex, setInitialPhotoIndex] = useState(0);
|
||||
|
||||
const handlePhotoPress = (photo: MemoPhoto) => {
|
||||
const photoIndex = photos.findIndex((p) => p.path === photo.path);
|
||||
setInitialPhotoIndex(photoIndex >= 0 ? photoIndex : 0);
|
||||
setViewerVisible(true);
|
||||
};
|
||||
|
||||
const handleCloseViewer = () => {
|
||||
setViewerVisible(false);
|
||||
};
|
||||
|
||||
const handleDeletePhoto = (photoPath: string) => {
|
||||
if (onPhotoDelete) {
|
||||
onPhotoDelete(photoPath);
|
||||
}
|
||||
|
||||
// Update local state if onPhotosChange is provided
|
||||
if (onPhotosChange) {
|
||||
const updatedPhotos = photos.filter((photo) => photo.path !== photoPath);
|
||||
onPhotosChange(updatedPhotos);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeletePhotoWithConfirmation = (photoPath: string) => {
|
||||
Alert.alert(
|
||||
t('memo.delete_photo_title', 'Foto löschen'),
|
||||
t('memo.delete_photo_message', 'Möchten Sie dieses Foto wirklich löschen?'),
|
||||
[
|
||||
{
|
||||
text: t('common.cancel', 'Abbrechen'),
|
||||
style: 'cancel',
|
||||
},
|
||||
{
|
||||
text: t('common.delete', 'Löschen'),
|
||||
style: 'destructive',
|
||||
onPress: () => handleDeletePhoto(photoPath),
|
||||
},
|
||||
]
|
||||
);
|
||||
};
|
||||
|
||||
// Haptic feedback for long press
|
||||
const triggerLongPressHaptic = async () => {
|
||||
try {
|
||||
await Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Medium);
|
||||
} catch (error) {
|
||||
console.debug('Haptic feedback error:', error);
|
||||
}
|
||||
};
|
||||
|
||||
// Get photo menu items for Zeego DropdownMenu
|
||||
const getPhotoMenuItems = (photo: MemoPhoto) => {
|
||||
const menuItems = [
|
||||
{
|
||||
key: 'preview',
|
||||
title: t('memo.preview_photo', 'Vorschau anzeigen'),
|
||||
systemIcon: 'eye',
|
||||
onSelect: () => handlePhotoPress(photo),
|
||||
}
|
||||
];
|
||||
|
||||
if (editable) {
|
||||
menuItems.push({
|
||||
key: 'delete',
|
||||
title: t('common.delete', 'Löschen'),
|
||||
systemIcon: 'trash',
|
||||
destructive: true,
|
||||
onSelect: () => handleDeletePhotoWithConfirmation(photo.path),
|
||||
});
|
||||
}
|
||||
|
||||
return menuItems;
|
||||
};
|
||||
|
||||
|
||||
return (
|
||||
<View style={styles.fullWidthContainer}>
|
||||
<View style={styles.header}>
|
||||
<Text style={[styles.title, { color: isDark ? '#FFFFFF' : '#000000' }]}>
|
||||
{photos.length}{' '}
|
||||
{photos.length === 1
|
||||
? t('memo.photo_singular', 'Photo')
|
||||
: t('memo.photos_section', 'Fotos')}
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
<ScrollView
|
||||
horizontal
|
||||
showsHorizontalScrollIndicator={false}
|
||||
contentContainerStyle={styles.scrollContent}
|
||||
style={styles.scrollContainer}>
|
||||
{/* Existing Photos */}
|
||||
{photos.map((photo) => {
|
||||
const photoMenuItems = getPhotoMenuItems(photo);
|
||||
|
||||
// Photo thumbnail content with long-press handler
|
||||
const handlePhotoLongPress = () => {
|
||||
triggerLongPressHaptic();
|
||||
|
||||
// Show action sheet for native platforms
|
||||
if (Platform.OS !== 'web' && photoMenuItems.length > 0) {
|
||||
const buttons = photoMenuItems.map(item => ({
|
||||
text: item.title,
|
||||
style: item.destructive ? 'destructive' as const : 'default' as const,
|
||||
onPress: item.onSelect,
|
||||
}));
|
||||
buttons.push({ text: t('cancel'), style: 'cancel' });
|
||||
|
||||
Alert.alert(
|
||||
t('photo_options', 'Photo Options'),
|
||||
undefined,
|
||||
buttons
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const thumbnailContent = (
|
||||
<TouchableOpacity
|
||||
style={[styles.photoThumbnail, { borderColor: isDark ? '#333333' : '#E0E0E0' }]}
|
||||
onPress={() => handlePhotoPress(photo)}
|
||||
onLongPress={handlePhotoLongPress}
|
||||
activeOpacity={0.8}>
|
||||
{photo.signedUrl ? (
|
||||
<Image
|
||||
source={{ uri: photo.signedUrl }}
|
||||
style={styles.thumbnailImage}
|
||||
resizeMode="cover"
|
||||
/>
|
||||
) : (
|
||||
<View
|
||||
style={[
|
||||
styles.placeholderThumbnail,
|
||||
{ backgroundColor: isDark ? '#2A2A2A' : '#F0F0F0' },
|
||||
]}>
|
||||
<Icon name="image-outline" size={32} color={isDark ? '#666666' : '#CCCCCC'} />
|
||||
</View>
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
);
|
||||
|
||||
// Return the thumbnail directly (context menu functionality moved to long-press)
|
||||
return <View key={photo.path}>{thumbnailContent}</View>;
|
||||
})}
|
||||
|
||||
{/* Add Photo Button at the end */}
|
||||
{editable && (
|
||||
<TouchableOpacity
|
||||
style={[
|
||||
styles.addPhotoThumbnail,
|
||||
{
|
||||
borderColor: isDark ? '#333333' : '#E0E0E0',
|
||||
backgroundColor: isDark ? 'rgba(60, 60, 60, 0.3)' : 'rgba(240, 240, 240, 0.3)',
|
||||
},
|
||||
]}
|
||||
onPress={
|
||||
onAddPhotoPress || (() => console.log('Add photo pressed but no handler provided'))
|
||||
}
|
||||
disabled={loading || !onAddPhotoPress}
|
||||
activeOpacity={0.8}>
|
||||
{loading ? (
|
||||
<>
|
||||
<Icon name="hourglass-outline" size={32} color={isDark ? '#666666' : '#CCCCCC'} />
|
||||
<Text
|
||||
style={[styles.addPhotoThumbnailText, { color: isDark ? '#CCCCCC' : '#666666' }]}>
|
||||
{t('memo.uploading', 'Wird hochgeladen...')}
|
||||
</Text>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Icon name="add" size={32} color={isDark ? '#FFFFFF' : '#000000'} />
|
||||
<Text
|
||||
style={[styles.addPhotoThumbnailText, { color: isDark ? '#FFFFFF' : '#000000' }]}>
|
||||
{t('memo.add_photos', 'Fotos hinzufügen')}
|
||||
</Text>
|
||||
</>
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
</ScrollView>
|
||||
|
||||
<PhotoViewerModal
|
||||
visible={viewerVisible}
|
||||
photos={photos}
|
||||
initialIndex={initialPhotoIndex}
|
||||
onClose={handleCloseViewer}
|
||||
onDelete={handleDeletePhoto}
|
||||
editable={editable}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
fullWidthContainer: {
|
||||
marginHorizontal: -20, // Optimiertes negatives Margin für perfekte Ausrichtung
|
||||
marginTop: 16, // Erhöhter Abstand nach oben
|
||||
},
|
||||
header: {
|
||||
marginBottom: 12,
|
||||
paddingLeft: 16,
|
||||
},
|
||||
title: {
|
||||
fontSize: 16,
|
||||
fontWeight: '600',
|
||||
},
|
||||
scrollContainer: {
|
||||
// ScrollView nimmt volle Breite ein
|
||||
},
|
||||
scrollContent: {
|
||||
paddingLeft: 16,
|
||||
paddingRight: 16,
|
||||
gap: 16,
|
||||
},
|
||||
photoThumbnail: {
|
||||
width: 120,
|
||||
height: 120,
|
||||
borderRadius: 12,
|
||||
borderWidth: 1,
|
||||
overflow: 'hidden',
|
||||
position: 'relative',
|
||||
},
|
||||
addPhotoButton: {
|
||||
width: 120,
|
||||
height: 120,
|
||||
borderRadius: 12,
|
||||
borderWidth: 1,
|
||||
borderStyle: 'dashed',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
marginRight: 12,
|
||||
},
|
||||
addPhotoText: {
|
||||
fontSize: 12,
|
||||
textAlign: 'center',
|
||||
marginTop: 6,
|
||||
fontWeight: '500',
|
||||
},
|
||||
addPhotoThumbnail: {
|
||||
width: 120,
|
||||
height: 120,
|
||||
borderRadius: 12,
|
||||
borderWidth: 1,
|
||||
borderStyle: 'dashed',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
overflow: 'hidden',
|
||||
},
|
||||
addPhotoThumbnailText: {
|
||||
fontSize: 11,
|
||||
textAlign: 'center',
|
||||
marginTop: 6,
|
||||
fontWeight: '500',
|
||||
lineHeight: 13,
|
||||
},
|
||||
thumbnailImage: {
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
},
|
||||
placeholderThumbnail: {
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
},
|
||||
|
||||
// Modal styles
|
||||
modalOverlay: {
|
||||
flex: 1,
|
||||
},
|
||||
modalSafeArea: {
|
||||
flex: 1,
|
||||
},
|
||||
modalHeader: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
paddingHorizontal: 16,
|
||||
paddingVertical: 12,
|
||||
zIndex: 10,
|
||||
},
|
||||
modalTitle: {
|
||||
fontSize: 16,
|
||||
fontWeight: '600',
|
||||
flex: 1,
|
||||
marginRight: 16,
|
||||
},
|
||||
modalHeaderActions: {
|
||||
flexDirection: 'row',
|
||||
gap: 16,
|
||||
},
|
||||
modalHeaderButton: {
|
||||
padding: 8,
|
||||
},
|
||||
modalContent: {
|
||||
flex: 1,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
paddingHorizontal: 16,
|
||||
},
|
||||
modalImageContainer: {
|
||||
flex: 1,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
position: 'relative',
|
||||
},
|
||||
photoCarousel: {
|
||||
flex: 1,
|
||||
},
|
||||
photoSlide: {
|
||||
flex: 1,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
position: 'relative',
|
||||
},
|
||||
galleryContainer: {
|
||||
flex: 1,
|
||||
backgroundColor: 'transparent',
|
||||
},
|
||||
webImage: {
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
},
|
||||
modalImage: {
|
||||
backgroundColor: 'transparent',
|
||||
},
|
||||
imageWrapper: {
|
||||
flex: 1,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
},
|
||||
placeholderContainer: {
|
||||
width: 200,
|
||||
height: 200,
|
||||
borderRadius: 12,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
},
|
||||
modalFooter: {
|
||||
paddingHorizontal: 16,
|
||||
paddingVertical: 12,
|
||||
paddingBottom: 20,
|
||||
},
|
||||
imageInfo: {
|
||||
fontSize: 14,
|
||||
textAlign: 'center',
|
||||
marginVertical: 2,
|
||||
},
|
||||
|
||||
// Loading styles
|
||||
loadingContainer: {
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
zIndex: 10,
|
||||
},
|
||||
loadingText: {
|
||||
color: '#FFFFFF',
|
||||
fontSize: 16,
|
||||
marginTop: 16,
|
||||
textAlign: 'center',
|
||||
},
|
||||
});
|
||||
205
apps/memoro/apps/mobile/components/organisms/PromptEditModal.tsx
Normal file
205
apps/memoro/apps/mobile/components/organisms/PromptEditModal.tsx
Normal file
|
|
@ -0,0 +1,205 @@
|
|||
import React, { useState, useEffect } from 'react';
|
||||
import { View, Platform, Alert, ActivityIndicator } from 'react-native';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useTheme } from '~/features/theme/ThemeProvider';
|
||||
import Text from '~/components/atoms/Text';
|
||||
import BaseModal from '~/components/atoms/BaseModal';
|
||||
import Icon from '~/components/atoms/Icon';
|
||||
import Input from '~/components/atoms/Input';
|
||||
|
||||
interface PromptEditModalProps {
|
||||
isVisible: boolean;
|
||||
onClose: () => void;
|
||||
onSave: (title: string, promptText: string) => Promise<void>;
|
||||
onDelete?: () => Promise<void>;
|
||||
initialTitle?: string;
|
||||
initialPromptText?: string;
|
||||
currentLanguage?: string;
|
||||
}
|
||||
|
||||
const PromptEditModal: React.FC<PromptEditModalProps> = ({
|
||||
isVisible,
|
||||
onClose,
|
||||
onSave,
|
||||
onDelete,
|
||||
initialTitle = '',
|
||||
initialPromptText = '',
|
||||
currentLanguage = 'de'
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const { isDark, themeVariant } = useTheme();
|
||||
|
||||
const [title, setTitle] = useState(initialTitle);
|
||||
const [promptText, setPromptText] = useState(initialPromptText);
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
const [isDeleting, setIsDeleting] = useState(false);
|
||||
|
||||
// Reset form when modal opens with new data
|
||||
useEffect(() => {
|
||||
if (isVisible) {
|
||||
setTitle(initialTitle);
|
||||
setPromptText(initialPromptText);
|
||||
}
|
||||
}, [isVisible, initialTitle, initialPromptText]);
|
||||
|
||||
// Handle save action
|
||||
const handleSave = async () => {
|
||||
if (!title.trim() || !promptText.trim()) {
|
||||
Alert.alert(
|
||||
t('prompts.validation_error', 'Eingabefehler'),
|
||||
t('prompts.all_fields_required', 'Bitte fülle alle Felder aus.')
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setIsSaving(true);
|
||||
await onSave(title, promptText);
|
||||
onClose();
|
||||
} catch (error) {
|
||||
console.debug('Error saving prompt:', error);
|
||||
Alert.alert(
|
||||
t('prompts.save_error', 'Fehler beim Speichern'),
|
||||
t('prompts.try_again', 'Bitte versuche es später erneut.')
|
||||
);
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Handle delete action with confirmation
|
||||
const handleDelete = async () => {
|
||||
if (!onDelete) return;
|
||||
|
||||
// Show confirmation dialog
|
||||
if (Platform.OS === 'ios') {
|
||||
Alert.alert(
|
||||
t('prompts.delete_confirm_title', 'Prompt löschen'),
|
||||
t('prompts.delete_confirm_message', 'Möchtest du diesen Prompt wirklich löschen?'),
|
||||
[
|
||||
{
|
||||
text: t('common.cancel', 'Abbrechen'),
|
||||
style: 'cancel'
|
||||
},
|
||||
{
|
||||
text: t('common.delete', 'Löschen'),
|
||||
style: 'destructive',
|
||||
onPress: async () => {
|
||||
try {
|
||||
setIsDeleting(true);
|
||||
await onDelete();
|
||||
onClose();
|
||||
} catch (error) {
|
||||
console.debug('Error deleting prompt:', error);
|
||||
Alert.alert(
|
||||
t('prompts.delete_error', 'Fehler beim Löschen'),
|
||||
t('prompts.try_again', 'Bitte versuche es später erneut.')
|
||||
);
|
||||
} finally {
|
||||
setIsDeleting(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
);
|
||||
} else {
|
||||
// For Android and other platforms
|
||||
Alert.alert(
|
||||
t('prompts.delete_confirm_title', 'Prompt löschen'),
|
||||
t('prompts.delete_confirm_message', 'Möchtest du diesen Prompt wirklich löschen?'),
|
||||
[
|
||||
{
|
||||
text: t('common.cancel', 'Abbrechen'),
|
||||
style: 'cancel'
|
||||
},
|
||||
{
|
||||
text: t('common.delete', 'Löschen'),
|
||||
style: 'destructive',
|
||||
onPress: async () => {
|
||||
try {
|
||||
setIsDeleting(true);
|
||||
await onDelete();
|
||||
onClose();
|
||||
} catch (error) {
|
||||
console.debug('Error deleting prompt:', error);
|
||||
Alert.alert(
|
||||
t('prompts.delete_error', 'Fehler beim Löschen'),
|
||||
t('prompts.try_again', 'Bitte versuche es später erneut.')
|
||||
);
|
||||
} finally {
|
||||
setIsDeleting(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
// Theme-Farben werden direkt von den Komponenten verwendet
|
||||
|
||||
// Benutzerdefinierter Footer-Inhalt mit Lösch-Button
|
||||
const customFooter = onDelete ? (
|
||||
<View className='w-full'>
|
||||
<View
|
||||
className='bg-red-600 rounded-xl py-3 px-4 items-center justify-center mb-4'
|
||||
style={{ opacity: isDeleting ? 0.7 : 1 }}
|
||||
>
|
||||
{isDeleting ? (
|
||||
<ActivityIndicator size='small' color='#FFFFFF' />
|
||||
) : (
|
||||
<View className='flex-row items-center justify-center'>
|
||||
<Icon name='trash-outline' size={18} color='#FFFFFF' />
|
||||
<Text className='text-white font-medium text-base ml-2'>
|
||||
{t('common.delete', 'Löschen')}
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
) : undefined;
|
||||
|
||||
return (
|
||||
<BaseModal
|
||||
isVisible={isVisible}
|
||||
onClose={onClose}
|
||||
title={t('prompts.edit_prompt', 'Prompt bearbeiten')}
|
||||
animationType='none'
|
||||
primaryButtonText={t('common.save', 'Speichern')}
|
||||
secondaryButtonText={t('common.cancel', 'Abbrechen')}
|
||||
onPrimaryButtonPress={handleSave}
|
||||
primaryButtonLoading={isSaving}
|
||||
primaryButtonDisabled={isSaving}
|
||||
footerContent={customFooter}
|
||||
>
|
||||
<View className='w-full'>
|
||||
<Text className={`text-base font-medium mb-2 ${isDark ? 'text-white' : 'text-gray-800'}`}>
|
||||
{t('prompts.title', 'Titel')}
|
||||
</Text>
|
||||
<Input
|
||||
value={title}
|
||||
onChangeText={setTitle}
|
||||
placeholder={t('prompts.title_placeholder', 'Titel eingeben...')}
|
||||
style={{ marginBottom: 16 }}
|
||||
/>
|
||||
|
||||
<Text className={`text-base font-medium mb-2 ${isDark ? 'text-white' : 'text-gray-800'}`}>
|
||||
{t('prompts.prompt_text', 'Prompt')}
|
||||
</Text>
|
||||
<Input
|
||||
value={promptText}
|
||||
onChangeText={setPromptText}
|
||||
placeholder={t('prompts.prompt_placeholder', 'Prompt eingeben...')}
|
||||
multiline
|
||||
numberOfLines={5}
|
||||
textAlignVertical='top'
|
||||
style={{ minHeight: 120, marginBottom: 16, textAlignVertical: 'top' }}
|
||||
/>
|
||||
</View>
|
||||
</BaseModal>
|
||||
);
|
||||
};
|
||||
|
||||
// Keine Styles mehr nötig, da wir NativeWind verwenden
|
||||
|
||||
export default PromptEditModal;
|
||||
574
apps/memoro/apps/mobile/components/organisms/RecordingsList.tsx
Normal file
574
apps/memoro/apps/mobile/components/organisms/RecordingsList.tsx
Normal file
|
|
@ -0,0 +1,574 @@
|
|||
import React, { useState, useEffect, useCallback } from 'react';
|
||||
import { View, FlatList, StyleSheet, ActivityIndicator, RefreshControl, Alert } from 'react-native';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useTheme } from '~/features/theme/ThemeProvider';
|
||||
import { useToast } from '~/features/toast/contexts/ToastContext';
|
||||
import { analyzeNetworkErrorSync } from '~/features/errorHandling/utils/networkErrorUtils';
|
||||
import { AudioFile } from '~/features/storage/storage.types';
|
||||
import { authService } from '~/features/auth/services/authService';
|
||||
import { cloudStorageService } from '~/features/storage/cloudStorage.service';
|
||||
import { fileStorageService } from '~/features/storage/fileStorage.service';
|
||||
import { triggerTranscription } from '~/features/storage/transcriptionUtils';
|
||||
import { useUploadStatusStore } from '~/features/storage/store/uploadStatusStore';
|
||||
import { UploadStatus, type AudioFileWithUploadStatus } from '~/features/storage/uploadStatus.types';
|
||||
import AudioPlayer from './AudioPlayer';
|
||||
import Text from '~/components/atoms/Text';
|
||||
import Icon from '~/components/atoms/Icon';
|
||||
import Button from '~/components/atoms/Button';
|
||||
import LoadingOverlay from '~/components/atoms/LoadingOverlay';
|
||||
import Divider from '~/components/atoms/Divider';
|
||||
import ArchiveStatistics from '~/components/molecules/ArchiveStatistics';
|
||||
import UploadStatusBadge from '~/components/atoms/UploadStatusBadge';
|
||||
|
||||
interface RecordingsListProps {
|
||||
onRecordingSelected?: (recording: AudioFile) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Komponente zur Anzeige einer Liste von Audioaufnahmen
|
||||
*/
|
||||
const RecordingsList = ({ onRecordingSelected }: RecordingsListProps) => {
|
||||
const { t, i18n } = useTranslation();
|
||||
const { isDark, themeVariant } = useTheme();
|
||||
const { showSuccess, showError } = useToast();
|
||||
const [recordings, setRecordings] = useState<AudioFile[]>([]);
|
||||
const [enrichedRecordings, setEnrichedRecordings] = useState<AudioFileWithUploadStatus[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [refreshing, setRefreshing] = useState(false);
|
||||
const [currentlyPlaying, setCurrentlyPlaying] = useState<string | null>(null);
|
||||
const [reuploadingIds, setReuploadingIds] = useState<Set<string>>(new Set());
|
||||
const [showReuploadLoading, setShowReuploadLoading] = useState(false);
|
||||
const [totalCount, setTotalCount] = useState(0);
|
||||
const [currentPage, setCurrentPage] = useState(0);
|
||||
const [loadingMore, setLoadingMore] = useState(false);
|
||||
const [archiveStats, setArchiveStats] = useState<{
|
||||
totalCount: number;
|
||||
totalDurationSeconds: number;
|
||||
totalSizeBytes: number;
|
||||
} | null>(null);
|
||||
const [statsLoading, setStatsLoading] = useState(true);
|
||||
const itemsPerPage = 3;
|
||||
|
||||
// Initialize and use upload status store
|
||||
const {
|
||||
initialize: initUploadStatus,
|
||||
enrichAudioFiles,
|
||||
getStatus,
|
||||
updateStatus,
|
||||
removeStatus,
|
||||
isInitialized: uploadStatusInitialized,
|
||||
} = useUploadStatusStore();
|
||||
|
||||
// Subscribe to statusMap changes to trigger re-enrichment when upload status updates
|
||||
const statusMap = useUploadStatusStore((state) => state.statusMap);
|
||||
|
||||
// Lade die Aufnahmen
|
||||
const loadRecordings = useCallback(async (page: number = 0, append: boolean = false) => {
|
||||
try {
|
||||
if (page === 0) {
|
||||
setIsLoading(true);
|
||||
} else {
|
||||
setLoadingMore(true);
|
||||
}
|
||||
|
||||
const offset = page * itemsPerPage;
|
||||
const [audioFiles, count] = await Promise.all([
|
||||
fileStorageService.getAllRecordings(itemsPerPage, offset),
|
||||
page === 0 ? fileStorageService.getRecordingsCount() : Promise.resolve(totalCount)
|
||||
]);
|
||||
|
||||
if (append) {
|
||||
setRecordings(prev => [...prev, ...audioFiles]);
|
||||
} else {
|
||||
setRecordings(audioFiles);
|
||||
}
|
||||
|
||||
if (page === 0) {
|
||||
setTotalCount(count);
|
||||
setCurrentPage(0);
|
||||
} else {
|
||||
setCurrentPage(page);
|
||||
}
|
||||
} catch (error) {
|
||||
console.debug('Error loading recordings:', error);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
setLoadingMore(false);
|
||||
setRefreshing(false);
|
||||
}
|
||||
}, [itemsPerPage, totalCount]);
|
||||
|
||||
// Lade die Archiv-Statistiken
|
||||
const loadArchiveStats = useCallback(async () => {
|
||||
try {
|
||||
setStatsLoading(true);
|
||||
const stats = await fileStorageService.getArchiveStatistics();
|
||||
setArchiveStats(stats);
|
||||
} catch (error) {
|
||||
console.debug('Error loading archive statistics:', error);
|
||||
} finally {
|
||||
setStatsLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Initialize upload status store
|
||||
useEffect(() => {
|
||||
initUploadStatus();
|
||||
}, [initUploadStatus]);
|
||||
|
||||
// Enrich recordings with upload status
|
||||
// Re-enrich whenever recordings change OR when statusMap changes (i.e., upload status updates)
|
||||
useEffect(() => {
|
||||
if (uploadStatusInitialized && recordings.length > 0) {
|
||||
const enriched = enrichAudioFiles(recordings);
|
||||
setEnrichedRecordings(enriched);
|
||||
} else {
|
||||
setEnrichedRecordings([]);
|
||||
}
|
||||
}, [recordings, uploadStatusInitialized, enrichAudioFiles, statusMap]);
|
||||
|
||||
// Lade die Aufnahmen beim ersten Rendern
|
||||
useEffect(() => {
|
||||
loadRecordings();
|
||||
loadArchiveStats();
|
||||
}, [loadRecordings, loadArchiveStats]);
|
||||
|
||||
// Behandle das Löschen einer Aufnahme
|
||||
const handleDelete = async (recording: AudioFile) => {
|
||||
try {
|
||||
const success = await fileStorageService.deleteRecording(recording);
|
||||
if (success) {
|
||||
// Remove upload status for this recording
|
||||
await removeStatus(recording.id);
|
||||
|
||||
// Aktualisiere die Liste nach dem Löschen
|
||||
setRecordings(prevRecordings =>
|
||||
prevRecordings.filter(rec => rec.id !== recording.id)
|
||||
);
|
||||
|
||||
// Zeige einen Erfolgs-Toast an
|
||||
showSuccess(t('audio_archive.delete_success', 'Aufnahme wurde erfolgreich gelöscht'));
|
||||
|
||||
// Aktualisiere die Statistiken nach dem Löschen
|
||||
loadArchiveStats();
|
||||
}
|
||||
} catch (error) {
|
||||
console.debug('Error deleting recording:', error);
|
||||
}
|
||||
};
|
||||
|
||||
// Behandle das Aktualisieren der Liste durch Ziehen
|
||||
const onRefresh = useCallback(() => {
|
||||
setRefreshing(true);
|
||||
loadRecordings(0, false);
|
||||
loadArchiveStats();
|
||||
}, [loadRecordings, loadArchiveStats]);
|
||||
|
||||
// Behandle das Laden weiterer Elemente
|
||||
const loadMore = useCallback(() => {
|
||||
const hasMore = recordings.length < totalCount;
|
||||
if (hasMore && !loadingMore && !isLoading) {
|
||||
const nextPage = currentPage + 1;
|
||||
loadRecordings(nextPage, true);
|
||||
}
|
||||
}, [recordings.length, totalCount, loadingMore, isLoading, currentPage, loadRecordings]);
|
||||
|
||||
// Behandle Statusänderungen bei der Wiedergabe
|
||||
const handlePlayStatusChange = (uri: string, isPlaying: boolean) => {
|
||||
if (isPlaying) {
|
||||
setCurrentlyPlaying(uri);
|
||||
} else if (currentlyPlaying === uri) {
|
||||
setCurrentlyPlaying(null);
|
||||
}
|
||||
};
|
||||
|
||||
// Formatiere das Datum für die Anzeige
|
||||
const formatDate = (date: Date): string => {
|
||||
const dateStr = date.toLocaleDateString(i18n.language, {
|
||||
day: '2-digit',
|
||||
month: '2-digit',
|
||||
year: 'numeric'
|
||||
});
|
||||
|
||||
const langCode = i18n.language.split('-')[0];
|
||||
const is12Hour = ['en', 'hi', 'ur', 'tl', 'ms'].includes(langCode);
|
||||
const timeStr = date.toLocaleTimeString(i18n.language, {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
hour12: is12Hour
|
||||
});
|
||||
|
||||
// Apply language-specific time formatting
|
||||
let timeWithSuffix = timeStr;
|
||||
if (langCode === 'de') timeWithSuffix = `${timeStr} Uhr`;
|
||||
else if (langCode === 'nl') timeWithSuffix = `${timeStr} uur`;
|
||||
else if (langCode === 'da') timeWithSuffix = `kl. ${timeStr.replace(':', '.')}`;
|
||||
else if (langCode === 'sv') timeWithSuffix = `kl. ${timeStr}`;
|
||||
else if (langCode === 'fr') timeWithSuffix = timeStr.replace(':', 'h');
|
||||
else if (langCode === 'bg') timeWithSuffix = `${timeStr} ч.`;
|
||||
else if (langCode === 'lt') timeWithSuffix = `${timeStr} val.`;
|
||||
else if (langCode === 'fi') timeWithSuffix = `klo ${timeStr.replace(':', '.')}`;
|
||||
else if (langCode === 'th') timeWithSuffix = `${timeStr} น.`;
|
||||
else if (langCode === 'id') timeWithSuffix = timeStr.replace(':', '.');
|
||||
|
||||
return `${dateStr}, ${timeWithSuffix}`;
|
||||
};
|
||||
|
||||
// Funktion zum erneuten Hochladen einer Audiodatei
|
||||
const handleReupload = async (recording: AudioFileWithUploadStatus) => {
|
||||
// Check if already uploaded or currently uploading
|
||||
if (recording.uploadStatus === UploadStatus.SUCCESS) {
|
||||
showError(t('audio_archive.already_uploaded', 'This recording has already been uploaded.'));
|
||||
return;
|
||||
}
|
||||
|
||||
if (recording.uploadStatus === UploadStatus.UPLOADING || recording.uploadStatus === UploadStatus.PENDING) {
|
||||
showError(t('audio_archive.upload_in_progress', 'Upload is already in progress or pending.'));
|
||||
return;
|
||||
}
|
||||
|
||||
// Setze Loading-State für diese spezifische Aufnahme
|
||||
setReuploadingIds(prev => new Set(prev.add(recording.id)));
|
||||
setShowReuploadLoading(true);
|
||||
|
||||
// Update status to UPLOADING
|
||||
await updateStatus(recording.id, UploadStatus.UPLOADING, {
|
||||
lastAttemptAt: Date.now(),
|
||||
});
|
||||
|
||||
try {
|
||||
// Prüfen, ob der Benutzer angemeldet ist
|
||||
const userData = await authService.getUserFromToken();
|
||||
if (!userData) {
|
||||
Alert.alert(
|
||||
t('common.error', 'Fehler'),
|
||||
t('audio_archive.login_required', 'Du musst angemeldet sein, um Audiodateien hochzuladen.')
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Hochladen zur Cloud-Speicherung mit der Benutzer-ID aus dem Auth-Kontext
|
||||
const uploadResult = await cloudStorageService.uploadAudioForProcessing({
|
||||
userId: userData.id,
|
||||
filePath: recording.uri,
|
||||
fileName: recording.filename,
|
||||
durationMillis: recording.duration * 1000, // Umrechnung in Millisekunden
|
||||
});
|
||||
|
||||
console.debug('Upload result:', uploadResult);
|
||||
|
||||
if (uploadResult.success) {
|
||||
// Trigger the transcription process using our utility function
|
||||
const transcriptionResult = await triggerTranscription({
|
||||
userId: userData.id,
|
||||
fileName: recording.filename,
|
||||
duration: recording.duration,
|
||||
showAlerts: false, // We'll handle errors manually to show network toasts
|
||||
t
|
||||
});
|
||||
|
||||
// Handle transcription result
|
||||
if (!transcriptionResult.success) {
|
||||
// Update status to FAILED
|
||||
await updateStatus(recording.id, UploadStatus.FAILED, {
|
||||
lastError: transcriptionResult.userMessage,
|
||||
isNetworkError: transcriptionResult.isNetworkError,
|
||||
});
|
||||
|
||||
if (transcriptionResult.isNetworkError) {
|
||||
showError(t('audio_archive.upload_failed_with_archive_hint', 'Upload failed. Your recording is safely stored in the Audio Archive where you can retry the upload later.'));
|
||||
} else {
|
||||
Alert.alert(
|
||||
t('common.error', 'Fehler'),
|
||||
transcriptionResult.userMessage || t('audio_archive.transcription_start_error', 'Die Transkription konnte nicht gestartet werden.')
|
||||
);
|
||||
}
|
||||
} else {
|
||||
// Update status to SUCCESS
|
||||
await updateStatus(recording.id, UploadStatus.SUCCESS, {
|
||||
uploadedAt: Date.now(),
|
||||
});
|
||||
|
||||
// Show success message
|
||||
Alert.alert(
|
||||
t('common.success', 'Erfolg'),
|
||||
t('audio_archive.upload_success', 'Die Audiodatei wurde hochgeladen und wird jetzt transkribiert. Du findest das Ergebnis in Kürze in deiner Memo-Liste.')
|
||||
);
|
||||
}
|
||||
} else {
|
||||
// Update status to FAILED
|
||||
await updateStatus(recording.id, UploadStatus.FAILED, {
|
||||
lastError: uploadResult.userMessage,
|
||||
isNetworkError: uploadResult.isNetworkError,
|
||||
});
|
||||
|
||||
// Check if it's a network error and show appropriate message
|
||||
if (uploadResult.isNetworkError) {
|
||||
showError(t('audio_archive.upload_failed_with_archive_hint', 'Upload failed. Your recording is safely stored in the Audio Archive where you can retry the upload later.'));
|
||||
} else {
|
||||
Alert.alert(
|
||||
t('common.error', 'Fehler'),
|
||||
t('audio_archive.upload_error', 'Beim Hochladen der Audiodatei ist ein Fehler aufgetreten.')
|
||||
);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.debug('Error reuploading audio:', error);
|
||||
|
||||
// Check if it's a network error
|
||||
const networkErrorInfo = analyzeNetworkErrorSync(error);
|
||||
|
||||
// Update status to FAILED
|
||||
await updateStatus(recording.id, UploadStatus.FAILED, {
|
||||
lastError: networkErrorInfo.userMessage,
|
||||
isNetworkError: networkErrorInfo.isNetworkError,
|
||||
});
|
||||
|
||||
if (networkErrorInfo.isNetworkError) {
|
||||
showError(t('audio_archive.upload_failed_with_archive_hint', 'Upload failed. Your recording is safely stored in the Audio Archive where you can retry the upload later.'));
|
||||
} else {
|
||||
Alert.alert(
|
||||
t('common.error', 'Fehler'),
|
||||
t('audio_archive.reupload_error', 'Beim erneuten Hochladen der Audiodatei ist ein Fehler aufgetreten.')
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
// Entferne Loading-State für diese spezifische Aufnahme
|
||||
setReuploadingIds(prev => {
|
||||
const newSet = new Set(prev);
|
||||
newSet.delete(recording.id);
|
||||
return newSet;
|
||||
});
|
||||
setShowReuploadLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Render-Funktion für ein einzelnes Listenelement
|
||||
const renderItem = ({ item }: { item: AudioFileWithUploadStatus }) => {
|
||||
const formattedDate = formatDate(item.createdAt);
|
||||
const formattedDuration = fileStorageService.formatDuration(item.duration);
|
||||
const title = t('audio_archive.recording', 'Aufnahme');
|
||||
const isUploading = reuploadingIds.has(item.id) || item.uploadStatus === UploadStatus.UPLOADING;
|
||||
const canRetry =
|
||||
item.uploadStatus === UploadStatus.FAILED ||
|
||||
item.uploadStatus === UploadStatus.NOT_UPLOADED;
|
||||
|
||||
// Determine button text based on status
|
||||
let buttonTitle = t('audio_archive.upload', 'Hochladen');
|
||||
if (isUploading) {
|
||||
buttonTitle = t('audio_archive.uploading', 'Lädt hoch...');
|
||||
} else if (item.uploadStatus === UploadStatus.PENDING) {
|
||||
buttonTitle = t('audio_archive.queued', 'In Warteschlange');
|
||||
} else if (item.uploadStatus === UploadStatus.FAILED) {
|
||||
buttonTitle = t('audio_archive.retry_upload', 'Erneut hochladen');
|
||||
}
|
||||
|
||||
return (
|
||||
<View style={styles.itemContainer}>
|
||||
<AudioPlayer
|
||||
audioUri={item.uri}
|
||||
headlineText={title}
|
||||
dateText={formattedDate}
|
||||
durationText={formattedDuration}
|
||||
fileSizeBytes={item.size}
|
||||
onDelete={() => handleDelete(item)}
|
||||
onPlayStatusChange={(isPlaying) => handlePlayStatusChange(item.uri, isPlaying)}
|
||||
showCopyButton={false}
|
||||
/>
|
||||
|
||||
{/* Upload Status Badge */}
|
||||
<View style={styles.statusRow}>
|
||||
<UploadStatusBadge
|
||||
status={item.uploadStatus}
|
||||
attemptCount={item.uploadMetadata?.attemptCount || 0}
|
||||
size="medium"
|
||||
showLabel
|
||||
/>
|
||||
</View>
|
||||
|
||||
{/* Upload/Retry Button - only show if not SUCCESS */}
|
||||
{item.uploadStatus !== UploadStatus.SUCCESS && (
|
||||
<Button
|
||||
title={buttonTitle}
|
||||
variant={item.uploadStatus === UploadStatus.FAILED ? 'secondary' : 'primary'}
|
||||
iconName={isUploading ? 'cloud-upload' : 'cloud-upload-outline'}
|
||||
onPress={() => handleReupload(item)}
|
||||
style={styles.reuploadButton}
|
||||
disabled={
|
||||
isUploading ||
|
||||
item.uploadStatus === UploadStatus.PENDING ||
|
||||
item.uploadStatus === UploadStatus.UPLOADING
|
||||
}
|
||||
loading={isUploading}
|
||||
/>
|
||||
)}
|
||||
<Divider spacing="large" />
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
// Farben aus dem Theme-System
|
||||
const textColor = isDark ? '#FFFFFF' : '#000000';
|
||||
|
||||
// Render-Funktion für leere Liste
|
||||
const renderEmptyList = () => {
|
||||
if (isLoading) {
|
||||
return (
|
||||
<View style={styles.emptyContainer}>
|
||||
<ActivityIndicator size="large" color={isDark ? '#FFFFFF' : '#000000'} />
|
||||
<Text style={[styles.emptyText, { color: textColor }]}>
|
||||
{t('audio_archive.loading', 'Aufnahmen werden geladen...')}
|
||||
</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<View style={styles.emptyContainer}>
|
||||
<Icon name="mic-off" size={48} color={textColor} />
|
||||
<Text style={[styles.emptyText, { color: textColor }]}>
|
||||
{t('audio_archive.no_recordings', 'Keine Aufnahmen vorhanden')}
|
||||
</Text>
|
||||
<Text style={[styles.emptySubtext, { color: textColor }]}>
|
||||
{t('audio_archive.record_prompt', 'Drücke den Aufnahme-Button, um dein erstes Memo aufzunehmen')}
|
||||
</Text>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
// Render-Funktion für "Mehr laden" Button
|
||||
const renderLoadMoreButton = () => {
|
||||
const hasMore = recordings.length < totalCount;
|
||||
|
||||
if (!hasMore) {
|
||||
return (
|
||||
<View style={styles.loadMoreContainer}>
|
||||
<Text style={[styles.loadMoreText, { color: textColor }]}>
|
||||
{t('audio_archive.all_loaded', 'Alle {{totalCount}} Aufnahmen geladen', { totalCount })}
|
||||
</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<View style={styles.loadMoreContainer}>
|
||||
<Button
|
||||
title={loadingMore ?
|
||||
t('audio_archive.loading_more', 'Lädt mehr...') :
|
||||
t('audio_archive.load_more_simple', 'Weitere laden')
|
||||
}
|
||||
variant="secondary"
|
||||
iconName={loadingMore ? "reload1" : "arrow-down"}
|
||||
onPress={loadMore}
|
||||
disabled={loadingMore}
|
||||
loading={loadingMore}
|
||||
style={styles.loadMoreButton}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
},
|
||||
itemContainer: {
|
||||
marginBottom: 16,
|
||||
paddingHorizontal: 16,
|
||||
},
|
||||
statusRow: {
|
||||
marginTop: 12,
|
||||
marginBottom: 8,
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
},
|
||||
emptyContainer: {
|
||||
flex: 1,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
padding: 20,
|
||||
},
|
||||
emptyText: {
|
||||
fontSize: 18,
|
||||
fontWeight: 'bold',
|
||||
marginTop: 16,
|
||||
textAlign: 'center',
|
||||
},
|
||||
emptySubtext: {
|
||||
fontSize: 14,
|
||||
marginTop: 8,
|
||||
textAlign: 'center',
|
||||
opacity: 0.7,
|
||||
},
|
||||
reuploadButton: {
|
||||
marginTop: 8,
|
||||
width: '100%',
|
||||
},
|
||||
loadMoreContainer: {
|
||||
padding: 16,
|
||||
alignItems: 'center',
|
||||
},
|
||||
loadMoreButton: {
|
||||
width: '100%',
|
||||
maxWidth: 300,
|
||||
},
|
||||
loadMoreText: {
|
||||
fontSize: 14,
|
||||
opacity: 0.7,
|
||||
textAlign: 'center',
|
||||
},
|
||||
});
|
||||
|
||||
// Render-Funktion für den Header mit Statistiken
|
||||
const renderListHeader = () => {
|
||||
return (
|
||||
<ArchiveStatistics
|
||||
totalCount={archiveStats?.totalCount || 0}
|
||||
totalDurationSeconds={archiveStats?.totalDurationSeconds || 0}
|
||||
totalSizeBytes={archiveStats?.totalSizeBytes || 0}
|
||||
isLoading={statsLoading}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<FlatList
|
||||
data={enrichedRecordings}
|
||||
renderItem={renderItem}
|
||||
keyExtractor={(item) => item.id}
|
||||
contentContainerStyle={{ flexGrow: 1 }}
|
||||
ListHeaderComponent={renderListHeader}
|
||||
ListEmptyComponent={renderEmptyList}
|
||||
ListFooterComponent={enrichedRecordings.length > 0 ? renderLoadMoreButton : null}
|
||||
refreshControl={
|
||||
<RefreshControl
|
||||
refreshing={refreshing}
|
||||
onRefresh={onRefresh}
|
||||
colors={[isDark ? '#FFFFFF' : '#000000']}
|
||||
tintColor={isDark ? '#FFFFFF' : '#000000'}
|
||||
/>
|
||||
}
|
||||
removeClippedSubviews={true}
|
||||
windowSize={10}
|
||||
initialNumToRender={5}
|
||||
maxToRenderPerBatch={5}
|
||||
updateCellsBatchingPeriod={50}
|
||||
getItemLayout={(data, index) => ({
|
||||
length: 120, // Estimated item height
|
||||
offset: 120 * index,
|
||||
index,
|
||||
})}
|
||||
/>
|
||||
|
||||
<LoadingOverlay
|
||||
visible={showReuploadLoading}
|
||||
message={t('audio_archive.uploading_processing', 'Audiodatei wird hochgeladen und verarbeitet...')}
|
||||
modal={true}
|
||||
size="large"
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
export default RecordingsList;
|
||||
160
apps/memoro/apps/mobile/components/organisms/SettingsToggle.tsx
Normal file
160
apps/memoro/apps/mobile/components/organisms/SettingsToggle.tsx
Normal file
|
|
@ -0,0 +1,160 @@
|
|||
import React, { useState } from 'react';
|
||||
import { View, Pressable } from 'react-native';
|
||||
import Text from '~/components/atoms/Text';
|
||||
import { useTheme } from '~/features/theme/ThemeProvider';
|
||||
import Icon from '~/components/atoms/Icon';
|
||||
import Toggle from '~/components/atoms/Toggle';
|
||||
import colors from '~/tailwind.config.js';
|
||||
|
||||
interface SettingsToggleProps {
|
||||
title: string;
|
||||
description: string;
|
||||
type: 'toggle' | 'dropdown' | 'button';
|
||||
isOn?: boolean;
|
||||
onToggle?: (value: boolean) => void;
|
||||
options?: string[];
|
||||
selectedOption?: string;
|
||||
onSelect?: (option: string) => void;
|
||||
onPress?: () => void;
|
||||
secondaryText?: string;
|
||||
}
|
||||
|
||||
function SettingsToggle({
|
||||
title,
|
||||
description,
|
||||
type,
|
||||
isOn = false,
|
||||
onToggle = undefined,
|
||||
options = undefined,
|
||||
selectedOption = '',
|
||||
onSelect = undefined,
|
||||
onPress = undefined,
|
||||
secondaryText = undefined,
|
||||
}: SettingsToggleProps) {
|
||||
const [isDropdownOpen, setIsDropdownOpen] = useState(false);
|
||||
const { isDark, themeVariant, tw } = useTheme();
|
||||
|
||||
const handlePress = () => {
|
||||
if (type === 'toggle' && onToggle) {
|
||||
onToggle(!isOn);
|
||||
} else if (type === 'dropdown') {
|
||||
setIsDropdownOpen(!isDropdownOpen);
|
||||
} else if (type === 'button' && onPress) {
|
||||
onPress();
|
||||
}
|
||||
};
|
||||
|
||||
const handleSelect = (option: string) => {
|
||||
if (onSelect) {
|
||||
onSelect(option);
|
||||
}
|
||||
setIsDropdownOpen(false);
|
||||
};
|
||||
|
||||
// Farben aus dem Theme-System
|
||||
const textColor = isDark ? '#FFFFFF' : '#000000';
|
||||
|
||||
// Zugriff auf die Theme-Farben für die Dropdown-Optionen
|
||||
const themeColors = (colors as any).theme?.extend?.colors;
|
||||
|
||||
// Primary-Farbe direkt aus der Tailwind-Konfiguration
|
||||
const primaryColor = isDark
|
||||
? themeColors?.dark?.[themeVariant]?.primary || '#f8d62b'
|
||||
: themeColors?.[themeVariant]?.primary || '#f8d62b';
|
||||
|
||||
// Hintergrundfarbe für den Container aus der Tailwind-Konfiguration
|
||||
const containerBgColor = isDark
|
||||
? themeColors?.dark?.[themeVariant]?.contentBackground || '#1E1E1E'
|
||||
: themeColors?.[themeVariant]?.contentBackground || '#FFFFFF';
|
||||
|
||||
// Randfarbe für den Container aus der Tailwind-Konfiguration
|
||||
const containerBorderColor = isDark
|
||||
? themeColors?.dark?.[themeVariant]?.border || '#424242'
|
||||
: themeColors?.[themeVariant]?.border || '#e6e6e6';
|
||||
|
||||
return (
|
||||
<Pressable
|
||||
onPress={handlePress}
|
||||
style={{
|
||||
backgroundColor: containerBgColor,
|
||||
borderColor: containerBorderColor,
|
||||
borderWidth: 1,
|
||||
borderRadius: 16,
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
className={`${isDark ? 'active:bg-gray-800/50' : 'active:bg-gray-200/50'}`}
|
||||
>
|
||||
<View className="px-4 py-6">
|
||||
<View className="flex-row justify-between items-center mb-4">
|
||||
<Text className={`text-base font-semibold flex-1 mr-2 ${isDark ? 'text-white' : 'text-black'}`}>
|
||||
{title}
|
||||
</Text>
|
||||
{type === 'toggle' && onToggle && (
|
||||
<Toggle
|
||||
isOn={isOn}
|
||||
onToggle={onToggle}
|
||||
size="medium"
|
||||
/>
|
||||
)}
|
||||
{type === 'dropdown' && options && onSelect && (
|
||||
<Icon
|
||||
name={isDropdownOpen ? "chevron-up-outline" : "chevron-down-outline"}
|
||||
size={20}
|
||||
color={textColor}
|
||||
/>
|
||||
)}
|
||||
{type === 'button' && onPress && (
|
||||
<View className="flex-row items-center">
|
||||
{secondaryText && (
|
||||
<Text
|
||||
className={`mr-2 text-sm ${isDark ? 'text-gray-400' : 'text-gray-600'}`}
|
||||
numberOfLines={1}
|
||||
>
|
||||
{secondaryText}
|
||||
</Text>
|
||||
)}
|
||||
<Icon
|
||||
name="chevron-forward-outline"
|
||||
size={20}
|
||||
color={textColor}
|
||||
/>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
<Text
|
||||
className={`text-sm ${isDark ? 'text-white/70' : 'text-black/70'}`}
|
||||
style={{ lineHeight: 26 }}
|
||||
>
|
||||
{description}
|
||||
</Text>
|
||||
{type === 'dropdown' && isDropdownOpen && options && onSelect && (
|
||||
<View className="mt-2">
|
||||
{options.map((option) => {
|
||||
const isSelected = option === selectedOption;
|
||||
return (
|
||||
<Pressable
|
||||
key={option}
|
||||
style={{
|
||||
padding: 12,
|
||||
borderRadius: 8,
|
||||
marginVertical: 2,
|
||||
backgroundColor: isSelected ? primaryColor : 'transparent',
|
||||
}}
|
||||
className="active:opacity-70"
|
||||
onPress={() => handleSelect(option)}
|
||||
>
|
||||
<Text className={`${isSelected ? 'text-white font-bold' : isDark ? 'text-white' : 'text-black'}`}
|
||||
>
|
||||
{option}
|
||||
</Text>
|
||||
</Pressable>
|
||||
);
|
||||
})}
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
</Pressable>
|
||||
);
|
||||
}
|
||||
|
||||
export default SettingsToggle;
|
||||
|
|
@ -0,0 +1,905 @@
|
|||
import React, { useState } from 'react';
|
||||
import {
|
||||
View,
|
||||
StyleSheet,
|
||||
ScrollView as RNScrollView,
|
||||
Alert,
|
||||
Share,
|
||||
TextInput,
|
||||
Pressable,
|
||||
Platform,
|
||||
} from 'react-native';
|
||||
import { useTheme } from '~/features/theme/ThemeProvider';
|
||||
import Text from '~/components/atoms/Text';
|
||||
import Button from '~/components/atoms/Button';
|
||||
import Icon from '~/components/atoms/Icon';
|
||||
import * as Clipboard from 'expo-clipboard';
|
||||
import SpeakerLabelModal from '~/components/molecules/SpeakerLabelModal';
|
||||
import HighlightedText from '~/components/atoms/HighlightedText';
|
||||
import { getLanguageDisplayName } from '~/utils/languageMapping';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
interface TranscriptData {
|
||||
audio_path?: string;
|
||||
type?: string;
|
||||
speakers?: Record<string, string>;
|
||||
utterances?: Array<{ speakerId: string; text: string; offset: number; duration: number }>;
|
||||
transcription_parts?: Array<{
|
||||
text: string;
|
||||
speaker?: string;
|
||||
start_time?: number;
|
||||
end_time?: number;
|
||||
// For combined memos:
|
||||
memo_id?: string;
|
||||
title?: string;
|
||||
transcript?: string;
|
||||
created_at?: string;
|
||||
index?: number;
|
||||
original_source?: any;
|
||||
}>;
|
||||
languages?: string[];
|
||||
primary_language?: string;
|
||||
transcript?: string;
|
||||
}
|
||||
|
||||
interface TranscriptDisplayProps {
|
||||
data: TranscriptData;
|
||||
defaultExpanded?: boolean;
|
||||
title?: string;
|
||||
speakerLabels?: Record<string, string>;
|
||||
onNameSpeakersPress?: () => void;
|
||||
onCopyPress?: () => void;
|
||||
onSharePress?: () => void;
|
||||
onUpdateSpeakerLabels?: (speakerMappings: Array<{ id: string; label: string }>) => void;
|
||||
onCopySuccess?: () => void;
|
||||
// Search highlighting props
|
||||
searchQuery?: string;
|
||||
isSearchMode?: boolean;
|
||||
currentResultIndex?: number;
|
||||
searchResults?: Array<{
|
||||
id: string;
|
||||
type: string;
|
||||
text: string;
|
||||
index: number;
|
||||
matchIndex: number;
|
||||
}>;
|
||||
// Edit mode props
|
||||
isEditing?: boolean;
|
||||
onTranscriptChange?: (newTranscript: string) => void;
|
||||
onUtteranceChange?: (index: number, newText: string) => void;
|
||||
}
|
||||
|
||||
function TranscriptDisplay({
|
||||
data,
|
||||
defaultExpanded = true,
|
||||
title = 'Transkript',
|
||||
speakerLabels = {},
|
||||
onNameSpeakersPress,
|
||||
onCopyPress,
|
||||
onSharePress,
|
||||
onUpdateSpeakerLabels,
|
||||
onCopySuccess,
|
||||
searchQuery = '',
|
||||
isSearchMode = false,
|
||||
currentResultIndex,
|
||||
searchResults,
|
||||
isEditing = false,
|
||||
onTranscriptChange,
|
||||
onUtteranceChange,
|
||||
}: TranscriptDisplayProps) {
|
||||
const { isDark, themeVariant } = useTheme();
|
||||
const { t } = useTranslation();
|
||||
const [isSpeakerModalVisible, setIsSpeakerModalVisible] = useState(false);
|
||||
const [editableSpeakerLabels, setEditableSpeakerLabels] = useState<Record<string, string>>({
|
||||
...speakerLabels,
|
||||
});
|
||||
const [showStatistics, setShowStatistics] = useState(false);
|
||||
|
||||
// Local state for editing
|
||||
const [localUtterances, setLocalUtterances] = useState<Array<any>>([]);
|
||||
const [localTranscript, setLocalTranscript] = useState<string>('');
|
||||
const [isLocalStateInitialized, setIsLocalStateInitialized] = useState(false);
|
||||
|
||||
// Check if this is a combined memo
|
||||
const isCombinedMemo = data.type === 'combined';
|
||||
|
||||
// Convert transcription_parts to utterances format if needed
|
||||
const getUtterancesFromData = () => {
|
||||
if (data.utterances && data.utterances.length > 0) {
|
||||
return data.utterances;
|
||||
}
|
||||
|
||||
if (data.transcription_parts && data.transcription_parts.length > 0) {
|
||||
// Handle combined memos differently
|
||||
if (isCombinedMemo) {
|
||||
// For combined memos, flatten all utterances from all parts
|
||||
const allUtterances: any[] = [];
|
||||
|
||||
data.transcription_parts.forEach((part, partIndex) => {
|
||||
// Add a separator utterance for each memo part
|
||||
allUtterances.push({
|
||||
speakerId: '__separator__',
|
||||
text: part.title || `Memo ${partIndex + 1}`,
|
||||
offset: 0,
|
||||
duration: 0,
|
||||
memoId: part.memo_id,
|
||||
createdAt: part.created_at,
|
||||
isSeparator: true,
|
||||
});
|
||||
|
||||
// Add utterances from this part
|
||||
if (part.utterances && Array.isArray(part.utterances)) {
|
||||
part.utterances.forEach((utterance: any) => {
|
||||
allUtterances.push({
|
||||
speakerId: utterance.speakerId || 'unknown',
|
||||
text: utterance.text || '',
|
||||
offset: utterance.offset || 0,
|
||||
duration: utterance.duration || 0,
|
||||
partIndex: partIndex,
|
||||
speakers: part.speakers || {},
|
||||
});
|
||||
});
|
||||
} else if (part.transcript) {
|
||||
// If no utterances but has transcript text, create a single utterance
|
||||
allUtterances.push({
|
||||
speakerId: 'default',
|
||||
text: part.transcript,
|
||||
offset: 0,
|
||||
duration: 0,
|
||||
partIndex: partIndex,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
return allUtterances;
|
||||
} else {
|
||||
// Handle normal transcription parts
|
||||
return data.transcription_parts.map((part, index) => ({
|
||||
speakerId: part.speaker || `speaker${index + 1}`,
|
||||
text: part.text,
|
||||
offset: part.start_time ? part.start_time * 1000 : 0, // Convert to ms
|
||||
duration: part.end_time && part.start_time ? (part.end_time - part.start_time) * 1000 : 0,
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
return [];
|
||||
};
|
||||
|
||||
// Initialize local state when entering edit mode ONLY - ignore data changes while editing
|
||||
React.useEffect(() => {
|
||||
if (isEditing && !isLocalStateInitialized) {
|
||||
const utterancesFromData = getUtterancesFromData();
|
||||
setLocalUtterances(utterancesFromData);
|
||||
setLocalTranscript(data.transcript || '');
|
||||
setIsLocalStateInitialized(true);
|
||||
console.debug('TranscriptDisplay: Local state initialized for editing', {
|
||||
utterances: utterancesFromData.length,
|
||||
transcript: data.transcript?.length || 0,
|
||||
});
|
||||
} else if (!isEditing && isLocalStateInitialized) {
|
||||
// Reset when leaving edit mode
|
||||
setLocalUtterances([]);
|
||||
setLocalTranscript('');
|
||||
setIsLocalStateInitialized(false);
|
||||
console.debug('TranscriptDisplay: Local state reset - leaving edit mode');
|
||||
}
|
||||
}, [isEditing]); // Removed 'data' dependency to prevent overwrites during editing
|
||||
|
||||
// Use local state when editing, original data when not
|
||||
const utterances =
|
||||
isEditing && isLocalStateInitialized ? localUtterances : getUtterancesFromData();
|
||||
const transcriptText =
|
||||
isEditing && isLocalStateInitialized ? localTranscript : data.transcript || '';
|
||||
const hasUtterances = utterances.length > 0;
|
||||
|
||||
// Funktion zum Formatieren des Transkripts
|
||||
const getFormattedTranscript = (): string => {
|
||||
let formattedText = '';
|
||||
|
||||
if (hasUtterances && utterances) {
|
||||
if (isCombinedMemo) {
|
||||
// Spezielle Formatierung für kombinierte Memos
|
||||
let currentMemoSection = '';
|
||||
utterances.forEach((utterance, index) => {
|
||||
if (utterance.isSeparator) {
|
||||
// Add separator header
|
||||
if (currentMemoSection) {
|
||||
formattedText += '\n\n';
|
||||
}
|
||||
const createdAt = utterance.createdAt
|
||||
? new Date(utterance.createdAt).toLocaleDateString('de-DE', {
|
||||
day: '2-digit',
|
||||
month: '2-digit',
|
||||
year: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
})
|
||||
: '';
|
||||
const header = createdAt
|
||||
? `=== ${utterance.text} (${createdAt}) ===`
|
||||
: `=== ${utterance.text} ===`;
|
||||
formattedText += header + '\n\n';
|
||||
currentMemoSection = utterance.text;
|
||||
} else {
|
||||
// Add regular utterance
|
||||
const speakerName = utterance.speakers && utterance.speakers[utterance.speakerId]
|
||||
? utterance.speakers[utterance.speakerId]
|
||||
: getSpeakerDisplayName(utterance.speakerId);
|
||||
formattedText += `${speakerName}: ${utterance.text}\n\n`;
|
||||
}
|
||||
});
|
||||
formattedText = formattedText.trim();
|
||||
} else {
|
||||
// Normale Formatierung mit Sprechernamen
|
||||
formattedText = utterances
|
||||
.map((utterance) => {
|
||||
const speakerName = getSpeakerDisplayName(utterance.speakerId);
|
||||
return `${speakerName}: ${utterance.text}`;
|
||||
})
|
||||
.join('\n\n');
|
||||
}
|
||||
} else if (data.transcript) {
|
||||
// Wenn keine Äußerungen vorhanden sind, verwende den einfachen Transkripttext
|
||||
formattedText = data.transcript;
|
||||
}
|
||||
|
||||
return formattedText;
|
||||
};
|
||||
|
||||
// Funktion zum Kopieren des Transkripts in die Zwischenablage
|
||||
const handleCopyPress = async () => {
|
||||
try {
|
||||
const textToCopy = getFormattedTranscript();
|
||||
|
||||
if (textToCopy) {
|
||||
await Clipboard.setStringAsync(textToCopy);
|
||||
|
||||
// Use onCopySuccess callback if provided, otherwise fallback to system alert
|
||||
if (onCopySuccess) {
|
||||
onCopySuccess();
|
||||
} else {
|
||||
Alert.alert('Erfolg', 'Transkript wurde in die Zwischenablage kopiert');
|
||||
}
|
||||
|
||||
if (onCopyPress) {
|
||||
onCopyPress();
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.debug('Fehler beim Kopieren:', error);
|
||||
Alert.alert('Fehler', 'Das Transkript konnte nicht kopiert werden');
|
||||
}
|
||||
};
|
||||
|
||||
// Funktion zum Teilen des Transkripts über den nativen Share-Dialog
|
||||
const handleSharePress = async () => {
|
||||
try {
|
||||
const textToShare = getFormattedTranscript();
|
||||
|
||||
if (textToShare) {
|
||||
await Share.share({
|
||||
message: textToShare,
|
||||
title: title || 'Transkript',
|
||||
});
|
||||
|
||||
if (onSharePress) {
|
||||
onSharePress();
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.debug('Fehler beim Teilen:', error);
|
||||
Alert.alert('Fehler', 'Das Transkript konnte nicht geteilt werden');
|
||||
}
|
||||
};
|
||||
|
||||
// Funktion zum Öffnen des Modals für die Benennung von Sprechern
|
||||
const handleNameSpeakersPress = () => {
|
||||
if (onNameSpeakersPress) {
|
||||
onNameSpeakersPress();
|
||||
} else {
|
||||
setIsSpeakerModalVisible(true);
|
||||
}
|
||||
};
|
||||
|
||||
// Funktion zum Schließen des Modals für die Benennung von Sprechern
|
||||
const handleCloseSpeakerModal = () => {
|
||||
setIsSpeakerModalVisible(false);
|
||||
};
|
||||
|
||||
// Funktion zum Speichern der Sprechernamen
|
||||
const handleSubmitSpeakerLabels = (speakerMappings: Array<{ id: string; label: string }>) => {
|
||||
// Konvertiere das Array von Mappings in ein Record-Objekt
|
||||
const updatedLabels: Record<string, string> = {};
|
||||
speakerMappings.forEach((mapping) => {
|
||||
updatedLabels[mapping.id] = mapping.label;
|
||||
});
|
||||
|
||||
// Aktualisiere den lokalen State
|
||||
setEditableSpeakerLabels(updatedLabels);
|
||||
|
||||
// Rufe die übergebene Callback-Funktion auf, falls vorhanden
|
||||
if (onUpdateSpeakerLabels) {
|
||||
onUpdateSpeakerLabels(speakerMappings);
|
||||
}
|
||||
|
||||
// Schließe das Modal
|
||||
setIsSpeakerModalVisible(false);
|
||||
};
|
||||
|
||||
// Format timestamp (offset in ms to mm:ss format)
|
||||
const formatTimestamp = (offsetMs?: number): string => {
|
||||
if (offsetMs === undefined) return '';
|
||||
|
||||
const totalSeconds = Math.floor(offsetMs / 1000);
|
||||
const minutes = Math.floor(totalSeconds / 60);
|
||||
const seconds = totalSeconds % 60;
|
||||
return `${minutes.toString().padStart(2, '0')}:${seconds.toString().padStart(2, '0')}`;
|
||||
};
|
||||
|
||||
// Statistics calculation functions
|
||||
const calculateStatistics = () => {
|
||||
let totalWords = 0;
|
||||
const speakerWordCounts: Record<string, number> = {};
|
||||
let totalDurationMs = 0;
|
||||
|
||||
if (hasUtterances && utterances) {
|
||||
utterances.forEach((utterance) => {
|
||||
const words = utterance.text
|
||||
.trim()
|
||||
.split(/\s+/)
|
||||
.filter((word) => word.length > 0);
|
||||
const wordCount = words.length;
|
||||
|
||||
totalWords += wordCount;
|
||||
|
||||
// Count words per speaker
|
||||
const speakerName = getSpeakerDisplayName(utterance.speakerId);
|
||||
speakerWordCounts[speakerName] = (speakerWordCounts[speakerName] || 0) + wordCount;
|
||||
|
||||
// Calculate total duration (use duration if available, otherwise estimate)
|
||||
if (utterance.duration && utterance.duration > 0) {
|
||||
totalDurationMs += utterance.duration;
|
||||
}
|
||||
});
|
||||
} else if (transcriptText) {
|
||||
// For plain transcript without utterances
|
||||
const words = transcriptText
|
||||
.trim()
|
||||
.split(/\s+/)
|
||||
.filter((word) => word.length > 0);
|
||||
totalWords = words.length;
|
||||
speakerWordCounts['Total'] = totalWords;
|
||||
}
|
||||
|
||||
// Calculate words per minute
|
||||
const totalDurationMinutes = totalDurationMs > 0 ? totalDurationMs / (1000 * 60) : 0;
|
||||
const wordsPerMinute =
|
||||
totalDurationMinutes > 0 ? Math.round(totalWords / totalDurationMinutes) : 0;
|
||||
|
||||
return {
|
||||
totalWords,
|
||||
speakerWordCounts,
|
||||
wordsPerMinute: totalDurationMinutes > 0 ? wordsPerMinute : null,
|
||||
totalDurationMinutes: Math.round(totalDurationMinutes * 10) / 10, // Round to 1 decimal
|
||||
};
|
||||
};
|
||||
|
||||
// Determine language for display - use primary_language if available, otherwise fallback to first language in array
|
||||
const languageCode =
|
||||
data.primary_language ||
|
||||
(data.languages && data.languages.length > 0 ? data.languages[0] : 'unknown');
|
||||
const language = getLanguageDisplayName(languageCode);
|
||||
|
||||
// Theme colors
|
||||
const textColor = isDark ? '#FFFFFF' : '#000000';
|
||||
const secondaryTextColor = isDark ? 'rgba(255, 255, 255, 0.7)' : 'rgba(0, 0, 0, 0.7)';
|
||||
const backgroundColor = isDark ? 'rgba(30, 30, 30, 0.8)' : 'rgba(245, 245, 245, 0.8)';
|
||||
const borderColor = isDark
|
||||
? `var(--color-dark-${themeVariant}-border)`
|
||||
: `var(--color-${themeVariant}-border)`;
|
||||
const speakerColors = {
|
||||
speaker1: isDark ? '#64B5F6' : '#2196F3', // Blue
|
||||
speaker2: isDark ? '#81C784' : '#4CAF50', // Green
|
||||
speaker3: isDark ? '#FFB74D' : '#FF9800', // Orange
|
||||
speaker4: isDark ? '#E57373' : '#F44336', // Red
|
||||
default: isDark ? '#B39DDB' : '#673AB7', // Purple
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
borderRadius: 8,
|
||||
overflow: 'hidden',
|
||||
marginVertical: 8,
|
||||
width: '100%',
|
||||
maxWidth: 720,
|
||||
alignSelf: 'center',
|
||||
},
|
||||
header: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
padding: 12,
|
||||
paddingHorizontal: 20, // Horizontales Padding für interne Ausrichtung
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: isDark ? 'rgba(255, 255, 255, 0.1)' : 'rgba(0, 0, 0, 0.1)',
|
||||
gap: 12, // Add gap between title and language container
|
||||
},
|
||||
languageContainer: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
flexShrink: 0, // Prevent language container from shrinking
|
||||
},
|
||||
title: {
|
||||
fontSize: 16,
|
||||
fontWeight: 'bold',
|
||||
color: textColor,
|
||||
flex: 1, // Allow title to take available space but not push language container out
|
||||
marginRight: 12, // Add some space between title and language container
|
||||
},
|
||||
languageTag: {
|
||||
fontSize: 12,
|
||||
color: secondaryTextColor,
|
||||
backgroundColor: isDark ? 'rgba(255, 255, 255, 0.1)' : 'rgba(0, 0, 0, 0.05)',
|
||||
paddingHorizontal: 8,
|
||||
paddingVertical: 4,
|
||||
borderRadius: 4,
|
||||
marginRight: 8,
|
||||
},
|
||||
infoIcon: {
|
||||
padding: 4,
|
||||
},
|
||||
statisticsSection: {
|
||||
backgroundColor: isDark ? 'rgba(255, 255, 255, 0.05)' : 'rgba(0, 0, 0, 0.02)',
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: isDark ? 'rgba(255, 255, 255, 0.05)' : 'rgba(0, 0, 0, 0.05)',
|
||||
padding: 12,
|
||||
paddingHorizontal: 20, // Horizontales Padding für interne Ausrichtung
|
||||
},
|
||||
statisticsRow: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
marginBottom: 6,
|
||||
},
|
||||
statisticsLabel: {
|
||||
fontSize: 13,
|
||||
color: secondaryTextColor,
|
||||
},
|
||||
statisticsValue: {
|
||||
fontSize: 13,
|
||||
fontWeight: '600',
|
||||
color: textColor,
|
||||
},
|
||||
speakerStatsContainer: {
|
||||
marginTop: 8,
|
||||
paddingTop: 8,
|
||||
borderTopWidth: 1,
|
||||
borderTopColor: isDark ? 'rgba(255, 255, 255, 0.1)' : 'rgba(0, 0, 0, 0.1)',
|
||||
},
|
||||
speakerStatsTitle: {
|
||||
fontSize: 12,
|
||||
fontWeight: '600',
|
||||
color: secondaryTextColor,
|
||||
marginBottom: 6,
|
||||
},
|
||||
actionButtonsContainer: {
|
||||
paddingVertical: 8,
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: isDark ? 'rgba(255, 255, 255, 0.05)' : 'rgba(0, 0, 0, 0.05)',
|
||||
// Kein marginHorizontal - volle Breite für Buttons
|
||||
},
|
||||
buttonScrollContainer: {
|
||||
flexDirection: 'row',
|
||||
paddingHorizontal: 20, // Padding für korrekte Ausrichtung mit anderem Content
|
||||
},
|
||||
buttonContainer: {
|
||||
marginRight: 8,
|
||||
},
|
||||
content: {
|
||||
padding: 12,
|
||||
paddingHorizontal: 20, // Horizontales Padding für interne Ausrichtung
|
||||
width: '100%',
|
||||
},
|
||||
plainTranscript: {
|
||||
fontSize: 16,
|
||||
lineHeight: 24,
|
||||
color: textColor,
|
||||
},
|
||||
speakerItem: {
|
||||
marginBottom: 16,
|
||||
width: '100%',
|
||||
},
|
||||
speakerHeader: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
marginBottom: 4,
|
||||
},
|
||||
speakerName: {
|
||||
fontSize: 14,
|
||||
fontWeight: 'bold',
|
||||
marginRight: 8,
|
||||
},
|
||||
timestamp: {
|
||||
fontSize: 12,
|
||||
color: secondaryTextColor,
|
||||
},
|
||||
speakerText: {
|
||||
fontSize: 16,
|
||||
lineHeight: 24,
|
||||
color: textColor,
|
||||
},
|
||||
speakerTextInput: {
|
||||
fontSize: 16,
|
||||
lineHeight: 24,
|
||||
color: textColor,
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: isDark ? 'rgba(255, 255, 255, 0.3)' : 'rgba(0, 0, 0, 0.3)',
|
||||
paddingBottom: 4,
|
||||
minHeight: 24,
|
||||
textAlignVertical: 'top',
|
||||
},
|
||||
plainTranscriptInput: {
|
||||
fontSize: 16,
|
||||
lineHeight: 24,
|
||||
color: textColor,
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: isDark ? 'rgba(255, 255, 255, 0.3)' : 'rgba(0, 0, 0, 0.3)',
|
||||
paddingBottom: 4,
|
||||
minHeight: 24,
|
||||
textAlignVertical: 'top',
|
||||
},
|
||||
memoSeparator: {
|
||||
marginVertical: 24,
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
paddingHorizontal: 0,
|
||||
},
|
||||
memoSeparatorLine: {
|
||||
flex: 1,
|
||||
height: 1,
|
||||
backgroundColor: isDark ? 'rgba(255, 255, 255, 0.1)' : 'rgba(0, 0, 0, 0.1)',
|
||||
},
|
||||
memoSeparatorContent: {
|
||||
paddingHorizontal: 16,
|
||||
alignItems: 'center',
|
||||
},
|
||||
memoSeparatorTitle: {
|
||||
fontSize: 14,
|
||||
fontWeight: '600',
|
||||
color: isDark ? 'rgba(255, 255, 255, 0.8)' : 'rgba(0, 0, 0, 0.8)',
|
||||
marginBottom: 4,
|
||||
},
|
||||
memoSeparatorDate: {
|
||||
fontSize: 12,
|
||||
color: isDark ? 'rgba(255, 255, 255, 0.5)' : 'rgba(0, 0, 0, 0.5)',
|
||||
},
|
||||
});
|
||||
|
||||
// Helper function to get speaker name display
|
||||
const getSpeakerDisplayName = (speakerId: string): string => {
|
||||
// 1. Priorität: Benutzerdefinierte Labels aus den Metadaten
|
||||
if (speakerLabels && speakerLabels[speakerId]) {
|
||||
return speakerLabels[speakerId];
|
||||
}
|
||||
|
||||
// 2. Priorität: Namen aus dem speakers-Objekt - aber überprüfe ob es ein Standard-Speaker-Name ist
|
||||
if (data.speakers && data.speakers[speakerId]) {
|
||||
const speakerName = data.speakers[speakerId];
|
||||
|
||||
// Ensure speakerName is a string before processing
|
||||
if (typeof speakerName === 'string' && speakerName) {
|
||||
// Check if it's a default "Speaker X" format that needs translation
|
||||
const defaultSpeakerMatch = speakerName.match(/^Speaker\s+(\d+)$/i);
|
||||
if (defaultSpeakerMatch) {
|
||||
const number = defaultSpeakerMatch[1];
|
||||
return t('memo.speaker_default', { number });
|
||||
}
|
||||
|
||||
// Otherwise return the custom name
|
||||
return speakerName;
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Priorität: Übersetze Speaker-ID
|
||||
const match = speakerId.match(/([a-zA-Z]+)(\d+)/i);
|
||||
if (match) {
|
||||
const prefix = match[1].toLowerCase();
|
||||
const number = match[2];
|
||||
|
||||
if (prefix === 'speaker') {
|
||||
return t('memo.speaker_default', { number });
|
||||
}
|
||||
}
|
||||
|
||||
// Check if speakerId itself is already formatted like "Speaker 1"
|
||||
const formattedMatch = speakerId.match(/^Speaker\s+(\d+)$/i);
|
||||
if (formattedMatch) {
|
||||
const number = formattedMatch[1];
|
||||
return t('memo.speaker_default', { number });
|
||||
}
|
||||
|
||||
// Fallback: Formatierte Speaker-ID
|
||||
return speakerId.replace(
|
||||
/([a-zA-Z]+)(\d+)/i,
|
||||
(_, text, num) => `${text.charAt(0).toUpperCase()}${text.slice(1)} ${num}`
|
||||
);
|
||||
};
|
||||
|
||||
// Helper function to get speaker text color
|
||||
const getSpeakerColor = (speakerId: string): string => {
|
||||
return speakerColors[speakerId as keyof typeof speakerColors] || speakerColors.default;
|
||||
};
|
||||
|
||||
// Extrahiere die Sprecher-IDs aus den Äußerungen oder Sprechern
|
||||
const getSpeakerIds = (): string[] => {
|
||||
if (utterances.length > 0) {
|
||||
// Extrahiere eindeutige Sprecher-IDs aus den Äußerungen
|
||||
const speakerIds = new Set<string>();
|
||||
utterances.forEach((utterance) => {
|
||||
speakerIds.add(utterance.speakerId);
|
||||
});
|
||||
return Array.from(speakerIds);
|
||||
} else if (data.speakers) {
|
||||
// Verwende die Schlüssel aus dem speakers-Objekt
|
||||
return Object.keys(data.speakers);
|
||||
}
|
||||
return [];
|
||||
};
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<View style={styles.header}>
|
||||
<Text style={styles.title} numberOfLines={1} ellipsizeMode="tail">{title}</Text>
|
||||
{language !== 'unknown' && (
|
||||
<View style={styles.languageContainer}>
|
||||
<View style={styles.languageTag}>
|
||||
<Text style={{ color: secondaryTextColor }}>{language}</Text>
|
||||
</View>
|
||||
<Pressable style={styles.infoIcon} onPress={() => setShowStatistics(!showStatistics)}>
|
||||
<Icon
|
||||
name={showStatistics ? "information-circle" : "information-circle-outline"}
|
||||
size={24}
|
||||
color={showStatistics ? `var(--color-${isDark ? 'dark-' : ''}${themeVariant}-primary)` : secondaryTextColor}
|
||||
/>
|
||||
</Pressable>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
|
||||
{/* Statistics Section */}
|
||||
{showStatistics && language !== 'unknown' && (
|
||||
<View style={styles.statisticsSection}>
|
||||
{(() => {
|
||||
const stats = calculateStatistics();
|
||||
return (
|
||||
<>
|
||||
<View style={styles.statisticsRow}>
|
||||
<Text style={styles.statisticsLabel}>
|
||||
{t('transcript.total_words', 'Total Words')}
|
||||
</Text>
|
||||
<Text style={styles.statisticsValue}>{stats.totalWords}</Text>
|
||||
</View>
|
||||
|
||||
{stats.wordsPerMinute !== null && (
|
||||
<View style={styles.statisticsRow}>
|
||||
<Text style={styles.statisticsLabel}>
|
||||
{t('transcript.words_per_minute', 'Words per Minute')}
|
||||
</Text>
|
||||
<Text style={styles.statisticsValue}>{stats.wordsPerMinute}</Text>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{stats.totalDurationMinutes > 0 && (
|
||||
<View style={styles.statisticsRow}>
|
||||
<Text style={styles.statisticsLabel}>
|
||||
{t('transcript.duration', 'Duration')}
|
||||
</Text>
|
||||
<Text style={styles.statisticsValue}>
|
||||
{stats.totalDurationMinutes} {t('transcript.minutes_short', 'min')}
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{Object.keys(stats.speakerWordCounts).length > 1 && (
|
||||
<View style={styles.speakerStatsContainer}>
|
||||
<Text style={styles.speakerStatsTitle}>
|
||||
{t('transcript.words_per_speaker', 'Words per Speaker')}
|
||||
</Text>
|
||||
{Object.entries(stats.speakerWordCounts).map(([speaker, count]) => (
|
||||
<View key={speaker} style={styles.statisticsRow}>
|
||||
<Text style={styles.statisticsLabel}>{speaker}</Text>
|
||||
<Text style={styles.statisticsValue}>{count}</Text>
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
})()}
|
||||
</View>
|
||||
)}
|
||||
|
||||
{!isEditing && (
|
||||
<View style={styles.actionButtonsContainer}>
|
||||
<RNScrollView
|
||||
horizontal
|
||||
showsHorizontalScrollIndicator={false}
|
||||
contentContainerStyle={styles.buttonScrollContainer}>
|
||||
<View style={styles.buttonContainer}>
|
||||
<Button
|
||||
variant="secondary"
|
||||
title={t('memo.name_speakers')}
|
||||
onPress={handleNameSpeakersPress}
|
||||
leftIcon={() => (
|
||||
<View style={{ marginRight: 8 }}>
|
||||
<Icon name="person-outline" size={18} color={textColor} />
|
||||
</View>
|
||||
)}
|
||||
/>
|
||||
</View>
|
||||
<View style={styles.buttonContainer}>
|
||||
<Button
|
||||
variant="secondary"
|
||||
title={t('memo.copy')}
|
||||
onPress={handleCopyPress}
|
||||
leftIcon={() => (
|
||||
<View style={{ marginRight: 8 }}>
|
||||
<Icon name="copy-outline" size={18} color={textColor} />
|
||||
</View>
|
||||
)}
|
||||
/>
|
||||
</View>
|
||||
<View style={styles.buttonContainer}>
|
||||
<Button
|
||||
variant="secondary"
|
||||
title={t('common.share')}
|
||||
onPress={handleSharePress}
|
||||
leftIcon={() => (
|
||||
<View style={{ marginRight: 8 }}>
|
||||
<Icon
|
||||
name={Platform.OS === 'android' ? 'share-social-outline' : 'share-outline'}
|
||||
size={18}
|
||||
color={textColor}
|
||||
/>
|
||||
</View>
|
||||
)}
|
||||
/>
|
||||
</View>
|
||||
</RNScrollView>
|
||||
</View>
|
||||
)}
|
||||
|
||||
<View style={styles.content}>
|
||||
{hasUtterances ? (
|
||||
// Zeige Äußerungen chronologisch an
|
||||
utterances.map((utterance, index) => {
|
||||
// Special handling for separator utterances in combined memos
|
||||
if (utterance.isSeparator) {
|
||||
return (
|
||||
<View key={`separator-${index}`} style={styles.memoSeparator}>
|
||||
<View style={styles.memoSeparatorLine} />
|
||||
<View style={styles.memoSeparatorContent}>
|
||||
<Text style={styles.memoSeparatorTitle}>{utterance.text}</Text>
|
||||
{utterance.createdAt && (
|
||||
<Text style={styles.memoSeparatorDate}>
|
||||
{new Date(utterance.createdAt).toLocaleDateString('de-DE', {
|
||||
day: '2-digit',
|
||||
month: '2-digit',
|
||||
year: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
})}
|
||||
</Text>
|
||||
)}
|
||||
</View>
|
||||
<View style={styles.memoSeparatorLine} />
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
// Regular utterance handling
|
||||
const speakerName = utterance.speakers && utterance.speakers[utterance.speakerId]
|
||||
? utterance.speakers[utterance.speakerId]
|
||||
: getSpeakerDisplayName(utterance.speakerId);
|
||||
|
||||
return (
|
||||
<View key={`utterance-${index}`} style={styles.speakerItem}>
|
||||
<View style={styles.speakerHeader}>
|
||||
<Text style={[styles.speakerName, { color: getSpeakerColor(utterance.speakerId) }]}>
|
||||
{speakerName}
|
||||
</Text>
|
||||
<Text style={styles.timestamp}>{formatTimestamp(utterance.offset)}</Text>
|
||||
</View>
|
||||
{isEditing ? (
|
||||
<TextInput
|
||||
style={styles.speakerTextInput}
|
||||
value={utterance.text}
|
||||
onChangeText={(newText) => {
|
||||
console.debug('TranscriptDisplay: onChangeText called', {
|
||||
index,
|
||||
newText,
|
||||
hasCallback: !!onUtteranceChange,
|
||||
});
|
||||
|
||||
// Update local state immediately for instant feedback
|
||||
setLocalUtterances((prev) =>
|
||||
prev.map((utt, i) => (i === index ? { ...utt, text: newText } : utt))
|
||||
);
|
||||
|
||||
// Call the parent callback
|
||||
onUtteranceChange?.(index, newText);
|
||||
}}
|
||||
placeholder="Äußerung eingeben"
|
||||
placeholderTextColor={isDark ? 'rgba(255, 255, 255, 0.5)' : 'rgba(0, 0, 0, 0.5)'}
|
||||
multiline
|
||||
scrollEnabled={false}
|
||||
textAlignVertical="top"
|
||||
editable={true}
|
||||
/>
|
||||
) : isSearchMode && searchQuery ? (
|
||||
<HighlightedText
|
||||
text={utterance.text}
|
||||
searchQuery={searchQuery}
|
||||
style={styles.speakerText}
|
||||
currentResultIndex={currentResultIndex}
|
||||
searchResults={searchResults}
|
||||
textType="transcript"
|
||||
/>
|
||||
) : (
|
||||
<Text style={styles.speakerText}>{utterance.text}</Text>
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
})
|
||||
) : // Kein strukturiertes Transkript: Zeige einfachen Text
|
||||
isEditing ? (
|
||||
<TextInput
|
||||
style={styles.plainTranscriptInput}
|
||||
value={transcriptText}
|
||||
onChangeText={(newText) => {
|
||||
console.debug('TranscriptDisplay: plain transcript onChangeText called', {
|
||||
newText,
|
||||
hasCallback: !!onTranscriptChange,
|
||||
});
|
||||
|
||||
// Update local state immediately for instant feedback
|
||||
setLocalTranscript(newText);
|
||||
|
||||
// Call the parent callback
|
||||
onTranscriptChange?.(newText);
|
||||
}}
|
||||
placeholder="Transkript eingeben"
|
||||
placeholderTextColor={isDark ? 'rgba(255, 255, 255, 0.5)' : 'rgba(0, 0, 0, 0.5)'}
|
||||
multiline
|
||||
scrollEnabled={false}
|
||||
textAlignVertical="top"
|
||||
editable={true}
|
||||
/>
|
||||
) : isSearchMode && searchQuery ? (
|
||||
<HighlightedText
|
||||
text={transcriptText}
|
||||
searchQuery={searchQuery}
|
||||
style={styles.plainTranscript}
|
||||
currentResultIndex={currentResultIndex}
|
||||
searchResults={searchResults}
|
||||
textType="transcript"
|
||||
/>
|
||||
) : (
|
||||
<Text style={styles.plainTranscript}>{transcriptText}</Text>
|
||||
)}
|
||||
</View>
|
||||
|
||||
{/* SpeakerLabelModal */}
|
||||
<SpeakerLabelModal
|
||||
visible={isSpeakerModalVisible}
|
||||
onClose={handleCloseSpeakerModal}
|
||||
onSubmit={handleSubmitSpeakerLabels}
|
||||
speakers={getSpeakerIds()}
|
||||
initialMappings={speakerLabels}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
export default TranscriptDisplay;
|
||||
|
|
@ -0,0 +1,67 @@
|
|||
import React from 'react';
|
||||
import { ScrollView, View, StyleSheet } from 'react-native';
|
||||
import { useTheme } from '~/features/theme/ThemeProvider';
|
||||
import { MemoTitleSkeleton } from './sections/MemoTitleSkeleton';
|
||||
import { MemoMetadataSkeleton } from './sections/MemoMetadataSkeleton';
|
||||
import { MemoryCardSkeleton } from './sections/MemoryCardSkeleton';
|
||||
import { AudioPlayerSkeleton } from './sections/AudioPlayerSkeleton';
|
||||
import { TranscriptSkeleton } from './sections/TranscriptSkeleton';
|
||||
import { QuestionButtonSkeleton } from './sections/QuestionButtonSkeleton';
|
||||
|
||||
interface MemoDetailSkeletonProps {
|
||||
showMemories?: boolean;
|
||||
showAudioPlayer?: boolean;
|
||||
showTranscript?: boolean;
|
||||
showQuestionButton?: boolean;
|
||||
}
|
||||
|
||||
export const MemoDetailSkeleton: React.FC<MemoDetailSkeletonProps> = ({
|
||||
showMemories = true,
|
||||
showAudioPlayer = true,
|
||||
showTranscript = true,
|
||||
showQuestionButton = true,
|
||||
}) => {
|
||||
const { colors } = useTheme();
|
||||
|
||||
return (
|
||||
<ScrollView
|
||||
style={[styles.container, { backgroundColor: colors.background }]}
|
||||
contentContainerStyle={styles.contentContainer}
|
||||
showsVerticalScrollIndicator={false}
|
||||
>
|
||||
<MemoTitleSkeleton />
|
||||
<MemoMetadataSkeleton />
|
||||
|
||||
{showMemories && (
|
||||
<MemoryCardSkeleton showMultiple />
|
||||
)}
|
||||
|
||||
{showAudioPlayer && (
|
||||
<AudioPlayerSkeleton />
|
||||
)}
|
||||
|
||||
{showTranscript && (
|
||||
<TranscriptSkeleton />
|
||||
)}
|
||||
|
||||
{showQuestionButton && (
|
||||
<QuestionButtonSkeleton />
|
||||
)}
|
||||
|
||||
<View style={styles.bottomPadding} />
|
||||
</ScrollView>
|
||||
);
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
},
|
||||
contentContainer: {
|
||||
paddingTop: 8, // Same as content paddingTop in memo page
|
||||
paddingBottom: 32, // Same as scrollContentContainer paddingBottom
|
||||
},
|
||||
bottomPadding: {
|
||||
height: 120, // Account for bottom bar
|
||||
},
|
||||
});
|
||||
7
apps/memoro/apps/mobile/components/skeletons/index.ts
Normal file
7
apps/memoro/apps/mobile/components/skeletons/index.ts
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
export { MemoDetailSkeleton } from './MemoDetailSkeleton';
|
||||
export { MemoTitleSkeleton } from './sections/MemoTitleSkeleton';
|
||||
export { MemoMetadataSkeleton } from './sections/MemoMetadataSkeleton';
|
||||
export { MemoryCardSkeleton } from './sections/MemoryCardSkeleton';
|
||||
export { AudioPlayerSkeleton } from './sections/AudioPlayerSkeleton';
|
||||
export { TranscriptSkeleton } from './sections/TranscriptSkeleton';
|
||||
export { QuestionButtonSkeleton } from './sections/QuestionButtonSkeleton';
|
||||
|
|
@ -0,0 +1,58 @@
|
|||
import React from 'react';
|
||||
import { View, StyleSheet } from 'react-native';
|
||||
import { useTheme } from '~/features/theme/ThemeProvider';
|
||||
import { getSkeletonColor, skeletonBase, skeletonCircle, skeletonBox, skeletonRow, skeletonSpacing } from '../utils/skeletonStyles';
|
||||
import { SkeletonAnimation } from '../utils/SkeletonAnimation';
|
||||
|
||||
export const AudioPlayerSkeleton: React.FC = () => {
|
||||
const { colors, isDark } = useTheme();
|
||||
const skeletonColor = getSkeletonColor(isDark, colors);
|
||||
|
||||
return (
|
||||
<SkeletonAnimation style={styles.container}>
|
||||
<View style={[styles.playerCard, { backgroundColor: colors.surface, borderColor: colors.border }]}>
|
||||
<View style={[skeletonRow, styles.controls]}>
|
||||
<View style={[skeletonBase, skeletonCircle(48), { backgroundColor: skeletonColor }]} />
|
||||
<View style={styles.progressContainer}>
|
||||
<View style={[skeletonBase, skeletonBox('100%', 4), { backgroundColor: skeletonColor }]} />
|
||||
<View style={[skeletonRow, styles.timeRow]}>
|
||||
<View style={[skeletonBase, skeletonBox(40, 12), { backgroundColor: skeletonColor }]} />
|
||||
<View style={styles.spacer} />
|
||||
<View style={[skeletonBase, skeletonBox(40, 12), { backgroundColor: skeletonColor }]} />
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
</SkeletonAnimation>
|
||||
);
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
paddingHorizontal: 20, // Same as audioPlayerContainer
|
||||
marginBottom: 20, // Same as audioPlayerContainer
|
||||
},
|
||||
playerCard: {
|
||||
borderRadius: 12,
|
||||
borderWidth: 1,
|
||||
padding: 16,
|
||||
shadowColor: '#000',
|
||||
shadowOffset: { width: 0, height: 1 },
|
||||
shadowOpacity: 0.05,
|
||||
shadowRadius: 2,
|
||||
elevation: 2,
|
||||
},
|
||||
controls: {
|
||||
gap: skeletonSpacing.md,
|
||||
},
|
||||
progressContainer: {
|
||||
flex: 1,
|
||||
gap: skeletonSpacing.xs,
|
||||
},
|
||||
timeRow: {
|
||||
justifyContent: 'space-between',
|
||||
},
|
||||
spacer: {
|
||||
flex: 1,
|
||||
},
|
||||
});
|
||||
|
|
@ -0,0 +1,43 @@
|
|||
import React from 'react';
|
||||
import { View, StyleSheet } from 'react-native';
|
||||
import { useTheme } from '~/features/theme/ThemeProvider';
|
||||
import { getSkeletonColor, skeletonBase, skeletonText, skeletonBox, skeletonRow, skeletonSpacing } from '../utils/skeletonStyles';
|
||||
import { SkeletonAnimation } from '../utils/SkeletonAnimation';
|
||||
|
||||
export const MemoMetadataSkeleton: React.FC = () => {
|
||||
const { colors, isDark } = useTheme();
|
||||
const skeletonColor = getSkeletonColor(isDark, colors);
|
||||
|
||||
return (
|
||||
<SkeletonAnimation style={styles.container}>
|
||||
<View style={[skeletonRow, styles.dateRow]}>
|
||||
<View style={[skeletonBase, skeletonText(14), styles.dateText, { backgroundColor: skeletonColor }]} />
|
||||
</View>
|
||||
<View style={[skeletonRow, styles.tagsRow]}>
|
||||
<View style={[skeletonBase, skeletonBox(60, 24), styles.tag, { backgroundColor: skeletonColor }]} />
|
||||
<View style={[skeletonBase, skeletonBox(80, 24), styles.tag, { backgroundColor: skeletonColor }]} />
|
||||
<View style={[skeletonBase, skeletonBox(70, 24), styles.tag, { backgroundColor: skeletonColor }]} />
|
||||
</View>
|
||||
</SkeletonAnimation>
|
||||
);
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
paddingHorizontal: 20, // Same as memo page
|
||||
marginBottom: 24, // Add proper spacing
|
||||
},
|
||||
dateRow: {
|
||||
marginBottom: skeletonSpacing.sm,
|
||||
},
|
||||
dateText: {
|
||||
width: 150,
|
||||
},
|
||||
tagsRow: {
|
||||
flexWrap: 'wrap',
|
||||
gap: 8,
|
||||
},
|
||||
tag: {
|
||||
borderRadius: 12,
|
||||
},
|
||||
});
|
||||
|
|
@ -0,0 +1,115 @@
|
|||
import React from 'react';
|
||||
import { View, StyleSheet } from 'react-native';
|
||||
import { useTheme } from '~/features/theme/ThemeProvider';
|
||||
import { getSkeletonColor, skeletonBase, skeletonText, skeletonSpacing } from '../utils/skeletonStyles';
|
||||
import { SkeletonAnimation } from '../utils/SkeletonAnimation';
|
||||
|
||||
export const MemoTitleSkeleton: React.FC = () => {
|
||||
const { colors, isDark } = useTheme();
|
||||
const skeletonColor = getSkeletonColor(isDark, colors);
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
{/* Metadata - small line */}
|
||||
<SkeletonAnimation delay={0}>
|
||||
<View style={[styles.metadataLine, skeletonBase, skeletonText(12), { backgroundColor: skeletonColor }]} />
|
||||
</SkeletonAnimation>
|
||||
|
||||
{/* Title - 3 lines */}
|
||||
<SkeletonAnimation delay={100}>
|
||||
<View style={[styles.titleLine1, skeletonBase, skeletonText(24), { backgroundColor: skeletonColor }]} />
|
||||
</SkeletonAnimation>
|
||||
<SkeletonAnimation delay={150}>
|
||||
<View style={[styles.titleLine2, skeletonBase, skeletonText(24), { backgroundColor: skeletonColor }]} />
|
||||
</SkeletonAnimation>
|
||||
<SkeletonAnimation delay={200}>
|
||||
<View style={[styles.titleLine3, skeletonBase, skeletonText(24), { backgroundColor: skeletonColor }]} />
|
||||
</SkeletonAnimation>
|
||||
|
||||
{/* Intro - 8 lines */}
|
||||
<SkeletonAnimation delay={250}>
|
||||
<View style={[styles.introLine1, skeletonBase, skeletonText(16), { backgroundColor: skeletonColor }]} />
|
||||
</SkeletonAnimation>
|
||||
<SkeletonAnimation delay={300}>
|
||||
<View style={[styles.introLine2, skeletonBase, skeletonText(16), { backgroundColor: skeletonColor }]} />
|
||||
</SkeletonAnimation>
|
||||
<SkeletonAnimation delay={350}>
|
||||
<View style={[styles.introLine3, skeletonBase, skeletonText(16), { backgroundColor: skeletonColor }]} />
|
||||
</SkeletonAnimation>
|
||||
<SkeletonAnimation delay={400}>
|
||||
<View style={[styles.introLine4, skeletonBase, skeletonText(16), { backgroundColor: skeletonColor }]} />
|
||||
</SkeletonAnimation>
|
||||
<SkeletonAnimation delay={450}>
|
||||
<View style={[styles.introLine5, skeletonBase, skeletonText(16), { backgroundColor: skeletonColor }]} />
|
||||
</SkeletonAnimation>
|
||||
<SkeletonAnimation delay={500}>
|
||||
<View style={[styles.introLine6, skeletonBase, skeletonText(16), { backgroundColor: skeletonColor }]} />
|
||||
</SkeletonAnimation>
|
||||
<SkeletonAnimation delay={550}>
|
||||
<View style={[styles.introLine7, skeletonBase, skeletonText(16), { backgroundColor: skeletonColor }]} />
|
||||
</SkeletonAnimation>
|
||||
<SkeletonAnimation delay={600}>
|
||||
<View style={[styles.introLine8, skeletonBase, skeletonText(16), { backgroundColor: skeletonColor }]} />
|
||||
</SkeletonAnimation>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
marginTop: 24, // Same as MemoTitle component
|
||||
marginBottom: 24, // Same as MemoTitle component
|
||||
paddingHorizontal: 20, // Same as MemoTitle component (20 instead of 16)
|
||||
overflow: 'hidden',
|
||||
},
|
||||
// Metadata line
|
||||
metadataLine: {
|
||||
width: '50%',
|
||||
marginBottom: skeletonSpacing.xl * 2, // Increased spacing to headline
|
||||
},
|
||||
// Title lines
|
||||
titleLine1: {
|
||||
width: '90%',
|
||||
marginBottom: skeletonSpacing.sm, // Increased from xs (4) to sm (8)
|
||||
},
|
||||
titleLine2: {
|
||||
width: '85%',
|
||||
marginBottom: skeletonSpacing.sm, // Increased from xs (4) to sm (8)
|
||||
},
|
||||
titleLine3: {
|
||||
width: '60%',
|
||||
marginBottom: skeletonSpacing.xl * 1.5, // Increased gap between title and intro
|
||||
},
|
||||
// Intro lines
|
||||
introLine1: {
|
||||
width: '100%',
|
||||
marginBottom: skeletonSpacing.sm, // Increased from xs (4) to sm (8)
|
||||
},
|
||||
introLine2: {
|
||||
width: '95%',
|
||||
marginBottom: skeletonSpacing.sm, // Increased from xs (4) to sm (8)
|
||||
},
|
||||
introLine3: {
|
||||
width: '100%',
|
||||
marginBottom: skeletonSpacing.sm, // Increased from xs (4) to sm (8)
|
||||
},
|
||||
introLine4: {
|
||||
width: '90%',
|
||||
marginBottom: skeletonSpacing.sm, // Increased from xs (4) to sm (8)
|
||||
},
|
||||
introLine5: {
|
||||
width: '98%',
|
||||
marginBottom: skeletonSpacing.sm, // Increased from xs (4) to sm (8)
|
||||
},
|
||||
introLine6: {
|
||||
width: '85%',
|
||||
marginBottom: skeletonSpacing.sm, // Increased from xs (4) to sm (8)
|
||||
},
|
||||
introLine7: {
|
||||
width: '92%',
|
||||
marginBottom: skeletonSpacing.sm, // Increased from xs (4) to sm (8)
|
||||
},
|
||||
introLine8: {
|
||||
width: '70%',
|
||||
},
|
||||
});
|
||||
|
|
@ -0,0 +1,69 @@
|
|||
import React from 'react';
|
||||
import { View, StyleSheet } from 'react-native';
|
||||
import { useTheme } from '~/features/theme/ThemeProvider';
|
||||
import { getSkeletonColor, skeletonBase, skeletonText, skeletonSpacing } from '../utils/skeletonStyles';
|
||||
import { SkeletonAnimation } from '../utils/SkeletonAnimation';
|
||||
|
||||
interface MemoryCardSkeletonProps {
|
||||
showMultiple?: boolean;
|
||||
}
|
||||
|
||||
export const MemoryCardSkeleton: React.FC<MemoryCardSkeletonProps> = ({ showMultiple = false }) => {
|
||||
const { colors, isDark } = useTheme();
|
||||
const skeletonColor = getSkeletonColor(isDark, colors);
|
||||
|
||||
const renderCard = () => (
|
||||
<SkeletonAnimation>
|
||||
<View style={[styles.card, { backgroundColor: colors.surface, borderColor: colors.border }]}>
|
||||
<View style={[styles.headerLine, skeletonBase, skeletonText(20), { backgroundColor: skeletonColor }]} />
|
||||
<View style={styles.content}>
|
||||
<View style={[styles.textLine, skeletonBase, skeletonText(16), { backgroundColor: skeletonColor }]} />
|
||||
<View style={[styles.textLine, skeletonBase, skeletonText(16), { backgroundColor: skeletonColor, width: '90%' }]} />
|
||||
<View style={[styles.textLine, skeletonBase, skeletonText(16), { backgroundColor: skeletonColor, width: '75%' }]} />
|
||||
</View>
|
||||
</View>
|
||||
</SkeletonAnimation>
|
||||
);
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
{renderCard()}
|
||||
{showMultiple && (
|
||||
<>
|
||||
<View style={styles.spacing} />
|
||||
{renderCard()}
|
||||
</>
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
paddingHorizontal: 20, // Same as memo page
|
||||
marginBottom: 20, // Same as memoriesContainer
|
||||
},
|
||||
card: {
|
||||
borderRadius: 12,
|
||||
borderWidth: 1,
|
||||
padding: 16,
|
||||
shadowColor: '#000',
|
||||
shadowOffset: { width: 0, height: 1 },
|
||||
shadowOpacity: 0.05,
|
||||
shadowRadius: 2,
|
||||
elevation: 2,
|
||||
},
|
||||
headerLine: {
|
||||
width: '60%',
|
||||
marginBottom: skeletonSpacing.md,
|
||||
},
|
||||
content: {
|
||||
gap: skeletonSpacing.xs,
|
||||
},
|
||||
textLine: {
|
||||
width: '100%',
|
||||
},
|
||||
spacing: {
|
||||
height: skeletonSpacing.md,
|
||||
},
|
||||
});
|
||||
|
|
@ -0,0 +1,26 @@
|
|||
import React from 'react';
|
||||
import { View, StyleSheet } from 'react-native';
|
||||
import { useTheme } from '~/features/theme/ThemeProvider';
|
||||
import { getSkeletonColor, skeletonBase, skeletonBox } from '../utils/skeletonStyles';
|
||||
import { SkeletonAnimation } from '../utils/SkeletonAnimation';
|
||||
|
||||
export const QuestionButtonSkeleton: React.FC = () => {
|
||||
const { colors, isDark } = useTheme();
|
||||
const skeletonColor = getSkeletonColor(isDark, colors);
|
||||
|
||||
return (
|
||||
<SkeletonAnimation style={styles.container}>
|
||||
<View style={[skeletonBase, skeletonBox('100%', 48), styles.button, { backgroundColor: skeletonColor }]} />
|
||||
</SkeletonAnimation>
|
||||
);
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
paddingHorizontal: 20, // Same as questionButtonContainer
|
||||
marginBottom: 12, // Same as questionButtonContainer
|
||||
},
|
||||
button: {
|
||||
borderRadius: 24,
|
||||
},
|
||||
});
|
||||
|
|
@ -0,0 +1,65 @@
|
|||
import React from 'react';
|
||||
import { View, StyleSheet } from 'react-native';
|
||||
import { useTheme } from '~/features/theme/ThemeProvider';
|
||||
import { getSkeletonColor, skeletonBase, skeletonText, skeletonSpacing } from '../utils/skeletonStyles';
|
||||
import { SkeletonAnimation } from '../utils/SkeletonAnimation';
|
||||
|
||||
interface TranscriptSkeletonProps {
|
||||
lineCount?: number;
|
||||
}
|
||||
|
||||
export const TranscriptSkeleton: React.FC<TranscriptSkeletonProps> = ({ lineCount = 8 }) => {
|
||||
const { colors, isDark } = useTheme();
|
||||
const skeletonColor = getSkeletonColor(isDark, colors);
|
||||
|
||||
return (
|
||||
<SkeletonAnimation style={styles.container}>
|
||||
<View style={[styles.transcriptCard, { backgroundColor: colors.surface, borderColor: colors.border }]}>
|
||||
<View style={[styles.headerLine, skeletonBase, skeletonText(20), { backgroundColor: skeletonColor }]} />
|
||||
<View style={styles.content}>
|
||||
{Array.from({ length: lineCount }).map((_, index) => (
|
||||
<View
|
||||
key={index}
|
||||
style={[
|
||||
styles.textLine,
|
||||
skeletonBase,
|
||||
skeletonText(14),
|
||||
{
|
||||
backgroundColor: skeletonColor,
|
||||
width: `${Math.random() * 30 + 70}%`, // Random width between 70-100%
|
||||
},
|
||||
]}
|
||||
/>
|
||||
))}
|
||||
</View>
|
||||
</View>
|
||||
</SkeletonAnimation>
|
||||
);
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
paddingHorizontal: 20, // Match memo page spacing
|
||||
marginBottom: 20, // Same as transcriptContainer
|
||||
},
|
||||
transcriptCard: {
|
||||
borderRadius: 12,
|
||||
borderWidth: 1,
|
||||
padding: 16,
|
||||
shadowColor: '#000',
|
||||
shadowOffset: { width: 0, height: 1 },
|
||||
shadowOpacity: 0.05,
|
||||
shadowRadius: 2,
|
||||
elevation: 2,
|
||||
},
|
||||
headerLine: {
|
||||
width: '40%',
|
||||
marginBottom: skeletonSpacing.md,
|
||||
},
|
||||
content: {
|
||||
gap: skeletonSpacing.xs,
|
||||
},
|
||||
textLine: {
|
||||
marginBottom: 2,
|
||||
},
|
||||
});
|
||||
|
|
@ -0,0 +1,77 @@
|
|||
import React, { useEffect, useRef } from 'react';
|
||||
import { Animated, View, StyleSheet, Dimensions } from 'react-native';
|
||||
import { LinearGradient } from 'expo-linear-gradient';
|
||||
|
||||
const { width: SCREEN_WIDTH } = Dimensions.get('window');
|
||||
|
||||
interface ShimmerPlaceholderProps {
|
||||
style?: any;
|
||||
shimmerColors?: string[];
|
||||
children?: React.ReactNode;
|
||||
}
|
||||
|
||||
export const ShimmerPlaceholder: React.FC<ShimmerPlaceholderProps> = ({
|
||||
style,
|
||||
shimmerColors,
|
||||
children,
|
||||
}) => {
|
||||
const animatedValue = useRef(new Animated.Value(0)).current;
|
||||
|
||||
useEffect(() => {
|
||||
const animation = Animated.loop(
|
||||
Animated.timing(animatedValue, {
|
||||
toValue: 1,
|
||||
duration: 1500,
|
||||
useNativeDriver: true,
|
||||
})
|
||||
);
|
||||
animation.start();
|
||||
|
||||
return () => {
|
||||
animation.stop();
|
||||
};
|
||||
}, [animatedValue]);
|
||||
|
||||
const translateX = animatedValue.interpolate({
|
||||
inputRange: [0, 1],
|
||||
outputRange: [-SCREEN_WIDTH, SCREEN_WIDTH],
|
||||
});
|
||||
|
||||
const defaultShimmerColors = [
|
||||
'transparent',
|
||||
'rgba(255, 255, 255, 0.05)',
|
||||
'rgba(255, 255, 255, 0.1)',
|
||||
'rgba(255, 255, 255, 0.05)',
|
||||
'transparent',
|
||||
];
|
||||
|
||||
return (
|
||||
<View style={[styles.container, style]}>
|
||||
{children}
|
||||
<Animated.View
|
||||
style={[
|
||||
StyleSheet.absoluteFillObject,
|
||||
{
|
||||
transform: [{ translateX }],
|
||||
},
|
||||
]}
|
||||
>
|
||||
<LinearGradient
|
||||
colors={shimmerColors || defaultShimmerColors}
|
||||
start={{ x: 0, y: 0 }}
|
||||
end={{ x: 1, y: 0 }}
|
||||
style={styles.gradient}
|
||||
/>
|
||||
</Animated.View>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
overflow: 'hidden',
|
||||
},
|
||||
gradient: {
|
||||
flex: 1,
|
||||
},
|
||||
});
|
||||
|
|
@ -0,0 +1,49 @@
|
|||
import React, { useEffect, useRef } from 'react';
|
||||
import { Animated, ViewStyle, Easing } from 'react-native';
|
||||
|
||||
interface SkeletonAnimationProps {
|
||||
children: React.ReactNode;
|
||||
style?: ViewStyle;
|
||||
delay?: number;
|
||||
}
|
||||
|
||||
export const SkeletonAnimation: React.FC<SkeletonAnimationProps> = ({ children, style, delay = 0 }) => {
|
||||
const animatedValue = useRef(new Animated.Value(0)).current;
|
||||
|
||||
useEffect(() => {
|
||||
const animation = Animated.loop(
|
||||
Animated.sequence([
|
||||
Animated.timing(animatedValue, {
|
||||
toValue: 1,
|
||||
duration: 800,
|
||||
easing: Easing.bezier(0.5, 0, 0.5, 1),
|
||||
useNativeDriver: true,
|
||||
delay,
|
||||
}),
|
||||
Animated.timing(animatedValue, {
|
||||
toValue: 0,
|
||||
duration: 800,
|
||||
easing: Easing.bezier(0.5, 0, 0.5, 1),
|
||||
useNativeDriver: true,
|
||||
}),
|
||||
])
|
||||
);
|
||||
|
||||
animation.start();
|
||||
|
||||
return () => {
|
||||
animation.stop();
|
||||
};
|
||||
}, [animatedValue, delay]);
|
||||
|
||||
const opacity = animatedValue.interpolate({
|
||||
inputRange: [0, 1],
|
||||
outputRange: [0.4, 0.8],
|
||||
});
|
||||
|
||||
return (
|
||||
<Animated.View style={[style, { opacity }]}>
|
||||
{children}
|
||||
</Animated.View>
|
||||
);
|
||||
};
|
||||
|
|
@ -0,0 +1,44 @@
|
|||
import { ViewStyle } from 'react-native';
|
||||
|
||||
export const getSkeletonColor = (isDark: boolean, themeColors: any): string => {
|
||||
return themeColors.skeleton || (isDark ? 'rgba(255, 255, 255, 0.1)' : 'rgba(128, 128, 128, 0.2)');
|
||||
};
|
||||
|
||||
export const skeletonBase: ViewStyle = {
|
||||
overflow: 'hidden',
|
||||
position: 'relative',
|
||||
};
|
||||
|
||||
export const skeletonText = (height: number = 16): ViewStyle => ({
|
||||
height,
|
||||
borderRadius: 4,
|
||||
});
|
||||
|
||||
export const skeletonBox = (width: number | string, height: number): ViewStyle => ({
|
||||
width,
|
||||
height,
|
||||
borderRadius: 8,
|
||||
});
|
||||
|
||||
export const skeletonCircle = (size: number): ViewStyle => ({
|
||||
width: size,
|
||||
height: size,
|
||||
borderRadius: size / 2,
|
||||
});
|
||||
|
||||
export const skeletonContainer: ViewStyle = {
|
||||
padding: 16,
|
||||
};
|
||||
|
||||
export const skeletonRow: ViewStyle = {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
};
|
||||
|
||||
export const skeletonSpacing = {
|
||||
xs: 4,
|
||||
sm: 8,
|
||||
md: 12,
|
||||
lg: 16,
|
||||
xl: 20,
|
||||
} as const;
|
||||
|
|
@ -0,0 +1,112 @@
|
|||
import React from 'react';
|
||||
import { Pressable, View, Text } from 'react-native';
|
||||
import { useTheme } from '~/features/theme/ThemeProvider';
|
||||
import Icon from '~/components/atoms/Icon';
|
||||
import colors from '~/tailwind.config.js';
|
||||
|
||||
interface ClickableStatRowProps {
|
||||
title: string;
|
||||
value: string;
|
||||
icon: string;
|
||||
subtitle?: string;
|
||||
onPress?: () => void;
|
||||
isClickable?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Clickable version of StatRow for interactive elements like memo links
|
||||
*/
|
||||
const ClickableStatRow: React.FC<ClickableStatRowProps> = ({
|
||||
title,
|
||||
value,
|
||||
icon,
|
||||
subtitle,
|
||||
onPress,
|
||||
isClickable = true,
|
||||
}) => {
|
||||
const { isDark, themeVariant } = useTheme();
|
||||
|
||||
const textColor = isDark ? '#FFFFFF' : '#000000';
|
||||
const textSecondaryColor = isDark ? '#CCCCCC' : '#666666';
|
||||
const primaryColor = isDark
|
||||
? (colors as any).theme?.extend?.colors?.dark?.[themeVariant]?.primary
|
||||
: (colors as any).theme?.extend?.colors?.[themeVariant]?.primary;
|
||||
const borderColor = isDark ? 'rgba(255, 255, 255, 0.1)' : 'rgba(0, 0, 0, 0.1)';
|
||||
|
||||
const contentBackgroundColor = isDark
|
||||
? (colors as any).theme?.extend?.colors?.dark?.[themeVariant]?.contentBackgroundHover
|
||||
: (colors as any).theme?.extend?.colors?.[themeVariant]?.contentBackgroundHover;
|
||||
|
||||
const content = (isPressed?: boolean) => (
|
||||
<View
|
||||
style={{
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
paddingVertical: 10,
|
||||
paddingHorizontal: 12,
|
||||
backgroundColor: isPressed
|
||||
? (isDark ? 'rgba(255, 255, 255, 0.08)' : 'rgba(0, 0, 0, 0.05)')
|
||||
: (isDark ? 'rgba(255, 255, 255, 0.03)' : 'rgba(0, 0, 0, 0.02)'),
|
||||
borderRadius: 12,
|
||||
}}>
|
||||
<Icon name={icon} size={22} color={primaryColor} />
|
||||
<View style={{ flex: 1, marginLeft: 14 }}>
|
||||
<Text
|
||||
style={{
|
||||
fontSize: 14,
|
||||
fontWeight: '500',
|
||||
color: textColor,
|
||||
}}>
|
||||
{title}
|
||||
</Text>
|
||||
{subtitle && (
|
||||
<Text
|
||||
style={{
|
||||
fontSize: 12,
|
||||
color: textSecondaryColor,
|
||||
marginTop: 2,
|
||||
}}
|
||||
numberOfLines={1}
|
||||
ellipsizeMode="tail">
|
||||
{subtitle}
|
||||
</Text>
|
||||
)}
|
||||
</View>
|
||||
<View
|
||||
style={{
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: 6,
|
||||
}}>
|
||||
<Text
|
||||
style={{
|
||||
fontSize: 17,
|
||||
fontWeight: '700',
|
||||
color: textColor,
|
||||
textAlign: 'right',
|
||||
}}>
|
||||
{value}
|
||||
</Text>
|
||||
{isClickable && onPress && (
|
||||
<Icon name="chevron-forward" size={18} color={textSecondaryColor} />
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
|
||||
if (isClickable && onPress) {
|
||||
return (
|
||||
<Pressable
|
||||
onPress={onPress}
|
||||
style={({ pressed }) => ({
|
||||
opacity: pressed ? 0.7 : 1,
|
||||
})}>
|
||||
{({ pressed }) => content(pressed)}
|
||||
</Pressable>
|
||||
);
|
||||
}
|
||||
|
||||
return content(false);
|
||||
};
|
||||
|
||||
export default ClickableStatRow;
|
||||
139
apps/memoro/apps/mobile/components/statistics/EngagementCard.tsx
Normal file
139
apps/memoro/apps/mobile/components/statistics/EngagementCard.tsx
Normal file
|
|
@ -0,0 +1,139 @@
|
|||
import React from 'react';
|
||||
import { View, Text, StyleSheet, ScrollView } from 'react-native';
|
||||
import { useTheme } from '~/features/theme/ThemeProvider';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useRouter } from 'expo-router';
|
||||
import GlassCard from './GlassCard';
|
||||
import StatRow from './StatRow';
|
||||
import ClickableStatRow from './ClickableStatRow';
|
||||
|
||||
interface EngagementCardProps {
|
||||
mostViewedMemo: { id: string; title: string; viewCount: number } | null;
|
||||
lastViewedMemo: { id: string; title: string; lastViewed: string } | null;
|
||||
unreadMemos: number;
|
||||
memoCount: number;
|
||||
}
|
||||
|
||||
const EngagementCard: React.FC<EngagementCardProps> = ({
|
||||
mostViewedMemo,
|
||||
lastViewedMemo,
|
||||
unreadMemos,
|
||||
memoCount,
|
||||
}) => {
|
||||
const { isDark } = useTheme();
|
||||
const { t } = useTranslation();
|
||||
const router = useRouter();
|
||||
|
||||
const textColor = isDark ? '#FFFFFF' : '#000000';
|
||||
const sectionTitleColor = isDark ? '#CCCCCC' : '#666666';
|
||||
|
||||
// Calculate read percentage
|
||||
const readMemos = memoCount - unreadMemos;
|
||||
const readPercentage = memoCount > 0 ? Math.round((readMemos / memoCount) * 100) : 0;
|
||||
|
||||
return (
|
||||
<GlassCard>
|
||||
<ScrollView showsVerticalScrollIndicator={false}>
|
||||
<Text style={[styles.title, { color: textColor }]}>
|
||||
Engagement
|
||||
</Text>
|
||||
|
||||
{/* View Statistics */}
|
||||
<Text style={[styles.sectionTitle, { color: sectionTitleColor }]}>
|
||||
{t('statistics.section_engagement') || 'Aufrufe'}
|
||||
</Text>
|
||||
<View style={styles.statsContainer}>
|
||||
{mostViewedMemo && (
|
||||
<ClickableStatRow
|
||||
title={t('statistics.most_viewed_memo')}
|
||||
value={`${mostViewedMemo.viewCount}x`}
|
||||
icon="eye-outline"
|
||||
subtitle={mostViewedMemo.title}
|
||||
onPress={() => router.push(`/(protected)/(memo)/${mostViewedMemo.id}`)}
|
||||
/>
|
||||
)}
|
||||
{lastViewedMemo && (
|
||||
<ClickableStatRow
|
||||
title={t('statistics.last_viewed_memo')}
|
||||
value={new Date(lastViewedMemo.lastViewed).toLocaleDateString()}
|
||||
icon="time-outline"
|
||||
subtitle={lastViewedMemo.title}
|
||||
onPress={() => router.push(`/(protected)/(memo)/${lastViewedMemo.id}`)}
|
||||
/>
|
||||
)}
|
||||
</View>
|
||||
|
||||
<View style={styles.divider} />
|
||||
|
||||
{/* Reading Statistics */}
|
||||
<Text style={[styles.sectionTitle, { color: sectionTitleColor }]}>
|
||||
Lesestatus
|
||||
</Text>
|
||||
<View style={styles.statsContainer}>
|
||||
<StatRow
|
||||
title={t('statistics.unread_memos') || 'Ungelesene Memos'}
|
||||
value={unreadMemos.toString()}
|
||||
icon="mail-unread-outline"
|
||||
/>
|
||||
<StatRow
|
||||
title="Gelesene Memos"
|
||||
value={readMemos.toString()}
|
||||
icon="checkmark-done-outline"
|
||||
/>
|
||||
<StatRow
|
||||
title="Gelesen"
|
||||
value={`${readPercentage}%`}
|
||||
icon="stats-chart-outline"
|
||||
subtitle={`${readMemos} von ${memoCount} Memos`}
|
||||
/>
|
||||
</View>
|
||||
|
||||
{unreadMemos > 0 && (
|
||||
<>
|
||||
<View style={styles.divider} />
|
||||
<View style={styles.hintContainer}>
|
||||
<Text style={[styles.hintText, { color: sectionTitleColor }]}>
|
||||
💡 Du hast noch {unreadMemos} ungelesene{' '}
|
||||
{unreadMemos === 1 ? 'Memo' : 'Memos'}
|
||||
</Text>
|
||||
</View>
|
||||
</>
|
||||
)}
|
||||
</ScrollView>
|
||||
</GlassCard>
|
||||
);
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
title: {
|
||||
fontSize: 26,
|
||||
fontWeight: '700',
|
||||
marginBottom: 20,
|
||||
},
|
||||
sectionTitle: {
|
||||
fontSize: 15,
|
||||
fontWeight: '600',
|
||||
marginBottom: 10,
|
||||
marginTop: 4,
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: 0.5,
|
||||
},
|
||||
statsContainer: {
|
||||
gap: 6,
|
||||
},
|
||||
divider: {
|
||||
height: 0,
|
||||
marginVertical: 18,
|
||||
},
|
||||
hintContainer: {
|
||||
backgroundColor: 'rgba(255, 255, 255, 0.05)',
|
||||
borderRadius: 12,
|
||||
padding: 14,
|
||||
},
|
||||
hintText: {
|
||||
fontSize: 13,
|
||||
lineHeight: 18,
|
||||
},
|
||||
});
|
||||
|
||||
export default EngagementCard;
|
||||
78
apps/memoro/apps/mobile/components/statistics/GlassCard.tsx
Normal file
78
apps/memoro/apps/mobile/components/statistics/GlassCard.tsx
Normal file
|
|
@ -0,0 +1,78 @@
|
|||
import React from 'react';
|
||||
import { View, StyleSheet, ViewStyle } from 'react-native';
|
||||
import { LinearGradient } from 'expo-linear-gradient';
|
||||
import { useTheme } from '~/features/theme/ThemeProvider';
|
||||
|
||||
interface GlassCardProps {
|
||||
children: React.ReactNode;
|
||||
style?: ViewStyle;
|
||||
width?: number | string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Glass morphism card component with blur effect and transparency
|
||||
*/
|
||||
const GlassCard: React.FC<GlassCardProps> = ({ children, style, width = '100%' }) => {
|
||||
const { isDark } = useTheme();
|
||||
|
||||
// Glass effect colors
|
||||
const glassBackground = isDark
|
||||
? 'rgba(255, 255, 255, 0.08)'
|
||||
: 'rgba(255, 255, 255, 0.7)';
|
||||
|
||||
const borderColor = isDark
|
||||
? 'rgba(255, 255, 255, 0.15)'
|
||||
: 'rgba(255, 255, 255, 0.3)';
|
||||
|
||||
const gradientColors = isDark
|
||||
? ['rgba(255, 255, 255, 0.1)', 'rgba(255, 255, 255, 0.05)']
|
||||
: ['rgba(255, 255, 255, 0.8)', 'rgba(255, 255, 255, 0.6)'];
|
||||
|
||||
return (
|
||||
<View style={[styles.container, { width }, style]}>
|
||||
<LinearGradient
|
||||
colors={gradientColors}
|
||||
start={{ x: 0, y: 0 }}
|
||||
end={{ x: 1, y: 1 }}
|
||||
style={styles.gradient}
|
||||
>
|
||||
<View
|
||||
style={[
|
||||
styles.innerContainer,
|
||||
{
|
||||
backgroundColor: glassBackground,
|
||||
borderColor: borderColor,
|
||||
},
|
||||
]}
|
||||
>
|
||||
{children}
|
||||
</View>
|
||||
</LinearGradient>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
borderRadius: 24,
|
||||
overflow: 'hidden',
|
||||
shadowColor: '#000',
|
||||
shadowOffset: { width: 0, height: 8 },
|
||||
shadowOpacity: 0.15,
|
||||
shadowRadius: 20,
|
||||
elevation: 8,
|
||||
},
|
||||
gradient: {
|
||||
flex: 1,
|
||||
borderRadius: 24,
|
||||
},
|
||||
innerContainer: {
|
||||
flex: 1,
|
||||
borderRadius: 24,
|
||||
borderWidth: 1,
|
||||
padding: 24,
|
||||
},
|
||||
});
|
||||
|
||||
export default GlassCard;
|
||||
176
apps/memoro/apps/mobile/components/statistics/InsightsCard.tsx
Normal file
176
apps/memoro/apps/mobile/components/statistics/InsightsCard.tsx
Normal file
|
|
@ -0,0 +1,176 @@
|
|||
import React from 'react';
|
||||
import { View, Text, StyleSheet, ScrollView } from 'react-native';
|
||||
import { useTheme } from '~/features/theme/ThemeProvider';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import GlassCard from './GlassCard';
|
||||
import StatRow from './StatRow';
|
||||
import WeekdayChart from './WeekdayChart';
|
||||
import { formatDurationWithUnits } from '~/utils/formatters';
|
||||
|
||||
interface InsightsCardProps {
|
||||
averageAudioDuration: number;
|
||||
averageWordsPerMinute: number;
|
||||
longestRecording: number;
|
||||
recordingsByWeekday: { [key: string]: number };
|
||||
totalTags: number;
|
||||
assignedTags: number;
|
||||
memosWithoutTags: number;
|
||||
averageTagsPerMemo: number;
|
||||
mostUsedTags: { name: string; count: number; color: string }[];
|
||||
topLocations: { city: string; count: number }[];
|
||||
}
|
||||
|
||||
const InsightsCard: React.FC<InsightsCardProps> = ({
|
||||
averageAudioDuration,
|
||||
averageWordsPerMinute,
|
||||
longestRecording,
|
||||
recordingsByWeekday,
|
||||
totalTags,
|
||||
assignedTags,
|
||||
memosWithoutTags,
|
||||
averageTagsPerMemo,
|
||||
mostUsedTags,
|
||||
topLocations,
|
||||
}) => {
|
||||
const { isDark } = useTheme();
|
||||
const { t } = useTranslation();
|
||||
|
||||
const textColor = isDark ? '#FFFFFF' : '#000000';
|
||||
const sectionTitleColor = isDark ? '#CCCCCC' : '#666666';
|
||||
|
||||
return (
|
||||
<GlassCard>
|
||||
<ScrollView showsVerticalScrollIndicator={false}>
|
||||
<Text style={[styles.title, { color: textColor }]}>
|
||||
Insights
|
||||
</Text>
|
||||
|
||||
{/* Audio Insights */}
|
||||
<Text style={[styles.sectionTitle, { color: sectionTitleColor }]}>
|
||||
{t('statistics.section_audio')}
|
||||
</Text>
|
||||
<View style={styles.statsContainer}>
|
||||
<StatRow
|
||||
title={t('statistics.average_recording_duration')}
|
||||
value={formatDurationWithUnits(averageAudioDuration)}
|
||||
icon="time-outline"
|
||||
subtitle={t('statistics.per_memo')}
|
||||
/>
|
||||
<StatRow
|
||||
title={t('statistics.average_words_per_minute')}
|
||||
value={averageWordsPerMinute.toString()}
|
||||
icon="speedometer-outline"
|
||||
subtitle={t('statistics.speaking_speed')}
|
||||
/>
|
||||
<StatRow
|
||||
title={t('statistics.longest_recording')}
|
||||
value={formatDurationWithUnits(longestRecording)}
|
||||
icon="timer-outline"
|
||||
/>
|
||||
<WeekdayChart data={recordingsByWeekday} />
|
||||
</View>
|
||||
|
||||
<View style={styles.divider} />
|
||||
|
||||
{/* Tag Analytics */}
|
||||
<Text style={[styles.sectionTitle, { color: sectionTitleColor }]}>
|
||||
{t('statistics.section_tags')}
|
||||
</Text>
|
||||
<View style={styles.statsContainer}>
|
||||
<StatRow
|
||||
title={t('statistics.total_tags') || 'Gesamt Tags'}
|
||||
value={totalTags.toString()}
|
||||
icon="pricetag-outline"
|
||||
/>
|
||||
<StatRow
|
||||
title={t('statistics.assigned_tags') || 'Zugewiesene Tags'}
|
||||
value={assignedTags.toString()}
|
||||
icon="checkmark-circle-outline"
|
||||
/>
|
||||
<StatRow
|
||||
title={t('statistics.memos_without_tags') || 'Memos ohne Tags'}
|
||||
value={memosWithoutTags.toString()}
|
||||
icon="alert-circle-outline"
|
||||
/>
|
||||
<StatRow
|
||||
title={t('statistics.average_tags_per_memo') || 'Ø Tags/Memo'}
|
||||
value={averageTagsPerMemo.toString()}
|
||||
icon="analytics-outline"
|
||||
/>
|
||||
{mostUsedTags.length > 0 && (
|
||||
<>
|
||||
<View style={styles.tagDivider} />
|
||||
<Text style={[styles.subSectionTitle, { color: sectionTitleColor }]}>
|
||||
{t('statistics.most_used_tags') || 'Meistgenutzte Tags'}
|
||||
</Text>
|
||||
{mostUsedTags.slice(0, 5).map((tag) => (
|
||||
<StatRow
|
||||
key={tag.name}
|
||||
title={tag.name}
|
||||
value={tag.count.toString()}
|
||||
icon="pricetag"
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</View>
|
||||
|
||||
{/* Location Data */}
|
||||
{topLocations.length > 0 && (
|
||||
<>
|
||||
<View style={styles.divider} />
|
||||
<Text style={[styles.sectionTitle, { color: sectionTitleColor }]}>
|
||||
{t('statistics.section_locations')}
|
||||
</Text>
|
||||
<View style={styles.statsContainer}>
|
||||
{topLocations.map((location) => (
|
||||
<StatRow
|
||||
key={location.city}
|
||||
title={location.city}
|
||||
value={`${location.count}${t('statistics.memos_suffix')}`}
|
||||
icon="location-outline"
|
||||
/>
|
||||
))}
|
||||
</View>
|
||||
</>
|
||||
)}
|
||||
</ScrollView>
|
||||
</GlassCard>
|
||||
);
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
title: {
|
||||
fontSize: 26,
|
||||
fontWeight: '700',
|
||||
marginBottom: 20,
|
||||
},
|
||||
sectionTitle: {
|
||||
fontSize: 15,
|
||||
fontWeight: '600',
|
||||
marginBottom: 10,
|
||||
marginTop: 4,
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: 0.5,
|
||||
},
|
||||
subSectionTitle: {
|
||||
fontSize: 13,
|
||||
fontWeight: '600',
|
||||
marginTop: 8,
|
||||
marginBottom: 8,
|
||||
opacity: 0.8,
|
||||
},
|
||||
statsContainer: {
|
||||
gap: 6,
|
||||
},
|
||||
divider: {
|
||||
height: 0,
|
||||
marginVertical: 18,
|
||||
},
|
||||
tagDivider: {
|
||||
height: 0,
|
||||
marginVertical: 12,
|
||||
},
|
||||
});
|
||||
|
||||
export default InsightsCard;
|
||||
101
apps/memoro/apps/mobile/components/statistics/OverviewCard.tsx
Normal file
101
apps/memoro/apps/mobile/components/statistics/OverviewCard.tsx
Normal file
|
|
@ -0,0 +1,101 @@
|
|||
import React from 'react';
|
||||
import { View, Text, StyleSheet } from 'react-native';
|
||||
import { useTheme } from '~/features/theme/ThemeProvider';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import GlassCard from './GlassCard';
|
||||
import StatRow from './StatRow';
|
||||
|
||||
interface OverviewCardProps {
|
||||
memoCount: number;
|
||||
memoryCount: number;
|
||||
totalDuration: number;
|
||||
totalWords: number;
|
||||
currentStreak: number;
|
||||
averageWordCount: number;
|
||||
}
|
||||
|
||||
const OverviewCard: React.FC<OverviewCardProps> = ({
|
||||
memoCount,
|
||||
memoryCount,
|
||||
totalDuration,
|
||||
totalWords,
|
||||
currentStreak,
|
||||
averageWordCount,
|
||||
}) => {
|
||||
const { isDark } = useTheme();
|
||||
const { t } = useTranslation();
|
||||
|
||||
const textColor = isDark ? '#FFFFFF' : '#000000';
|
||||
const highlightColor = isDark ? '#FFD700' : '#FFA500';
|
||||
|
||||
const formatDuration = (seconds: number): string => {
|
||||
const hours = Math.floor(seconds / 3600);
|
||||
const minutes = Math.floor((seconds % 3600) / 60);
|
||||
|
||||
if (hours > 0) {
|
||||
return `${hours}h ${minutes}m`;
|
||||
} else if (minutes > 0) {
|
||||
return `${minutes}m`;
|
||||
} else {
|
||||
return `${seconds}s`;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<GlassCard>
|
||||
<Text style={[styles.title, { color: textColor }]}>
|
||||
{t('statistics.filter_overview')}
|
||||
</Text>
|
||||
<View style={styles.statsContainer}>
|
||||
<StatRow
|
||||
title={t('statistics.memos')}
|
||||
value={memoCount.toString()}
|
||||
icon="document-text-outline"
|
||||
/>
|
||||
<StatRow
|
||||
title={t('statistics.memories')}
|
||||
value={memoryCount.toString()}
|
||||
icon="sparkles-outline"
|
||||
/>
|
||||
<StatRow
|
||||
title={t('statistics.recording_duration')}
|
||||
value={formatDuration(totalDuration)}
|
||||
icon="volume-high-outline"
|
||||
/>
|
||||
<StatRow
|
||||
title={t('statistics.words')}
|
||||
value={totalWords.toLocaleString()}
|
||||
icon="text-outline"
|
||||
/>
|
||||
<View style={styles.divider} />
|
||||
<StatRow
|
||||
title={t('statistics.current_streak')}
|
||||
value={`${currentStreak} ${t('statistics.days')}`}
|
||||
icon="flame-outline"
|
||||
/>
|
||||
<StatRow
|
||||
title={t('statistics.average_word_count') || 'Ø Wörter/Memo'}
|
||||
value={averageWordCount.toString()}
|
||||
icon="analytics-outline"
|
||||
/>
|
||||
</View>
|
||||
</GlassCard>
|
||||
);
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
title: {
|
||||
fontSize: 26,
|
||||
fontWeight: '700',
|
||||
marginBottom: 20,
|
||||
},
|
||||
statsContainer: {
|
||||
gap: 6,
|
||||
},
|
||||
divider: {
|
||||
height: 0,
|
||||
marginVertical: 12,
|
||||
},
|
||||
});
|
||||
|
||||
export default OverviewCard;
|
||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue