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>
150 lines
5.5 KiB
TypeScript
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>
|
|
);
|
|
}
|