From d9fbc08218dd22dbbae441a9b4ed7e57bd74ffbc Mon Sep 17 00:00:00 2001 From: goon-foss Date: Mon, 7 Sep 2026 11:45:53 +0200 Subject: [PATCH] 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 --- mobile/src/changelog.ts | 8 ++ mobile/src/components/BottomTabsBar.tsx | 63 ++++++++++++-- mobile/src/components/BugReportFAB.tsx | 2 +- mobile/src/lib/bottomBar.ts | 108 ++++++++++++++++++++++++ mobile/src/screens/MoviesScreen.tsx | 10 ++- mobile/src/screens/ScenesScreen.tsx | 90 +++++++++++--------- mobile/src/screens/SitesScreen.tsx | 9 +- 7 files changed, 240 insertions(+), 50 deletions(-) create mode 100644 mobile/src/lib/bottomBar.ts diff --git a/mobile/src/changelog.ts b/mobile/src/changelog.ts index cb7d9f3..ea7790a 100644 --- a/mobile/src/changelog.ts +++ b/mobile/src/changelog.ts @@ -29,6 +29,14 @@ export type 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', date: 'September 2026', diff --git a/mobile/src/components/BottomTabsBar.tsx b/mobile/src/components/BottomTabsBar.tsx index 09ec037..2ad29f3 100644 --- a/mobile/src/components/BottomTabsBar.tsx +++ b/mobile/src/components/BottomTabsBar.tsx @@ -17,15 +17,37 @@ * tylko przycieta na krawedzi ekranu, jakby z niej wyrastala. Zaokraglone sa tylko * 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 * zalezy od ekranu). Do pierwszego pomiaru pierscienia nie rysujemy. */ import { MaterialCommunityIcons } from '@expo/vector-icons'; import * as Haptics from 'expo-haptics'; 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 { + BOTTOM_BAR_GAP, + BOTTOM_BAR_HEIGHT, + barHidden, + emitScrollTop, + setBarHidden, +} from '../lib/bottomBar'; import { fonts, theme } from '../theme'; type IconName = React.ComponentProps['name']; @@ -43,15 +65,24 @@ const RING_TOP = 6; const ICON_SIZE = 22; /** O ile poswiata wystaje poza pierscien, z kazdej strony. */ 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({ tabs, icons, current, onSelect }: Props) { const insets = useSafeAreaInsets(); 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>({}); const ready = tabs.every((_, i) => centers[i] != null); @@ -116,8 +147,15 @@ export function BottomTabsBar({ tabs, icons, current, onSelect }; }, [ready, centers, trip, lead, trail]); + if (keyboardUp) return null; + return ( - + {ring ? ( <> @@ -137,8 +175,12 @@ export function BottomTabsBar({ tabs, icons, current, onSelect accessibilityState={{ selected: active }} onLayout={onTabLayout(i)} onPress={() => { - if (active) return; + if (active) { + emitScrollTop(); + return; + } Haptics.selectionAsync().catch(() => {}); + setBarHidden(false); onSelect(t); }} > @@ -156,15 +198,18 @@ export function BottomTabsBar({ tabs, icons, current, onSelect ); })} - + ); } const styles = StyleSheet.create({ wrap: { + position: 'absolute', + left: 0, + right: 0, + bottom: 0, paddingHorizontal: 14, paddingTop: 6, - backgroundColor: theme.bg, }, pill: { height: BOTTOM_BAR_HEIGHT, diff --git a/mobile/src/components/BugReportFAB.tsx b/mobile/src/components/BugReportFAB.tsx index 41bb7b4..4f96488 100644 --- a/mobile/src/components/BugReportFAB.tsx +++ b/mobile/src/components/BugReportFAB.tsx @@ -35,7 +35,7 @@ import { captureScreen } from 'react-native-view-shot'; import { GoonClient } from '../api'; 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 { getSxyprnDiag } from '../lib/sxyprnResolver'; diff --git a/mobile/src/lib/bottomBar.ts b/mobile/src/lib/bottomBar.ts new file mode 100644 index 0000000..34b1a96 --- /dev/null +++ b/mobile/src/lib/bottomBar.ts @@ -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) => { + 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(); + +export function emitScrollTop() { + listeners.forEach((l) => l()); +} + +export function useScrollTopListener(scrollToTop: () => void) { + React.useEffect(() => { + listeners.add(scrollToTop); + return () => { + listeners.delete(scrollToTop); + }; + }, [scrollToTop]); +} diff --git a/mobile/src/screens/MoviesScreen.tsx b/mobile/src/screens/MoviesScreen.tsx index 1f04b6c..9495e73 100644 --- a/mobile/src/screens/MoviesScreen.tsx +++ b/mobile/src/screens/MoviesScreen.tsx @@ -20,8 +20,10 @@ import { MovieFilters, MovieFiltersSheet, } from '../components/MovieFiltersSheet'; +import { useBottomBarInset, useScrollTopListener } from '../lib/bottomBar'; import { usePersistedState } from '../lib/persistedState'; import { MoviePosterCard } from '../components/MoviePosterCard'; +import type { MovieOut } from '../types'; import { getLastSeen, markSeen } from '../lib/seenStore'; import type { RootStackParamList } from '../navigation'; import { theme } from '../theme'; @@ -44,6 +46,11 @@ export function MoviesScreen() { DEFAULT_MOVIE_FILTERS, ); const [filtersOpen, setFiltersOpen] = React.useState(false); + const listRef = React.useRef>(null); + useScrollTopListener( + React.useCallback(() => listRef.current?.scrollToOffset({ offset: 0, animated: true }), []), + ); + const bottomInset = useBottomBarInset(); // 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 @@ -142,6 +149,7 @@ export function MoviesScreen() { {error instanceof Error && {error.message}} m.id} @@ -161,7 +169,7 @@ export function MoviesScreen() { total > 0 ? {total} films : null } ListEmptyComponent={!isLoading ? no movies : null} - contentContainerStyle={{ paddingBottom: 24 }} + contentContainerStyle={{ paddingBottom: bottomInset }} columnWrapperStyle={{ gap: 8 }} onEndReached={() => { if (hasNextPage && !isFetchingNextPage) fetchNextPage(); diff --git a/mobile/src/screens/ScenesScreen.tsx b/mobile/src/screens/ScenesScreen.tsx index f74edd5..efc9c56 100644 --- a/mobile/src/screens/ScenesScreen.tsx +++ b/mobile/src/screens/ScenesScreen.tsx @@ -17,13 +17,13 @@ import * as Haptics from 'expo-haptics'; import { MigrationBanner } from '../components/MigrationBanner'; import { IconButton, - PillButton, SCREEN_PAD_H, SCREEN_PAD_TOP, SearchInput, SettingsButton, Toolbar, } from '../ui'; +import { useBottomBarInset, useScrollTopListener } from '../lib/bottomBar'; import { SceneTile, sceneGridProps } from '../components/SceneTile'; import { SceneGridSkeleton } from '../components/SceneGridSkeleton'; import { Thumb } from '../components/Thumb'; @@ -69,6 +69,12 @@ export function ScenesScreen({ DEFAULT_FILTER, ); const [filterOpen, setFilterOpen] = useState(false); + const [searchFocused, setSearchFocused] = useState(false); + const listRef = React.useRef>(null); + useScrollTopListener( + React.useCallback(() => listRef.current?.scrollToOffset({ offset: 0, animated: true }), []), + ); + const bottomInset = useBottomBarInset(); React.useEffect(() => { const t = setTimeout(() => setDebouncedQ(q), 350); @@ -169,17 +175,13 @@ export function ScenesScreen({ return ( - - {q.trim().length > 0 ? ( - { - const v = q.trim(); - if (v && !alreadySaved) addSaved.mutate(v); - }} - /> - ) : null} + setSearchFocused(true)} + onBlur={() => setSearchFocused(false)} + /> setFilterOpen(true)} /> + {hasCast ? ( + + navigation.navigate('Performers', verticalKey ? { vertical: verticalKey } : undefined) + } + /> + ) : null} + navigation.navigate('Tags', verticalKey ? { vertical: verticalKey } : undefined)} + /> + - {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) ? ( + {q.trim().length > 0 ? ( + { + const v = q.trim(); + if (v && !alreadySaved) addSaved.mutate(v); + }} + > + + {alreadySaved ? '★ Saved' : '☆ Save this search'} + + + ) : null} {saved.map((s) => { const active = debouncedQ === s.query; return ( @@ -227,26 +260,6 @@ export function ScenesScreen({ ) : null} - - {hasCast ? ( - - navigation.navigate('Performers', verticalKey ? { vertical: verticalKey } : undefined) - } - /> - ) : null} - navigation.navigate('Tags', verticalKey ? { vertical: verticalKey } : undefined)} - /> - - - {error instanceof Error && {error.message}} {/* Nad listą, nie w ListHeaderComponent: tam znikałby przy wyszukiwaniu i odjeżdżał @@ -257,6 +270,7 @@ export function ScenesScreen({ ) : ( s.id} @@ -278,7 +292,7 @@ export function ScenesScreen({ ) : null } ListEmptyComponent={!isLoading ? no scenes : null} - contentContainerStyle={{ paddingBottom: 24 }} + contentContainerStyle={{ paddingBottom: bottomInset }} /> )} @@ -356,11 +370,11 @@ function FavoritesButton() { const hasNew = newTotal > 0; return ( - navigation.navigate('Favorites')} /> ); diff --git a/mobile/src/screens/SitesScreen.tsx b/mobile/src/screens/SitesScreen.tsx index d6e45c3..82710ea 100644 --- a/mobile/src/screens/SitesScreen.tsx +++ b/mobile/src/screens/SitesScreen.tsx @@ -23,6 +23,7 @@ import type { RootStackParamList } from '../navigation'; import { theme } from '../theme'; import { PillButton, SCREEN_PAD_H, SCREEN_PAD_TOP, SearchInput, SettingsButton, Toolbar } from '../ui'; import type { SourceOut, SourceRating } from '../types'; +import { useBottomBarInset, useScrollTopListener } from '../lib/bottomBar'; type Order = 'popular' | 'recent'; @@ -36,6 +37,11 @@ export function SitesScreen() { const [searchFocused, setSearchFocused] = useState(false); // Źródło którego rozkład oceny pokazujemy w modalu (tap w gwiazdki). const [detail, setDetail] = useState(null); + const listRef = React.useRef>(null); + useScrollTopListener( + React.useCallback(() => listRef.current?.scrollToOffset({ offset: 0, animated: true }), []), + ); + const bottomInset = useBottomBarInset(); React.useEffect(() => { const t = setTimeout(() => setDebouncedQ(q), 250); @@ -108,6 +114,7 @@ export function SitesScreen() { {error instanceof Error && {error.message}} s.origin} numColumns={2} @@ -131,7 +138,7 @@ export function SitesScreen() { ListEmptyComponent={ !isLoading ? no sites : null } - contentContainerStyle={{ paddingBottom: 24 }} + contentContainerStyle={{ paddingBottom: bottomInset }} /> setDetail(null)} />