chahinebrini d7b15e231a feat(theme): Dark Mode Wave 2 — blocker, mail, chat, community, notifications, all remaining screens
Wave 2 = ALLE app-files die in Wave 1 noch hardcoded waren. Komplette App-weit
theme-aware-Migration jetzt durch. Legacy `import { colors }` flat export
vollständig eliminiert.

Migrated this wave:

Top-level Screens:
- app/urge.tsx (makeStyles factory mit ~20 colors)
- app/room.tsx + dm.tsx + games.tsx
- app/(app)/chat.tsx + mail.tsx + coach.tsx + notifications.tsx
- app/profile/[userId].tsx + profile/edit.tsx (INPUT_STYLE in body moved)
- app/debug.tsx + auth/callback.tsx

Blocker (7):
- AddDomainSheet, CooldownBanner, DeactivationExplainerSheet, DomainGrid,
  ProtectionCard, ProtectionDetailsSheet, ProtectionLockedCard

Mail (3):
- ConnectMailSheet, EditMailAccountSheet, MailEmptyState

Chat (1):
- ChatBubble, ChatInput

Community/Posts/Notifications:
- PostCard, PostCardSkeleton, ComposeCard, PostCommentsSheet
- NotificationsDropdown
- StreakBadge (Nativewind classes durch inline dynamic styles ersetzt)

Reusable Sheets:
- WheelPickerModal, OptionsBottomSheet, DeviceLimitReachedSheet

Urge subsystem (5):
- InlineRatingDrawer, ShareSuccessDrawer, UrgeStats, SosFeedbackModal,
  Breathing

Profile components:
- DigaMissionBanner

Pattern: useColors() hook in component body, makeStyles(colors) factory wo
StyleSheet.create vorher hardcoded war. 11 base-tokens (bg/surface/
surfaceElevated/border/text/textMuted/brandOrange/brandBlue/success/error/
warning) nutzen colors.light vs colors.dark scheme.

