goon/mobile/src/screens/MoviesScreen.tsx
goon-foss 7deda639bf fix(theme): rozdziel role akcentu i dodaj ramke, ktora cokolwiek oddziela
Pomiar WCAG na realnych parach z apki (2026-08-13):

    accent #B23A48 jako tekst:  3,21 na bg | 2,96 na bgElevated | 2,75 na card
    border #3A3128 jako granica: 1,48 na bg | 1,27 na card

Czyli kolor MARKI byl ponizej nawet progu 3:1 dla elementow interfejsu,
a nie tylko ponizej 4,5 dla tekstu. Dotyczylo to aktywnych zakladek,
gwiazdek ulubionych, linkow i chipow. Ramki przy 1,27 nie byly granica,
tylko cieniem.

NIE zmieniam koloru marki. Podniesienie samego `accent` do progu tekstu
wymagaloby zejscia w roz (#CE6975), co likwiduje tozsamosc, ktorej broni
naglowek theme.ts. Zamiast tego rozdzielam ROLE tego samego koloru:

  accent      zostaje do WYPELNIEN — jasny tekst na nim ma 5,03, przechodzi
  accentText  #C75260 dla tekstu i obwodek — 4,30 / 3,96 / 3,68

62 podmiany w 24 plikach (34 color, 26 borderColor, 2 thumbColor).
38 wypelnien nietknietych. Sprawdzone skryptem, ze zaden zmieniony tekst
nie stoi na tle akcentu.

borderStrong #7B7065 dla granic NIOSACYCH ZNACZENIE (pola tekstowe, 12
miejsc); dekoracyjne wloski zostaja na `border`. Wartosc dobrana pod obie
powierzchnie, bo pola leza i na bg, i na card: #6B6157 dawal na karcie
2,66, a #73695E dokladnie 2,999, czyli wciaz ponizej progu.
2026-08-14 09:50:08 +02:00

207 lines
6.7 KiB
TypeScript

// 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<NativeStackNavigationProp<RootStackParamList, 'Movies'>>();
const [q, setQ] = React.useState('');
const [debouncedQ, setDebouncedQ] = React.useState('');
const [filters, setFilters] = React.useState<MovieFilters>(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<string | null>(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 (
<View style={styles.container}>
<View style={styles.toolbar}>
<TextInput
style={[styles.search, { flex: 1 }]}
placeholder="search movies"
placeholderTextColor={theme.muted}
value={q}
onChangeText={setQ}
autoCorrect={false}
autoCapitalize="none"
returnKeyType="search"
/>
<Pressable
style={[styles.filterBtn, !isDefaultFilters(filters) && styles.filterBtnActive]}
onPress={() => setFiltersOpen(true)}
>
<Text style={styles.filterBtnText}>Filter{!isDefaultFilters(filters) ? '·' : ''}</Text>
</Pressable>
</View>
<MovieFiltersSheet
visible={filtersOpen}
value={filters}
onChange={setFilters}
onClose={() => setFiltersOpen(false)}
/>
{isLoading && <ActivityIndicator color={theme.fg} style={{ marginTop: 24 }} />}
{error instanceof Error && <Text style={styles.error}>{error.message}</Text>}
<FlatList
data={items}
numColumns={NUM_COLS}
keyExtractor={(m) => 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 }) => (
<MoviePosterCard
movie={item}
isNew={!!(item.created_at && seenSince && item.created_at > seenSince)}
onPress={() => navigation.navigate('MovieDetail', { id: item.id })}
/>
)}
refreshing={isRefetching}
onRefresh={refetch}
ListHeaderComponent={
total > 0 ? <Text style={styles.subtitle}>{total} films</Text> : null
}
ListEmptyComponent={!isLoading ? <Text style={styles.muted}>no movies</Text> : null}
contentContainerStyle={{ paddingBottom: 24, paddingHorizontal: 6 }}
columnWrapperStyle={{ gap: 8 }}
onEndReached={() => {
if (hasNextPage && !isFetchingNextPage) fetchNextPage();
}}
onEndReachedThreshold={0.3}
ListFooterComponent={
isFetchingNextPage ? <ActivityIndicator color={theme.fg} style={{ margin: 16 }} /> : null
}
/>
</View>
);
}
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 },
});