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

866 lines
27 KiB
TypeScript

import { useState, useEffect, useRef, useCallback } from 'react';
import {
View,
Text,
FlatList,
Pressable,
Image,
Modal,
TextInput,
ActivityIndicator,
KeyboardAvoidingView,
Platform,
Alert,
StyleSheet,
ScrollView,
} from 'react-native';
import { SafeAreaView, useSafeAreaInsets } from 'react-native-safe-area-context';
import { useRouter, useLocalSearchParams } from 'expo-router';
import { Ionicons } from '@expo/vector-icons';
import { useQuery, useQueryClient } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next';
import * as ImagePicker from 'expo-image-picker';
// TODO(sdk54): migrate to new expo-file-system class-based API (File/Directory/Paths) — see Task #14
import * as FileSystem from 'expo-file-system/legacy';
import { apiFetch } from '../lib/api';
import { supabase } from '../lib/supabase';
import { ChatBubble, type ChatMsg } from '../components/chat/ChatBubble';
import { ChatInput, type SendPayload } from '../components/chat/ChatInput';
import { useRoomRealtime } from '../hooks/useChatRealtime';
import { useColors } from '../lib/theme';
const GROUP_GAP_MS = 5 * 60 * 1000;
type RoomDetail = {
room: {
id: string;
name: string;
description: string | null;
isPublic: boolean;
isDefault: boolean;
joinMode: 'approval' | 'invite_only' | 'open';
avatarUrl: string | null;
inviteCode: string | null;
memberCount: number;
createdBy: string;
myRole: 'owner' | 'admin' | 'member' | null;
isMember: boolean;
};
members: Array<{ userId: string; role: string; nickname: string; avatar: string | null }>;
messages: Array<any>;
hasMore: boolean;
};
function decodeBase64(b64: string): Uint8Array {
const binary = (globalThis as any).atob
? (globalThis as any).atob(b64)
: Buffer.from(b64, 'base64').toString('binary');
const bytes = new Uint8Array(binary.length);
for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i);
return bytes;
}
export default function RoomScreen() {
const { t } = useTranslation();
const router = useRouter();
const insets = useSafeAreaInsets();
const colors = useColors();
const styles = makeStyles(colors);
const queryClient = useQueryClient();
const flatRef = useRef<FlatList>(null);
const [myUserId, setMyUserId] = useState<string | undefined>();
const { roomId } = useLocalSearchParams<{ roomId: string }>();
const [messages, setMessages] = useState<ChatMsg[]>([]);
const [replyTo, setReplyTo] = useState<{ id: string; nickname: string; content: string } | null>(
null,
);
const [sending, setSending] = useState(false);
const [settingsOpen, setSettingsOpen] = useState(false);
const [joining, setJoining] = useState(false);
const [joinStatus, setJoinStatus] = useState<'joined' | 'pending' | null>(null);
useEffect(() => {
supabase.auth.getSession().then(({ data }) => setMyUserId(data.session?.user.id));
}, []);
const { data, isLoading, refetch } = useQuery<RoomDetail>({
queryKey: ['chat-room', roomId],
queryFn: async () => {
const d = await apiFetch<RoomDetail>(`/api/chat/rooms/${roomId}`);
const msgs: ChatMsg[] = d.messages.map((m: any) => ({
id: m.id,
userId: m.userId,
nickname: m.nickname,
avatar: m.avatar,
content: m.content,
replyTo: m.replyTo
? {
id: m.replyTo.id,
userId: m.replyTo.userId,
nickname: m.replyTo.nickname,
content: m.replyTo.content,
attachmentType: m.replyTo.attachmentType ?? null,
}
: null,
attachmentUrl: m.attachmentUrl,
attachmentType: m.attachmentType,
attachmentName: m.attachmentName,
likesCount: m.likesCount ?? 0,
likedByMe: false,
createdAt: m.createdAt,
isOwn: m.isOwn,
}));
setMessages(msgs);
return d;
},
enabled: !!roomId,
});
const room = data?.room;
const members = data?.members ?? [];
const isAdmin = room?.myRole === 'owner' || room?.myRole === 'admin';
// Realtime: neue Messages anderer User
const onRoomInsert = useCallback(
(row: any) => {
const sender = members.find((m) => m.userId === row.user_id);
setMessages((prev) => {
if (prev.some((m) => m.id === row.id)) return prev;
return [
...prev,
{
id: row.id,
userId: row.user_id,
nickname: sender?.nickname ?? 'Anonym',
avatar: sender?.avatar ?? null,
content: row.content ?? '',
replyTo: null,
attachmentUrl: row.attachment_url ?? null,
attachmentType: row.attachment_type ?? null,
attachmentName: row.attachment_name ?? null,
likesCount: 0,
likedByMe: false,
createdAt: row.created_at,
isOwn: false,
},
];
});
},
[members],
);
useRoomRealtime(roomId, myUserId, onRoomInsert, !!myUserId && !!room?.isMember);
useEffect(() => {
if (messages.length > 0) {
requestAnimationFrame(() => flatRef.current?.scrollToEnd({ animated: true }));
}
}, [messages.length]);
async function handleSend(payload: SendPayload) {
if (sending || !room?.isMember) return;
setSending(true);
try {
const newMsg = await apiFetch<any>(`/api/chat/rooms/${roomId}/messages`, {
method: 'POST',
body: payload,
});
setMessages((prev) => [
...prev,
{
id: newMsg.id,
userId: myUserId ?? '',
nickname: 'Du',
avatar: null,
content: newMsg.content,
replyTo: newMsg.replyTo
? {
id: newMsg.replyTo.id,
userId: newMsg.replyTo.userId,
nickname: newMsg.replyTo.nickname,
content: newMsg.replyTo.content,
attachmentType: newMsg.replyTo.attachmentType ?? null,
}
: null,
attachmentUrl: newMsg.attachmentUrl,
attachmentType: newMsg.attachmentType,
attachmentName: newMsg.attachmentName,
likesCount: 0,
likedByMe: false,
createdAt: newMsg.createdAt,
isOwn: true,
},
]);
setReplyTo(null);
} catch (err) {
console.error('Room send failed:', err);
} finally {
setSending(false);
}
}
async function toggleLike(msg: ChatMsg) {
try {
const { liked } = await apiFetch<{ liked: boolean }>('/api/chat/like', {
method: 'POST',
body: { messageId: msg.id, type: 'chat' },
});
setMessages((prev) =>
prev.map((m) =>
m.id === msg.id
? { ...m, likedByMe: liked, likesCount: m.likesCount + (liked ? 1 : -1) }
: m,
),
);
} catch {}
}
function startReply(msg: ChatMsg) {
setReplyTo({
id: msg.id,
nickname: msg.nickname ?? '?',
content: msg.content?.slice(0, 100) || (msg.attachmentType === 'image' ? 'Bild' : ''),
});
}
function sameAuthor(a: ChatMsg | undefined, b: ChatMsg | undefined): boolean {
if (!a || !b) return false;
if (a.userId !== b.userId) return false;
return Math.abs(new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()) <= GROUP_GAP_MS;
}
async function handleJoin() {
if (joining) return;
setJoining(true);
try {
const res = await apiFetch<{ status: 'joined' | 'pending' | 'already_member' }>(
`/api/chat/rooms/${roomId}/join`,
{ method: 'POST' },
);
if (res.status === 'pending') {
setJoinStatus('pending');
} else {
setJoinStatus('joined');
await refetch();
queryClient.invalidateQueries({ queryKey: ['chat-rooms'] });
}
} catch (err: any) {
Alert.alert('Fehler', err?.message ?? 'Beitritt fehlgeschlagen');
} finally {
setJoining(false);
}
}
async function handleAvatarUpload() {
const perm = await ImagePicker.requestMediaLibraryPermissionsAsync();
if (!perm.granted) return;
const result = await ImagePicker.launchImageLibraryAsync({
mediaTypes: ['images'],
quality: 0.8,
allowsEditing: true,
aspect: [1, 1],
});
if (result.canceled || !result.assets[0]) return;
const asset = result.assets[0];
try {
const ext = asset.uri.split('.').pop()?.toLowerCase() || 'jpg';
const path = `room-avatars/${roomId}-${Date.now()}.${ext}`;
const b64 = await FileSystem.readAsStringAsync(asset.uri, {
encoding: FileSystem.EncodingType.Base64,
});
const bytes = decodeBase64(b64);
const { error: upErr } = await supabase.storage
.from('rebreak-public')
.upload(path, bytes, {
contentType: asset.mimeType ?? `image/${ext}`,
upsert: true,
});
if (upErr) throw upErr;
const { data: pub } = supabase.storage.from('rebreak-public').getPublicUrl(path);
await apiFetch(`/api/chat/rooms/${roomId}`, {
method: 'PATCH',
body: { avatarUrl: pub.publicUrl },
});
await refetch();
queryClient.invalidateQueries({ queryKey: ['chat-rooms'] });
} catch (err: any) {
Alert.alert('Fehler', err?.message ?? t('chat.upload_failed'));
}
}
const initials = (room?.name ?? '?')
.split(' ')
.slice(0, 2)
.map((w) => w[0]?.toUpperCase() ?? '')
.join('');
return (
<SafeAreaView style={styles.container} edges={['top']}>
{/* Header */}
<View style={styles.header}>
<Pressable style={styles.iconBtn} onPress={() => router.back()} hitSlop={8}>
<Ionicons name="chevron-back" size={22} color={colors.text} />
</Pressable>
<View style={styles.headerCenter}>
<View style={styles.headerAvatar}>
{room?.avatarUrl ? (
<Image source={{ uri: room.avatarUrl }} style={styles.headerAvatarImg} />
) : (
<Text style={styles.headerAvatarInitials}>{initials}</Text>
)}
</View>
<View style={{ flexShrink: 1 }}>
<Text style={styles.headerName} numberOfLines={1}>
{room?.name ?? '…'}
</Text>
{room && (
<Text style={styles.headerSub} numberOfLines={1}>
{t('chat.member_count', { n: room.memberCount })}
</Text>
)}
</View>
</View>
<Pressable style={styles.iconBtn} onPress={() => setSettingsOpen(true)} hitSlop={8}>
<Ionicons name="ellipsis-horizontal" size={20} color={colors.text} />
</Pressable>
</View>
{isLoading || !room ? (
<View style={styles.loadingBox}>
<ActivityIndicator color={colors.brandOrange} />
</View>
) : !room.isMember ? (
<View style={styles.joinBox}>
<Ionicons name="people" size={48} color="#d4d4d4" />
<Text style={styles.joinTitle}>{room.name}</Text>
{room.description && <Text style={styles.joinDesc}>{room.description}</Text>}
<Text style={styles.joinHint}>{t('chat.join_required')}</Text>
{joinStatus === 'pending' ? (
<View style={styles.pendingBadge}>
<Ionicons name="time-outline" size={14} color="#92400e" />
<Text style={styles.pendingText}>{t('chat.join_pending')}</Text>
</View>
) : (
<Pressable
onPress={handleJoin}
disabled={joining}
style={({ pressed }) => [
styles.joinBtn,
{ opacity: pressed || joining ? 0.7 : 1 },
]}
>
{joining ? (
<ActivityIndicator color="#fff" />
) : (
<Text style={styles.joinBtnText}>{t('chat.join')}</Text>
)}
</Pressable>
)}
</View>
) : (
<KeyboardAvoidingView
style={{ flex: 1 }}
behavior={Platform.OS === 'ios' ? 'padding' : undefined}
>
<FlatList
ref={flatRef}
data={messages}
style={{ flex: 1 }}
renderItem={({ item, index }) => (
<ChatBubble
msg={item}
showName
isFirstInGroup={!sameAuthor(messages[index - 1], item)}
isLastInGroup={!sameAuthor(item, messages[index + 1])}
hideReadStatus
onReply={startReply}
onLike={toggleLike}
onOpenImage={() => {}}
/>
)}
keyExtractor={(m) => m.id}
contentContainerStyle={{ paddingTop: 12, paddingBottom: 8 }}
showsVerticalScrollIndicator={false}
onContentSizeChange={() => flatRef.current?.scrollToEnd({ animated: false })}
/>
<View style={{ paddingBottom: Math.max(insets.bottom - 8, 0) }}>
<ChatInput
replyTo={replyTo}
sending={sending}
onSend={handleSend}
onCancelReply={() => setReplyTo(null)}
/>
</View>
</KeyboardAvoidingView>
)}
{/* Settings Modal */}
<RoomSettingsModal
visible={settingsOpen}
onClose={() => setSettingsOpen(false)}
room={room}
members={members}
isAdmin={isAdmin}
onAvatarChange={handleAvatarUpload}
onRefetch={refetch}
roomId={roomId}
/>
</SafeAreaView>
);
}
// ----- Settings Modal -----
function RoomSettingsModal({
visible,
onClose,
room,
members,
isAdmin,
onAvatarChange,
onRefetch,
roomId,
}: {
visible: boolean;
onClose: () => void;
room: RoomDetail['room'] | undefined;
members: RoomDetail['members'];
isAdmin: boolean;
onAvatarChange: () => void;
onRefetch: () => void;
roomId: string;
}) {
const { t } = useTranslation();
const colors = useColors();
const modal = makeModalStyles(colors);
const [pendingRequests, setPendingRequests] = useState<any[]>([]);
const [loadingReqs, setLoadingReqs] = useState(false);
useEffect(() => {
if (!visible || !isAdmin) return;
setLoadingReqs(true);
apiFetch<{ requests: any[] }>(`/api/chat/rooms/${roomId}`, {
method: 'PATCH',
body: { action: 'list_requests' },
})
.then((d: any) => setPendingRequests(d?.requests ?? d ?? []))
.catch(() => setPendingRequests([]))
.finally(() => setLoadingReqs(false));
}, [visible, isAdmin, roomId]);
async function handleRequest(userId: string, action: 'approve' | 'reject') {
try {
await apiFetch(`/api/chat/rooms/${roomId}`, {
method: 'PATCH',
body: { action, userId },
});
setPendingRequests((prev) => prev.filter((r) => r.userId !== userId));
onRefetch();
} catch (err: any) {
Alert.alert('Fehler', err?.message ?? 'Aktion fehlgeschlagen');
}
}
async function handlePromote(userId: string) {
try {
await apiFetch(`/api/chat/rooms/${roomId}`, {
method: 'PATCH',
body: { action: 'promote_admin', userId },
});
onRefetch();
} catch (err: any) {
Alert.alert('Fehler', err?.message ?? 'Aktion fehlgeschlagen');
}
}
async function handleBan(userId: string) {
Alert.alert('Bannen?', 'User wird aus dem Raum entfernt.', [
{ text: 'Abbrechen', style: 'cancel' },
{
text: 'Bannen',
style: 'destructive',
onPress: async () => {
try {
await apiFetch(`/api/chat/rooms/${roomId}`, {
method: 'PATCH',
body: { action: 'ban', userId },
});
onRefetch();
} catch (err: any) {
Alert.alert('Fehler', err?.message ?? 'Aktion fehlgeschlagen');
}
},
},
]);
}
async function handleLeave() {
Alert.alert(t('chat.leave_room'), '', [
{ text: 'Abbrechen', style: 'cancel' },
{
text: t('chat.leave_room'),
style: 'destructive',
onPress: async () => {
try {
await apiFetch(`/api/chat/rooms/${roomId}/leave`, { method: 'POST' });
onClose();
} catch (err: any) {
Alert.alert('Fehler', err?.message ?? 'Verlassen fehlgeschlagen');
}
},
},
]);
}
if (!room) return null;
return (
<Modal visible={visible} animationType="slide" presentationStyle="pageSheet" onRequestClose={onClose}>
<SafeAreaView style={modal.container} edges={['top']}>
<View style={modal.header}>
<Pressable onPress={onClose} hitSlop={8}>
<Ionicons name="close" size={24} color="#0a0a0a" />
</Pressable>
<Text style={modal.title}>{t('chat.settings')}</Text>
<View style={{ width: 24 }} />
</View>
<ScrollView contentContainerStyle={{ padding: 16, paddingBottom: 60 }}>
{/* Avatar + Name */}
<View style={modal.section}>
<Pressable
onPress={isAdmin ? onAvatarChange : undefined}
style={modal.avatarWrap}
>
{room.avatarUrl ? (
<Image source={{ uri: room.avatarUrl }} style={modal.avatar} />
) : (
<View style={[modal.avatar, modal.avatarPlaceholder]}>
<Ionicons name="people" size={32} color="#737373" />
</View>
)}
{isAdmin && (
<View style={modal.avatarEdit}>
<Ionicons name="camera" size={14} color="#fff" />
</View>
)}
</Pressable>
<Text style={modal.roomName}>{room.name}</Text>
{room.description && <Text style={modal.roomDesc}>{room.description}</Text>}
</View>
{/* Pending Requests */}
{isAdmin && (
<View style={modal.section}>
<Text style={modal.sectionTitle}>{t('chat.pending_request')}</Text>
{loadingReqs ? (
<ActivityIndicator color={colors.brandOrange} style={{ marginVertical: 12 }} />
) : pendingRequests.length === 0 ? (
<Text style={modal.emptyText}></Text>
) : (
pendingRequests.map((req) => (
<View key={req.userId} style={modal.memberRow}>
<View style={{ flex: 1 }}>
<Text style={modal.memberName}>{req.nickname ?? 'Anonym'}</Text>
</View>
<Pressable
style={[modal.actionBtn, { backgroundColor: '#dcfce7' }]}
onPress={() => handleRequest(req.userId, 'approve')}
>
<Text style={[modal.actionText, { color: '#166534' }]}>
{t('chat.approve')}
</Text>
</Pressable>
<Pressable
style={[modal.actionBtn, { backgroundColor: '#fee2e2', marginLeft: 6 }]}
onPress={() => handleRequest(req.userId, 'reject')}
>
<Text style={[modal.actionText, { color: '#991b1b' }]}>
{t('chat.reject')}
</Text>
</Pressable>
</View>
))
)}
</View>
)}
{/* Members */}
<View style={modal.section}>
<Text style={modal.sectionTitle}>
{t('chat.members')} ({members.length})
</Text>
{members.map((m) => (
<View key={m.userId} style={modal.memberRow}>
<View style={modal.memberAvatar}>
{m.avatar ? (
<Image source={{ uri: m.avatar }} style={modal.memberAvatarImg} />
) : (
<Text style={modal.memberInitials}>
{m.nickname.slice(0, 2).toUpperCase()}
</Text>
)}
</View>
<View style={{ flex: 1 }}>
<Text style={modal.memberName}>{m.nickname}</Text>
{m.role !== 'member' && (
<Text style={modal.memberRole}>{m.role}</Text>
)}
</View>
{isAdmin && m.role === 'member' && (
<>
<Pressable
style={[modal.actionBtn, { backgroundColor: '#fef3c7' }]}
onPress={() => handlePromote(m.userId)}
>
<Text style={[modal.actionText, { color: '#92400e' }]}>Admin</Text>
</Pressable>
<Pressable
style={[modal.actionBtn, { backgroundColor: '#fee2e2', marginLeft: 6 }]}
onPress={() => handleBan(m.userId)}
>
<Text style={[modal.actionText, { color: '#991b1b' }]}>Ban</Text>
</Pressable>
</>
)}
</View>
))}
</View>
{/* Leave */}
{!room.isDefault && (
<Pressable style={modal.leaveBtn} onPress={handleLeave}>
<Ionicons name="exit-outline" size={18} color="#991b1b" />
<Text style={modal.leaveText}>{t('chat.leave_room')}</Text>
</Pressable>
)}
</ScrollView>
</SafeAreaView>
</Modal>
);
}
function makeStyles(colors: ReturnType<typeof useColors>) {
return StyleSheet.create({
container: { flex: 1, backgroundColor: colors.bg },
header: {
flexDirection: 'row',
alignItems: 'center',
paddingHorizontal: 12,
paddingVertical: 10,
backgroundColor: colors.bg,
borderBottomWidth: StyleSheet.hairlineWidth,
borderBottomColor: colors.border,
},
iconBtn: {
width: 36,
height: 36,
borderRadius: 12,
backgroundColor: colors.surfaceElevated,
alignItems: 'center',
justifyContent: 'center',
},
headerCenter: {
flex: 1,
flexDirection: 'row',
alignItems: 'center',
marginHorizontal: 8,
},
headerAvatar: {
width: 36,
height: 36,
borderRadius: 18,
backgroundColor: colors.surfaceElevated,
alignItems: 'center',
justifyContent: 'center',
overflow: 'hidden',
marginRight: 8,
},
headerAvatarImg: { width: 36, height: 36 },
headerAvatarInitials: {
fontSize: 12,
fontFamily: 'Nunito_700Bold',
color: colors.textMuted,
},
headerName: {
fontSize: 15,
fontFamily: 'Nunito_700Bold',
color: colors.text,
},
headerSub: {
fontSize: 11,
fontFamily: 'Nunito_500Medium',
color: colors.textMuted,
marginTop: 1,
},
loadingBox: { flex: 1, alignItems: 'center', justifyContent: 'center' },
joinBox: {
flex: 1,
alignItems: 'center',
justifyContent: 'center',
paddingHorizontal: 32,
},
joinTitle: {
fontSize: 20,
fontFamily: 'Nunito_700Bold',
color: colors.text,
marginTop: 14,
},
joinDesc: {
fontSize: 13,
fontFamily: 'Nunito_500Medium',
color: colors.textMuted,
marginTop: 6,
textAlign: 'center',
},
joinHint: {
fontSize: 12,
fontFamily: 'Nunito_500Medium',
color: colors.textMuted,
marginTop: 18,
textAlign: 'center',
},
joinBtn: {
marginTop: 16,
backgroundColor: '#007AFF',
paddingHorizontal: 32,
paddingVertical: 12,
borderRadius: 12,
minWidth: 140,
alignItems: 'center',
},
joinBtnText: {
color: '#fff',
fontSize: 14,
fontFamily: 'Nunito_700Bold',
},
pendingBadge: {
flexDirection: 'row',
alignItems: 'center',
backgroundColor: '#fef3c7',
paddingHorizontal: 14,
paddingVertical: 8,
borderRadius: 20,
marginTop: 16,
},
pendingText: {
color: '#92400e',
fontSize: 12,
fontFamily: 'Nunito_700Bold',
marginLeft: 6,
},
});
}
function makeModalStyles(colors: ReturnType<typeof useColors>) {
return StyleSheet.create({
container: { flex: 1, backgroundColor: colors.bg },
header: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
paddingHorizontal: 16,
paddingVertical: 12,
backgroundColor: colors.bg,
borderBottomWidth: StyleSheet.hairlineWidth,
borderBottomColor: colors.border,
},
title: { fontSize: 16, fontFamily: 'Nunito_700Bold', color: colors.text },
section: {
backgroundColor: colors.surface,
borderRadius: 12,
padding: 14,
marginBottom: 12,
},
sectionTitle: {
fontSize: 12,
fontFamily: 'Nunito_700Bold',
color: colors.textMuted,
textTransform: 'uppercase',
marginBottom: 10,
letterSpacing: 0.5,
},
avatarWrap: { alignSelf: 'center', marginBottom: 10 },
avatar: { width: 80, height: 80, borderRadius: 40 },
avatarPlaceholder: {
backgroundColor: colors.surfaceElevated,
alignItems: 'center',
justifyContent: 'center',
},
avatarEdit: {
position: 'absolute',
right: -2,
bottom: -2,
width: 28,
height: 28,
borderRadius: 14,
backgroundColor: '#007AFF',
borderWidth: 3,
borderColor: colors.bg,
alignItems: 'center',
justifyContent: 'center',
},
roomName: {
fontSize: 17,
fontFamily: 'Nunito_700Bold',
color: colors.text,
textAlign: 'center',
},
roomDesc: {
fontSize: 12,
fontFamily: 'Nunito_500Medium',
color: colors.textMuted,
textAlign: 'center',
marginTop: 4,
},
memberRow: {
flexDirection: 'row',
alignItems: 'center',
paddingVertical: 8,
borderBottomWidth: StyleSheet.hairlineWidth,
borderBottomColor: colors.border,
},
memberAvatar: {
width: 32,
height: 32,
borderRadius: 16,
backgroundColor: colors.surfaceElevated,
alignItems: 'center',
justifyContent: 'center',
overflow: 'hidden',
marginRight: 10,
},
memberAvatarImg: { width: 32, height: 32 },
memberInitials: {
fontSize: 11,
fontFamily: 'Nunito_700Bold',
color: colors.textMuted,
},
memberName: { fontSize: 13, fontFamily: 'Nunito_600SemiBold', color: colors.text },
memberRole: { fontSize: 11, color: colors.textMuted, marginTop: 1, textTransform: 'capitalize' },
actionBtn: {
paddingHorizontal: 10,
paddingVertical: 5,
borderRadius: 6,
},
actionText: { fontSize: 11, fontFamily: 'Nunito_700Bold' },
emptyText: { fontSize: 12, color: colors.textMuted },
leaveBtn: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
backgroundColor: '#fee2e2',
paddingVertical: 12,
borderRadius: 10,
marginTop: 8,
},
leaveText: {
color: '#991b1b',
fontSize: 13,
fontFamily: 'Nunito_700Bold',
marginLeft: 6,
},
});
}