refactor: restructure

monorepo with apps/ and services/
  directories
This commit is contained in:
Wuesteon 2025-11-26 03:03:24 +01:00
parent 25824ed0ac
commit ff80aeec1f
4062 changed files with 2592 additions and 1278 deletions

View 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,
},
});

View 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>
);
}

View 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>
);
}

View 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>
);
};

View 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;

View 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;

View 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;

View 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;

View 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;

View 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;

View 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>;

View 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;

View 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;

View 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);

View file

@ -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;

View file

@ -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}
/>
);
}

View 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;

File diff suppressed because it is too large Load diff

View 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;

View 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;

View 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;

View 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;

View 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;

View 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;

View 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);
}

View file

@ -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>
);
}

View file

@ -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;

View file

@ -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;

View file

@ -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>
);
}

View file

@ -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;

View 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;

View file

@ -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>
);
}

View file

@ -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;

View file

@ -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;

View file

@ -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>
);
}

View file

@ -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>
);
}

View 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);

File diff suppressed because it is too large Load diff

View 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;

File diff suppressed because it is too large Load diff

View file

@ -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;

View file

@ -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;

View 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>
);
}

View file

@ -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;

View file

@ -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;

View 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;

View 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;

View file

@ -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;

View 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;

View file

@ -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>
);
}

View 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;

View file

@ -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;

View 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;

View 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;

View 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;

View file

@ -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;

View 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;

View file

@ -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;

View file

@ -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;

View file

@ -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>
);
}

View 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);

View file

@ -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;

View 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;

View 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;

View 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;

View file

@ -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;

View file

@ -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;

View 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;

View 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;

View file

@ -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();
});
});
});
});

View file

@ -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';

View 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;

View 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;

View 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;

View file

@ -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;

View 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;

View 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>
);
}

View file

@ -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,
},
});

View 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;

View 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',
},
});

View 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;

View 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;

View 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;

View file

@ -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;

View file

@ -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
},
});

View 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';

View file

@ -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,
},
});

View file

@ -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,
},
});

View file

@ -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%',
},
});

View file

@ -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,
},
});

View file

@ -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,
},
});

View file

@ -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,
},
});

View file

@ -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,
},
});

View file

@ -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>
);
};

View file

@ -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;

View file

@ -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;

View 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;

View 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;

View 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;

View 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