fix(ui): pasek zakladek naprawde plynie - zakladki jako stan, nie trasy

Zgloszenie: "zamiast slicznych animowanych przesuwajacych sie elementow mam
statyczna kropke". Dwie niezalezne przyczyny, obie konieczne do naprawy:

1. Animacja NIE MIALA FIZYCZNEJ MOZLIWOSCI sie wykonac. Kazda zakladka byla
   osobnym ekranem native stacka, a przelaczenie robilo navigation.replace(t),
   czyli wymienialo caly ekran razem z naglowkiem. LiquidTabs montowal sie od
   nowa z kropla juz na docelowej pozycji (pierwsze ustawienie celowo bez
   animacji). Fix: jeden HomeScreen, zakladka to stan lokalny, pasek przezywa
   przelaczenie. key={tab} na tresci zachowuje dotychczasowy reset stanu.

2. Pasek byl NIEWIDZIALNY: wypelnienie theme.card na naglowku o tle theme.card.
   Z calego "roztapiania" zostawala sama akcentowa kropka. Fix: naglowek na
   theme.bg.

Do tego drobne: easing Easing.out(cubic) 260ms (wczesniej bez znaczenia, bo kod
nigdy nie dzialal); BugReportFAB czyta zakladke z params.tab (nazwa trasy to
teraz zawsze 'Scenes'); ikony naglowka ujednolicone na wszystkich zakladkach;
App.tsx: blad ladowania fontow przestaje wieszac boot na wiecznym spinnerze
(fail-open na system font) - obnazone przez rozjazd JS/natywki w dev buildzie.

Trasy Movies/Sites/JAV/Anime wyrejestrowane (nic poza paskiem do nich nie
nawigowalo); typy w RootStackParamList zostaja dla useNavigation.

Zweryfikowane nagraniem ekranu emulatora klatka po klatce: kropla plynie
Scenes->Sites i JAV->Anime z wedrujacym wglebieniem, pasek widoczny, tresc
i przycisk Performers/Tags zgodne z zakladka.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
goon-foss 2026-08-18 09:59:24 +02:00
parent f48fbbd4ad
commit 0949091f78
6 changed files with 120 additions and 137 deletions

View file

