Merge branch 'dev-1' into dev

This commit is contained in:
Wuesteon 2025-12-05 17:57:26 +01:00
commit d41d060bb3
1770 changed files with 168028 additions and 31031 deletions

View file

@ -0,0 +1,46 @@
import { ScrollViewStyleReset } from 'expo-router/html';
// This file is web-only and used to configure the root HTML for every
// web page during static rendering.
// The contents of this function only run in Node.js environments and
// do not have access to the DOM or browser APIs.
export default function Root({ children }: { children: React.ReactNode }) {
return (
<html lang="en">
<head>
<meta charSet="utf-8" />
<meta httpEquiv="X-UA-Compatible" content="IE=edge" />
{/*
This viewport disables scaling which makes the mobile website act more like a native app.
However this does reduce built-in accessibility. If you want to enable scaling, use this instead:
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
*/}
<meta
name="viewport"
content="width=device-width,initial-scale=1,minimum-scale=1,maximum-scale=1.00001,viewport-fit=cover"
/>
{/*
Disable body scrolling on web. This makes ScrollView components work closer to how they do on native.
However, body scrolling is often nice to have for mobile web. If you want to enable it, remove this line.
*/}
<ScrollViewStyleReset />
{/* Using raw CSS styles as an escape-hatch to ensure the background color never flickers in dark-mode. */}
<style dangerouslySetInnerHTML={{ __html: responsiveBackground }} />
{/* Add any additional <head> elements that you want globally available on web... */}
</head>
<body>{children}</body>
</html>
);
}
const responsiveBackground = `
body {
background-color: #fff;
}
@media (prefers-color-scheme: dark) {
body {
background-color: #000;
}
}`;

View file

@ -0,0 +1,24 @@
import { Link, Stack } from 'expo-router';
import { Text } from 'react-native';
import { Container } from '~/components/Container';
export default function NotFoundScreen() {
return (
<>
<Stack.Screen options={{ title: 'Oops!' }} />
<Container>
<Text className={styles.title}>This screen doesn't exist.</Text>
<Link href="/" className={styles.link}>
<Text className={styles.linkText}>Go to home screen!</Text>
</Link>
</Container>
</>
);
}
const styles = {
title: `text-xl font-bold`,
link: `mt-4 pt-4`,
linkText: `text-base text-[#2e78b7]`,
};

View file

@ -0,0 +1,74 @@
import '../global.css';
import { useEffect } from 'react';
import { Slot, Stack, useRouter, useSegments } from 'expo-router';
import { useFonts } from 'expo-font';
import { SplashScreen } from 'expo-router';
import { AuthProvider, useAuth } from '../context/AuthContext';
import { ThemeProvider } from '../components/theme';
import { DebugProvider } from '../context/DebugContext';
import { I18nProvider } from '../context/I18nContext';
import { initializeRevenueCat } from '../services/revenueCatService';
import '../utils/i18n'; // Initialize i18n
// Prevent the splash screen from auto-hiding before asset loading is complete
SplashScreen.preventAutoHideAsync();
// Komponente zur Überprüfung der Authentifizierung und Weiterleitung
function RootLayoutNav() {
const { user, loading } = useAuth();
const segments = useSegments();
const router = useRouter();
const [fontsLoaded, fontError] = useFonts({
// You can add custom fonts here if needed
});
useEffect(() => {
if (fontsLoaded || fontError) {
// Hide the splash screen after the fonts have loaded (or an error was reported)
SplashScreen.hideAsync();
}
}, [fontsLoaded, fontError]);
// Prevent rendering until the fonts have loaded or an error was encountered
if (!fontsLoaded && !fontError) {
return null;
}
// Authentifizierungslogik
useEffect(() => {
if (loading) return;
const isAuthRoute = segments[0] === 'login' || segments[0] === 'register';
if (!user && !isAuthRoute) {
// Wenn der Benutzer nicht angemeldet ist und nicht auf einer Auth-Seite ist, leite zur Login-Seite weiter
router.replace('/login');
} else if (user && isAuthRoute) {
// Wenn der Benutzer angemeldet ist und auf einer Auth-Seite ist, leite zur Startseite weiter
router.replace('/');
}
// Initialisiere RevenueCat, wenn der Benutzer angemeldet ist
if (user) {
initializeRevenueCat(user.id);
}
}, [user, loading, segments, router]);
return <Slot />;
}
// Root-Layout mit AuthProvider, ThemeProvider, I18nProvider und DebugProvider
export default function RootLayout() {
return (
<I18nProvider>
<AuthProvider>
<ThemeProvider>
<DebugProvider>
<RootLayoutNav />
</DebugProvider>
</ThemeProvider>
</AuthProvider>
</I18nProvider>
);
}

View file

@ -0,0 +1,17 @@
import { Stack, useLocalSearchParams } from 'expo-router';
import { Container } from '~/components/Container';
import { ScreenContent } from '~/components/ScreenContent';
export default function Details() {
const { name } = useLocalSearchParams();
return (
<>
<Stack.Screen options={{ title: 'Details' }} />
<Container>
<ScreenContent path="screens/details.tsx" title={`Showing details for user ${name}`} />
</Container>
</>
);
}

View file

