feat(theme): Dark Theme — global color-system + Wave 1 screens

Theme-switch in Settings (System/Light/Dark) jetzt App-weit wirksam für die
Core-Screens. Wave 2 dokumentiert (siehe unten).

Color-System:
- lib/theme.ts: refactored zu colors.light + colors.dark (gleiche keys)
  Light: bg #fff, surface #fafafa, surfaceElevated #f5f5f5, border #e5e5e5,
         text #0a0a0a, textMuted #737373
  Dark:  bg #000, surface #1c1c1e, surfaceElevated #2c2c2e, border #38383a,
         text #fff, textMuted #8e8e93
  brandOrange unverändert #007AFF (iOS system blue)
  success/error variieren (light: #16a34a/#dc2626, dark: #30d158/#ff453a)
- legacy `colors` export bleibt als Light-Fallback für nicht-migrierte Files
- new `useColors()` hook → liest aktiven scheme aus useThemeStore

stores/theme.ts:
- Appearance.addChangeListener für live System-Theme-Updates (User schaltet
  iOS Dark/Light → App reagiert sofort ohne Reload)

Wave 1 — migrated Files (Core Screens):
- app/_layout.tsx + app/(app)/_layout.tsx + app/(app)/index.tsx (root + home)
- app/settings.tsx (full theme-aware inkl. TrueSheet)
- app/profile/index.tsx (bg + dividers)
- app/devices.tsx (bg, surface, border, icons)
- app/lyra.tsx (chat container, backdrop, bubbles, ThinkingDots, LoadingPulse)
- components/AppHeader (Nativewind classes ersetzt durch theme-aware Styles)
- components/header/HeaderDropdownMenu
- components/profile/* (ProfileHeader, StatsBar, StreakSection, UrgeStatsCard,
  ApprovedDomainsList, DemographicsAccordion)

Wave 2 (TODOs für separate Session):
- app/urge.tsx (~20 hardcoded colors, größter Screen)
- app/room.tsx, app/dm.tsx, app/(app)/chat.tsx, app/(app)/mail.tsx, app/(app)/coach.tsx
- app/games.tsx, app/profile/[userId].tsx
- Nativewind classes in PostCard, ComposeCard, PostCardSkeleton, NotificationsDropdown

StatusBar style dynamisch synchronisiert (light bei dark-mode, dark bei light).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
chahinebrini 2026-05-08 22:15:55 +02:00
parent 8f2b93f881
commit 594a43cbf9
16 changed files with 244 additions and 146 deletions

View File

@ -5,7 +5,7 @@ import * as Notifications from 'expo-notifications';
import { useTranslation } from 'react-i18next';
import { useAuthStore } from '../../stores/auth';
import { useNotificationStore } from '../../stores/notifications';
import { colors } from '../../lib/theme';
import { useColors } from '../../lib/theme';
import { NativeTabs } from '../../components/NativeTabs';
import { protection } from '../../lib/protection';
import { preloadTabIcons, getTabIcon } from '../../lib/tabIcons';
@ -14,6 +14,7 @@ export default function AppLayout() {
const router = useRouter();
const { t } = useTranslation();
const { session, loading } = useAuthStore();
const colors = useColors();
const loadNotifications = useNotificationStore((s) => s.load);
const startRealtime = useNotificationStore((s) => s.startRealtime);
const stopRealtime = useNotificationStore((s) => s.stopRealtime);
@ -143,7 +144,7 @@ export default function AppLayout() {
if (loading || !session) {
return (
<View className="flex-1 bg-white items-center justify-center">
<View style={{ flex: 1, backgroundColor: colors.bg, alignItems: 'center', justifyContent: 'center' }}>
<ActivityIndicator color={colors.brandOrange} size="large" />
</View>
);

View File

@ -19,7 +19,7 @@ import { PostCardSkeleton } from '../../components/PostCardSkeleton';
import { PostCommentsSheet } from '../../components/PostCommentsSheet';
import { useCommunityStore, type CommunityCategory, type CommunityPost } from '../../stores/community';
import { useCommunityRealtime } from '../../hooks/useCommunityRealtime';
import { colors } from '../../lib/theme';
import { useColors } from '../../lib/theme';
type FilterChip = {
value: CommunityCategory;
@ -30,6 +30,7 @@ type FilterChip = {
export default function HomeScreen() {
const { t } = useTranslation();
const queryClient = useQueryClient();
const colors = useColors();
// Granular selectors: subscribing to the whole store (incl. optimisticLikes)
// would re-render the screen — and thus the FlatList — on every like.
const activeCategory = useCommunityStore((s) => s.activeCategory);
@ -79,7 +80,7 @@ export default function HomeScreen() {
);
return (
<View className="flex-1 bg-neutral-50">
<View style={{ flex: 1, backgroundColor: colors.bg }}>
<AppHeader />
<FlatList
@ -139,23 +140,30 @@ export default function HomeScreen() {
<Pressable
key={f.value}
onPress={() => toggleFilter(f.value)}
className={`flex-row items-center gap-1.5 h-8 px-3 rounded-full border ${
active
? 'bg-rebreak-500 border-rebreak-500'
: 'bg-white border-neutral-200'
}`}
style={({ pressed }) => ({ opacity: pressed ? 0.7 : 1 })}
style={({ pressed }) => ({
opacity: pressed ? 0.7 : 1,
flexDirection: 'row',
alignItems: 'center',
gap: 6,
height: 32,
paddingHorizontal: 12,
borderRadius: 999,
borderWidth: 1,
backgroundColor: active ? colors.brandOrange : colors.surface,
borderColor: active ? colors.brandOrange : colors.border,
})}
>
<Ionicons
name={f.icon}
size={13}
color={active ? '#fff' : '#737373'}
color={active ? '#fff' : colors.textMuted}
/>
<Text
className={`text-xs ${
active ? 'text-white' : 'text-neutral-500'
}`}
style={{ fontFamily: 'Nunito_600SemiBold' }}
style={{
fontSize: 12,
fontFamily: 'Nunito_600SemiBold',
color: active ? '#fff' : colors.textMuted,
}}
>
{f.label}
</Text>
@ -178,9 +186,9 @@ export default function HomeScreen() {
}
ListEmptyComponent={
isLoading ? null : (
<View className="items-center py-16">
<Ionicons name="chatbubbles-outline" size={40} color="#d4d4d4" />
<Text className="text-sm text-neutral-400 mt-3 text-center" style={{ fontFamily: 'Nunito_400Regular' }}>
<View style={{ alignItems: 'center', paddingVertical: 64 }}>
<Ionicons name="chatbubbles-outline" size={40} color={colors.border} />
<Text style={{ fontSize: 14, color: colors.textMuted, marginTop: 12, textAlign: 'center', fontFamily: 'Nunito_400Regular' }}>
{t('community.no_posts')}
</Text>
</View>

View File

@ -16,6 +16,7 @@ import {
} from '@expo-google-fonts/nunito';
import { useAuthStore } from '../stores/auth';
import { useThemeStore } from '../stores/theme';
import { useColors } from '../lib/theme';
import { useLanguageStore } from '../stores/language';
import { BrandSplash } from '../components/BrandSplash';
import { DeviceLimitReachedSheet } from '../components/DeviceLimitReachedSheet';
@ -45,7 +46,9 @@ const queryClient = new QueryClient({
function RootLayoutInner() {
const { loading, init } = useAuthStore();
const initTheme = useThemeStore((s) => s.init);
const colorScheme = useThemeStore((s) => s.colorScheme);
const initLanguage = useLanguageStore((s) => s.init);
const colors = useColors();
const [fontsLoaded] = useFonts({
Nunito_400Regular,
Nunito_600SemiBold,
@ -71,13 +74,13 @@ function RootLayoutInner() {
return (
<>
<StatusBar style="dark" />
<StatusBar style={colorScheme === 'dark' ? 'light' : 'dark'} />
<DeviceLimitReachedSheet />
<Stack
screenOptions={{
headerShown: false,
animation: 'slide_from_right',
contentStyle: { backgroundColor: '#ffffff' },
contentStyle: { backgroundColor: colors.bg },
}}
>
<Stack.Screen name="index" />

View File

@ -11,7 +11,7 @@ import { useEffect } from 'react';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
import { Ionicons } from '@expo/vector-icons';
import { useTranslation } from 'react-i18next';
import { colors } from '../lib/theme';
import { useColors } from '../lib/theme';
import { useDevicesStore, type UserDevice } from '../stores/devices';
import { AppHeader } from '../components/AppHeader';
@ -55,6 +55,7 @@ function DeviceRow({
onRemove: (id: string) => void;
}) {
const { t } = useTranslation();
const colors = useColors();
function confirmRemove() {
Alert.alert(
@ -86,7 +87,7 @@ function DeviceRow({
width: 40,
height: 40,
borderRadius: 12,
backgroundColor: 'rgba(0,0,0,0.04)',
backgroundColor: colors.surfaceElevated,
alignItems: 'center',
justifyContent: 'center',
}}
@ -199,6 +200,7 @@ function DeviceRow({
export default function DevicesScreen() {
const insets = useSafeAreaInsets();
const { t } = useTranslation();
const colors = useColors();
const { devices, maxDevices, plan, loading, loadDevices, removeDevice } =
useDevicesStore();
@ -210,7 +212,7 @@ export default function DevicesScreen() {
const fillRatio = Math.min(1, devices.length / Math.max(1, maxDevices));
return (
<View style={{ flex: 1, backgroundColor: '#fafafa' }}>
<View style={{ flex: 1, backgroundColor: colors.bg }}>
<AppHeader showBack title={t('settings.devices_page_title')} />
<ScrollView
@ -225,7 +227,7 @@ export default function DevicesScreen() {
{/* Slot counter card */}
<View
style={{
backgroundColor: '#ffffff',
backgroundColor: colors.surface,
borderRadius: 14,
padding: 16,
marginBottom: 16,
@ -316,7 +318,7 @@ export default function DevicesScreen() {
{/* Device list card */}
<View
style={{
backgroundColor: '#ffffff',
backgroundColor: colors.surface,
borderRadius: 14,
overflow: 'hidden',
shadowColor: '#000',
@ -353,7 +355,7 @@ export default function DevicesScreen() {
key={device.id}
style={{
borderBottomWidth: i < devices.length - 1 ? 1 : 0,
borderBottomColor: 'rgba(0,0,0,0.04)',
borderBottomColor: colors.border,
}}
>
<DeviceRow device={device} onRemove={removeDevice} />

View File

@ -31,7 +31,8 @@ import { RiveAvatar, type Emotion } from '../components/RiveAvatar';
import { useCoachStore, type Message } from '../stores/coach';
import { apiFetch } from '../lib/api';
import { supabase } from '../lib/supabase';
import { colors } from '../lib/theme';
import { useColors } from '../lib/theme';
import { useThemeStore } from '../stores/theme';
const EMPATHY_RE = /schwer|rückfall|traurig|schlimm|hoffnungslos|hilf|verloren|scham|schuld|verzweifelt/i;
const HAPPY_RE = /toll|super|geschafft|stark|glückwunsch|stolz|fantastisch|weiter so|prima|gut gemacht/i;
@ -56,6 +57,7 @@ function formatTimestamp(date: Date): string {
// Standard-Spinner — kein zweiter Rive-Avatar (der ist bereits im topBar oben).
function LoadingPulse() {
const colors = useColors();
return (
<View style={{ flex: 1, alignItems: 'center', justifyContent: 'center', gap: 12 }}>
<ActivityIndicator size="large" color={colors.textMuted} />
@ -66,6 +68,7 @@ function LoadingPulse() {
// ── Thinking dots ─────────────────────────────────────────────────────────────
function ThinkingDots() {
const colors = useColors();
const anim = useRef([new Animated.Value(0), new Animated.Value(0), new Animated.Value(0)]).current;
useEffect(() => {
@ -89,7 +92,7 @@ function ThinkingDots() {
key={i}
style={[
styles.thinkingDot,
{ transform: [{ translateY: a.interpolate({ inputRange: [0, 1], outputRange: [0, -5] }) }] },
{ backgroundColor: colors.border, transform: [{ translateY: a.interpolate({ inputRange: [0, 1], outputRange: [0, -5] }) }] },
]}
/>
))}
@ -138,13 +141,14 @@ function MessageRow({
item: MessageWithMeta;
t: (key: string) => string;
}) {
const colors = useColors();
const isUser = item.role === 'user';
return (
<View style={[styles.msgRow, isUser ? styles.msgRowUser : styles.msgRowAssistant]}>
<View style={[styles.bubbleCol, isUser ? styles.bubbleColUser : styles.bubbleColAssistant]}>
<View style={[styles.bubble, isUser ? styles.bubbleUser : styles.bubbleAssistant]}>
<Text style={[styles.bubbleText, isUser ? styles.bubbleTextUser : styles.bubbleTextAssistant]}>
<View style={[styles.bubble, isUser ? styles.bubbleUser : [styles.bubbleAssistant, { backgroundColor: colors.surfaceElevated }]]}>
<Text style={[styles.bubbleText, isUser ? styles.bubbleTextUser : [styles.bubbleTextAssistant, { color: colors.text }]]}>
{item.content}
</Text>
</View>
@ -152,11 +156,11 @@ function MessageRow({
<View style={[styles.metaRow, isUser ? styles.metaRowUser : styles.metaRowAssistant]}>
{item.feedbackSaved && (
<>
<Ionicons name="checkmark-circle" size={11} color="#16a34a" />
<Text style={styles.feedbackText}>{t('coach.feedback_saved')}</Text>
<Ionicons name="checkmark-circle" size={11} color={colors.success} />
<Text style={[styles.feedbackText, { color: colors.success }]}>{t('coach.feedback_saved')}</Text>
</>
)}
<Text style={styles.timestampText}>{formatTimestamp(item.timestamp)}</Text>
<Text style={[styles.timestampText, { color: colors.textMuted }]}>{formatTimestamp(item.timestamp)}</Text>
</View>
</View>
</View>
@ -170,6 +174,8 @@ export default function CoachScreen() {
const router = useRouter();
const insets = useSafeAreaInsets();
const flatRef = useRef<FlatList>(null);
const colors = useColors();
const colorScheme = useThemeStore((s) => s.colorScheme);
// Reaktive Slices — nur was UI-relevant ist (Re-Render bei diesen).
const messages = useCoachStore((s) => s.messages);
@ -539,17 +545,17 @@ export default function CoachScreen() {
);
return (
<SafeAreaView style={styles.container} edges={['top']}>
<SafeAreaView style={[styles.container, { backgroundColor: colors.bg }]} edges={['top']}>
{/* Backdrop hinter topBar — verhindert dass Chat-Texte hinter dem Avatar */}
{/* sichtbar werden ("kollidieren mit Lyra schriftzug"). */}
<View
style={[styles.topBarBackdrop, { height: insets.top + 170 }]}
style={[styles.topBarBackdrop, { height: insets.top + 170, backgroundColor: colors.bg }]}
pointerEvents="none"
/>
{/* Floating header — no bar, avatar + 2 icon buttons hover over chat */}
<View style={[styles.topBar, { top: insets.top + 6 }]}>
<Pressable style={styles.backBtn} onPress={() => router.replace('/(app)' as never)} hitSlop={12}>
<Pressable style={[styles.backBtn, { backgroundColor: colorScheme === 'dark' ? 'rgba(44,44,46,0.92)' : 'rgba(255,255,255,0.92)' }]} onPress={() => router.replace('/(app)' as never)} hitSlop={12}>
<Ionicons name="chevron-back" size={24} color={colors.text} />
</Pressable>
@ -558,12 +564,12 @@ export default function CoachScreen() {
<RiveAvatar emotion={emotion} size="md" />
</View>
<View style={styles.avatarMeta}>
<Text style={styles.avatarName}>{t('coach.lyra')}</Text>
<Text style={[styles.avatarName, { color: colors.text }]}>{t('coach.lyra')}</Text>
{isSpeaking && (
<View style={styles.speakingRow}>
<VoiceBars count={5} baseColor={colors.brandOrange} />
<Text style={[styles.speakingLabel, { color: colors.brandOrange }]}>{t('coach.speaking')}</Text>
<Pressable style={styles.stopBtn} onPress={stopSpeaking} hitSlop={6}>
<Pressable style={[styles.stopBtn, { backgroundColor: colors.surfaceElevated }]} onPress={stopSpeaking} hitSlop={6}>
<Ionicons name="square" size={10} color={colors.brandOrange} />
</Pressable>
</View>
@ -571,7 +577,7 @@ export default function CoachScreen() {
</View>
</View>
<Pressable style={styles.newChatBtn} onPress={handleNewChat} hitSlop={12}>
<Pressable style={[styles.newChatBtn, { backgroundColor: colorScheme === 'dark' ? 'rgba(44,44,46,0.92)' : 'rgba(255,255,255,0.92)' }]} onPress={handleNewChat} hitSlop={12}>
<Ionicons name="refresh-outline" size={22} color={colors.textMuted} />
</Pressable>
</View>
@ -603,7 +609,7 @@ export default function CoachScreen() {
ListFooterComponent={
thinking ? (
<View style={styles.msgRowAssistant}>
<View style={styles.bubbleAssistant}>
<View style={[styles.bubbleAssistant, { backgroundColor: colors.surfaceElevated }]}>
<ThinkingDots />
</View>
</View>
@ -621,7 +627,7 @@ export default function CoachScreen() {
)}
{/* Input bar */}
<View style={[styles.inputBar, { paddingBottom: keyboardHeight > 0 ? 8 : Math.max(12, insets.bottom) }]}>
<View style={[styles.inputBar, { paddingBottom: keyboardHeight > 0 ? 8 : Math.max(12, insets.bottom), backgroundColor: colors.bg, borderTopColor: colors.border }]}>
{isRecording ? (
<View style={styles.recordingContainer}>
<Pressable style={styles.cancelBtn} onPress={cancelRecording}>
@ -636,13 +642,13 @@ export default function CoachScreen() {
) : isTranscribing ? (
<View style={styles.transcribingRow}>
<Ionicons name="sync" size={16} color={colors.textMuted} />
<Text style={styles.transcribingText}>{t('coach.transcribing')}</Text>
<Text style={[styles.transcribingText, { color: colors.textMuted }]}>{t('coach.transcribing')}</Text>
</View>
) : (
<TextInput
style={styles.textInput}
style={[styles.textInput, { backgroundColor: colors.surfaceElevated, color: colors.text }]}
placeholder={t('coach.placeholder')}
placeholderTextColor="#a3a3a3"
placeholderTextColor={colors.textMuted}
value={input}
onChangeText={handleInputChange}
multiline
@ -654,7 +660,7 @@ export default function CoachScreen() {
{!isTranscribing && (
<Pressable
style={[styles.micBtn, isRecording && styles.micBtnActive, thinking && styles.micBtnDisabled]}
style={[styles.micBtn, { backgroundColor: colors.surfaceElevated }, isRecording && styles.micBtnActive, thinking && styles.micBtnDisabled]}
onPressIn={onMicDown}
onPressOut={onMicUp}
disabled={thinking}
@ -741,7 +747,7 @@ const styles = StyleSheet.create({
statusLabel: {
fontSize: 11,
fontFamily: 'Nunito_400Regular',
color: colors.textMuted,
color: '#737373',
},
speakingRow: {
flexDirection: 'row',

View File

@ -13,7 +13,7 @@ import { Ionicons } from '@expo/vector-icons';
import { MenuView, type MenuAction } from '@react-native-menu/menu';
import { TrueSheet } from '@lodev09/react-native-true-sheet';
import { useTranslation } from 'react-i18next';
import { colors } from '../lib/theme';
import { useColors } from '../lib/theme';
import { useAuthStore } from '../stores/auth';
import { useThemeStore, type ThemeMode } from '../stores/theme';
import { useLanguageStore, type AppLanguage } from '../stores/language';
@ -54,6 +54,7 @@ export default function SettingsScreen() {
const { mode: themeMode, setMode: setThemeMode } = useThemeStore();
const { language, setLanguage } = useLanguageStore();
const { plan } = useUserPlan();
const colors = useColors();
// Lyra Voice: hardcoded ElevenLabs voice IDs (expandable by user later)
// Backend endpoint PATCH /api/profile/me/demographics does NOT accept lyraVoiceId.
@ -246,7 +247,7 @@ export default function SettingsScreen() {
}
return (
<View style={{ flex: 1, backgroundColor: '#fafafa' }}>
<View style={{ flex: 1, backgroundColor: colors.bg }}>
<AppHeader showBack title={t('settings.title')} />
<ScrollView
@ -263,7 +264,7 @@ export default function SettingsScreen() {
<Text
style={{
fontSize: 11,
color: '#a3a3a3',
color: colors.textMuted,
fontFamily: 'Nunito_600SemiBold',
textTransform: 'uppercase',
letterSpacing: 1,
@ -275,7 +276,7 @@ export default function SettingsScreen() {
</Text>
<View
style={{
backgroundColor: '#ffffff',
backgroundColor: colors.surface,
borderRadius: 14,
overflow: 'hidden',
shadowColor: '#000',
@ -330,7 +331,7 @@ export default function SettingsScreen() {
paddingVertical: 12,
minHeight: 56,
borderBottomWidth: i < section.rows.length - 1 ? 1 : 0,
borderBottomColor: 'rgba(0,0,0,0.04)',
borderBottomColor: colors.border,
opacity: row.soon ? 0.5 : 1,
};
@ -359,6 +360,7 @@ export default function SettingsScreen() {
paddingHorizontal: 8,
paddingVertical: 6,
borderRadius: 8,
backgroundColor: colors.surfaceElevated,
}}
>
{row.value ? (
@ -401,7 +403,7 @@ export default function SettingsScreen() {
<Text
style={{
fontSize: 10,
color: '#a3a3a3',
color: colors.textMuted,
fontFamily: 'Nunito_600SemiBold',
textTransform: 'uppercase',
letterSpacing: 0.5,
@ -425,7 +427,7 @@ export default function SettingsScreen() {
<Ionicons
name="chevron-forward"
size={16}
color="#d4d4d8"
color={colors.border}
/>
)}
</View>
@ -440,7 +442,7 @@ export default function SettingsScreen() {
style={{
textAlign: 'center',
fontSize: 11,
color: '#a3a3a3',
color: colors.textMuted,
fontFamily: 'Nunito_400Regular',
marginTop: 6,
opacity: 0.7,
@ -452,7 +454,7 @@ export default function SettingsScreen() {
style={{
textAlign: 'center',
fontSize: 10,
color: '#a3a3a3',
color: colors.textMuted,
fontFamily: 'Nunito_400Regular',
marginTop: 4,
opacity: 0.5,
@ -467,8 +469,9 @@ export default function SettingsScreen() {
detents={['auto', 1]}
cornerRadius={20}
grabber
backgroundColor={colors.surface}
>
<View style={{ paddingHorizontal: 20, paddingTop: 8, paddingBottom: 24 }}>
<View style={{ paddingHorizontal: 20, paddingTop: 8, paddingBottom: 24, backgroundColor: colors.surface }}>
<Text
style={{
fontSize: 22,
@ -508,7 +511,7 @@ export default function SettingsScreen() {
justifyContent: 'space-between',
paddingVertical: 16,
borderBottomWidth: idx < voiceOptions.length - 1 ? 1 : 0,
borderBottomColor: 'rgba(0,0,0,0.06)',
borderBottomColor: colors.border,
}}
>
<Text

View File

@ -7,6 +7,7 @@ import { useTranslation } from 'react-i18next';
import { useAuthStore } from '../stores/auth';
import { useNotificationStore } from '../stores/notifications';
import { resolveAvatar } from '../lib/resolveAvatar';
import { useColors } from '../lib/theme';
import { useMe } from '../hooks/useMe';
import { NotificationsDropdown } from './NotificationsDropdown';
import { HeaderDropdownMenu } from './header/HeaderDropdownMenu';
@ -22,6 +23,7 @@ export function AppHeader({ notifCount, showBack, title }: Props = {}) {
const router = useRouter();
const { t } = useTranslation();
const { user } = useAuthStore();
const colors = useColors();
const { me } = useMe();
const storeUnread = useNotificationStore((s) => s.unread);
const badge = notifCount ?? storeUnread;
@ -47,8 +49,12 @@ export function AppHeader({ notifCount, showBack, title }: Props = {}) {
return (
<View
className="bg-white border-b border-neutral-200"
style={{ paddingTop: insets.top }}
style={{
paddingTop: insets.top,
backgroundColor: colors.surface,
borderBottomWidth: 1,
borderBottomColor: colors.border,
}}
>
<View className="h-14 flex-row items-center justify-between px-5">
<View className="flex-row items-center" style={{ gap: 8 }}>
@ -56,14 +62,21 @@ export function AppHeader({ notifCount, showBack, title }: Props = {}) {
<Pressable
onPress={() => router.back()}
hitSlop={10}
className="w-9 h-9 rounded-full items-center justify-center"
style={({ pressed }) => ({ opacity: pressed ? 0.6 : 1, marginLeft: -8 })}
style={({ pressed }) => ({
opacity: pressed ? 0.6 : 1,
marginLeft: -8,
width: 36,
height: 36,
borderRadius: 18,
alignItems: 'center',
justifyContent: 'center',
})}
accessibilityLabel="Zurück"
>
<Ionicons name="chevron-back" size={22} color="#0a0a0a" />
<Ionicons name="chevron-back" size={22} color={colors.text} />
</Pressable>
) : null}
<Text className="text-lg text-midnight-900 tracking-tight" style={{ fontFamily: 'Nunito_700Bold' }}>
<Text style={{ fontFamily: 'Nunito_700Bold', fontSize: 18, color: colors.text, letterSpacing: -0.3 }}>
{title ?? t('appHeader.appName')}
</Text>
</View>
@ -72,10 +85,17 @@ export function AppHeader({ notifCount, showBack, title }: Props = {}) {
<Pressable
onPress={() => setNotifOpen(true)}
hitSlop={{ top: 4, bottom: 4, left: 4, right: 4 }}
className="w-9 h-9 rounded-full bg-white items-center justify-center"
style={({ pressed }) => ({ opacity: pressed ? 0.7 : 1 })}
style={({ pressed }) => ({
opacity: pressed ? 0.7 : 1,
width: 36,
height: 36,
borderRadius: 18,
backgroundColor: colors.surface,
alignItems: 'center',
justifyContent: 'center',
})}
>
<Ionicons name="notifications-outline" size={22} color="#737373" />
<Ionicons name="notifications-outline" size={22} color={colors.textMuted} />
{badge > 0 && (
<View className="absolute top-0 right-0 w-4 h-4 rounded-full bg-rebreak-500 items-center justify-center">
<Text className="text-white text-[9px]" style={{ fontFamily: 'Nunito_700Bold' }}>
@ -89,8 +109,16 @@ export function AppHeader({ notifCount, showBack, title }: Props = {}) {
<Pressable
onPress={() => setMenuOpen(true)}
hitSlop={{ top: 4, bottom: 4, left: 4, right: 4 }}
className={`w-9 h-9 rounded-full items-center justify-center overflow-hidden ${showAvatarImage ? 'bg-neutral-100' : 'bg-rebreak-500'}`}
style={({ pressed }) => ({ opacity: pressed ? 0.7 : 1 })}
style={({ pressed }) => ({
opacity: pressed ? 0.7 : 1,
width: 36,
height: 36,
borderRadius: 18,
alignItems: 'center',
justifyContent: 'center',
overflow: 'hidden',
backgroundColor: showAvatarImage ? colors.surfaceElevated : colors.brandOrange,
})}
>
{showAvatarImage ? (
<Image

View File

@ -3,6 +3,7 @@ import { useRouter, type RelativePathString } from 'expo-router';
import { Ionicons } from '@expo/vector-icons';
import { useTranslation } from 'react-i18next';
import { useAuthStore } from '../../stores/auth';
import { useColors } from '../../lib/theme';
// Controlled-Modal-Pattern. Trigger ist NICHT in dieser Komponente — der
// Avatar im AppHeader öffnet das Modal via `visible`-Prop (User-Anweisung
@ -33,6 +34,7 @@ export function HeaderDropdownMenu({ visible, onClose, topOffset = 80 }: Props)
const router = useRouter();
const { t } = useTranslation();
const { signOut } = useAuthStore();
const colors = useColors();
function nav(path: RelativePathString) {
onClose();
@ -93,7 +95,7 @@ export function HeaderDropdownMenu({ visible, onClose, topOffset = 80 }: Props)
position: 'absolute',
top: topOffset,
right: 12,
backgroundColor: '#ffffff',
backgroundColor: colors.surface,
borderRadius: 18,
shadowColor: '#000',
shadowOffset: { width: 0, height: 8 },
@ -148,7 +150,7 @@ export function HeaderDropdownMenu({ visible, onClose, topOffset = 80 }: Props)
style={{
fontSize: 12,
fontFamily: 'Nunito_400Regular',
color: '#a3a3a3',
color: colors.textMuted,
marginTop: 1,
}}
numberOfLines={1}
@ -156,11 +158,11 @@ export function HeaderDropdownMenu({ visible, onClose, topOffset = 80 }: Props)
{t('appHeader.sosTagline')}
</Text>
</View>
<Ionicons name="chevron-forward" size={16} color="#d4d4d8" />
<Ionicons name="chevron-forward" size={16} color={colors.border} />
</View>
</Pressable>
<View style={{ height: 1, backgroundColor: '#f0f0f0' }} />
<View style={{ height: 1, backgroundColor: colors.border }} />
{/* Profile · Settings · Games · [Debug DEV] */}
{items.map((item) => (
@ -170,7 +172,7 @@ export function HeaderDropdownMenu({ visible, onClose, topOffset = 80 }: Props)
onClose();
void item.onSelect();
}}
android_ripple={{ color: '#e5e7eb' }}
android_ripple={{ color: colors.surfaceElevated }}
style={({ pressed }) => ({ opacity: pressed ? 0.7 : 1 })}
>
<View
@ -184,14 +186,14 @@ export function HeaderDropdownMenu({ visible, onClose, topOffset = 80 }: Props)
<Ionicons
name={item.icon}
size={18}
color="#737373"
color={colors.textMuted}
style={{ marginRight: 14 }}
/>
<Text
style={{
fontSize: 14,
fontFamily: 'Nunito_600SemiBold',
color: '#0a0a0a',
color: colors.text,
}}
>
{item.label}
@ -200,12 +202,12 @@ export function HeaderDropdownMenu({ visible, onClose, topOffset = 80 }: Props)
</Pressable>
))}
<View style={{ height: 1, backgroundColor: '#f0f0f0' }} />
<View style={{ height: 1, backgroundColor: colors.border }} />
{/* Abmelden — neutral, nicht rot */}
<Pressable
onPress={handleLogout}
android_ripple={{ color: '#e5e7eb' }}
android_ripple={{ color: colors.surfaceElevated }}
style={({ pressed }) => ({ opacity: pressed ? 0.7 : 1 })}
>
<View
@ -219,14 +221,14 @@ export function HeaderDropdownMenu({ visible, onClose, topOffset = 80 }: Props)
<Ionicons
name="log-out-outline"
size={18}
color="#737373"
color={colors.textMuted}
style={{ marginRight: 14 }}
/>
<Text
style={{
fontSize: 14,
fontFamily: 'Nunito_600SemiBold',
color: '#0a0a0a',
color: colors.text,
}}
>
{t('headerMenu.logout')}

View File

@ -1,7 +1,7 @@
import { useState } from 'react';
import { View, Text, Pressable, LayoutAnimation, Platform, UIManager } from 'react-native';
import { Ionicons } from '@expo/vector-icons';
import { colors } from '../../lib/theme';
import { useColors } from '../../lib/theme';
if (Platform.OS === 'android' && UIManager.setLayoutAnimationEnabledExperimental) {
UIManager.setLayoutAnimationEnabledExperimental(true);
@ -18,6 +18,7 @@ type Props = {
};
export function ApprovedDomainsList({ domains, loading }: Props) {
const colors = useColors();
const [expanded, setExpanded] = useState(false);
function toggle() {
@ -36,9 +37,9 @@ export function ApprovedDomainsList({ domains, loading }: Props) {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
backgroundColor: '#ffffff',
backgroundColor: colors.surface,
borderWidth: 1,
borderColor: '#e5e5e5',
borderColor: colors.border,
borderRadius: 14,
padding: 16,
}}
@ -63,9 +64,9 @@ export function ApprovedDomainsList({ domains, loading }: Props) {
<View
style={{
marginTop: 6,
backgroundColor: '#ffffff',
backgroundColor: colors.surface,
borderWidth: 1,
borderColor: '#e5e5e5',
borderColor: colors.border,
borderRadius: 12,
paddingVertical: 4,
}}
@ -99,7 +100,7 @@ export function ApprovedDomainsList({ domains, loading }: Props) {
paddingVertical: 10,
paddingHorizontal: 14,
borderTopWidth: idx === 0 ? 0 : 1,
borderTopColor: 'rgba(0,0,0,0.06)',
borderTopColor: colors.border,
}}
>
<Text style={{ fontSize: 13, color: colors.text, fontFamily: 'Nunito_600SemiBold' }}>
@ -120,11 +121,12 @@ export function ApprovedDomainsList({ domains, loading }: Props) {
}
function SkeletonRow() {
const colors = useColors();
return (
<View
style={{
height: 14,
backgroundColor: '#f5f5f5',
backgroundColor: colors.surfaceElevated,
borderRadius: 4,
marginVertical: 6,
}}

View File

@ -12,7 +12,7 @@ import { Ionicons } from '@expo/vector-icons';
import { MenuView } from '@react-native-menu/menu';
import { getCitiesForBundesland } from '../../lib/germanCities';
import { WheelPickerModal } from '../WheelPickerModal';
import { colors } from '../../lib/theme';
import { useColors } from '../../lib/theme';
import type { Plan } from '../../hooks/useUserPlan';
// Geburtsjahr-Optionen: 2010 (oldest 13y) → 1920, descending (neueste oben)
@ -203,6 +203,7 @@ export function DemographicsAccordion({
setLocal(next);
}
const colors = useColors();
const { filled, total } = relevantFieldCount(local);
const completed = filled === total;
const showProTrialBanner = plan === 'free';
@ -220,9 +221,9 @@ export function DemographicsAccordion({
>
<View
style={{
backgroundColor: '#ffffff',
backgroundColor: colors.surface,
borderWidth: 1,
borderColor: '#e5e5e5',
borderColor: colors.border,
borderRadius: 14,
padding: 16,
}}
@ -282,7 +283,7 @@ export function DemographicsAccordion({
style={{
flex: 1,
height: 5,
backgroundColor: '#e5e5e5',
backgroundColor: colors.border,
borderRadius: 999,
overflow: 'hidden',
}}
@ -316,9 +317,9 @@ export function DemographicsAccordion({
<View
style={{
marginTop: 8,
backgroundColor: '#ffffff',
backgroundColor: colors.surface,
borderWidth: 1,
borderColor: '#e5e5e5',
borderColor: colors.border,
borderRadius: 14,
paddingVertical: 4,
}}
@ -516,7 +517,7 @@ export function DemographicsAccordion({
<Switch
value={local.shiftWork === true}
onValueChange={(v) => flushSave({ ...local, shiftWork: v })}
trackColor={{ false: '#e5e5e5', true: colors.brandOrange }}
trackColor={{ false: colors.border, true: colors.brandOrange }}
thumbColor="#ffffff"
/>
</View>
@ -677,19 +678,6 @@ export function DemographicsAccordion({
);
}
const inputStyle = {
fontSize: 14,
color: colors.text,
fontFamily: 'Nunito_600SemiBold',
paddingVertical: 8,
paddingHorizontal: 10,
backgroundColor: '#fafafa',
borderRadius: 8,
borderWidth: 1,
borderColor: '#ececec',
minWidth: 140,
textAlign: 'right' as const,
};
function FieldRow({
label,
@ -708,13 +696,14 @@ function FieldRow({
filled: boolean;
children: React.ReactNode;
}) {
const colors = useColors();
return (
<View
style={{
paddingHorizontal: indent ? 20 : 14,
paddingVertical: 12,
borderBottomWidth: isLast ? 0 : 1,
borderBottomColor: 'rgba(0,0,0,0.06)',
borderBottomColor: colors.border,
}}
>
<View
@ -762,6 +751,7 @@ function FieldRow({
}
function SelectButton({ value, onPress }: { value: string | null; onPress: () => void }) {
const colors = useColors();
return (
<Pressable
onPress={onPress}
@ -773,10 +763,10 @@ function SelectButton({ value, onPress }: { value: string | null; onPress: () =>
style={{
paddingVertical: 6,
paddingHorizontal: 12,
backgroundColor: '#f4f4f5',
backgroundColor: colors.surfaceElevated,
borderRadius: 999,
borderWidth: 1,
borderColor: '#e4e4e7',
borderColor: colors.border,
}}
>
<Text

View File

@ -2,7 +2,7 @@ import { useState } from 'react';
import { View, Text, Pressable, Image } from 'react-native';
import { Ionicons } from '@expo/vector-icons';
import Svg, { Path } from 'react-native-svg';
import { colors } from '../../lib/theme';
import { useColors } from '../../lib/theme';
import { resolveAvatar } from '../../lib/resolveAvatar';
import type { Plan } from '../../hooks/useUserPlan';
@ -67,6 +67,7 @@ export function ProfileHeader({
onEditNickname,
onDemographicsHintPress,
}: Props) {
const colors = useColors();
const [imageFailed, setImageFailed] = useState(false);
const avatarUrl = resolveAvatar(avatar, nickname);
const initials = nickname.slice(0, 2).toUpperCase();
@ -100,7 +101,7 @@ export function ProfileHeader({
alignItems: 'center',
justifyContent: 'center',
overflow: 'hidden',
backgroundColor: showImage ? '#fafafa' : colors.brandOrange,
backgroundColor: showImage ? colors.surfaceElevated : colors.brandOrange,
}}
>
{showImage ? (
@ -126,9 +127,9 @@ export function ProfileHeader({
width: 30,
height: 30,
borderRadius: 15,
backgroundColor: '#0a0a0a',
backgroundColor: colors.text,
borderWidth: 2,
borderColor: '#ffffff',
borderColor: colors.bg,
alignItems: 'center',
justifyContent: 'center',
}}
@ -212,9 +213,9 @@ export function ProfileHeader({
paddingHorizontal: 10,
paddingVertical: 4,
borderRadius: 999,
backgroundColor: '#f5f5f5',
backgroundColor: colors.surfaceElevated,
borderWidth: 1,
borderColor: '#e5e5e5',
borderColor: colors.border,
}}
>
{provider === 'google' ? <GoogleIcon /> : <AppleIcon />}

View File

@ -1,5 +1,5 @@
import { View, Text, Pressable } from 'react-native';
import { colors } from '../../lib/theme';
import { useColors } from '../../lib/theme';
type Props = {
postsCount: number;
@ -17,6 +17,7 @@ type CardProps = {
};
function StatPill({ value, label, onPress }: CardProps) {
const colors = useColors();
return (
<Pressable
onPress={onPress}
@ -24,9 +25,9 @@ function StatPill({ value, label, onPress }: CardProps) {
>
<View
style={{
backgroundColor: '#ffffff',
backgroundColor: colors.surface,
borderWidth: 1,
borderColor: '#e5e5e5',
borderColor: colors.border,
borderRadius: 999,
paddingVertical: 10,
paddingHorizontal: 18,

View File

@ -1,6 +1,6 @@
import { View, Text } from 'react-native';
import { Ionicons } from '@expo/vector-icons';
import { colors } from '../../lib/theme';
import { useColors } from '../../lib/theme';
export type CooldownEntry = {
id: string;
@ -30,6 +30,7 @@ const statusColor: Record<CooldownEntry['status'], { bg: string; text: string }>
};
export function StreakSection({ currentDays, longestDays, startDate, cooldowns }: Props) {
const colors = useColors();
return (
<View style={{ marginHorizontal: 16, marginTop: 24 }}>
<View
@ -55,9 +56,9 @@ export function StreakSection({ currentDays, longestDays, startDate, cooldowns }
<View
style={{
backgroundColor: '#ffffff',
backgroundColor: colors.surface,
borderWidth: 1,
borderColor: '#e5e5e5',
borderColor: colors.border,
borderRadius: 14,
padding: 16,
}}
@ -121,9 +122,9 @@ export function StreakSection({ currentDays, longestDays, startDate, cooldowns }
<View
style={{
backgroundColor: '#ffffff',
backgroundColor: colors.surface,
borderWidth: 1,
borderColor: '#e5e5e5',
borderColor: colors.border,
borderRadius: 14,
padding: 14,
}}
@ -153,7 +154,7 @@ export function StreakSection({ currentDays, longestDays, startDate, cooldowns }
style={{
width: 1,
flex: 1,
backgroundColor: 'rgba(0,0,0,0.06)',
backgroundColor: colors.border,
marginTop: 2,
}}
/>

View File

@ -1,6 +1,6 @@
import { View, Text } from 'react-native';
import { Ionicons } from '@expo/vector-icons';
import { colors } from '../../lib/theme';
import { useColors } from '../../lib/theme';
export type HelpedByEntry = {
key: 'breathing' | 'game' | 'talk' | 'other';
@ -16,6 +16,7 @@ type Props = {
};
export function UrgeStatsCard({ sessions, overcome, helpedBy, topEmotion }: Props) {
const colors = useColors();
const overcomePct = sessions > 0 ? Math.round((overcome / sessions) * 100) : 0;
const totalHelped = helpedBy.reduce((sum, h) => sum + h.count, 0);
@ -44,9 +45,9 @@ export function UrgeStatsCard({ sessions, overcome, helpedBy, topEmotion }: Prop
<View
style={{
backgroundColor: '#ffffff',
backgroundColor: colors.surface,
borderWidth: 1,
borderColor: '#e5e5e5',
borderColor: colors.border,
borderRadius: 14,
padding: 16,
}}
@ -99,7 +100,7 @@ export function UrgeStatsCard({ sessions, overcome, helpedBy, topEmotion }: Prop
style={{
marginTop: 8,
height: 6,
backgroundColor: '#f5f5f5',
backgroundColor: colors.surfaceElevated,
borderRadius: 999,
overflow: 'hidden',
}}
@ -169,7 +170,7 @@ export function UrgeStatsCard({ sessions, overcome, helpedBy, topEmotion }: Prop
<View
style={{
height: 4,
backgroundColor: '#f5f5f5',
backgroundColor: colors.surfaceElevated,
borderRadius: 999,
overflow: 'hidden',
}}

View File

@ -1,3 +1,5 @@
import { useThemeStore } from '../stores/theme';
export const theme = {
bg: 'bg-white',
surface: 'bg-neutral-50',
@ -10,17 +12,55 @@ export const theme = {
brandBlue: 'bg-midnight-800',
} as const;
export const colors = {
export type ColorScheme = {
bg: string;
surface: string;
surfaceElevated: string;
border: string;
text: string;
textMuted: string;
brandOrange: string;
brandBlue: string;
success: string;
error: string;
warning: string;
};
const light: ColorScheme = {
bg: '#ffffff',
surface: '#fafafa',
surfaceElevated: '#f5f5f5',
border: '#e5e5e5',
text: '#0a0a0a',
textMuted: '#737373',
// TEMP zum Testen: iOS native blue. Wieder auf '#f59e0b' wenn du zur Brand zurück willst.
brandOrange: '#007AFF',
brandBlue: '#0e1f3a',
success: '#16a34a',
error: '#dc2626',
warning: '#f59e0b',
} as const;
};
const dark: ColorScheme = {
bg: '#000000',
surface: '#1c1c1e',
surfaceElevated: '#2c2c2e',
border: '#38383a',
text: '#ffffff',
textMuted: '#8e8e93',
brandOrange: '#007AFF',
brandBlue: '#0e1f3a',
success: '#30d158',
error: '#ff453a',
warning: '#ffd60a',
};
export const colorSchemes = { light, dark };
export function useColors(): ColorScheme {
const scheme = useThemeStore((s) => s.colorScheme);
return scheme === 'dark' ? dark : light;
}
// Legacy flat export — used by files that haven't migrated to useColors() yet.
// Wave 2 should remove this.
export const colors = light;

View File

@ -20,19 +20,28 @@ type ThemeState = {
init: () => Promise<void>;
};
export const useThemeStore = create<ThemeState>((set) => ({
mode: 'system',
colorScheme: 'light',
export const useThemeStore = create<ThemeState>((set, get) => {
// Listen for OS-level theme changes and update store when mode === 'system'.
Appearance.addChangeListener(() => {
if (get().mode === 'system') {
set({ colorScheme: resolveColorScheme('system') });
}
});
init: async () => {
const stored = await AsyncStorage.getItem(STORAGE_KEY);
const mode: ThemeMode =
stored === 'light' || stored === 'dark' || stored === 'system' ? stored : 'system';
set({ mode, colorScheme: resolveColorScheme(mode) });
},
return {
mode: 'system',
colorScheme: 'light',
setMode: async (mode) => {
await AsyncStorage.setItem(STORAGE_KEY, mode);
set({ mode, colorScheme: resolveColorScheme(mode) });
},
}));
init: async () => {
const stored = await AsyncStorage.getItem(STORAGE_KEY);
const mode: ThemeMode =
stored === 'light' || stored === 'dark' || stored === 'system' ? stored : 'system';
set({ mode, colorScheme: resolveColorScheme(mode) });
},
setMode: async (mode) => {
await AsyncStorage.setItem(STORAGE_KEY, mode);
set({ mode, colorScheme: resolveColorScheme(mode) });
},
};
});