Bewusst NICHT migriert (semantic colors):
- DigaMissionBanner amber (#fffbeb, #854d0e) — DiGA-brand, nicht neutral
- Lyra-thinking #3b82f6 in urge.tsx — Lyra-brand-color
- scrollDownBtn #374151 — intentional dark floating-button

TS clean. Test: Settings → Theme → Dark — alle screens sollen jetzt dunkel
werden ohne white-flashes.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 14:51:02 +02:00

370 lines
11 KiB
TypeScript

import { useCallback, useEffect, useMemo, useState } from 'react';
import { View, Text, ActivityIndicator } from 'react-native';
import { Ionicons } from '@expo/vector-icons';
import { useTranslation } from 'react-i18next';
import { apiFetch } from '../../lib/api';
import { useColors } from '../../lib/theme';
type Emotion = 'stress' | 'sadness' | 'anger' | 'empty' | 'boredom' | 'other';
type UrgeLog = {
id: string;
timestamp: string;
emotion: Emotion;
wasOvercome: boolean;
breathingDone: boolean;
};
const WEEKDAY_LABELS = ['Mo', 'Di', 'Mi', 'Do', 'Fr', 'Sa', 'So'];
function emotionLabel(key: string, t: (k: string) => string): string {
const map: Record<string, string> = {
stress: t('urge.emotion_stress'),
sadness: t('urge.emotion_sadness'),
anger: t('urge.emotion_anger'),
empty: t('urge.emotion_empty'),
boredom: t('urge.emotion_boredom'),
other: t('urge.emotion_other'),
};
return map[key] ?? key;
}
function StatCard({ label, value, color }: { label: string; value: string; color: string }) {
const colors = useColors();
return (
<View
style={{
flex: 1,
marginHorizontal: 4,
borderRadius: 12,
borderWidth: 1,
borderColor: colors.border,
backgroundColor: colors.surface,
paddingVertical: 10,
alignItems: 'center',
}}
>
<Text style={{ fontFamily: 'Nunito_800ExtraBold', fontSize: 22, color }}>{value}</Text>
<Text
style={{
marginTop: 2,
textAlign: 'center',
fontFamily: 'Nunito_400Regular',
fontSize: 11,
color: colors.textMuted,
}}
>
{label}
</Text>
</View>
);
}
export function UrgeStats() {
const { t } = useTranslation();
const colors = useColors();
const [logs, setLogs] = useState<UrgeLog[]>([]);
const [loading, setLoading] = useState(true);
const load = useCallback(async () => {
try {
setLoading(true);
const data = await apiFetch<UrgeLog[]>('/api/urge?limit=100');
setLogs(Array.isArray(data) ? data : []);
} catch {
setLogs([]);
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
load();
}, [load]);
const weeklyStats = useMemo(() => {
const weekAgo = Date.now() - 7 * 24 * 60 * 60 * 1000;
const thisWeek = logs.filter((log) => new Date(log.timestamp).getTime() > weekAgo);
return {
total: thisWeek.length,
overcome: thisWeek.filter((log) => log.wasOvercome).length,
breathingDone: thisWeek.filter((log) => log.breathingDone).length,
};
}, [logs]);
const patterns = useMemo(() => {
if (logs.length < 5) return null;
const weekday = new Array(7).fill(0) as number[];
const timeBlockCounts = [0, 0, 0, 0] as number[];
const emotionCount: Record<string, number> = {};
for (const log of logs) {
const d = new Date(log.timestamp);
const jsDay = d.getDay();
const mondayIndex = jsDay === 0 ? 6 : jsDay - 1;
weekday[mondayIndex]!++;
const hour = d.getHours();
if (hour >= 6 && hour < 12) timeBlockCounts[0]!++;
else if (hour >= 12 && hour < 18) timeBlockCounts[1]!++;
else if (hour >= 18 && hour < 23) timeBlockCounts[2]!++;
else timeBlockCounts[3]!++;
emotionCount[log.emotion] = (emotionCount[log.emotion] ?? 0) + 1;
}
const maxWeekday = Math.max(...weekday, 1);
const maxTime = Math.max(...timeBlockCounts, 1);
const topEmotions = Object.entries(emotionCount)
.sort((a, b) => b[1] - a[1])
.slice(0, 3);
const peakDayIdx = weekday.indexOf(Math.max(...weekday));
const peakTimeIdx = timeBlockCounts.indexOf(Math.max(...timeBlockCounts));
const peakTimeLabel = ['morgens', 'mittags', 'abends', 'nachts'][peakTimeIdx] ?? '';
const peakDayLabel = peakDayIdx >= 5 ? 'am Wochenende' : `${WEEKDAY_LABELS[peakDayIdx]}s`;
return {
weekday: weekday.map((countValue, i) => ({
label: WEEKDAY_LABELS[i]!,
count: countValue,
pct: Math.round((countValue / maxWeekday) * 100),
})),
timeBlocks: [
{
emoji: '🌅',
label: t('urge.block_morning'),
count: timeBlockCounts[0]!,
pct: Math.round((timeBlockCounts[0]! / maxTime) * 100),
},
{
emoji: '☀️',
label: t('urge.block_noon'),
count: timeBlockCounts[1]!,
pct: Math.round((timeBlockCounts[1]! / maxTime) * 100),
},
{
emoji: '🌆',
label: t('urge.block_evening'),
count: timeBlockCounts[2]!,
pct: Math.round((timeBlockCounts[2]! / maxTime) * 100),
},
{
emoji: '🌙',
label: t('urge.block_night'),
count: timeBlockCounts[3]!,
pct: Math.round((timeBlockCounts[3]! / maxTime) * 100),
},
],
topEmotions,
insight: `${t('urge.pattern_insight_prefix')} ${peakDayLabel} ${peakTimeLabel}.`,
};
}, [logs, t]);
if (loading) {
return (
<View style={{ paddingVertical: 24, alignItems: 'center' }}>
<ActivityIndicator color={colors.brandOrange} />
</View>
);
}
return (
<View style={{ gap: 12 }}>
{/* Weekly counters */}
<View
style={{
borderRadius: 18,
borderWidth: 1,
borderColor: colors.border,
backgroundColor: colors.bg,
padding: 14,
}}
>
<Text
style={{ fontFamily: 'Nunito_700Bold', fontSize: 15, color: colors.text, marginBottom: 10 }}
>
{t('urge.this_week')}
</Text>
<View style={{ flexDirection: 'row', justifyContent: 'space-between' }}>
<StatCard label={t('urge.total_urges')} value={String(weeklyStats.total)} color={colors.text} />
<StatCard
label={t('urge.overcome_count')}
value={String(weeklyStats.overcome)}
color="#16a34a"
/>
<StatCard
label={t('urge.breathing_exercises')}
value={String(weeklyStats.breathingDone)}
color={colors.brandOrange}
/>
</View>
</View>
{patterns && (
<>
{/* Insight */}
<View
style={{
borderRadius: 18,
borderWidth: 1,
borderColor: colors.border,
backgroundColor: colors.bg,
padding: 14,
flexDirection: 'row',
alignItems: 'center',
}}
>
<Ionicons name="bulb-outline" size={16} color={colors.brandOrange} />
<Text
style={{
marginLeft: 8,
color: colors.textMuted,
fontFamily: 'Nunito_600SemiBold',
flex: 1,
}}
>
{patterns.insight}
</Text>
</View>
{/* Weekday chart */}
<View
style={{
borderRadius: 18,
borderWidth: 1,
borderColor: colors.border,
backgroundColor: colors.bg,
padding: 14,
}}
>
<Text style={{ fontFamily: 'Nunito_700Bold', fontSize: 15, color: colors.text }}>
{t('urge.chart_weekday_title')}
</Text>
<View style={{ flexDirection: 'row', alignItems: 'flex-end', height: 70, marginTop: 10 }}>
{patterns.weekday.map((day) => (
<View key={day.label} style={{ flex: 1, alignItems: 'center' }}>
<View
style={{
width: 12,
height: day.pct > 0 ? Math.max(6, day.pct * 0.5) : 4,
borderRadius: 5,
backgroundColor:
day.pct >= 80 ? '#fb7185' : day.pct >= 50 ? '#f59e0b' : '#60a5fa',
marginBottom: 6,
}}
/>
<Text
style={{ fontFamily: 'Nunito_600SemiBold', fontSize: 10, color: colors.textMuted }}
>
{day.label}
</Text>
</View>
))}
</View>
</View>
{/* Time blocks */}
<View
style={{
borderRadius: 18,
borderWidth: 1,
borderColor: colors.border,
backgroundColor: colors.bg,
padding: 14,
}}
>
<Text style={{ fontFamily: 'Nunito_700Bold', fontSize: 15, color: colors.text }}>
{t('urge.chart_time_title')}
</Text>
<View style={{ marginTop: 8, gap: 8 }}>
{patterns.timeBlocks.map((b) => (
<View key={b.label} style={{ flexDirection: 'row', alignItems: 'center' }}>
<Text style={{ width: 26 }}>{b.emoji}</Text>
<Text
style={{
width: 74,
fontSize: 12,
color: colors.textMuted,
fontFamily: 'Nunito_600SemiBold',
}}
>
{b.label}
</Text>
<View
style={{
flex: 1,
height: 7,
borderRadius: 4,
backgroundColor: colors.surfaceElevated,
}}
>
<View
style={{
width: `${b.pct}%`,
height: 7,
borderRadius: 4,
backgroundColor: '#60a5fa',
}}
/>
</View>
<Text
style={{
width: 24,
textAlign: 'right',
fontSize: 12,
color: colors.textMuted,
marginLeft: 6,
}}
>
{b.count}
</Text>
</View>
))}
</View>
</View>
{/* Top emotions */}
<View
style={{
borderRadius: 18,
borderWidth: 1,
borderColor: colors.border,
backgroundColor: colors.bg,
padding: 14,
}}
>
<Text style={{ fontFamily: 'Nunito_700Bold', fontSize: 15, color: colors.text }}>
{t('urge.chart_top_emotions')}
</Text>
<View style={{ flexDirection: 'row', flexWrap: 'wrap', marginTop: 8 }}>
{patterns.topEmotions.map(([emo, c]) => (
<View
key={emo}
style={{
backgroundColor: colors.surfaceElevated,
borderWidth: 1,
borderColor: colors.border,
borderRadius: 999,
paddingHorizontal: 10,
paddingVertical: 6,
marginRight: 8,
marginBottom: 8,
}}
>
<Text
style={{ fontSize: 12, color: colors.textMuted, fontFamily: 'Nunito_600SemiBold' }}
>
{emotionLabel(emo, t)} x{c}
</Text>
</View>
))}
</View>
</View>
</>
)}
</View>
);
}