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>
208 lines
7.8 KiB
TypeScript
208 lines
7.8 KiB
TypeScript
import { useCallback, useState } from 'react';
|
|
import {
|
|
View,
|
|
Text,
|
|
ScrollView,
|
|
FlatList,
|
|
Pressable,
|
|
RefreshControl,
|
|
ActivityIndicator,
|
|
} from 'react-native';
|
|
import { Ionicons } from '@expo/vector-icons';
|
|
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
|
import { useTranslation } from 'react-i18next';
|
|
import { apiFetch } from '../../lib/api';
|
|
import { AppHeader } from '../../components/AppHeader';
|
|
import { ComposeCard } from '../../components/ComposeCard';
|
|
import { PostCard } from '../../components/PostCard';
|
|
import { PostCardSkeleton } from '../../components/PostCardSkeleton';
|
|
import { PostCommentsSheet } from '../../components/PostCommentsSheet';
|
|
import { useCommunityStore, type CommunityCategory, type CommunityPost } from '../../stores/community';
|
|
import { useCommunityRealtime } from '../../hooks/useCommunityRealtime';
|
|
import { useColors } from '../../lib/theme';
|
|
|
|
type FilterChip = {
|
|
value: CommunityCategory;
|
|
label: string;
|
|
icon: React.ComponentProps<typeof Ionicons>['name'];
|
|
};
|
|
|
|
export default function HomeScreen() {
|
|
const { t } = useTranslation();
|
|
const queryClient = useQueryClient();
|
|
const colors = useColors();
|
|
// Granular selectors: subscribing to the whole store (incl. optimisticLikes)
|
|
// would re-render the screen — and thus the FlatList — on every like.
|
|
const activeCategory = useCommunityStore((s) => s.activeCategory);
|
|
const setCategory = useCommunityStore((s) => s.setCategory);
|
|
|
|
const FILTERS: FilterChip[] = [
|
|
{ value: 'all', label: t('community.cat_all'), icon: 'grid-outline' },
|
|
{ value: 'games', label: t('community.cat_games'), icon: 'trophy-outline' },
|
|
{ value: 'domain_vote', label: t('community.cat_domain'), icon: 'shield-outline' },
|
|
{ value: 'lyra', label: t('community.cat_lyra'), icon: 'sparkles-outline' },
|
|
{ value: 'rebreak', label: t('community.cat_rebreak'), icon: 'megaphone-outline' },
|
|
];
|
|
const [filterOpen, setFilterOpen] = useState(false);
|
|
const [activeCommentsPostId, setActiveCommentsPostId] = useState<string | null>(null);
|
|
|
|
const { data: posts = [], isLoading, isRefetching, refetch } = useQuery<CommunityPost[]>({
|
|
queryKey: ['community-posts', activeCategory],
|
|
queryFn: () => apiFetch(`/api/community/posts?category=${activeCategory}&limit=30`),
|
|
staleTime: 60_000,
|
|
});
|
|
|
|
// Realtime: live updates für Posts (likes/comments/neue Posts/domain-vote-Status)
|
|
useCommunityRealtime(true);
|
|
|
|
const toggleFilter = (value: CommunityCategory) => {
|
|
const next = activeCategory === value ? 'all' : value;
|
|
setCategory(next);
|
|
setFilterOpen(false);
|
|
};
|
|
|
|
// Stable callbacks — passed to memoized PostCards. Inline arrows would
|
|
// bust React.memo on every parent render (which also happens on every
|
|
// realtime patch since the posts array gets a new reference).
|
|
const openComments = useCallback((postId: string) => {
|
|
setActiveCommentsPostId(postId);
|
|
}, []);
|
|
|
|
const closeComments = useCallback(() => setActiveCommentsPostId(null), []);
|
|
|
|
const keyExtractor = useCallback((item: CommunityPost) => item.id, []);
|
|
|
|
const renderItem = useCallback(
|
|
({ item }: { item: CommunityPost }) => (
|
|
<PostCard post={item} onCommentPress={openComments} />
|
|
),
|
|
[openComments],
|
|
);
|
|
|
|
return (
|
|
<View style={{ flex: 1, backgroundColor: colors.bg }}>
|
|
<AppHeader />
|
|
|
|
<FlatList
|
|
data={isLoading ? [] : posts}
|
|
keyExtractor={keyExtractor}
|
|
contentContainerStyle={{ paddingHorizontal: 16, paddingTop: 12, paddingBottom: 32 }}
|
|
showsVerticalScrollIndicator={false}
|
|
keyboardShouldPersistTaps="handled"
|
|
// Performance tuning — measured on Galaxy A50 (older mid-range):
|
|
// VirtualizedList warned with dt=2.26s for ~7k content. These props
|
|
// shrink the per-batch render cost and reclaim off-screen views.
|
|
removeClippedSubviews
|
|
initialNumToRender={5}
|
|
maxToRenderPerBatch={5}
|
|
windowSize={7}
|
|
updateCellsBatchingPeriod={50}
|
|
refreshControl={
|
|
<RefreshControl
|
|
refreshing={isRefetching}
|
|
onRefresh={refetch}
|
|
tintColor={colors.brandOrange}
|
|
/>
|
|
}
|
|
ListHeaderComponent={
|
|
<View>
|
|
<ComposeCard onPosted={() => refetch()} />
|
|
|
|
{/* Filter toggle */}
|
|
<View className="mb-3">
|
|
<Pressable
|
|
onPress={() => setFilterOpen((o) => !o)}
|
|
className="flex-row items-center gap-1.5 self-start"
|
|
style={({ pressed }) => ({ opacity: pressed ? 0.6 : 1 })}
|
|
>
|
|
<Ionicons
|
|
name={filterOpen ? 'close-outline' : 'options-outline'}
|
|
size={18}
|
|
color={activeCategory !== 'all' ? colors.brandOrange : '#737373'}
|
|
/>
|
|
{activeCategory !== 'all' && (
|
|
<Text className="text-xs text-rebreak-500" style={{ fontFamily: 'Nunito_600SemiBold' }}>
|
|
{FILTERS.find((f) => f.value === activeCategory)?.label}
|
|
</Text>
|
|
)}
|
|
</Pressable>
|
|
|
|
{filterOpen && (
|
|
<ScrollView
|
|
horizontal
|
|
showsHorizontalScrollIndicator={false}
|
|
className="mt-2"
|
|
contentContainerStyle={{ gap: 8, paddingBottom: 4 }}
|
|
>
|
|
{FILTERS.map((f) => {
|
|
const active = activeCategory === f.value;
|
|
return (
|
|
<Pressable
|
|
key={f.value}
|
|
onPress={() => toggleFilter(f.value)}
|
|
style={({ pressed }) => ({
|
|
opacity: pressed ? 0.7 : 1,
|
|
flexDirection: 'row',
|
|
alignItems: 'center',
|
|
gap: 6,
|
|
height: 32,
|
|
paddingHorizontal: 12,
|
|
borderRadius: 999,
|
|
borderWidth: 1,
|
|
backgroundColor: active ? colors.brandOrange : colors.surface,
|
|
borderColor: active ? colors.brandOrange : colors.border,
|
|
})}
|
|
>
|
|
<Ionicons
|
|
name={f.icon}
|
|
size={13}
|
|
color={active ? '#fff' : colors.textMuted}
|
|
/>
|
|
<Text
|
|
style={{
|
|
fontSize: 12,
|
|
fontFamily: 'Nunito_600SemiBold',
|
|
color: active ? '#fff' : colors.textMuted,
|
|
}}
|
|
>
|
|
{f.label}
|
|
</Text>
|
|
</Pressable>
|
|
);
|
|
})}
|
|
</ScrollView>
|
|
)}
|
|
</View>
|
|
|
|
{/* Skeleton */}
|
|
{isLoading && (
|
|
<View>
|
|
<PostCardSkeleton />
|
|
<PostCardSkeleton />
|
|
<PostCardSkeleton />
|
|
</View>
|
|
)}
|
|
</View>
|
|
}
|
|
ListEmptyComponent={
|
|
isLoading ? null : (
|
|
<View style={{ alignItems: 'center', paddingVertical: 64 }}>
|
|
<Ionicons name="chatbubbles-outline" size={40} color={colors.border} />
|
|
<Text style={{ fontSize: 14, color: colors.textMuted, marginTop: 12, textAlign: 'center', fontFamily: 'Nunito_400Regular' }}>
|
|
{t('community.no_posts')}
|
|
</Text>
|
|
</View>
|
|
)
|
|
}
|
|
renderItem={renderItem}
|
|
/>
|
|
|
|
<PostCommentsSheet
|
|
postId={activeCommentsPostId}
|
|
visible={activeCommentsPostId !== null}
|
|
onClose={closeComments}
|
|
/>
|
|
</View>
|
|
);
|
|
}
|