chahinebrini 594a43cbf9 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>
2026-05-08 22:15:55 +02:00

150 lines
5.5 KiB
TypeScript

import { useState } from 'react';
import { View, Text, Pressable, Image } from 'react-native';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
import { Ionicons } from '@expo/vector-icons';
import { useRouter, type RelativePathString } from 'expo-router';
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';
type Props = {
notifCount?: number;
showBack?: boolean;
title?: string;
};
export function AppHeader({ notifCount, showBack, title }: Props = {}) {
const insets = useSafeAreaInsets();
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;
const [notifOpen, setNotifOpen] = useState(false);
const [menuOpen, setMenuOpen] = useState(false);
const firstName = (user?.user_metadata?.first_name as string | undefined) ?? '';
const lastName = (user?.user_metadata?.last_name as string | undefined) ?? '';
const email = user?.email ?? '';
const initials = (() => {
if (me?.nickname) return me.nickname.slice(0, 2).toUpperCase();
return ((firstName.charAt(0) + (lastName.charAt(0) || email.charAt(0))).toUpperCase() || '?');
})();
// Avatar: aus DB (`/api/auth/me` → profiles.avatar). Kann Hero-Avatar-ID
// ("spider"/"hulk"/...) ODER Custom-Photo-URL (https://... von Foto-Upload)
// sein. resolveAvatar handlet beide Fälle.
const avatarUrl = me ? resolveAvatar(me.avatar, me.nickname ?? '') : '';
const [avatarLoadFailed, setAvatarLoadFailed] = useState(false);
const showAvatarImage = !!avatarUrl && !avatarLoadFailed && !!me?.avatar;
const headerHeight = insets.top + 56;
return (
<View
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 }}>
{showBack ? (
<Pressable
onPress={() => router.back()}
hitSlop={10}
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={colors.text} />
</Pressable>
) : null}
<Text style={{ fontFamily: 'Nunito_700Bold', fontSize: 18, color: colors.text, letterSpacing: -0.3 }}>
{title ?? t('appHeader.appName')}
</Text>
</View>
<View className="flex-row items-center gap-1">
<Pressable
onPress={() => setNotifOpen(true)}
hitSlop={{ top: 4, bottom: 4, left: 4, right: 4 }}
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={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' }}>
{badge > 9 ? '9+' : String(badge)}
</Text>
</View>
)}
</Pressable>
{/* Avatar = Trigger für Dropdown-Menu (kein separates 3-Punkte-Icon) */}
<Pressable
onPress={() => setMenuOpen(true)}
hitSlop={{ top: 4, bottom: 4, left: 4, right: 4 }}
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
source={{ uri: avatarUrl }}
onError={() => setAvatarLoadFailed(true)}
style={{ width: 36, height: 36, borderRadius: 18 }}
/>
) : (
<Text className="text-white text-xs" style={{ fontFamily: 'Nunito_700Bold' }}>{initials}</Text>
)}
</Pressable>
<HeaderDropdownMenu
visible={menuOpen}
onClose={() => setMenuOpen(false)}
topOffset={headerHeight + 6}
/>
</View>
</View>
<NotificationsDropdown
visible={notifOpen}
onClose={() => setNotifOpen(false)}
topOffset={headerHeight}
/>
</View>
);
}