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:
goon-foss 2026-09-07 11:45:53 +02:00
parent 90724af8ef
commit d9fbc08218
7 changed files with 240 additions and 50 deletions

View file

@ -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',

View file

@ -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<typeof MaterialCommunityIcons>['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<T extends string>({ tabs, icons, current, onSelect }: Props<T>) {
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<Record<number, number>>({});
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]);
if (keyboardUp) return null;
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}>
{ring ? (
<>
@ -137,8 +175,12 @@ export function BottomTabsBar<T extends string>({ 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<T extends string>({ tabs, icons, current, onSelect
);
})}
</View>
</View>
</Animated.View>
);
}
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,

View file

@ -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';

108
mobile/src/lib/bottomBar.ts Normal file
View 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]);
}

View file

@ -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<FlatList<MovieOut>>(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 && <Text style={styles.error}>{error.message}</Text>}
<FlatList
ref={listRef}
data={items}
numColumns={NUM_COLS}
keyExtractor={(m) => m.id}
@ -161,7 +169,7 @@ export function MoviesScreen() {
total > 0 ? <Text style={styles.subtitle}>{total} films</Text> : null
}
ListEmptyComponent={!isLoading ? <Text style={styles.muted}>no movies</Text> : null}
contentContainerStyle={{ paddingBottom: 24 }}
contentContainerStyle={{ paddingBottom: bottomInset }}
columnWrapperStyle={{ gap: 8 }}
onEndReached={() => {
if (hasNextPage && !isFetchingNextPage) fetchNextPage();

View file

@ -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<FlatList<SceneOut>>(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 (
<View style={styles.container}>
<Toolbar>
<SearchInput value={q} onChangeText={setQ} placeholder="search title…" />
{q.trim().length > 0 ? (
<PillButton
label={alreadySaved ? '★ Saved' : '☆ Save'}
active={alreadySaved}
onPress={() => {
const v = q.trim();
if (v && !alreadySaved) addSaved.mutate(v);
}}
/>
) : null}
<SearchInput
value={q}
onChangeText={setQ}
placeholder="search title…"
onFocus={() => setSearchFocused(true)}
onBlur={() => setSearchFocused(false)}
/>
<IconButton
icon="filter-variant"
accessibilityLabel="Filters"
@ -187,16 +189,47 @@ export function ScenesScreen({
badge={activeCount}
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 />
</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
horizontal
showsHorizontalScrollIndicator={false}
style={styles.savedRow}
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) => {
const active = debouncedQ === s.query;
return (
@ -227,26 +260,6 @@ export function ScenesScreen({
</ScrollView>
) : 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>}
{/* Nad listą, nie w ListHeaderComponent: tam znikałby przy wyszukiwaniu i odjeżdżał
@ -257,6 +270,7 @@ export function ScenesScreen({
<SceneGridSkeleton count={8} />
) : (
<FlatList
ref={listRef}
{...sceneGridProps(gridColumns)}
data={items}
keyExtractor={(s) => s.id}
@ -278,7 +292,7 @@ export function ScenesScreen({
) : 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;
return (
<PillButton
label={`★ ${hasNew ? newTotal : ''}`.trim()}
tone="secondary"
compact
<IconButton
icon={hasNew ? 'star' : 'star-outline'}
accessibilityLabel="Favorites"
active={hasNew}
badge={newTotal}
onPress={() => navigation.navigate('Favorites')}
/>
);

View file

@ -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<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(() => {
const t = setTimeout(() => setDebouncedQ(q), 250);
@ -108,6 +114,7 @@ export function SitesScreen() {
{error instanceof Error && <Text style={styles.error}>{error.message}</Text>}
<FlatList
ref={listRef}
data={items}
keyExtractor={(s) => s.origin}
numColumns={2}
@ -131,7 +138,7 @@ export function SitesScreen() {
ListEmptyComponent={
!isLoading ? <Text style={styles.emptyText}>no sites</Text> : null
}
contentContainerStyle={{ paddingBottom: 24 }}
contentContainerStyle={{ paddingBottom: bottomInset }}
/>
<RatingModal source={detail} onClose={() => setDetail(null)} />