@ -0,0 +1,374 @@
import { Stack, useRouter } from 'expo-router';
import {
View,
RefreshControl,
TouchableOpacity,
ScrollView,
useWindowDimensions,
} from 'react-native';
import { useState, useEffect, useCallback, useMemo } from 'react';
import { Ionicons } from '@expo/vector-icons';
import { Screen } from '~/components/layout/Screen';
import { Text } from '~/components/ui/Text';
import { useAuth } from '~/context/AuthContext';
import { getSpaces, getDocuments, Document, Space } from '~/services/supabaseService';
import { useTheme } from '~/utils/theme/theme';
import { useTranslations } from '~/context/I18nContext';
import { SpaceFilterPill } from '~/components/spaces/SpaceFilterPill';
import { AllSpacesFilterPill } from '~/components/spaces/AllSpacesFilterPill';
import { SpaceFilterPillSkeleton } from '~/components/spaces/SpaceFilterPillSkeleton';
import { DocumentTypeBadge } from '~/components/documents/DocumentTypeBadge';
import { DocumentGallery } from '~/components/documents/DocumentGallery';
import { SpaceCreator } from '~/components/spaces/SpaceCreator';
import { InlineSpaceCreator } from '~/components/spaces/InlineSpaceCreator';
import { Breadcrumbs } from '~/components/navigation/Breadcrumbs';
import {
DocumentTypeFilterDropdown,
FilterType,
} from '~/components/documents/DocumentTypeFilterDropdown';
import { FilterPill } from '~/components/ui/FilterPill';
import { Skeleton } from '~/components/ui/Skeleton';
export default function Home() {
const router = useRouter();
const { user } = useAuth();
const { isDark } = useTheme();
const { width } = useWindowDimensions();
const isDesktop = width > 1024;
const { t, homepage, spaces: spacesT, common, errors } = useTranslations();
const [spaces, setSpaces] = useState<Space[]>([]);
const [documents, setDocuments] = useState<Document[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [refreshing, setRefreshing] = useState(false);
const [selectedSpaceIds, setSelectedSpaceIds] = useState<string[]>([]);
const [searchQuery, setSearchQuery] = useState('');
const [showSpaceCreator, setShowSpaceCreator] = useState(false);
const [showInlineCreator, setShowInlineCreator] = useState(false);
const [selectedDocumentType, setSelectedDocumentType] = useState<FilterType | null>(null);
// Optimierte Sortierfunktion
const sortDocuments = useCallback((docs: Document[]) => {
return docs.sort((a, b) => {
// Zuerst nach Pin-Status sortieren (angepinnte zuerst)
if ((a.pinned || false) && !(b.pinned || false)) return -1;
if (!(a.pinned || false) && (b.pinned || false)) return 1;
// Bei gleichem Pin-Status nach Aktualisierungsdatum sortieren (neueste zuerst)
const dateA = new Date(a.updated_at || a.created_at);
const dateB = new Date(b.updated_at || b.created_at);
return dateB.getTime() - dateA.getTime();
});
}, []);
// Funktion zum Laden der Daten
const loadData = useCallback(async () => {
try {
setLoading(true);
setError(null);
// Alle Spaces laden
const spacesData = await getSpaces();
setSpaces(spacesData);
// Alle Dokumente aus allen Spaces laden (parallel)
let allDocuments: Document[] = [];
if (spacesData.length > 0) {
// Wenn keine Spaces ausgewählt sind, alle Dokumente laden
if (selectedSpaceIds.length === 0) {
const documentPromises = spacesData.map((space) => getDocuments(space.id));
const documentResults = await Promise.all(documentPromises);
allDocuments = documentResults.flat();
} else {
// Nur Dokumente aus ausgewählten Spaces laden
const documentPromises = selectedSpaceIds.map((spaceId) => getDocuments(spaceId));
const documentResults = await Promise.all(documentPromises);
allDocuments = documentResults.flat();
}
}
// Dokumente sortieren
const sortedDocuments = sortDocuments(allDocuments);
setDocuments(sortedDocuments);
} catch (err: any) {
console.error('Fehler beim Laden der Daten:', err);
setError(homepage('errorLoadingData'));
} finally {
setLoading(false);
setRefreshing(false);
}
}, [selectedSpaceIds]);
// Lade die Daten beim ersten Rendern und wenn sich die ausgewählten Spaces ändern
useEffect(() => {
if (user) {
loadData();
}
}, [user, loadData, selectedSpaceIds]);
// Funktion zum Aktualisieren der Daten (Pull-to-Refresh)
const onRefresh = useCallback(() => {
setRefreshing(true);
loadData();
}, [loadData]);
// Funktion zum Umschalten eines Space-Filters (Single-Select)
const toggleSpaceFilter = (spaceId: string | null) => {
// Wenn null ("Alle") oder der bereits ausgewählte Space angeklickt wird, alle deselektieren
if (spaceId === null || (selectedSpaceIds.length === 1 && selectedSpaceIds[0] === spaceId)) {
setSelectedSpaceIds([]);
} else {
// Sonst nur den angeklickten Space auswählen
setSelectedSpaceIds([spaceId]);
}
};
// Filtere Dokumente basierend auf der Suche und dem ausgewählten Dokumenttyp
const filteredDocuments = useMemo(() => {
return documents.filter((doc) => {
const titleMatch = doc.title?.toLowerCase().includes(searchQuery.toLowerCase());
const contentMatch = doc.content?.toLowerCase().includes(searchQuery.toLowerCase());
const typeMatch = !selectedDocumentType || doc.type === selectedDocumentType;
return (titleMatch || contentMatch) && typeMatch;
});
}, [documents, searchQuery, selectedDocumentType]);
return (
<>
<Stack.Screen
options={{
title: homepage('title'),
headerShown: true,
}}
/>
<Screen
scrollable={false}
padded={false}
style={{ flex: 1, height: '100%' }}
refreshControl={
<RefreshControl
refreshing={refreshing}
onRefresh={onRefresh}
colors={['#6366f1']}
tintColor="#6366f1"
/>
}
>
{/* Hauptcontainer ohne Breitenbegrenzung */}
<View
style={{
flex: 1,
width: '100%',
paddingTop: 4, // Optimaler Abstand oben
height: '100%',
}}
>
{/* Breadcrumbs mit Suchfunktion und Settings-Icon */}
<View
style={{
marginBottom: 24,
backgroundColor: isDark ? '#111827' : '#f9fafb',
paddingHorizontal: 16,
}}
>
<View
style={{
maxWidth: isDesktop ? 800 : '100%',
width: '100%',
marginHorizontal: 'auto',
}}
>
<Breadcrumbs
items={[
{ label: homepage('title'), href: undefined },
{
label: homepage('selectSpace'),
dropdownItems: spaces.map((space) => ({
id: space.id,
label: space.name,
href: `/spaces/${space.id}`,
})),
},
]}
showSettingsIcon={false}
className="justify-between"
loading={loading}
rightComponent={
<DocumentTypeFilterDropdown
selectedType={selectedDocumentType}
onTypeChange={setSelectedDocumentType}
/>
}
/>
</View>
</View>
{/* Filter-Bereich mit Space-Filtern */}
<View style={{ marginBottom: 24 }}>
<ScrollView
horizontal
showsHorizontalScrollIndicator={false}
contentContainerStyle={{
paddingHorizontal: 16,
height: 28,
flexGrow: 1,
justifyContent: 'center', // Zentriert die Inhalte horizontal
}}
>
{loading ? (
<>
{/* Space Filter Skeleton */}
<FilterPill
label={spacesT('newSpace')}
icon="add"
variant="action"
disabled={true}
style={{ opacity: 0.7 }}
onPress={() => {}}
/>
<SpaceFilterPillSkeleton count={3} />
</>
) : (
<>
{/* Space Filter */}
{spaces.length > 0 ? (
<>
{/* Neuer Space Button oder Inline Creator */}
{showInlineCreator ? (
<InlineSpaceCreator
onCancel={() => setShowInlineCreator(false)}
onCreated={(spaceId) => {
setShowInlineCreator(false);
loadData();
}}
/>
) : (
<FilterPill
label={spacesT('newSpace')}
icon="add"
variant="action"
onPress={() => setShowInlineCreator(true)}
/>
)}
{/* "Alle" Filter Pill */}
<AllSpacesFilterPill
isSelected={selectedSpaceIds.length === 0}
onPress={() => toggleSpaceFilter(null)}
/>
{/* Space Filter Pills - nur gepinnte Spaces anzeigen */}
{spaces
.filter((space) => space.pinned)
.map((space) => (
<SpaceFilterPill
key={space.id}
id={space.id}
name={space.name}
isSelected={
selectedSpaceIds.length === 1 && selectedSpaceIds[0] === space.id
}
onPress={toggleSpaceFilter}
/>
))}
</>
) : (
<>
{/* Neuer Space Button oder Inline Creator */}
{showInlineCreator ? (
<InlineSpaceCreator
onCancel={() => setShowInlineCreator(false)}
onCreated={(spaceId) => {
setShowInlineCreator(false);
loadData();
}}
/>
) : (
<FilterPill
label={spacesT('newSpace')}
icon="add"
variant="action"
onPress={() => setShowInlineCreator(true)}
/>
)}
{/* "Alle" Filter Pill */}
<AllSpacesFilterPill
isSelected={true}
onPress={() => toggleSpaceFilter(null)}
/>
<Text
style={{
color: isDark ? '#9ca3af' : '#6b7280',
fontSize: 14,
fontStyle: 'italic',
marginRight: 12,
}}
>
{spacesT('noSpaces')}
</Text>
</>
)}
</>
)}
</ScrollView>
</View>
{/* Dokumente als Galerie anzeigen */}
<View style={{ flex: 1, position: 'relative' }}>
<DocumentGallery
documents={filteredDocuments}
loading={loading}
error={error}
searchQuery={searchQuery}
selectedSpaceIds={selectedSpaceIds}
onCreateDocument={() => {
if (selectedSpaceIds.length === 1) {
// Wenn genau ein Space ausgewählt ist, navigiere zur Dokumenterstellung für diesen Space
router.push(`/spaces/${selectedSpaceIds[0]}/documents/create?mode=edit`);
} else if (selectedSpaceIds.length === 0 && spaces.length > 0) {
// Wenn kein Space ausgewählt ist, aber Spaces vorhanden sind, nehme den ersten Space
router.push(`/spaces/${spaces[0].id}/documents/create?mode=edit`);
} else if (spaces.length > 0) {
// Wenn mehrere Spaces ausgewählt sind, nehme den ersten ausgewählten Space
router.push(`/spaces/${selectedSpaceIds[0]}/documents/create?mode=edit`);
} else {
// Wenn keine Spaces vorhanden sind, zeige eine Meldung an
alert(spacesT('createSpaceFirst'));
}
}}
/>
{/* Settings Icon (unten rechts) */}
<Ionicons
name="settings-outline"
size={24}
color={isDark ? '#9ca3af' : '#6b7280'}
style={{
position: 'absolute',
bottom: 12,
right: 20,
}}
onPress={() => router.push('/settings')}
/>
</View>
</View>
</Screen>
{/* Space Creator Modal */}
<SpaceCreator
visible={showSpaceCreator}
onClose={() => setShowSpaceCreator(false)}
onCreated={(spaceId) => {
// Space wurde erstellt, füge ihn zur Auswahl hinzu
setSelectedSpaceIds([spaceId]);
// Lade die Daten neu
loadData();
}}
/>
</>
);
}

View file

@ -0,0 +1,36 @@
import { Stack } from 'expo-router';
import { View } from 'react-native';
import { LoginForm } from '~/components/auth/LoginForm';
import { Text } from '~/components/ui/Text';
import { Card } from '~/components/ui/Card';
export default function LoginScreen() {
return (
<>
<Stack.Screen
options={{
title: 'Anmelden',
headerShown: true,
headerBackVisible: false,
}}
/>
<View className="flex-1 bg-gray-50 dark:bg-gray-900 p-4 justify-center">
<View className="w-full max-w-md mx-auto">
<Card className="p-6">
<View className="items-center mb-6">
<Text variant="h1" className="text-center mb-2">
BaseText
</Text>
<Text variant="body" className="text-center text-gray-600 dark:text-gray-400">
Melde dich an, um deine Texte zu verwalten
</Text>
</View>
<LoginForm />
</Card>
</View>
</View>
</>
);
}

View file

@ -0,0 +1,35 @@
import { Stack } from 'expo-router';
import { View } from 'react-native';
import { RegisterForm } from '~/components/auth/RegisterForm';
import { Text } from '~/components/ui/Text';
import { Card } from '~/components/ui/Card';
export default function RegisterScreen() {
return (
<>
<Stack.Screen
options={{
title: 'Registrieren',
headerShown: true,
}}
/>
<View className="flex-1 bg-gray-50 dark:bg-gray-900 p-4 justify-center">
<View className="w-full max-w-md mx-auto">
<Card className="p-6">
<View className="items-center mb-6">
<Text variant="h1" className="text-center mb-2">
BaseText
</Text>
<Text variant="body" className="text-center text-gray-600 dark:text-gray-400">
Erstelle ein Konto, um mit BaseText zu starten
</Text>
</View>
<RegisterForm />
</Card>
</View>
</View>
</>
);
}

View file

@ -0,0 +1,286 @@
import React from 'react';
import { View, Text, ScrollView, StyleSheet, TouchableOpacity, Alert, Switch } from 'react-native';
import { Stack, useRouter } from 'expo-router';
import { ThemeSelector } from '~/components/theme';
import { useTheme } from '~/utils/theme/theme';
import { useAuth } from '~/context/AuthContext';
import { useDebug } from '~/context/DebugContext';
import { useTranslations } from '~/context/I18nContext';
import { LanguagePicker } from '~/components/settings/LanguagePicker';
import { Ionicons } from '@expo/vector-icons';
/**
* Einstellungsseite
* Ermöglicht die Konfiguration der App-Einstellungen, wie z.B. das Theme
*/
export default function SettingsScreen() {
const { isDark } = useTheme();
const { signOut, user } = useAuth();
const { showDebugBorders, toggleDebugBorders } = useDebug();
const { t, settings, auth, common } = useTranslations();
const router = useRouter();
const handleSignOut = async () => {
Alert.alert(auth('signOut'), 'Are you sure you want to sign out?', [
{
text: common('cancel'),
style: 'cancel',
},
{
text: auth('signOut'),
onPress: async () => {
await signOut();
router.replace('/login');
},
style: 'destructive',
},
]);
};
return (
<>
<Stack.Screen
options={{
title: settings('settings'),
headerStyle: {
backgroundColor: isDark ? '#1f2937' : '#ffffff',
},
headerTintColor: isDark ? '#f9fafb' : '#1f2937',
}}
/>
<ScrollView
style={[styles.container, { backgroundColor: isDark ? '#111827' : '#f9fafb' }]}
contentContainerStyle={styles.contentContainer}
>
{/* Benutzerinfo */}
<View style={styles.section}>
<Text style={[styles.sectionTitle, { color: isDark ? '#f9fafb' : '#1f2937' }]}>
{settings('account')}
</Text>
<View
style={[
styles.card,
styles.userCard,
{ backgroundColor: isDark ? '#1f2937' : '#ffffff' },
]}
>
<View style={styles.userInfo}>
<View
style={[styles.userAvatar, { backgroundColor: isDark ? '#4b5563' : '#e5e7eb' }]}
>
<Ionicons name="person" size={24} color={isDark ? '#d1d5db' : '#6b7280'} />
</View>
<View style={styles.userDetails}>
<Text style={[styles.userName, { color: isDark ? '#f9fafb' : '#1f2937' }]}>
{user?.email || 'User'}
</Text>
<Text style={[styles.userEmail, { color: isDark ? '#9ca3af' : '#6b7280' }]}>
{user?.email || auth('noEmailAddress')}
</Text>
</View>
</View>
</View>
{/* Token-Management */}
<TouchableOpacity
style={[
styles.card,
styles.tokenButton,
{ backgroundColor: isDark ? '#1f2937' : '#ffffff', marginTop: 12 },
]}
onPress={() => router.push('/tokens')}
>
<View style={styles.tokenContent}>
<Ionicons name="wallet-outline" size={24} color={isDark ? '#818cf8' : '#4f46e5'} />
<Text style={[styles.tokenText, { color: isDark ? '#f9fafb' : '#1f2937' }]}>
{settings('tokenManagement')}
</Text>
<Ionicons
name="chevron-forward"
size={20}
color={isDark ? '#9ca3af' : '#6b7280'}
style={styles.arrowIcon}
/>
</View>
</TouchableOpacity>
</View>
{/* Erscheinungsbild */}
<View style={styles.section}>
<Text style={[styles.sectionTitle, { color: isDark ? '#f9fafb' : '#1f2937' }]}>
{settings('appearance')}
</Text>
<View style={styles.card}>
<ThemeSelector />
</View>
<View style={[styles.card, { marginTop: 12 }]}>
<LanguagePicker />
</View>
</View>
{/* Entwickleroptionen */}
<View style={styles.section}>
<Text style={[styles.sectionTitle, { color: isDark ? '#f9fafb' : '#1f2937' }]}>
{settings('developer')}
</Text>
<View
style={[styles.card, { backgroundColor: isDark ? '#1f2937' : '#ffffff', padding: 16 }]}
>
<View style={styles.settingRow}>
<View style={styles.settingLabelContainer}>
<Ionicons
name="grid-outline"
size={20}
color={isDark ? '#9ca3af' : '#6b7280'}
style={styles.settingIcon}
/>
<Text style={[styles.settingLabel, { color: isDark ? '#f9fafb' : '#1f2937' }]}>
Show Debug Borders
</Text>
</View>
<Switch
value={showDebugBorders}
onValueChange={toggleDebugBorders}
trackColor={{ false: '#d1d5db', true: '#818cf8' }}
thumbColor={showDebugBorders ? '#4f46e5' : '#f9fafb'}
/>
</View>
<Text style={[styles.settingDescription, { color: isDark ? '#9ca3af' : '#6b7280' }]}>
Shows colored borders around UI elements for development and debugging
</Text>
</View>
</View>
{/* Abmelden */}
<View style={styles.section}>
<Text style={[styles.sectionTitle, { color: isDark ? '#f9fafb' : '#1f2937' }]}>
{settings('session')}
</Text>
<TouchableOpacity
style={[
styles.card,
styles.logoutButton,
{ backgroundColor: isDark ? '#1f2937' : '#ffffff' },
]}
onPress={handleSignOut}
>
<View style={styles.logoutContent}>
<Ionicons name="log-out-outline" size={24} color="#ef4444" />
<Text style={styles.logoutText}>{auth('signOut')}</Text>
</View>
</TouchableOpacity>
</View>
</ScrollView>
</>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
},
contentContainer: {
padding: 16,
paddingBottom: 32,
},
section: {
marginBottom: 24,
},
sectionTitle: {
fontSize: 18,
fontWeight: 'bold',
marginBottom: 12,
},
card: {
borderRadius: 8,
overflow: 'hidden',
},
settingRow: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
marginBottom: 8,
},
settingLabelContainer: {
flexDirection: 'row',
alignItems: 'center',
flex: 1,
},
settingIcon: {
marginRight: 12,
},
settingLabel: {
fontSize: 16,
fontWeight: '500',
},
settingDescription: {
fontSize: 14,
marginTop: 4,
},
userCard: {
padding: 16,
shadowColor: '#000',
shadowOffset: { width: 0, height: 1 },
shadowOpacity: 0.1,
shadowRadius: 2,
elevation: 2,
},
userInfo: {
flexDirection: 'row',
alignItems: 'center',
},
// Token-Button Styles
tokenButton: {
padding: 16,
shadowColor: '#000',
shadowOffset: { width: 0, height: 1 },
shadowOpacity: 0.1,
shadowRadius: 2,
elevation: 2,
},
tokenContent: {
flexDirection: 'row',
alignItems: 'center',
},
tokenText: {
fontSize: 16,
fontWeight: '500',
marginLeft: 12,
flex: 1,
},
arrowIcon: {
marginLeft: 'auto',
},
userAvatar: {
width: 50,
height: 50,
borderRadius: 25,
justifyContent: 'center',
alignItems: 'center',
marginRight: 16,
},
userDetails: {
flex: 1,
},
userName: {
fontSize: 16,
fontWeight: 'bold',
marginBottom: 4,
},
userEmail: {
fontSize: 14,
},
logoutButton: {
padding: 16,
},
logoutContent: {
flexDirection: 'row',
alignItems: 'center',
},
logoutText: {
marginLeft: 12,
fontSize: 16,
fontWeight: '500',
color: '#ef4444',
},
});

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,80 @@
import { StyleSheet } from 'react-native';
import { colors } from '~/utils/theme';
import { useTheme } from '~/utils/theme/theme';
/**
* Styles für die Dokumentenseite
* Verwendet das zentrale Theme-System für konsistente Farben
*/
export const documentStyles = StyleSheet.create({
noFocusRing: {
// On React Native, focus rings are not rendered - no styles needed
},
input: {
borderWidth: 1,
borderColor: colors.gray[100],
},
fullHeightContent: {
flexGrow: 1,
},
});
/**
* NativeWind-Klassen für die Dokumentenseite
* Ermöglicht eine einfache Verwendung des Theme-Systems mit NativeWind
*/
export const getDocumentClasses = (themeName = 'blue') => {
return {
// Container und Layout
container: 'flex-1 flex-col',
contentContainer: 'flex-1 px-4',
// Hintergrundfarben
background: 'bg-white dark:bg-gray-900',
backgroundSecondary: 'bg-gray-50 dark:bg-gray-800',
// Breadcrumbs-Container
breadcrumbsContainer:
'px-4 py-2 bg-gray-50 dark:bg-gray-900 border-b border-gray-200 dark:border-gray-700 w-full',
// Toolbars und Aktionsleisten
toolbar:
'flex-row justify-between items-center px-4 py-2 bg-gray-50 dark:bg-gray-900 border-b border-gray-200 dark:border-gray-700',
// Editoren und Textfelder
editor: 'p-4 bg-white dark:bg-gray-900 min-h-[200px]',
editorBorder: 'border border-gray-200 dark:border-gray-700 rounded-md',
// Vorschau
preview: 'p-4 bg-white dark:bg-gray-900',
previewBorder: 'border border-gray-200 dark:border-gray-700 rounded-md',
// Buttons und interaktive Elemente
button: `bg-${themeName}-500 hover:bg-${themeName}-600 text-white py-2 px-4 rounded-md`,
buttonSecondary:
'bg-gray-200 hover:bg-gray-300 dark:bg-gray-700 dark:hover:bg-gray-600 text-gray-800 dark:text-gray-200 py-2 px-4 rounded-md',
// Text
text: 'text-gray-900 dark:text-gray-100',
textSecondary: 'text-gray-600 dark:text-gray-400',
// Formular-Elemente
input: `bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-md p-2 text-gray-900 dark:text-gray-100 focus:outline-none focus:ring-2 focus:ring-${themeName}-500 focus:border-${themeName}-500`,
// Zustände
active: `bg-${themeName}-500 text-white`,
inactive: 'bg-gray-200 dark:bg-gray-700 text-gray-800 dark:text-gray-200',
};
};
/**
* Hook zum Abrufen der Dokumenten-Klassen basierend auf dem aktuellen Theme
* @returns Ein Objekt mit vordefinierten Tailwind-Klassen für das aktuelle Theme
*/
export function useDocumentClasses() {
const { themeName } = useTheme();
return getDocumentClasses(themeName);
}
// Für Abwärtskompatibilität
export const documentClasses = getDocumentClasses();

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,104 @@
import { useState } from 'react';
import { View, ActivityIndicator } from 'react-native';
import { Stack, useRouter } from 'expo-router';
import { Screen } from '~/components/layout/Screen';
import { Text } from '~/components/ui/Text';
import { Input } from '~/components/ui/Input';
import { Button } from '~/components/Button';
import { Card } from '~/components/ui/Card';
import { createSpace } from '~/services/supabaseService';
export default function CreateSpaceScreen() {
const router = useRouter();
const [name, setName] = useState('');
const [description, setDescription] = useState('');
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const handleCreateSpace = async () => {
// Validierung
if (!name.trim()) {
setError('Bitte gib einen Namen für den Space ein.');
return;
}
try {
setLoading(true);
setError(null);
const { data, error } = await createSpace(name.trim(), description.trim() || undefined);
if (error) {
setError(`Fehler beim Erstellen des Space: ${error.message}`);
return;
}
if (data) {
// Erfolgreich erstellt, navigiere zurück zur Space-Übersicht
router.replace('/spaces');
}
} catch (err: any) {
setError(`Unerwarteter Fehler: ${err.message}`);
} finally {
setLoading(false);
}
};
return (
<>
<Stack.Screen
options={{
title: 'Neuen Space erstellen',
headerShown: true,
}}
/>
<Screen scrollable padded>
<Card className="p-4">
{error && (
<View className="mb-4 p-3 bg-red-100 dark:bg-red-900 rounded-lg">
<Text className="text-red-800 dark:text-red-200">{error}</Text>
</View>
)}
<Input
label="Name"
placeholder="Name des Space"
value={name}
onChangeText={setName}
className="mb-4"
autoFocus
/>
<Input
label="Beschreibung (optional)"
placeholder="Beschreibung des Space"
value={description}
onChangeText={setDescription}
multiline
numberOfLines={3}
className="mb-6"
/>
<View className="flex-row justify-end space-x-4">
<Button
title="Abbrechen"
onPress={() => router.back()}
className="bg-gray-300 dark:bg-gray-700"
/>
<Button
title={loading ? 'Wird erstellt...' : 'Space erstellen'}
onPress={handleCreateSpace}
disabled={loading || !name.trim()}
className={loading || !name.trim() ? 'opacity-70' : ''}
>
{loading && (
<ActivityIndicator size="small" color="#ffffff" style={{ marginLeft: 8 }} />
)}
</Button>
</View>
</Card>
</Screen>
</>
);
}

