feat(mobile): pasek znika pod klawiatura, stukniecie w aktywna sekcje przewija na gore, Scenes w jednym wierszu ikon
- lib/bottomBar.ts: stale paska, szyna scroll-top, wartosc Animated do chowania (hysteresis po przebytej drodze, strefy gora/dol), inset dla list - BottomTabsBar: absolute nad trescia, translateY z native drivera, null przy klawiaturze - Scenes: Performers/Tags/Favorites jako IconButton obok lejka i zebatki, chipsy zapisanych wyszukiwan tylko przy fokusie albo wpisanym tekscie, Save jako chip - changelog 2026-09-07d, OTA 3f7e21c5 na 1.3
This commit is contained in:
parent
90724af8ef
commit
d9fbc08218
7 changed files with 240 additions and 50 deletions
|
|
@ -29,6 +29,14 @@ export type ChangelogEntry = {
|
||||||
};
|
};
|
||||||
|
|
||||||
export const CHANGELOG: ChangelogEntry[] = [
|
export const CHANGELOG: ChangelogEntry[] = [
|
||||||
|
{
|
||||||
|
id: '2026-09-07d',
|
||||||
|
date: 'September 2026',
|
||||||
|
items: [
|
||||||
|
'Scenes: Performers, Tags and Favorites are icons in the search row, so the grid starts one row higher. Saved searches show up while you type.',
|
||||||
|
'Tap the active section again to jump back to the top. The bottom bar gets out of the way while the keyboard is open.',
|
||||||
|
],
|
||||||
|
},
|
||||||
{
|
{
|
||||||
id: '2026-09-07c',
|
id: '2026-09-07c',
|
||||||
date: 'September 2026',
|
date: 'September 2026',
|
||||||
|
|
|
||||||
|
|
@ -17,15 +17,37 @@
|
||||||
* tylko przycieta na krawedzi ekranu, jakby z niej wyrastala. Zaokraglone sa tylko
|
* tylko przycieta na krawedzi ekranu, jakby z niej wyrastala. Zaokraglone sa tylko
|
||||||
* gorne rogi, dolne siedza na insecie systemowego paska.
|
* gorne rogi, dolne siedza na insecie systemowego paska.
|
||||||
*
|
*
|
||||||
|
* Pasek lezy NAD trescia (position absolute), a nie pod nia w kolumnie: przy
|
||||||
|
* chowaniu na scroll odjezdza samym transformem (native driver) i tresc nie musi
|
||||||
|
* sie przeliczac. Listy sekcji dokladaja sobie `useBottomBarInset()` do dolnego
|
||||||
|
* paddingu. Przy otwartej klawiaturze (adjustResize wypychal pasek nad klawiature)
|
||||||
|
* pasek znika w calosci; stukniecie w aktywna zakladke przewija liste na gore.
|
||||||
|
*
|
||||||
* Pozycje ikon mierzymy `onLayout` (flex:1 daje rowne komorki, ale szerokosc paska
|
* Pozycje ikon mierzymy `onLayout` (flex:1 daje rowne komorki, ale szerokosc paska
|
||||||
* zalezy od ekranu). Do pierwszego pomiaru pierscienia nie rysujemy.
|
* zalezy od ekranu). Do pierwszego pomiaru pierscienia nie rysujemy.
|
||||||
*/
|
*/
|
||||||
import { MaterialCommunityIcons } from '@expo/vector-icons';
|
import { MaterialCommunityIcons } from '@expo/vector-icons';
|
||||||
import * as Haptics from 'expo-haptics';
|
import * as Haptics from 'expo-haptics';
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { Animated, Easing, LayoutChangeEvent, Pressable, StyleSheet, Text, View } from 'react-native';
|
import {
|
||||||
|
Animated,
|
||||||
|
Easing,
|
||||||
|
Keyboard,
|
||||||
|
LayoutChangeEvent,
|
||||||
|
Pressable,
|
||||||
|
StyleSheet,
|
||||||
|
Text,
|
||||||
|
View,
|
||||||
|
} from 'react-native';
|
||||||
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||||
|
|
||||||
|
import {
|
||||||
|
BOTTOM_BAR_GAP,
|
||||||
|
BOTTOM_BAR_HEIGHT,
|
||||||
|
barHidden,
|
||||||
|
emitScrollTop,
|
||||||
|
setBarHidden,
|
||||||
|
} from '../lib/bottomBar';
|
||||||
import { fonts, theme } from '../theme';
|
import { fonts, theme } from '../theme';
|
||||||
|
|
||||||
type IconName = React.ComponentProps<typeof MaterialCommunityIcons>['name'];
|
type IconName = React.ComponentProps<typeof MaterialCommunityIcons>['name'];
|
||||||
|
|
@ -43,15 +65,24 @@ const RING_TOP = 6;
|
||||||
const ICON_SIZE = 22;
|
const ICON_SIZE = 22;
|
||||||
/** O ile poswiata wystaje poza pierscien, z kazdej strony. */
|
/** O ile poswiata wystaje poza pierscien, z kazdej strony. */
|
||||||
const GLOW_PAD = 3;
|
const GLOW_PAD = 3;
|
||||||
/** Wysokosc samej pigulki, bez marginesu i bez inset systemowego paska. */
|
|
||||||
export const BOTTOM_BAR_HEIGHT = 62;
|
|
||||||
/** Odstep pigulki od dolu (nad inset). Eksport, zeby FAB zglaszania mogl wisiec wyzej. */
|
|
||||||
export const BOTTOM_BAR_GAP = 0;
|
|
||||||
|
|
||||||
export function BottomTabsBar<T extends string>({ tabs, icons, current, onSelect }: Props<T>) {
|
export function BottomTabsBar<T extends string>({ tabs, icons, current, onSelect }: Props<T>) {
|
||||||
const insets = useSafeAreaInsets();
|
const insets = useSafeAreaInsets();
|
||||||
const activeIndex = Math.max(0, tabs.indexOf(current));
|
const activeIndex = Math.max(0, tabs.indexOf(current));
|
||||||
|
|
||||||
|
const [keyboardUp, setKeyboardUp] = React.useState(false);
|
||||||
|
React.useEffect(() => {
|
||||||
|
const s1 = Keyboard.addListener('keyboardDidShow', () => setKeyboardUp(true));
|
||||||
|
const s2 = Keyboard.addListener('keyboardDidHide', () => setKeyboardUp(false));
|
||||||
|
return () => {
|
||||||
|
s1.remove();
|
||||||
|
s2.remove();
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const totalHeight = BOTTOM_BAR_HEIGHT + insets.bottom + BOTTOM_BAR_GAP + 6;
|
||||||
|
const translateY = barHidden.interpolate({ inputRange: [0, 1], outputRange: [0, totalHeight] });
|
||||||
|
|
||||||
const [centers, setCenters] = React.useState<Record<number, number>>({});
|
const [centers, setCenters] = React.useState<Record<number, number>>({});
|
||||||
const ready = tabs.every((_, i) => centers[i] != null);
|
const ready = tabs.every((_, i) => centers[i] != null);
|
||||||
|
|
||||||
|
|
@ -116,8 +147,15 @@ export function BottomTabsBar<T extends string>({ tabs, icons, current, onSelect
|
||||||
};
|
};
|
||||||
}, [ready, centers, trip, lead, trail]);
|
}, [ready, centers, trip, lead, trail]);
|
||||||
|
|
||||||
|
if (keyboardUp) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View style={[styles.wrap, { paddingBottom: insets.bottom + BOTTOM_BAR_GAP }]}>
|
<Animated.View
|
||||||
|
style={[
|
||||||
|
styles.wrap,
|
||||||
|
{ paddingBottom: insets.bottom + BOTTOM_BAR_GAP, transform: [{ translateY }] },
|
||||||
|
]}
|
||||||
|
>
|
||||||
<View style={styles.pill}>
|
<View style={styles.pill}>
|
||||||
{ring ? (
|
{ring ? (
|
||||||
<>
|
<>
|
||||||
|
|
@ -137,8 +175,12 @@ export function BottomTabsBar<T extends string>({ tabs, icons, current, onSelect
|
||||||
accessibilityState={{ selected: active }}
|
accessibilityState={{ selected: active }}
|
||||||
onLayout={onTabLayout(i)}
|
onLayout={onTabLayout(i)}
|
||||||
onPress={() => {
|
onPress={() => {
|
||||||
if (active) return;
|
if (active) {
|
||||||
|
emitScrollTop();
|
||||||
|
return;
|
||||||
|
}
|
||||||
Haptics.selectionAsync().catch(() => {});
|
Haptics.selectionAsync().catch(() => {});
|
||||||
|
setBarHidden(false);
|
||||||
onSelect(t);
|
onSelect(t);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
|
@ -156,15 +198,18 @@ export function BottomTabsBar<T extends string>({ tabs, icons, current, onSelect
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</Animated.View>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const styles = StyleSheet.create({
|
const styles = StyleSheet.create({
|
||||||
wrap: {
|
wrap: {
|
||||||
|
position: 'absolute',
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
bottom: 0,
|
||||||
paddingHorizontal: 14,
|
paddingHorizontal: 14,
|
||||||
paddingTop: 6,
|
paddingTop: 6,
|
||||||
backgroundColor: theme.bg,
|
|
||||||
},
|
},
|
||||||
pill: {
|
pill: {
|
||||||
height: BOTTOM_BAR_HEIGHT,
|
height: BOTTOM_BAR_HEIGHT,
|
||||||
|
|
|
||||||
|
|
@ -35,7 +35,7 @@ import { captureScreen } from 'react-native-view-shot';
|
||||||
|
|
||||||
import { GoonClient } from '../api';
|
import { GoonClient } from '../api';
|
||||||
import { theme } from '../theme';
|
import { theme } from '../theme';
|
||||||
import { BOTTOM_BAR_GAP, BOTTOM_BAR_HEIGHT } from './BottomTabsBar';
|
import { BOTTOM_BAR_GAP, BOTTOM_BAR_HEIGHT } from '../lib/bottomBar';
|
||||||
import { formatLastPlayback } from '../lib/lastPlayback';
|
import { formatLastPlayback } from '../lib/lastPlayback';
|
||||||
import { getSxyprnDiag } from '../lib/sxyprnResolver';
|
import { getSxyprnDiag } from '../lib/sxyprnResolver';
|
||||||
|
|
||||||
|
|
|
||||||
108
mobile/src/lib/bottomBar.ts
Normal file
108
mobile/src/lib/bottomBar.ts
Normal file
|
|
@ -0,0 +1,108 @@
|
||||||
|
/**
|
||||||
|
* Wspolny stan dolnego paska zakladek: przewijanie listy na gore po stuknieciu w
|
||||||
|
* aktywna zakladke i chowanie paska przy przewijaniu w dol.
|
||||||
|
*
|
||||||
|
* Pasek i listy sa rodzenstwem (HomeScreen renderuje pasek nad trescia sekcji), wiec
|
||||||
|
* nie maja wspolnego rodzica, przez ktorego daloby sie przekazac ref. Zamiast wciskac
|
||||||
|
* to w context, jest maly modul z jedna wartoscia Animated i jedna szyna zdarzen.
|
||||||
|
*
|
||||||
|
* Chowanie bez migania (uwaga Jana 2026-09-07): decyzja zapada po PRZEBYTEJ drodze w
|
||||||
|
* jedna strone, nie po pojedynczym zdarzeniu scroll. Licznik zbiera delty w biezacym
|
||||||
|
* kierunku i zeruje sie przy zmianie kierunku, wiec drganie palca o kilka pikseli nic
|
||||||
|
* nie przelacza; trzeba przejechac `HIDE_AFTER` w dol albo `SHOW_AFTER` w gore.
|
||||||
|
* Przy samej gorze listy pasek jest zawsze widoczny, przy samym dole tez (zeby nie
|
||||||
|
* uciekal, gdy doladowuje sie nastepna strona). Overscroll (offset < 0) ignorujemy.
|
||||||
|
*/
|
||||||
|
import React from 'react';
|
||||||
|
import { Animated, Easing, NativeScrollEvent, NativeSyntheticEvent } from 'react-native';
|
||||||
|
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||||
|
|
||||||
|
/** Wysokosc samej pigulki, bez inset systemowego paska. */
|
||||||
|
export const BOTTOM_BAR_HEIGHT = 62;
|
||||||
|
/** Odstep pigulki od dolu (nad inset). Eksport, zeby FAB zglaszania mogl wisiec wyzej. */
|
||||||
|
export const BOTTOM_BAR_GAP = 0;
|
||||||
|
|
||||||
|
/** 0 = pasek widoczny, 1 = schowany pod dolna krawedz. Native driver (transform). */
|
||||||
|
export const barHidden = new Animated.Value(0);
|
||||||
|
let hiddenNow = false;
|
||||||
|
|
||||||
|
export function setBarHidden(hidden: boolean) {
|
||||||
|
if (hidden === hiddenNow) return;
|
||||||
|
hiddenNow = hidden;
|
||||||
|
Animated.timing(barHidden, {
|
||||||
|
toValue: hidden ? 1 : 0,
|
||||||
|
duration: 200,
|
||||||
|
easing: Easing.out(Easing.cubic),
|
||||||
|
useNativeDriver: true,
|
||||||
|
}).start();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Ile trzeba przejechac w dol, zeby pasek sie schowal, i w gore, zeby wrocil. */
|
||||||
|
const HIDE_AFTER = 48;
|
||||||
|
const SHOW_AFTER = 24;
|
||||||
|
/** Przy takim offsecie od gory pasek zawsze widoczny. */
|
||||||
|
const TOP_ZONE = 80;
|
||||||
|
/** Tyle od konca tresci pasek zawsze widoczny. */
|
||||||
|
const BOTTOM_ZONE = 120;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handler `onScroll` dla listy sekcji. Zwraca stabilna funkcje; podepnij tez
|
||||||
|
* `scrollEventThrottle={16}`.
|
||||||
|
*/
|
||||||
|
export function useAutoHideOnScroll() {
|
||||||
|
const lastY = React.useRef(0);
|
||||||
|
const acc = React.useRef(0);
|
||||||
|
return React.useCallback((e: NativeSyntheticEvent<NativeScrollEvent>) => {
|
||||||
|
const { contentOffset, contentSize, layoutMeasurement } = e.nativeEvent;
|
||||||
|
const y = contentOffset.y;
|
||||||
|
if (y < 0) return;
|
||||||
|
const dy = y - lastY.current;
|
||||||
|
lastY.current = y;
|
||||||
|
if (y <= TOP_ZONE) {
|
||||||
|
acc.current = 0;
|
||||||
|
setBarHidden(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const remaining = contentSize.height - layoutMeasurement.height - y;
|
||||||
|
if (remaining <= BOTTOM_ZONE) {
|
||||||
|
acc.current = 0;
|
||||||
|
setBarHidden(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Zmiana kierunku zeruje licznik: liczy sie ciagla droga w jedna strone.
|
||||||
|
if ((dy > 0 && acc.current < 0) || (dy < 0 && acc.current > 0)) acc.current = 0;
|
||||||
|
acc.current += dy;
|
||||||
|
if (acc.current > HIDE_AFTER) {
|
||||||
|
setBarHidden(true);
|
||||||
|
acc.current = 0;
|
||||||
|
} else if (acc.current < -SHOW_AFTER) {
|
||||||
|
setBarHidden(false);
|
||||||
|
acc.current = 0;
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Dolny padding listy, zeby ostatni wiersz nie chowal sie pod paskiem. */
|
||||||
|
export function useBottomBarInset() {
|
||||||
|
const insets = useSafeAreaInsets();
|
||||||
|
return BOTTOM_BAR_HEIGHT + insets.bottom + 12;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Szyna „przewin na gore": pasek emituje po stuknieciu w aktywna zakladke, lista
|
||||||
|
// biezacej sekcji slucha. Sekcje remontuja sie przy przelaczeniu (key={tab}), wiec
|
||||||
|
// w danej chwili sluchacz jest jeden.
|
||||||
|
type Listener = () => void;
|
||||||
|
const listeners = new Set<Listener>();
|
||||||
|
|
||||||
|
export function emitScrollTop() {
|
||||||
|
listeners.forEach((l) => l());
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useScrollTopListener(scrollToTop: () => void) {
|
||||||
|
React.useEffect(() => {
|
||||||
|
listeners.add(scrollToTop);
|
||||||
|
return () => {
|
||||||
|
listeners.delete(scrollToTop);
|
||||||
|
};
|
||||||
|
}, [scrollToTop]);
|
||||||
|
}
|
||||||
|
|
@ -20,8 +20,10 @@ import {
|
||||||
MovieFilters,
|
MovieFilters,
|
||||||
MovieFiltersSheet,
|
MovieFiltersSheet,
|
||||||
} from '../components/MovieFiltersSheet';
|
} from '../components/MovieFiltersSheet';
|
||||||
|
import { useBottomBarInset, useScrollTopListener } from '../lib/bottomBar';
|
||||||
import { usePersistedState } from '../lib/persistedState';
|
import { usePersistedState } from '../lib/persistedState';
|
||||||
import { MoviePosterCard } from '../components/MoviePosterCard';
|
import { MoviePosterCard } from '../components/MoviePosterCard';
|
||||||
|
import type { MovieOut } from '../types';
|
||||||
import { getLastSeen, markSeen } from '../lib/seenStore';
|
import { getLastSeen, markSeen } from '../lib/seenStore';
|
||||||
import type { RootStackParamList } from '../navigation';
|
import type { RootStackParamList } from '../navigation';
|
||||||
import { theme } from '../theme';
|
import { theme } from '../theme';
|
||||||
|
|
@ -44,6 +46,11 @@ export function MoviesScreen() {
|
||||||
DEFAULT_MOVIE_FILTERS,
|
DEFAULT_MOVIE_FILTERS,
|
||||||
);
|
);
|
||||||
const [filtersOpen, setFiltersOpen] = React.useState(false);
|
const [filtersOpen, setFiltersOpen] = React.useState(false);
|
||||||
|
const listRef = React.useRef<FlatList<MovieOut>>(null);
|
||||||
|
useScrollTopListener(
|
||||||
|
React.useCallback(() => listRef.current?.scrollToOffset({ offset: 0, animated: true }), []),
|
||||||
|
);
|
||||||
|
const bottomInset = useBottomBarInset();
|
||||||
// seenSince: ISO timestamp ostatniego "kompletnie obejrzanej listy". Movies
|
// seenSince: ISO timestamp ostatniego "kompletnie obejrzanej listy". Movies
|
||||||
// z created_at > seenSince mają NEW badge. Ładujemy z SecureStore raz
|
// z created_at > seenSince mają NEW badge. Ładujemy z SecureStore raz
|
||||||
// przy mount; aktualizujemy gdy app idzie do tła (user widzi NEW przez
|
// przy mount; aktualizujemy gdy app idzie do tła (user widzi NEW przez
|
||||||
|
|
@ -142,6 +149,7 @@ export function MoviesScreen() {
|
||||||
{error instanceof Error && <Text style={styles.error}>{error.message}</Text>}
|
{error instanceof Error && <Text style={styles.error}>{error.message}</Text>}
|
||||||
|
|
||||||
<FlatList
|
<FlatList
|
||||||
|
ref={listRef}
|
||||||
data={items}
|
data={items}
|
||||||
numColumns={NUM_COLS}
|
numColumns={NUM_COLS}
|
||||||
keyExtractor={(m) => m.id}
|
keyExtractor={(m) => m.id}
|
||||||
|
|
@ -161,7 +169,7 @@ export function MoviesScreen() {
|
||||||
total > 0 ? <Text style={styles.subtitle}>{total} films</Text> : null
|
total > 0 ? <Text style={styles.subtitle}>{total} films</Text> : null
|
||||||
}
|
}
|
||||||
ListEmptyComponent={!isLoading ? <Text style={styles.muted}>no movies</Text> : null}
|
ListEmptyComponent={!isLoading ? <Text style={styles.muted}>no movies</Text> : null}
|
||||||
contentContainerStyle={{ paddingBottom: 24 }}
|
contentContainerStyle={{ paddingBottom: bottomInset }}
|
||||||
columnWrapperStyle={{ gap: 8 }}
|
columnWrapperStyle={{ gap: 8 }}
|
||||||
onEndReached={() => {
|
onEndReached={() => {
|
||||||
if (hasNextPage && !isFetchingNextPage) fetchNextPage();
|
if (hasNextPage && !isFetchingNextPage) fetchNextPage();
|
||||||
|
|
|
||||||
|
|
@ -17,13 +17,13 @@ import * as Haptics from 'expo-haptics';
|
||||||
import { MigrationBanner } from '../components/MigrationBanner';
|
import { MigrationBanner } from '../components/MigrationBanner';
|
||||||
import {
|
import {
|
||||||
IconButton,
|
IconButton,
|
||||||
PillButton,
|
|
||||||
SCREEN_PAD_H,
|
SCREEN_PAD_H,
|
||||||
SCREEN_PAD_TOP,
|
SCREEN_PAD_TOP,
|
||||||
SearchInput,
|
SearchInput,
|
||||||
SettingsButton,
|
SettingsButton,
|
||||||
Toolbar,
|
Toolbar,
|
||||||
} from '../ui';
|
} from '../ui';
|
||||||
|
import { useBottomBarInset, useScrollTopListener } from '../lib/bottomBar';
|
||||||
import { SceneTile, sceneGridProps } from '../components/SceneTile';
|
import { SceneTile, sceneGridProps } from '../components/SceneTile';
|
||||||
import { SceneGridSkeleton } from '../components/SceneGridSkeleton';
|
import { SceneGridSkeleton } from '../components/SceneGridSkeleton';
|
||||||
import { Thumb } from '../components/Thumb';
|
import { Thumb } from '../components/Thumb';
|
||||||
|
|
@ -69,6 +69,12 @@ export function ScenesScreen({
|
||||||
DEFAULT_FILTER,
|
DEFAULT_FILTER,
|
||||||
);
|
);
|
||||||
const [filterOpen, setFilterOpen] = useState(false);
|
const [filterOpen, setFilterOpen] = useState(false);
|
||||||
|
const [searchFocused, setSearchFocused] = useState(false);
|
||||||
|
const listRef = React.useRef<FlatList<SceneOut>>(null);
|
||||||
|
useScrollTopListener(
|
||||||
|
React.useCallback(() => listRef.current?.scrollToOffset({ offset: 0, animated: true }), []),
|
||||||
|
);
|
||||||
|
const bottomInset = useBottomBarInset();
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
const t = setTimeout(() => setDebouncedQ(q), 350);
|
const t = setTimeout(() => setDebouncedQ(q), 350);
|
||||||
|
|
@ -169,17 +175,13 @@ export function ScenesScreen({
|
||||||
return (
|
return (
|
||||||
<View style={styles.container}>
|
<View style={styles.container}>
|
||||||
<Toolbar>
|
<Toolbar>
|
||||||
<SearchInput value={q} onChangeText={setQ} placeholder="search title…" />
|
<SearchInput
|
||||||
{q.trim().length > 0 ? (
|
value={q}
|
||||||
<PillButton
|
onChangeText={setQ}
|
||||||
label={alreadySaved ? '★ Saved' : '☆ Save'}
|
placeholder="search title…"
|
||||||
active={alreadySaved}
|
onFocus={() => setSearchFocused(true)}
|
||||||
onPress={() => {
|
onBlur={() => setSearchFocused(false)}
|
||||||
const v = q.trim();
|
|
||||||
if (v && !alreadySaved) addSaved.mutate(v);
|
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
) : null}
|
|
||||||
<IconButton
|
<IconButton
|
||||||
icon="filter-variant"
|
icon="filter-variant"
|
||||||
accessibilityLabel="Filters"
|
accessibilityLabel="Filters"
|
||||||
|
|
@ -187,16 +189,47 @@ export function ScenesScreen({
|
||||||
badge={activeCount}
|
badge={activeCount}
|
||||||
onPress={() => setFilterOpen(true)}
|
onPress={() => setFilterOpen(true)}
|
||||||
/>
|
/>
|
||||||
|
{hasCast ? (
|
||||||
|
<IconButton
|
||||||
|
icon="account-group-outline"
|
||||||
|
accessibilityLabel="Performers"
|
||||||
|
onPress={() =>
|
||||||
|
navigation.navigate('Performers', verticalKey ? { vertical: verticalKey } : undefined)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
<IconButton
|
||||||
|
icon="tag-multiple-outline"
|
||||||
|
accessibilityLabel="Tags"
|
||||||
|
onPress={() => navigation.navigate('Tags', verticalKey ? { vertical: verticalKey } : undefined)}
|
||||||
|
/>
|
||||||
|
<FavoritesButton />
|
||||||
<SettingsButton />
|
<SettingsButton />
|
||||||
</Toolbar>
|
</Toolbar>
|
||||||
|
|
||||||
{saved.length > 0 ? (
|
{/* Zapisane wyszukiwania i „Save" tylko podczas pisania: na co dzien ten wiersz
|
||||||
|
zabieral miejsce kafelkom (uwaga 2026-09-07). */}
|
||||||
|
{(searchFocused || q.trim().length > 0) && (saved.length > 0 || q.trim().length > 0) ? (
|
||||||
<ScrollView
|
<ScrollView
|
||||||
horizontal
|
horizontal
|
||||||
showsHorizontalScrollIndicator={false}
|
showsHorizontalScrollIndicator={false}
|
||||||
style={styles.savedRow}
|
style={styles.savedRow}
|
||||||
contentContainerStyle={styles.savedRowContent}
|
contentContainerStyle={styles.savedRowContent}
|
||||||
|
keyboardShouldPersistTaps="handled"
|
||||||
>
|
>
|
||||||
|
{q.trim().length > 0 ? (
|
||||||
|
<Pressable
|
||||||
|
style={[styles.savedChip, alreadySaved && styles.savedChipActive]}
|
||||||
|
onPress={() => {
|
||||||
|
const v = q.trim();
|
||||||
|
if (v && !alreadySaved) addSaved.mutate(v);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Text style={[styles.savedChipText, alreadySaved && styles.savedChipTextActive]}>
|
||||||
|
{alreadySaved ? '★ Saved' : '☆ Save this search'}
|
||||||
|
</Text>
|
||||||
|
</Pressable>
|
||||||
|
) : null}
|
||||||
{saved.map((s) => {
|
{saved.map((s) => {
|
||||||
const active = debouncedQ === s.query;
|
const active = debouncedQ === s.query;
|
||||||
return (
|
return (
|
||||||
|
|
@ -227,26 +260,6 @@ export function ScenesScreen({
|
||||||
</ScrollView>
|
</ScrollView>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
<View style={styles.subToolbar}>
|
|
||||||
{hasCast ? (
|
|
||||||
<PillButton
|
|
||||||
label="Performers"
|
|
||||||
tone="secondary"
|
|
||||||
compact
|
|
||||||
onPress={() =>
|
|
||||||
navigation.navigate('Performers', verticalKey ? { vertical: verticalKey } : undefined)
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
) : null}
|
|
||||||
<PillButton
|
|
||||||
label="Tags"
|
|
||||||
tone="secondary"
|
|
||||||
compact
|
|
||||||
onPress={() => navigation.navigate('Tags', verticalKey ? { vertical: verticalKey } : undefined)}
|
|
||||||
/>
|
|
||||||
<FavoritesButton />
|
|
||||||
</View>
|
|
||||||
|
|
||||||
{error instanceof Error && <Text style={styles.error}>{error.message}</Text>}
|
{error instanceof Error && <Text style={styles.error}>{error.message}</Text>}
|
||||||
|
|
||||||
{/* Nad listą, nie w ListHeaderComponent: tam znikałby przy wyszukiwaniu i odjeżdżał
|
{/* Nad listą, nie w ListHeaderComponent: tam znikałby przy wyszukiwaniu i odjeżdżał
|
||||||
|
|
@ -257,6 +270,7 @@ export function ScenesScreen({
|
||||||
<SceneGridSkeleton count={8} />
|
<SceneGridSkeleton count={8} />
|
||||||
) : (
|
) : (
|
||||||
<FlatList
|
<FlatList
|
||||||
|
ref={listRef}
|
||||||
{...sceneGridProps(gridColumns)}
|
{...sceneGridProps(gridColumns)}
|
||||||
data={items}
|
data={items}
|
||||||
keyExtractor={(s) => s.id}
|
keyExtractor={(s) => s.id}
|
||||||
|
|
@ -278,7 +292,7 @@ export function ScenesScreen({
|
||||||
) : null
|
) : null
|
||||||
}
|
}
|
||||||
ListEmptyComponent={!isLoading ? <Text style={styles.muted}>no scenes</Text> : null}
|
ListEmptyComponent={!isLoading ? <Text style={styles.muted}>no scenes</Text> : null}
|
||||||
contentContainerStyle={{ paddingBottom: 24 }}
|
contentContainerStyle={{ paddingBottom: bottomInset }}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|
@ -356,11 +370,11 @@ function FavoritesButton() {
|
||||||
const hasNew = newTotal > 0;
|
const hasNew = newTotal > 0;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<PillButton
|
<IconButton
|
||||||
label={`★ ${hasNew ? newTotal : ''}`.trim()}
|
icon={hasNew ? 'star' : 'star-outline'}
|
||||||
tone="secondary"
|
accessibilityLabel="Favorites"
|
||||||
compact
|
|
||||||
active={hasNew}
|
active={hasNew}
|
||||||
|
badge={newTotal}
|
||||||
onPress={() => navigation.navigate('Favorites')}
|
onPress={() => navigation.navigate('Favorites')}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -23,6 +23,7 @@ import type { RootStackParamList } from '../navigation';
|
||||||
import { theme } from '../theme';
|
import { theme } from '../theme';
|
||||||
import { PillButton, SCREEN_PAD_H, SCREEN_PAD_TOP, SearchInput, SettingsButton, Toolbar } from '../ui';
|
import { PillButton, SCREEN_PAD_H, SCREEN_PAD_TOP, SearchInput, SettingsButton, Toolbar } from '../ui';
|
||||||
import type { SourceOut, SourceRating } from '../types';
|
import type { SourceOut, SourceRating } from '../types';
|
||||||
|
import { useBottomBarInset, useScrollTopListener } from '../lib/bottomBar';
|
||||||
|
|
||||||
type Order = 'popular' | 'recent';
|
type Order = 'popular' | 'recent';
|
||||||
|
|
||||||
|
|
@ -36,6 +37,11 @@ export function SitesScreen() {
|
||||||
const [searchFocused, setSearchFocused] = useState(false);
|
const [searchFocused, setSearchFocused] = useState(false);
|
||||||
// Źródło którego rozkład oceny pokazujemy w modalu (tap w gwiazdki).
|
// Źródło którego rozkład oceny pokazujemy w modalu (tap w gwiazdki).
|
||||||
const [detail, setDetail] = useState<SourceOut | null>(null);
|
const [detail, setDetail] = useState<SourceOut | null>(null);
|
||||||
|
const listRef = React.useRef<FlatList<SourceOut>>(null);
|
||||||
|
useScrollTopListener(
|
||||||
|
React.useCallback(() => listRef.current?.scrollToOffset({ offset: 0, animated: true }), []),
|
||||||
|
);
|
||||||
|
const bottomInset = useBottomBarInset();
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
const t = setTimeout(() => setDebouncedQ(q), 250);
|
const t = setTimeout(() => setDebouncedQ(q), 250);
|
||||||
|
|
@ -108,6 +114,7 @@ export function SitesScreen() {
|
||||||
{error instanceof Error && <Text style={styles.error}>{error.message}</Text>}
|
{error instanceof Error && <Text style={styles.error}>{error.message}</Text>}
|
||||||
|
|
||||||
<FlatList
|
<FlatList
|
||||||
|
ref={listRef}
|
||||||
data={items}
|
data={items}
|
||||||
keyExtractor={(s) => s.origin}
|
keyExtractor={(s) => s.origin}
|
||||||
numColumns={2}
|
numColumns={2}
|
||||||
|
|
@ -131,7 +138,7 @@ export function SitesScreen() {
|
||||||
ListEmptyComponent={
|
ListEmptyComponent={
|
||||||
!isLoading ? <Text style={styles.emptyText}>no sites</Text> : null
|
!isLoading ? <Text style={styles.emptyText}>no sites</Text> : null
|
||||||
}
|
}
|
||||||
contentContainerStyle={{ paddingBottom: 24 }}
|
contentContainerStyle={{ paddingBottom: bottomInset }}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<RatingModal source={detail} onClose={() => setDetail(null)} />
|
<RatingModal source={detail} onClose={() => setDetail(null)} />
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue