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>
416 lines
12 KiB
TypeScript
416 lines
12 KiB
TypeScript
import { useState, useCallback } from 'react';
|
|
import {
|
|
View,
|
|
Text,
|
|
FlatList,
|
|
Pressable,
|
|
ActivityIndicator,
|
|
Image,
|
|
RefreshControl,
|
|
StyleSheet,
|
|
} from 'react-native';
|
|
import { useRouter } from 'expo-router';
|
|
import { Ionicons } from '@expo/vector-icons';
|
|
import { useQuery } from '@tanstack/react-query';
|
|
import { useTranslation } from 'react-i18next';
|
|
import { apiFetch } from '../../lib/api';
|
|
import { AppHeader } from '../../components/AppHeader';
|
|
import { RoomCard, type Room } from '../../components/chat/RoomCard';
|
|
import { CreateRoomSheet } from '../../components/chat/CreateRoomSheet';
|
|
import { useColors } from '../../lib/theme';
|
|
|
|
type DmConversation = {
|
|
partnerId: string;
|
|
partnerName: string;
|
|
partnerAvatar: string | null;
|
|
lastMessage: string;
|
|
lastMessageAt: string;
|
|
unreadCount: number;
|
|
isOwn: boolean;
|
|
};
|
|
|
|
function formatTime(ts: string, justNowLabel: string): string {
|
|
const diff = Date.now() - new Date(ts).getTime();
|
|
if (diff < 60_000) return justNowLabel;
|
|
if (diff < 3_600_000) return `${Math.floor(diff / 60_000)}m`;
|
|
if (diff < 86_400_000) return `${Math.floor(diff / 3_600_000)}h`;
|
|
return new Date(ts).toLocaleDateString('de-DE', { day: '2-digit', month: '2-digit' });
|
|
}
|
|
|
|
function DmItem({ conv, onPress }: { conv: DmConversation; onPress: () => void }) {
|
|
const { t } = useTranslation();
|
|
const colors = useColors();
|
|
const styles = makeStyles(colors);
|
|
const hasUnread = conv.unreadCount > 0;
|
|
|
|
return (
|
|
<Pressable onPress={onPress} android_ripple={{ color: '#f5f5f5' }}>
|
|
{({ pressed }) => (
|
|
<View style={[styles.dmRow, { opacity: pressed ? 0.75 : 1 }]}>
|
|
<View style={styles.dmAvatar}>
|
|
{conv.partnerAvatar ? (
|
|
<Image source={{ uri: conv.partnerAvatar }} style={styles.dmAvatarImg} />
|
|
) : (
|
|
<Text style={styles.dmAvatarInitials}>
|
|
{conv.partnerName.slice(0, 2).toUpperCase()}
|
|
</Text>
|
|
)}
|
|
</View>
|
|
<View style={styles.dmInfo}>
|
|
<View style={styles.dmHeaderRow}>
|
|
<Text style={styles.dmName} numberOfLines={1}>
|
|
{conv.partnerName}
|
|
</Text>
|
|
<Text
|
|
style={[styles.dmTime, { color: hasUnread ? '#007AFF' : '#a3a3a3' }]}
|
|
>
|
|
{formatTime(conv.lastMessageAt, t('chat.just_now'))}
|
|
</Text>
|
|
</View>
|
|
<View style={styles.dmBottomRow}>
|
|
<Text
|
|
numberOfLines={1}
|
|
style={[
|
|
styles.dmLast,
|
|
{
|
|
fontFamily: hasUnread ? 'Nunito_600SemiBold' : 'Nunito_400Regular',
|
|
color: hasUnread ? '#171717' : '#a3a3a3',
|
|
},
|
|
]}
|
|
>
|
|
{conv.isOwn ? t('chat.you') : ''}
|
|
{conv.lastMessage}
|
|
</Text>
|
|
{hasUnread && (
|
|
<View style={styles.unreadBadge}>
|
|
<Text style={styles.unreadBadgeText}>{conv.unreadCount}</Text>
|
|
</View>
|
|
)}
|
|
</View>
|
|
</View>
|
|
</View>
|
|
)}
|
|
</Pressable>
|
|
);
|
|
}
|
|
|
|
export default function ChatScreen() {
|
|
const { t } = useTranslation();
|
|
const router = useRouter();
|
|
const colors = useColors();
|
|
const styles = makeStyles(colors);
|
|
const [tab, setTab] = useState<'groups' | 'direct'>('groups');
|
|
const [createOpen, setCreateOpen] = useState(false);
|
|
|
|
const {
|
|
data: rooms = [],
|
|
isLoading: loadingRooms,
|
|
isRefetching: refetchingRooms,
|
|
refetch: refetchRooms,
|
|
} = useQuery<Room[]>({
|
|
queryKey: ['chat-rooms'],
|
|
queryFn: () => apiFetch('/api/chat/rooms'),
|
|
staleTime: 30_000,
|
|
});
|
|
|
|
const {
|
|
data: convs = [],
|
|
isLoading: loadingDms,
|
|
isRefetching: refetchingDms,
|
|
refetch: refetchDms,
|
|
} = useQuery<DmConversation[]>({
|
|
queryKey: ['dm-conversations'],
|
|
queryFn: () => apiFetch('/api/chat/dm-conversations'),
|
|
staleTime: 30_000,
|
|
enabled: tab === 'direct',
|
|
});
|
|
|
|
const unreadDms = convs.reduce((s, c) => s + (c.unreadCount ?? 0), 0);
|
|
|
|
const openRoom = useCallback(
|
|
(roomId: string) => {
|
|
router.push(`/room?roomId=${roomId}`);
|
|
},
|
|
[router],
|
|
);
|
|
|
|
const openDm = useCallback(
|
|
(userId: string) => {
|
|
router.push(`/dm?userId=${userId}`);
|
|
},
|
|
[router],
|
|
);
|
|
|
|
return (
|
|
<View style={styles.container}>
|
|
<AppHeader />
|
|
|
|
{/* Header */}
|
|
<View style={styles.headerSection}>
|
|
<View style={styles.titleRow}>
|
|
<Text style={styles.title}>{t('chat.title')}</Text>
|
|
{tab === 'groups' && (
|
|
<Pressable
|
|
onPress={() => setCreateOpen(true)}
|
|
style={({ pressed }) => [styles.createBtn, { opacity: pressed ? 0.7 : 1 }]}
|
|
>
|
|
<Ionicons name="add" size={20} color="#fff" />
|
|
</Pressable>
|
|
)}
|
|
</View>
|
|
|
|
{/* Tabs */}
|
|
<View style={styles.tabs}>
|
|
<Pressable
|
|
onPress={() => setTab('groups')}
|
|
style={[styles.tab, tab === 'groups' && styles.tabActive]}
|
|
>
|
|
<Ionicons
|
|
name="people"
|
|
size={14}
|
|
color={tab === 'groups' ? '#007AFF' : '#737373'}
|
|
/>
|
|
<Text style={[styles.tabText, tab === 'groups' && styles.tabTextActive]}>
|
|
{t('chat.groups')}
|
|
</Text>
|
|
</Pressable>
|
|
<Pressable
|
|
onPress={() => setTab('direct')}
|
|
style={[styles.tab, tab === 'direct' && styles.tabActive]}
|
|
>
|
|
<Ionicons
|
|
name="chatbubbles"
|
|
size={14}
|
|
color={tab === 'direct' ? '#007AFF' : '#737373'}
|
|
/>
|
|
<Text style={[styles.tabText, tab === 'direct' && styles.tabTextActive]}>
|
|
{t('chat.direct')}
|
|
</Text>
|
|
{unreadDms > 0 && (
|
|
<View style={styles.tabBadge}>
|
|
<Text style={styles.tabBadgeText}>{unreadDms}</Text>
|
|
</View>
|
|
)}
|
|
</Pressable>
|
|
</View>
|
|
</View>
|
|
|
|
{tab === 'groups' ? (
|
|
<FlatList
|
|
data={loadingRooms ? [] : rooms}
|
|
keyExtractor={(item) => item.id}
|
|
refreshControl={
|
|
<RefreshControl
|
|
refreshing={refetchingRooms}
|
|
onRefresh={refetchRooms}
|
|
tintColor="#007AFF"
|
|
/>
|
|
}
|
|
ListEmptyComponent={
|
|
loadingRooms ? (
|
|
<View style={styles.emptyBox}>
|
|
<ActivityIndicator color="#007AFF" />
|
|
</View>
|
|
) : (
|
|
<View style={styles.emptyBox}>
|
|
<Ionicons name="people-outline" size={42} color="#d4d4d4" />
|
|
<Text style={styles.emptyText}>{t('chat.no_rooms')}</Text>
|
|
</View>
|
|
)
|
|
}
|
|
renderItem={({ item }) => <RoomCard room={item} onPress={() => openRoom(item.id)} />}
|
|
contentContainerStyle={{ paddingBottom: 100 }}
|
|
/>
|
|
) : (
|
|
<FlatList
|
|
data={loadingDms ? [] : convs}
|
|
keyExtractor={(item) => item.partnerId}
|
|
refreshControl={
|
|
<RefreshControl
|
|
refreshing={refetchingDms}
|
|
onRefresh={refetchDms}
|
|
tintColor="#007AFF"
|
|
/>
|
|
}
|
|
ListEmptyComponent={
|
|
loadingDms ? (
|
|
<View style={styles.emptyBox}>
|
|
<ActivityIndicator color="#007AFF" />
|
|
</View>
|
|
) : (
|
|
<View style={styles.emptyBox}>
|
|
<Ionicons name="chatbubble-ellipses-outline" size={42} color="#d4d4d4" />
|
|
<Text style={styles.emptyText}>{t('chat.no_chats')}</Text>
|
|
</View>
|
|
)
|
|
}
|
|
renderItem={({ item }) => <DmItem conv={item} onPress={() => openDm(item.partnerId)} />}
|
|
contentContainerStyle={{ paddingBottom: 100 }}
|
|
/>
|
|
)}
|
|
|
|
<CreateRoomSheet
|
|
visible={createOpen}
|
|
onClose={() => setCreateOpen(false)}
|
|
onCreated={(room) => {
|
|
refetchRooms();
|
|
openRoom(room.id);
|
|
}}
|
|
/>
|
|
</View>
|
|
);
|
|
}
|
|
|
|
function makeStyles(colors: ReturnType<typeof useColors>) {
|
|
return StyleSheet.create({
|
|
container: { flex: 1, backgroundColor: colors.bg },
|
|
headerSection: {
|
|
paddingHorizontal: 16,
|
|
paddingTop: 14,
|
|
paddingBottom: 10,
|
|
backgroundColor: colors.bg,
|
|
borderBottomWidth: StyleSheet.hairlineWidth,
|
|
borderBottomColor: colors.border,
|
|
},
|
|
titleRow: {
|
|
flexDirection: 'row',
|
|
alignItems: 'center',
|
|
justifyContent: 'space-between',
|
|
},
|
|
title: {
|
|
fontSize: 22,
|
|
fontFamily: 'Nunito_800ExtraBold',
|
|
color: colors.text,
|
|
},
|
|
createBtn: {
|
|
width: 34,
|
|
height: 34,
|
|
borderRadius: 17,
|
|
backgroundColor: '#007AFF',
|
|
alignItems: 'center',
|
|
justifyContent: 'center',
|
|
},
|
|
tabs: {
|
|
flexDirection: 'row',
|
|
marginTop: 12,
|
|
backgroundColor: colors.surfaceElevated,
|
|
borderRadius: 10,
|
|
padding: 3,
|
|
},
|
|
tab: {
|
|
flex: 1,
|
|
flexDirection: 'row',
|
|
alignItems: 'center',
|
|
justifyContent: 'center',
|
|
paddingVertical: 7,
|
|
borderRadius: 8,
|
|
},
|
|
tabActive: {
|
|
backgroundColor: colors.surface,
|
|
shadowColor: '#000',
|
|
shadowOpacity: 0.05,
|
|
shadowRadius: 2,
|
|
shadowOffset: { width: 0, height: 1 },
|
|
},
|
|
tabText: {
|
|
fontSize: 12,
|
|
fontFamily: 'Nunito_600SemiBold',
|
|
color: colors.textMuted,
|
|
marginLeft: 5,
|
|
},
|
|
tabTextActive: {
|
|
color: '#007AFF',
|
|
fontFamily: 'Nunito_700Bold',
|
|
},
|
|
tabBadge: {
|
|
minWidth: 16,
|
|
height: 16,
|
|
borderRadius: 8,
|
|
backgroundColor: '#007AFF',
|
|
paddingHorizontal: 4,
|
|
alignItems: 'center',
|
|
justifyContent: 'center',
|
|
marginLeft: 5,
|
|
},
|
|
tabBadgeText: {
|
|
fontSize: 9,
|
|
fontFamily: 'Nunito_700Bold',
|
|
color: '#fff',
|
|
},
|
|
emptyBox: {
|
|
alignItems: 'center',
|
|
justifyContent: 'center',
|
|
paddingVertical: 60,
|
|
paddingHorizontal: 32,
|
|
},
|
|
emptyText: {
|
|
fontSize: 13,
|
|
fontFamily: 'Nunito_600SemiBold',
|
|
color: colors.textMuted,
|
|
marginTop: 12,
|
|
},
|
|
dmRow: {
|
|
width: '100%',
|
|
flexDirection: 'row',
|
|
alignItems: 'center',
|
|
paddingHorizontal: 14,
|
|
paddingVertical: 11,
|
|
backgroundColor: colors.bg,
|
|
borderBottomWidth: StyleSheet.hairlineWidth,
|
|
borderBottomColor: colors.border,
|
|
},
|
|
dmAvatar: {
|
|
width: 42,
|
|
height: 42,
|
|
borderRadius: 21,
|
|
backgroundColor: colors.surfaceElevated,
|
|
alignItems: 'center',
|
|
justifyContent: 'center',
|
|
overflow: 'hidden',
|
|
marginRight: 10,
|
|
},
|
|
dmAvatarImg: { width: 42, height: 42 },
|
|
dmAvatarInitials: {
|
|
fontSize: 13,
|
|
fontFamily: 'Nunito_700Bold',
|
|
color: colors.textMuted,
|
|
},
|
|
dmInfo: { flex: 1, minWidth: 0 },
|
|
dmHeaderRow: {
|
|
flexDirection: 'row',
|
|
alignItems: 'center',
|
|
justifyContent: 'space-between',
|
|
},
|
|
dmName: {
|
|
fontSize: 14,
|
|
fontFamily: 'Nunito_700Bold',
|
|
color: colors.text,
|
|
flexShrink: 1,
|
|
marginRight: 6,
|
|
},
|
|
dmTime: { fontSize: 11, fontFamily: 'Nunito_600SemiBold' },
|
|
dmBottomRow: {
|
|
flexDirection: 'row',
|
|
alignItems: 'center',
|
|
justifyContent: 'space-between',
|
|
marginTop: 2,
|
|
},
|
|
dmLast: { fontSize: 12, flex: 1 },
|
|
unreadBadge: {
|
|
minWidth: 20,
|
|
height: 20,
|
|
paddingHorizontal: 6,
|
|
borderRadius: 10,
|
|
backgroundColor: '#007AFF',
|
|
alignItems: 'center',
|
|
justifyContent: 'center',
|
|
marginLeft: 8,
|
|
},
|
|
unreadBadgeText: {
|
|
fontSize: 10,
|
|
fontFamily: 'Nunito_700Bold',
|
|
color: '#fff',
|
|
},
|
|
});
|
|
}
|