View file

@ -0,0 +1,130 @@
import { Stack, useRouter } from 'expo-router';
import { View, ActivityIndicator, RefreshControl } from 'react-native';
import { useState, useEffect, useCallback } from 'react';
import { Screen } from '~/components/layout/Screen';
import { Text } from '~/components/ui/Text';
import { SpaceCard } from '~/components/spaces/SpaceCard';
import { SearchBar } from '~/components/functional/SearchBar';
import { EmptyState } from '~/components/layout/EmptyState';
import { Button } from '~/components/Button';
import { Ionicons } from '@expo/vector-icons';
import { getSpaces, Space } from '~/services/supabaseService';
// Definiere den Typ für einen Space mit zusätzlichen Informationen für die UI
type UISpace = {
id: string;
name: string;
description: string | null;
documentCount: number;
tags: string[];
};
export default function SpacesScreen() {
const router = useRouter();
const [spaces, setSpaces] = useState<UISpace[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [searchQuery, setSearchQuery] = useState('');
const [refreshing, setRefreshing] = useState(false);
// Funktion zum Laden der Spaces
const loadSpaces = useCallback(async () => {
try {
setLoading(true);
setError(null);
const spaces = await getSpaces();
// Transformiere die Daten in das UI-Format
const uiSpaces: UISpace[] = spaces.map((space) => ({
id: space.id,
name: space.name,
description: space.description,
documentCount: 0, // Wird später durch eine separate Abfrage aktualisiert
tags: space.settings?.tags || [],
}));
setSpaces(uiSpaces);
} catch (err: any) {
setError('Fehler beim Laden der Spaces: ' + err.message);
} finally {
setLoading(false);
setRefreshing(false);
}
}, []);
// Lade die Spaces beim ersten Rendern
useEffect(() => {
loadSpaces();
}, [loadSpaces]);
// Funktion zum Aktualisieren der Spaces (Pull-to-Refresh)
const onRefresh = useCallback(() => {
setRefreshing(true);
loadSpaces();
}, [loadSpaces]);
// Funktion zum Filtern der Spaces nach Suchbegriff
const filteredSpaces = spaces.filter(
(space) =>
space.name.toLowerCase().includes(searchQuery.toLowerCase()) ||
(space.description && space.description.toLowerCase().includes(searchQuery.toLowerCase()))
);
const hasSpaces = filteredSpaces.length > 0;
return (
<>
<Stack.Screen options={{ title: 'Spaces', headerShown: true }} />
<Screen
scrollable
padded
refreshControl={
<RefreshControl
refreshing={refreshing}
onRefresh={onRefresh}
colors={['#6366f1']}
tintColor="#6366f1"
/>
}
>
<SearchBar placeholder="Spaces durchsuchen..." onSearch={setSearchQuery} />
{error && (
<View className="mb-4 p-3 bg-red-100 dark:bg-red-900 rounded-lg">
<Text className="text-red-800 dark:text-red-200">{error}</Text>
</View>
)}
<View className="flex-row justify-between items-center mb-4">
<Text variant="h2">Alle Spaces</Text>
<Button title="Neu" onPress={() => router.push('/spaces/create')} />
</View>
{loading ? (
<View className="flex-1 justify-center items-center py-8">
<ActivityIndicator size="large" color="#6366f1" />
</View>
) : hasSpaces ? (
<View>
{filteredSpaces.map((space) => (
<SpaceCard key={space.id} {...space} />
))}
</View>
) : (
<EmptyState
title={searchQuery ? 'Keine Spaces gefunden' : 'Noch keine Spaces vorhanden'}
description={
searchQuery
? 'Es wurden keine Spaces gefunden, die deiner Suche entsprechen.'
: 'Erstelle deinen ersten Space, um deine Dokumente zu organisieren.'
}
icon={<Ionicons name="folder-outline" size={48} color="#6366f1" />}
actionLabel="Space erstellen"
onAction={() => router.push('/spaces/create')}
/>
)}
</Screen>
</>
);
}

View file

@ -0,0 +1,617 @@
import React, { useState, useEffect } from 'react';
import {
View,
ScrollView,
StyleSheet,
TouchableOpacity,
ActivityIndicator,
Modal,
} from 'react-native';
import { Text } from '~/components/ui/Text';
import { useTheme } from '~/utils/theme/theme';
import { supabase } from '~/utils/supabase';
import {
getCurrentTokenBalance,
getTokenTransactions,
getTokenUsageStats,
} from '~/services/tokenTransactionService';
import { Stack } from 'expo-router';
import { Ionicons } from '@expo/vector-icons';
import TokenStore from '~/components/monetization/TokenStore';
export default function TokenManagementScreen() {
const { isDark } = useTheme();
const [tokenBalance, setTokenBalance] = useState<number | null>(null);
const [transactions, setTransactions] = useState<any[]>([]);
const [usageStats, setUsageStats] = useState<any>(null);
const [loading, setLoading] = useState(true);
const [timeframe, setTimeframe] = useState<'day' | 'week' | 'month' | 'year'>('month');
const [storeVisible, setStoreVisible] = useState(false);
useEffect(() => {
const loadData = async () => {
try {
setLoading(true);
// Hole den aktuellen Benutzer
const { data: sessionData } = await supabase.auth.getSession();
const userId = sessionData?.session?.user?.id;
if (!userId) {
throw new Error('Nicht angemeldet');
}
// Hole das Token-Guthaben
const balance = await getCurrentTokenBalance(userId);
setTokenBalance(balance);
// Hole die Token-Transaktionen
const transactionData = await getTokenTransactions(userId, 20);
setTransactions(transactionData);
// Hole die Nutzungsstatistiken
const stats = await getTokenUsageStats(userId, timeframe);
setUsageStats(stats);
} catch (error) {
console.error('Fehler beim Laden der Token-Daten:', error);
} finally {
setLoading(false);
}
};
loadData();
}, [timeframe, storeVisible]); // Aktualisieren, wenn der Store geschlossen wird
const formatDate = (dateString: string) => {
const date = new Date(dateString);
return date.toLocaleDateString('de-DE', {
day: '2-digit',
month: '2-digit',
year: 'numeric',
hour: '2-digit',
minute: '2-digit',
});
};
const getTransactionTypeLabel = (type: string) => {
switch (type) {
case 'usage':
return 'Nutzung';
case 'purchase':
return 'Kauf';
case 'monthly_reset':
return 'Monatliches Kontingent';
default:
return type;
}
};
const getTransactionColor = (type: string, amount: number) => {
if (amount > 0) {
return isDark ? '#10b981' : '#059669'; // Grün für positive Beträge
} else {
return isDark ? '#ef4444' : '#dc2626'; // Rot für negative Beträge
}
};
const handleTimeframeChange = (newTimeframe: 'day' | 'week' | 'month' | 'year') => {
setTimeframe(newTimeframe);
};
return (
<View style={[styles.container, isDark ? styles.containerDark : styles.containerLight]}>
<Stack.Screen
options={{
title: 'Token-Verwaltung',
headerStyle: {
backgroundColor: isDark ? '#1f2937' : '#ffffff',
},
headerTintColor: isDark ? '#f9fafb' : '#1f2937',
}}
/>
<ScrollView style={styles.scrollView} contentContainerStyle={styles.scrollViewContent}>
{loading ? (
<View style={styles.loadingContainer}>
<ActivityIndicator size="large" color={isDark ? '#f9fafb' : '#1f2937'} />
<Text style={[styles.loadingText, { color: isDark ? '#f9fafb' : '#000000' }]}>
Lade Token-Daten...
</Text>
</View>
) : (
<>
{/* Token-Guthaben */}
<View style={[styles.card, isDark ? styles.cardDark : styles.cardLight]}>
<Text style={[styles.cardTitle, { color: isDark ? '#f9fafb' : '#000000' }]}>
Aktuelles Token-Guthaben
</Text>
<Text style={[styles.balanceText, { color: isDark ? '#f9fafb' : '#000000' }]}>
{tokenBalance !== null ? tokenBalance.toLocaleString() : '---'}
</Text>
<TouchableOpacity
style={[styles.button, isDark ? styles.buttonDark : styles.buttonLight]}
onPress={() => setStoreVisible(true)}
>
<Text style={[styles.buttonText, { color: isDark ? '#ffffff' : '#ffffff' }]}>
Tokens kaufen
</Text>
</TouchableOpacity>
</View>
{/* Nutzungsstatistiken */}
<View style={[styles.card, isDark ? styles.cardDark : styles.cardLight]}>
<Text style={[styles.cardTitle, { color: isDark ? '#f9fafb' : '#000000' }]}>
Token-Nutzung
</Text>
<View style={styles.timeframeSelector}>
<TouchableOpacity
style={[
styles.timeframeButton,
timeframe === 'day' && styles.timeframeButtonActive,
isDark ? styles.timeframeButtonDark : styles.timeframeButtonLight,
timeframe === 'day' &&
(isDark
? styles.timeframeButtonActiveDark
: styles.timeframeButtonActiveLight),
]}
onPress={() => handleTimeframeChange('day')}
>
<Text
style={[
styles.timeframeButtonText,
{ color: isDark ? '#f9fafb' : '#000000' },
timeframe === 'day' && styles.timeframeButtonTextActive,
]}
>
Tag
</Text>
</TouchableOpacity>
<TouchableOpacity
style={[
styles.timeframeButton,
timeframe === 'week' && styles.timeframeButtonActive,
isDark ? styles.timeframeButtonDark : styles.timeframeButtonLight,
timeframe === 'week' &&
(isDark
? styles.timeframeButtonActiveDark
: styles.timeframeButtonActiveLight),
]}
onPress={() => handleTimeframeChange('week')}
>
<Text
style={[
styles.timeframeButtonText,
{ color: isDark ? '#f9fafb' : '#000000' },
timeframe === 'week' && styles.timeframeButtonTextActive,
]}
>
Woche
</Text>
</TouchableOpacity>
<TouchableOpacity
style={[
styles.timeframeButton,
timeframe === 'month' && styles.timeframeButtonActive,
isDark ? styles.timeframeButtonDark : styles.timeframeButtonLight,
timeframe === 'month' &&
(isDark
? styles.timeframeButtonActiveDark
: styles.timeframeButtonActiveLight),
]}
onPress={() => handleTimeframeChange('month')}
>
<Text
style={[
styles.timeframeButtonText,
{ color: isDark ? '#f9fafb' : '#000000' },
timeframe === 'month' && styles.timeframeButtonTextActive,
]}
>
Monat
</Text>
</TouchableOpacity>
<TouchableOpacity
style={[
styles.timeframeButton,
timeframe === 'year' && styles.timeframeButtonActive,
isDark ? styles.timeframeButtonDark : styles.timeframeButtonLight,
timeframe === 'year' &&
(isDark
? styles.timeframeButtonActiveDark
: styles.timeframeButtonActiveLight),
]}
onPress={() => handleTimeframeChange('year')}
>
<Text
style={[
styles.timeframeButtonText,
{ color: isDark ? '#f9fafb' : '#000000' },
timeframe === 'year' && styles.timeframeButtonTextActive,
]}
>
Jahr
</Text>
</TouchableOpacity>
</View>
{usageStats && (
<View style={styles.statsContainer}>
<View style={styles.statItem}>
<Text style={[styles.statLabel, { color: isDark ? '#f9fafb' : '#000000' }]}>
Gesamtnutzung:
</Text>
<Text style={[styles.statValue, { color: isDark ? '#f9fafb' : '#000000' }]}>
{usageStats.totalUsed.toLocaleString()} Tokens
</Text>
</View>
<Text style={[styles.sectionTitle, { color: isDark ? '#f9fafb' : '#000000' }]}>
Nach Modell:
</Text>
{Object.entries(usageStats.byModel).length > 0 ? (
Object.entries(usageStats.byModel).map(([model, amount]: [string, any]) => (
<View key={model} style={styles.statItem}>
<Text style={[styles.statLabel, { color: isDark ? '#f9fafb' : '#000000' }]}>
{model}:
</Text>
<Text style={[styles.statValue, { color: isDark ? '#f9fafb' : '#000000' }]}>
{amount.toLocaleString()} Tokens
</Text>
</View>
))
) : (
<Text style={[styles.emptyText, { color: isDark ? '#f9fafb' : '#000000' }]}>
Keine Nutzungsdaten für diesen Zeitraum
</Text>
)}
</View>
)}
</View>
{/* Transaktionshistorie */}
<View style={[styles.card, isDark ? styles.cardDark : styles.cardLight]}>
<Text style={[styles.cardTitle, { color: isDark ? '#f9fafb' : '#000000' }]}>
Transaktionshistorie
</Text>
{transactions.length > 0 ? (
transactions.map((transaction, index) => (
<View
key={transaction.id}
style={[
styles.transactionItem,
index < transactions.length - 1 && styles.transactionItemBorder,
isDark ? styles.transactionItemBorderDark : styles.transactionItemBorderLight,
]}
>
<View style={styles.transactionHeader}>
<Text
style={[styles.transactionType, { color: isDark ? '#f9fafb' : '#000000' }]}
>
{getTransactionTypeLabel(transaction.transaction_type)}
</Text>
<Text
style={[styles.transactionDate, { color: isDark ? '#f9fafb' : '#000000' }]}
>
{formatDate(transaction.created_at)}
</Text>
</View>
<View style={styles.transactionDetails}>
<Text
style={[
styles.transactionAmount,
{
color: getTransactionColor(
transaction.transaction_type,
transaction.amount
),
},
]}
>
{transaction.amount > 0 ? '+' : ''}
{transaction.amount.toLocaleString()} Tokens
</Text>
{transaction.model_used && (
<Text
style={[
styles.transactionModel,
{ color: isDark ? '#f9fafb' : '#000000' },
]}
>
Modell: {transaction.model_used}
</Text>
)}
{transaction.total_tokens && (
<Text
style={[
styles.transactionTokens,
{ color: isDark ? '#f9fafb' : '#000000' },
]}
>
{transaction.prompt_tokens?.toLocaleString() || 0} Input +{' '}
{transaction.completion_tokens?.toLocaleString() || 0} Output ={' '}
{transaction.total_tokens.toLocaleString()} Tokens
</Text>
)}
</View>
</View>
))
) : (
<Text style={[styles.emptyText, isDark ? styles.textDark : styles.textLight]}>
Keine Transaktionen gefunden
</Text>
)}
{transactions.length > 0 && (
<TouchableOpacity
style={[styles.linkButton]}
onPress={() => {
// Hier könnte eine Seite mit allen Transaktionen angezeigt werden
alert('Vollständige Transaktionshistorie wird bald implementiert!');
}}
>
<Text style={[styles.linkButtonText, { color: isDark ? '#818cf8' : '#4f46e5' }]}>
Alle Transaktionen anzeigen
</Text>
<Ionicons
name="chevron-forward"
size={16}
color={isDark ? '#818cf8' : '#4f46e5'}
/>
</TouchableOpacity>
)}
</View>
</>
)}
</ScrollView>
{/* Token Store Modal */}
<Modal
visible={storeVisible}
animationType="slide"
transparent={false}
onRequestClose={() => setStoreVisible(false)}
>
<TokenStore
onClose={() => setStoreVisible(false)}
onPurchaseComplete={() => {
setStoreVisible(false);
// Aktualisiere die Daten nach dem Kauf
const refreshData = async () => {
try {
const { data: sessionData } = await supabase.auth.getSession();
const userId = sessionData?.session?.user?.id;
if (userId) {
const balance = await getCurrentTokenBalance(userId);
setTokenBalance(balance);
const transactionData = await getTokenTransactions(userId, 20);
setTransactions(transactionData);
}
} catch (error) {
console.error('Fehler beim Aktualisieren der Daten:', error);
}
};
refreshData();
}}
/>
</Modal>
</View>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
width: '100%',
},
containerDark: {
backgroundColor: '#111827',
},
containerLight: {
backgroundColor: '#f9fafb',
},
scrollView: {
flex: 1,
},
scrollViewContent: {
padding: 16,
maxWidth: 800,
width: '100%',
alignSelf: 'center',
},
loadingContainer: {
flex: 1,
justifyContent: 'center',
alignItems: 'center',
padding: 20,
},
loadingText: {
marginTop: 12,
fontSize: 16,
},
card: {
borderRadius: 8,
padding: 16,
marginBottom: 16,
shadowColor: '#000',
shadowOffset: { width: 0, height: 2 },
shadowOpacity: 0.1,
shadowRadius: 4,
elevation: 2,
},
cardDark: {
backgroundColor: '#1f2937',
},
cardLight: {
backgroundColor: '#ffffff',
borderWidth: 1,
borderColor: '#e5e7eb',
},
cardTitle: {
fontSize: 18,
fontWeight: '600',
marginBottom: 12,
},
textDark: {
color: '#f9fafb',
},
textLight: {
color: '#000000',
},
balanceText: {
fontSize: 36,
fontWeight: '700',
marginBottom: 16,
},
button: {
borderRadius: 8,
padding: 12,
alignItems: 'center',
},
buttonDark: {
backgroundColor: '#4f46e5',
},
buttonLight: {
backgroundColor: '#4f46e5',
},
buttonText: {
fontSize: 16,
fontWeight: '600',
},
buttonTextDark: {
color: '#ffffff',
},
buttonTextLight: {
color: '#ffffff',
},
timeframeSelector: {
flexDirection: 'row',
marginBottom: 16,
},
timeframeButton: {
flex: 1,
paddingVertical: 8,
paddingHorizontal: 4,
alignItems: 'center',
borderRadius: 4,
marginHorizontal: 2,
},
timeframeButtonDark: {
backgroundColor: '#374151',
},
timeframeButtonLight: {
backgroundColor: '#f3f4f6',
},
timeframeButtonActive: {
borderWidth: 1,
},
timeframeButtonActiveDark: {
borderColor: '#818cf8',
backgroundColor: '#312e81',
},
timeframeButtonActiveLight: {
borderColor: '#4f46e5',
backgroundColor: '#e0e7ff',
},
timeframeButtonText: {
fontSize: 14,
},
timeframeButtonTextActive: {
fontWeight: '600',
},
statsContainer: {
marginTop: 8,
},
statItem: {
flexDirection: 'row',
justifyContent: 'space-between',
marginBottom: 8,
},
statLabel: {
fontSize: 14,
},
statValue: {
fontSize: 14,
fontWeight: '600',
},
sectionTitle: {
fontSize: 16,
fontWeight: '600',
marginTop: 16,
marginBottom: 8,
},
transactionItem: {
paddingVertical: 12,
},
transactionItemBorder: {
borderBottomWidth: 1,
},
transactionItemBorderDark: {
borderBottomColor: 'rgba(255, 255, 255, 0.1)',
},
transactionItemBorderLight: {
borderBottomColor: 'rgba(0, 0, 0, 0.1)',
},
transactionHeader: {
flexDirection: 'row',
justifyContent: 'space-between',
marginBottom: 4,
},
transactionType: {
fontSize: 14,
fontWeight: '600',
},
transactionDate: {
fontSize: 12,
opacity: 0.7,
},
transactionDetails: {
marginTop: 4,
},
transactionAmount: {
fontSize: 16,
fontWeight: '600',
marginBottom: 4,
},
transactionModel: {
fontSize: 12,
opacity: 0.8,
},
transactionTokens: {
fontSize: 12,
opacity: 0.8,
},
emptyText: {
textAlign: 'center',
padding: 16,
opacity: 0.7,
},
linkButton: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
paddingVertical: 8,
marginTop: 8,
},
linkButtonText: {
fontSize: 14,
marginRight: 4,
},
linkButtonTextDark: {
color: '#818cf8',
},
linkButtonTextLight: {
color: '#4f46e5',
},
});