diff --git a/mobile/App.tsx b/mobile/App.tsx index b00ed41..bc320c6 100644 --- a/mobile/App.tsx +++ b/mobile/App.tsx @@ -113,12 +113,16 @@ function applyDefaultFont() { } export default function App() { - const [fontsLoaded] = useFonts({ + const [fontsLoaded, fontsError] = useFonts({ 'GeneralSans-Regular': require('./assets/fonts/GeneralSans-Regular.ttf'), 'GeneralSans-Medium': require('./assets/fonts/GeneralSans-Medium.ttf'), 'GeneralSans-Semibold': require('./assets/fonts/GeneralSans-Semibold.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(); const [hydrated, setHydrated] = useState(false); @@ -323,7 +327,7 @@ export default function App() { return () => sub.remove(); }, []); - if (!fontsLoaded || !hydrated || !lockReady) { + if (!fontsSettled || !hydrated || !lockReady) { return ( diff --git a/mobile/src/changelog.ts b/mobile/src/changelog.ts index 56c287a..43396e0 100644 --- a/mobile/src/changelog.ts +++ b/mobile/src/changelog.ts @@ -16,6 +16,13 @@ export type 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', date: 'August 2026', diff --git a/mobile/src/components/BugReportFAB.tsx b/mobile/src/components/BugReportFAB.tsx index 5d885e0..f5e76d1 100644 --- a/mobile/src/components/BugReportFAB.tsx +++ b/mobile/src/components/BugReportFAB.tsx @@ -160,6 +160,10 @@ export function BugReportFAB({ client, appVersion, navRef }: Props) { const route = navRef.isReady() ? navRef.getCurrentRoute() : null; routeName = route?.name ?? null; const params = (route?.params ?? {}) as Record; + // 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 = (params['sceneId'] as string | undefined) ?? (params['id'] as string | undefined); diff --git a/mobile/src/components/LiquidTabs.tsx b/mobile/src/components/LiquidTabs.tsx index 90a399b..c1d2d14 100644 --- a/mobile/src/components/LiquidTabs.tsx +++ b/mobile/src/components/LiquidTabs.tsx @@ -15,7 +15,7 @@ * realna szerokość zależy od telefonu. Do pierwszego pomiaru rysujemy sam pasek. */ 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 { liquidTabPath, plainBar } from '../lib/liquidTabPath'; @@ -65,7 +65,11 @@ export function LiquidTabs({ tabs, current, onSelect }: Props< } const a = Animated.timing(anim, { 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, }); a.start(); diff --git a/mobile/src/navigation.tsx b/mobile/src/navigation.tsx index dcb15b9..368845d 100644 --- a/mobile/src/navigation.tsx +++ b/mobile/src/navigation.tsx @@ -10,6 +10,7 @@ import { GoonClient } from './api'; import { createNativeStackNavigator } from '@react-navigation/native-stack'; import React from 'react'; import { Pressable, Text, View } from 'react-native'; +import { useSafeAreaInsets } from 'react-native-safe-area-context'; import { AppLockSettingsScreen } from './screens/AppLockSettingsScreen'; import { BlacklistScreen } from './screens/BlacklistScreen'; import { DiagnosticBrowserScreen } from './screens/DiagnosticBrowserScreen'; @@ -31,7 +32,12 @@ import { TagsScreen } from './screens/TagsScreen'; import { fonts, theme } from './theme'; 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; Sites: undefined; // JAV: osobna sekcja, ScenesScreen z paramem jav → backend ?jav=true. @@ -112,23 +118,85 @@ const Stack = createNativeStackNavigator(); type TopTab = 'Scenes' | 'Movies' | 'Sites' | 'JAV' | 'Anime'; -function TopTabs({ - current, - onNavigate, +/** + * Jeden ekran-gospodarz dla pięciu zakładek. Zakładka to STAN LOKALNY, nie trasa. + * + * 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; - onNavigate: (tab: TopTab) => void; + navigation: { setParams: (p: { tab?: string }) => void; navigate: (r: 'Donate' | 'AppLockSettings') => void }; + onLogout: () => void; }) { + const insets = useSafeAreaInsets(); const tabs: TopTab[] = ['Scenes', 'Movies', 'Sites', 'JAV', 'Anime']; + const [tab, setTab] = React.useState('Scenes'); + + // BugReportFAB czyta zakładkę z route.params.tab (nazwa trasy to zawsze 'Scenes'). + React.useEffect(() => { + navigation.setParams({ tab }); + }, [tab, navigation]); + return ( - // Logo + separator zostają statyczne, przewija się (i „roztapia") tylko pasek. - // `flexShrink` trzyma całość w swojej połowie nagłówka: bez tego pasek rozpycha - // się na całą szerokość i nachodzi na ikony po prawej. - - - - - + + {/* Własny nagłówek zamiast natywnego (headerShown:false): natywny należy do + ekranu, a ten pasek musi przeżywać przełączanie zakładek. Ikony po prawej są + 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). */} + + + + + + + + navigation.navigate('Donate')} hitSlop={12}> + ♥ + + navigation.navigate('AppLockSettings')} hitSlop={12}> + ⚙ + + + 🚪 + + + + + {tab === 'Movies' ? ( + + ) : tab === 'Sites' ? ( + + ) : tab === 'JAV' ? ( + + ) : tab === 'Anime' ? ( + + ) : ( + + )} ); @@ -170,121 +238,9 @@ export function AppNavigator({ onLogout, client, appVersion }: AppNavigatorProps contentStyle: { backgroundColor: theme.bg }, }} > - ({ - title: '', - headerLeft: () => ( - navigation.replace(t)} - /> - ), - headerRight: () => ( - - navigation.navigate('Donate')} hitSlop={12}> - ♥ - - navigation.navigate('AppLockSettings')} - hitSlop={12} - > - ⚙ - - - 🚪 - - - ), - })} - /> - ({ - title: '', - headerBackVisible: false, - headerLeft: () => ( - navigation.replace(t)} - /> - ), - headerRight: () => ( - - - 🚪 - - - ), - })} - /> - ({ - title: '', - headerBackVisible: false, - headerLeft: () => ( - navigation.replace(t)} - /> - ), - headerRight: () => ( - - - 🚪 - - - ), - })} - /> - ({ - title: '', - headerBackVisible: false, - headerLeft: () => ( - navigation.replace(t)} - /> - ), - headerRight: () => ( - - - 🚪 - - - ), - })} - /> - ({ - title: '', - headerBackVisible: false, - headerLeft: () => ( - navigation.replace(t)} - /> - ), - headerRight: () => ( - - - 🚪 - - - ), - })} - /> + + {({ navigation }) => } + >(); - // 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. const route = useRoute(); const params = route.params as { jav?: boolean; vertical?: string } | undefined; - const jav = params?.jav === true; - const vertical = params?.vertical; + const jav = javProp ?? (params?.jav === true); + const vertical = verticalProp ?? params?.vertical; // Wspólna nazwa wertykału do zapytań o tagi (stary `jav` też ma swój słownik). const verticalKey = vertical ?? (jav ? 'jav' : undefined); // Animacja NIE MA obsady i to nie brak danych, tylko wlasciwosc tresci: dla postaci