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.
207 lines
6.7 KiB
TypeScript
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 },
|
|
});
|