// Lista filmów (paradisehill + mirrory). Grid plakatów + tytuł/rok/studio. import { useNavigation } from '@react-navigation/native'; import type { NativeStackNavigationProp } from '@react-navigation/native-stack'; import { useInfiniteQuery } from '@tanstack/react-query'; import React from 'react'; import { ActivityIndicator, AppState, AppStateStatus, FlatList, Pressable, StyleSheet, Text, TextInput, View, } from 'react-native'; import { useClient } from '../ClientContext'; import { DEFAULT_MOVIE_FILTERS, isDefaultFilters, MovieFilters, MovieFiltersSheet, } from '../components/MovieFiltersSheet'; import { MoviePosterCard } from '../components/MoviePosterCard'; import { getLastSeen, markSeen } from '../lib/seenStore'; import type { RootStackParamList } from '../navigation'; import { theme } from '../theme'; const PER_PAGE = 30; // 3 kolumny, plakaty 2:3 mieszczą się dobrze i widać 50% więcej filmów na ekran // (user-report 0200956f: lepiej wykorzystać przestrzeń). Karta ma flex:1, skaluje się. const NUM_COLS = 3; export function MoviesScreen() { const client = useClient(); const navigation = useNavigation>(); const [q, setQ] = React.useState(''); const [debouncedQ, setDebouncedQ] = React.useState(''); const [filters, setFilters] = React.useState(DEFAULT_MOVIE_FILTERS); const [filtersOpen, setFiltersOpen] = React.useState(false); // seenSince: ISO timestamp ostatniego "kompletnie obejrzanej listy". Movies // z created_at > seenSince mają NEW badge. Ładujemy z SecureStore raz // przy mount; aktualizujemy gdy app idzie do tła (user widzi NEW przez // całą sesję, dopiero next session wymaga rotacji). const [seenSince, setSeenSince] = React.useState(null); React.useEffect(() => { let mounted = true; getLastSeen('movies').then((v) => { if (mounted) setSeenSince(v); }); return () => { mounted = false; }; }, []); React.useEffect(() => { const sub = AppState.addEventListener('change', (next: AppStateStatus) => { if (next === 'background' || next === 'inactive') { markSeen('movies').catch(() => {}); } }); return () => sub.remove(); }, []); React.useEffect(() => { const t = setTimeout(() => setDebouncedQ(q), 350); return () => clearTimeout(t); }, [q]); const { data, isLoading, error, refetch, isRefetching, fetchNextPage, hasNextPage, isFetchingNextPage, } = useInfiniteQuery({ queryKey: ['movies', debouncedQ, filters], queryFn: ({ pageParam = 1 }) => client.listMovies({ q: debouncedQ || undefined, sort: filters.sort, year_from: filters.year_from ?? undefined, year_to: filters.year_to ?? undefined, has_playback: filters.has_playback || undefined, tags: filters.tag_slugs.length ? filters.tag_slugs : undefined, studio_slugs: filters.studio_slugs.length ? filters.studio_slugs : undefined, performer_ids: filters.performer_ids.length ? filters.performer_ids : undefined, page: pageParam, per_page: PER_PAGE, }), initialPageParam: 1, getNextPageParam: (last) => { const loaded = last.page * last.per_page; return loaded < last.total ? last.page + 1 : undefined; }, }); const items = data?.pages.flatMap((p) => p.items) ?? []; const total = data?.pages[0]?.total ?? 0; return ( setFiltersOpen(true)} > Filter{!isDefaultFilters(filters) ? '·' : ''} setFiltersOpen(false)} /> {isLoading && } {error instanceof Error && {error.message}} m.id} // Android removeClippedSubviews=true blankuje plakaty po scrollu (expo-image // nie re-renderuje odpiętych), ten sam bug co "znikające miniaturki" scen. removeClippedSubviews={false} renderItem={({ item }) => ( seenSince)} onPress={() => navigation.navigate('MovieDetail', { id: item.id })} /> )} refreshing={isRefetching} onRefresh={refetch} ListHeaderComponent={ total > 0 ? {total} films : null } ListEmptyComponent={!isLoading ? no movies : null} contentContainerStyle={{ paddingBottom: 24, paddingHorizontal: 6 }} columnWrapperStyle={{ gap: 8 }} onEndReached={() => { if (hasNextPage && !isFetchingNextPage) fetchNextPage(); }} onEndReachedThreshold={0.3} ListFooterComponent={ isFetchingNextPage ? : null } /> ); } const styles = StyleSheet.create({ container: { flex: 1, backgroundColor: theme.bg }, toolbar: { flexDirection: 'row', alignItems: 'center', gap: 8, paddingHorizontal: 12, paddingTop: 8, paddingBottom: 4, }, filterBtn: { height: 40, paddingHorizontal: 14, borderRadius: 10, backgroundColor: theme.card, borderWidth: 1, borderColor: theme.border, alignItems: 'center', justifyContent: 'center', }, filterBtnActive: { backgroundColor: theme.accentDeep, borderColor: theme.accentText, }, filterBtnText: { color: theme.fg, fontSize: 13, fontWeight: '600' }, search: { backgroundColor: theme.card, borderColor: theme.borderStrong, borderWidth: 1, borderRadius: 10, color: theme.fg, paddingHorizontal: 12, paddingVertical: 8, fontSize: 14, }, subtitle: { color: theme.muted, fontSize: 12, paddingHorizontal: 4, paddingTop: 6, paddingBottom: 8 }, error: { color: theme.bad, padding: 12 }, muted: { color: theme.muted, textAlign: 'center', marginTop: 24 }, });