@ -113,12 +113,16 @@ function applyDefaultFont() {
} }
export default function App() { export default function App() {
const [fontsLoaded] = useFonts({ const [fontsLoaded, fontsError] = useFonts({
'GeneralSans-Regular': require('./assets/fonts/GeneralSans-Regular.ttf'), 'GeneralSans-Regular': require('./assets/fonts/GeneralSans-Regular.ttf'),
'GeneralSans-Medium': require('./assets/fonts/GeneralSans-Medium.ttf'), 'GeneralSans-Medium': require('./assets/fonts/GeneralSans-Medium.ttf'),
'GeneralSans-Semibold': require('./assets/fonts/GeneralSans-Semibold.ttf'), 'GeneralSans-Semibold': require('./assets/fonts/GeneralSans-Semibold.ttf'),
'GeistMono-Regular': require('./assets/fonts/GeistMono-Regular.ttf'), 'GeistMono-Regular': require('./assets/fonts/GeistMono-Regular.ttf'),
}); });
// Fail-open: błąd ładowania fontów NIE MOŻE wieszać bootu. `fontsLoaded` zostaje
// false na zawsze przy odrzuconym loadAsync (np. rozjazd JS/natywki w dev build,
// 2026-08-18: 'AppDirectories not found' → wieczny spinner). System font < brak apki.
const fontsSettled = fontsLoaded || fontsError != null;
if (fontsLoaded) applyDefaultFont(); if (fontsLoaded) applyDefaultFont();
const [hydrated, setHydrated] = useState(false); const [hydrated, setHydrated] = useState(false);
@ -323,7 +327,7 @@ export default function App() {
return () => sub.remove(); return () => sub.remove();
}, []); }, []);
if (!fontsLoaded || !hydrated || !lockReady) { if (!fontsSettled || !hydrated || !lockReady) {
return ( return (
<View style={{ flex: 1, backgroundColor: theme.bg, justifyContent: 'center' }}> <View style={{ flex: 1, backgroundColor: theme.bg, justifyContent: 'center' }}>
<ActivityIndicator color={theme.fg} /> <ActivityIndicator color={theme.fg} />

View file

@ -16,6 +16,13 @@ export type ChangelogEntry = {
}; };
export const CHANGELOG: ChangelogEntry[] = [ export const CHANGELOG: ChangelogEntry[] = [
{
id: '2026-08-18',
date: 'August 2026',
items: [
'The liquid tab bar now actually flows. The melt animation existed but could never run: switching tabs rebuilt the whole header, so the blob just teleported. The bar also drew in the same colour as the header behind it, which reduced the whole effect to a floating dot. Both fixed, the blob now slides and the bar melts around it.',
],
},
{ {
id: '2026-08-14e', id: '2026-08-14e',
date: 'August 2026', date: 'August 2026',

View file

@ -160,6 +160,10 @@ export function BugReportFAB({ client, appVersion, navRef }: Props) {
const route = navRef.isReady() ? navRef.getCurrentRoute() : null; const route = navRef.isReady() ? navRef.getCurrentRoute() : null;
routeName = route?.name ?? null; routeName = route?.name ?? null;
const params = (route?.params ?? {}) as Record<string, unknown>; const params = (route?.params ?? {}) as Record<string, unknown>;
// Zakładki (Scenes/Movies/Sites/JAV/Anime) to JEDNA trasa ze stanem lokalnym
// (HomeScreen), więc nazwa trasy nie mówi, na której zakładce user był. HomeScreen
// odkłada ją w params.tab — bez tego triage zgłoszeń straciłby rozróżnienie.
if (typeof params.tab === 'string' && params.tab) routeName = params.tab;
rawSceneId = rawSceneId =
(params['sceneId'] as string | undefined) ?? (params['sceneId'] as string | undefined) ??
(params['id'] as string | undefined); (params['id'] as string | undefined);

View file

@ -15,7 +15,7 @@
* realna szerokość zależy od telefonu. Do pierwszego pomiaru rysujemy sam pasek. * realna szerokość zależy od telefonu. Do pierwszego pomiaru rysujemy sam pasek.
*/ */
import React from 'react'; import React from 'react';
import { Animated, Pressable, StyleSheet, Text, View } from 'react-native'; import { Animated, Easing, Pressable, StyleSheet, Text, View } from 'react-native';
import Svg, { Path } from 'react-native-svg'; import Svg, { Path } from 'react-native-svg';
import { liquidTabPath, plainBar } from '../lib/liquidTabPath'; import { liquidTabPath, plainBar } from '../lib/liquidTabPath';
@ -65,7 +65,11 @@ export function LiquidTabs<T extends string>({ tabs, current, onSelect }: Props<
} }
const a = Animated.timing(anim, { const a = Animated.timing(anim, {
toValue: targetCx, toValue: targetCx,
duration: 200, // Szybki start i miękkie dojście: kropla „dopływa" zamiast stawać jak wagon.
// Do 2026-08-18 ten kod nigdy nie działał (pasek montował się od nowa przy każdym
// przełączeniu, patrz HomeScreen w navigation.tsx), więc easing nie miał znaczenia.
duration: 260,
easing: Easing.out(Easing.cubic),
useNativeDriver: false, useNativeDriver: false,
}); });
a.start(); a.start();

View file

@ -10,6 +10,7 @@ import { GoonClient } from './api';
import { createNativeStackNavigator } from '@react-navigation/native-stack'; import { createNativeStackNavigator } from '@react-navigation/native-stack';
import React from 'react'; import React from 'react';
import { Pressable, Text, View } from 'react-native'; import { Pressable, Text, View } from 'react-native';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
import { AppLockSettingsScreen } from './screens/AppLockSettingsScreen'; import { AppLockSettingsScreen } from './screens/AppLockSettingsScreen';
import { BlacklistScreen } from './screens/BlacklistScreen'; import { BlacklistScreen } from './screens/BlacklistScreen';
import { DiagnosticBrowserScreen } from './screens/DiagnosticBrowserScreen'; import { DiagnosticBrowserScreen } from './screens/DiagnosticBrowserScreen';
@ -31,7 +32,12 @@ import { TagsScreen } from './screens/TagsScreen';
import { fonts, theme } from './theme'; import { fonts, theme } from './theme';
export type RootStackParamList = { export type RootStackParamList = {
Scenes: undefined; // `tab` odkłada HomeScreen (setParams) wyłącznie po to, żeby BugReportFAB wiedział,
// na której zakładce user był: zakładki to stan lokalny, nie osobne trasy.
Scenes: { tab?: string } | undefined;
// UWAGA: Movies/Sites/JAV/Anime NIE sa zarejestrowane w navigatorze od 2026-08-18 —
// zakladki to stan lokalny HomeScreen (trasa 'Scenes'). Klucze zostaja, bo ekrany
// typuja useNavigation<...,'Movies'> itd.; nawigowanie do nich rzuciloby bledem.
Movies: undefined; Movies: undefined;
Sites: undefined; Sites: undefined;
// JAV: osobna sekcja, ScenesScreen z paramem jav → backend ?jav=true. // JAV: osobna sekcja, ScenesScreen z paramem jav → backend ?jav=true.
@ -112,23 +118,85 @@ const Stack = createNativeStackNavigator<RootStackParamList>();
type TopTab = 'Scenes' | 'Movies' | 'Sites' | 'JAV' | 'Anime'; type TopTab = 'Scenes' | 'Movies' | 'Sites' | 'JAV' | 'Anime';
function TopTabs({ /**
current, * Jeden ekran-gospodarz dla pięciu zakładek. Zakładka to STAN LOKALNY, nie trasa.
onNavigate, *
* Poprzednio każda zakładka była osobnym ekranem native stacka, a przełączenie robiło
* `navigation.replace(t)` — czyli wymieniało cały ekran RAZEM z nagłówkiem. LiquidTabs
* montował się od nowa z kroplą już na docelowej pozycji (pierwsze ustawienie jest
* celowo bez animacji), więc animacja „przelewania" nie miała fizycznej możliwości się
* wykonać: user widział statyczną, teleportującą się kropkę (zgłoszenie 2026-08-18).
* Pasek musi przeżywać przełączenie, żeby się animować, stąd jeden ekran.
*
* `key={tab}` na treści wymusza czysty remount przy przełączeniu — to samo, co robiło
* `replace` (reset wyszukiwania/scrolla). Bez key przełączenie Scenes→JAV reużyłoby
* instancję ScenesScreen i zostawiło stary tekst wyszukiwania w nowej sekcji.
*/
function HomeScreen({
navigation,
onLogout,
}: { }: {
current: TopTab; navigation: { setParams: (p: { tab?: string }) => void; navigate: (r: 'Donate' | 'AppLockSettings') => void };
onNavigate: (tab: TopTab) => void; onLogout: () => void;
}) { }) {
const insets = useSafeAreaInsets();
const tabs: TopTab[] = ['Scenes', 'Movies', 'Sites', 'JAV', 'Anime']; const tabs: TopTab[] = ['Scenes', 'Movies', 'Sites', 'JAV', 'Anime'];
const [tab, setTab] = React.useState<TopTab>('Scenes');
// BugReportFAB czyta zakładkę z route.params.tab (nazwa trasy to zawsze 'Scenes').
React.useEffect(() => {
navigation.setParams({ tab });
}, [tab, navigation]);
return ( return (
// Logo + separator zostają statyczne, przewija się (i „roztapia") tylko pasek. <View style={{ flex: 1 }}>
// `flexShrink` trzyma całość w swojej połowie nagłówka: bez tego pasek rozpycha {/* Własny nagłówek zamiast natywnego (headerShown:false): natywny należy do
// się na całą szerokość i nachodzi na ikony po prawej. ekranu, a ten pasek musi przeżywać przełączanie zakładek. Ikony po prawej są
<View style={{ flexDirection: 'row', alignItems: 'center', gap: 8, paddingLeft: 10, flexShrink: 1 }}> teraz jednakowe na każdej zakładce (wcześniej ♥ i ⚙ miał tylko Scenes). */}
{/* Tło nagłówka MUSI różnić się od wypełnienia paska (theme.card), inaczej
roztopiony kształt jest niewidzialny i z całego efektu zostaje sama kropka —
to była druga połowa zgłoszenia „statyczna kropka" (2026-08-18). */}
<View
style={{
backgroundColor: theme.bg,
paddingTop: insets.top,
flexDirection: 'row',
alignItems: 'center',
gap: 8,
paddingLeft: 10,
paddingRight: 14,
paddingBottom: 2,
}}
>
<GoonMark size={28} /> <GoonMark size={28} />
<View style={{ width: 1, height: 16, backgroundColor: theme.border }} /> <View style={{ width: 1, height: 16, backgroundColor: theme.border }} />
<View style={{ flex: 1, minWidth: 170 }}> <View style={{ flex: 1, minWidth: 170 }}>
<LiquidTabs tabs={tabs} current={current} onSelect={onNavigate} /> <LiquidTabs tabs={tabs} current={tab} onSelect={setTab} />
</View>
<View style={{ flexDirection: 'row', gap: 14, alignItems: 'center', marginLeft: 6 }}>
<Pressable onPress={() => navigation.navigate('Donate')} hitSlop={12}>
<Text style={{ color: theme.accentText, fontSize: 18 }}>♥</Text>
</Pressable>
<Pressable onPress={() => navigation.navigate('AppLockSettings')} hitSlop={12}>
<Text style={{ color: theme.muted, fontSize: 18 }}>⚙</Text>
</Pressable>
<Pressable onPress={onLogout} hitSlop={12}>
<Text style={{ fontSize: 17 }}>🚪</Text>
</Pressable>
</View>
</View>
<View style={{ flex: 1 }} key={tab}>
{tab === 'Movies' ? (
<MoviesScreen />
) : tab === 'Sites' ? (
<SitesScreen />
) : tab === 'JAV' ? (
<ScenesScreen jav />
) : tab === 'Anime' ? (
<ScenesScreen vertical="animation" />
) : (
<ScenesScreen />
)}
</View> </View>
</View> </View>
); );
@ -170,121 +238,9 @@ export function AppNavigator({ onLogout, client, appVersion }: AppNavigatorProps
contentStyle: { backgroundColor: theme.bg }, contentStyle: { backgroundColor: theme.bg },
}} }}
> >
<Stack.Screen <Stack.Screen name="Scenes" options={{ headerShown: false }}>
name="Scenes" {({ navigation }) => <HomeScreen navigation={navigation} onLogout={onLogout} />}
component={ScenesScreen} </Stack.Screen>
options={({ navigation }) => ({
title: '',
headerLeft: () => (
<TopTabs
current="Scenes"
onNavigate={(t) => navigation.replace(t)}
/>
),
headerRight: () => (
<View style={{ flexDirection: 'row', gap: 14, alignItems: 'center' }}>
<Pressable onPress={() => navigation.navigate('Donate')} hitSlop={12}>
<Text style={{ color: theme.accentText, fontSize: 18 }}>♥</Text>
</Pressable>
<Pressable
onPress={() => navigation.navigate('AppLockSettings')}
hitSlop={12}
>
<Text style={{ color: theme.muted, fontSize: 18 }}>⚙</Text>
</Pressable>
<Pressable onPress={onLogout} hitSlop={12}>
<Text style={{ fontSize: 17 }}>🚪</Text>
</Pressable>
</View>
),
})}
/>
<Stack.Screen
name="Movies"
component={MoviesScreen}
options={({ navigation }) => ({
title: '',
headerBackVisible: false,
headerLeft: () => (
<TopTabs
current="Movies"
onNavigate={(t) => navigation.replace(t)}
/>
),
headerRight: () => (
<View style={{ flexDirection: 'row', gap: 14, alignItems: 'center' }}>
<Pressable onPress={onLogout} hitSlop={12}>
<Text style={{ fontSize: 17 }}>🚪</Text>
</Pressable>
</View>
),
})}
/>
<Stack.Screen
name="Sites"
component={SitesScreen}
options={({ navigation }) => ({
title: '',
headerBackVisible: false,
headerLeft: () => (
<TopTabs
current="Sites"
onNavigate={(t) => navigation.replace(t)}
/>
),
headerRight: () => (
<View style={{ flexDirection: 'row', gap: 14, alignItems: 'center' }}>
<Pressable onPress={onLogout} hitSlop={12}>
<Text style={{ fontSize: 17 }}>🚪</Text>
</Pressable>
</View>
),
})}
/>
<Stack.Screen
name="JAV"
component={ScenesScreen}
initialParams={{ jav: true }}
options={({ navigation }) => ({
title: '',
headerBackVisible: false,
headerLeft: () => (
<TopTabs
current="JAV"
onNavigate={(t) => navigation.replace(t)}
/>
),
headerRight: () => (
<View style={{ flexDirection: 'row', gap: 14, alignItems: 'center' }}>
<Pressable onPress={onLogout} hitSlop={12}>
<Text style={{ fontSize: 17 }}>🚪</Text>
</Pressable>
</View>
),
})}
/>
<Stack.Screen
name="Anime"
component={ScenesScreen}
initialParams={{ vertical: 'animation' }}
options={({ navigation }) => ({
title: '',
headerBackVisible: false,
headerLeft: () => (
<TopTabs
current="Anime"
onNavigate={(t) => navigation.replace(t)}
/>
),
headerRight: () => (
<View style={{ flexDirection: 'row', gap: 14, alignItems: 'center' }}>
<Pressable onPress={onLogout} hitSlop={12}>
<Text style={{ fontSize: 17 }}>🚪</Text>
</Pressable>
</View>
),
})}
/>
<Stack.Screen <Stack.Screen
name="SiteScenes" name="SiteScenes"
component={SiteScenesScreen} component={SiteScenesScreen}

View file

@ -26,16 +26,24 @@ import type { RootStackParamList } from '../navigation';
import type { SceneOut } from '../types'; import type { SceneOut } from '../types';
import { DEFAULT_FILTER, FilterState, ScenesFilterModal } from './ScenesFilterModal'; import { DEFAULT_FILTER, FilterState, ScenesFilterModal } from './ScenesFilterModal';
export function ScenesScreen() { export function ScenesScreen({
jav: javProp,
vertical: verticalProp,
}: {
/** Nadpisuje param trasy. HomeScreen renderuje ten ekran BEZ nawigacji (zakładki to
* stan lokalny, nie osobne trasy), więc wertykał przychodzi propsem, nie paramem. */
jav?: boolean;
vertical?: string;
} = {}) {
const client = useClient(); const client = useClient();
const { gridColumns } = usePreferences(); const { gridColumns } = usePreferences();
const navigation = useNavigation<NativeStackNavigationProp<RootStackParamList, 'Scenes'>>(); const navigation = useNavigation<NativeStackNavigationProp<RootStackParamList, 'Scenes'>>();
// Zakładki wertykałów reużywają ten ekran: JAV przez stary param `jav`, Anime przez // Zakładki wertykałów reużywają ten ekran: JAV przez `jav`, Anime przez
// `vertical='animation'`. Brak obu → główny feed, z którego backend odcina wertykały. // `vertical='animation'`. Brak obu → główny feed, z którego backend odcina wertykały.
const route = useRoute(); const route = useRoute();
const params = route.params as { jav?: boolean; vertical?: string } | undefined; const params = route.params as { jav?: boolean; vertical?: string } | undefined;
const jav = params?.jav === true; const jav = javProp ?? (params?.jav === true);
const vertical = params?.vertical; const vertical = verticalProp ?? params?.vertical;
// Wspólna nazwa wertykału do zapytań o tagi (stary `jav` też ma swój słownik). // Wspólna nazwa wertykału do zapytań o tagi (stary `jav` też ma swój słownik).
const verticalKey = vertical ?? (jav ? 'jav' : undefined); const verticalKey = vertical ?? (jav ? 'jav' : undefined);
// Animacja NIE MA obsady i to nie brak danych, tylko wlasciwosc tresci: dla postaci // Animacja NIE MA obsady i to nie brak danych, tylko wlasciwosc tresci: dla postaci