From b847b300bf57c6f59c1a1679776775a4e8c3a427 Mon Sep 17 00:00:00 2001 From: goon-foss Date: Mon, 7 Sep 2026 11:23:49 +0200 Subject: [PATCH] feat(mobile): zakladki na dole ekranu, pierscien swiatla rozciaga sie do nowej ikony - BottomTabsBar: pigulka nad krawedzia, aktywna ikona w pierscieniu; przy zmianie przednia krawedz wystrzela, tylna dogania (kapsula od starej do nowej), jeden kolor accentText z miekka poswiata zamiast neonu z reela - naglowek: nazwa sekcji obok logo zamiast gornego paska - BugReportFAB wisi nad paskiem na ekranie-gospodarzu - changelog 2026-09-07b, OTA a2328005 na 1.3 --- mobile/src/changelog.ts | 8 + mobile/src/components/BottomTabsBar.tsx | 214 ++++++++++++++++++++++++ mobile/src/components/BugReportFAB.tsx | 9 +- mobile/src/navigation.tsx | 27 ++- 4 files changed, 253 insertions(+), 5 deletions(-) create mode 100644 mobile/src/components/BottomTabsBar.tsx diff --git a/mobile/src/changelog.ts b/mobile/src/changelog.ts index 01864c6..cb6ef92 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-07b', + date: 'September 2026', + items: [ + 'Sections (Scenes, Movies, Sites, JAV, Anime) moved to a bar at the bottom of the screen, within thumb reach. The ring of light around the active icon stretches to the next one when you switch.', + 'The section name now sits in the header next to the logo.', + ], + }, { id: '2026-09-07a', date: 'September 2026', diff --git a/mobile/src/components/BottomTabsBar.tsx b/mobile/src/components/BottomTabsBar.tsx new file mode 100644 index 0000000..bce55b6 --- /dev/null +++ b/mobile/src/components/BottomTabsBar.tsx @@ -0,0 +1,214 @@ +/** + * Dolny pasek zakladek: pigulka nad krawedzia ekranu, aktywna zakladke wskazuje + * PIERSCIEN swiatla wokol ikony. Wzor z reela (Instagram DcC_M4Rv1Zp, 2026-09-07). + * + * Jak sie rusza. Przy zmianie zakladki pierscien nie skacze ani nie jedzie jako calosc. + * Krawedz od strony celu wystrzela pierwsza, tylna dogania z opoznieniem, wiec przez + * chwile pierscien jest kapsula rozciagnieta od starej ikony do nowej, po czym zwija + * sie do kola na nowej. Dwa niezalezne drivery (przednia i tylna krawedz), oba + * ease-out, tylna startuje 110 ms pozniej. Zadnego morfowania sciezek SVG, jeden + * z animowanym `left` i `width` (ta sama lekcja co przy gornym pasku, 6d33821). + * + * Kolor. Reel ma neon w innym kolorze na kazdej zakladce, u nas paleta zabrania neonu + * (theme.ts). Pierscien to `accentText` z miekka poswiata (druga, szersza obwodka o + * niskiej przezroczystosci), jeden kolor na wszystkich zakladkach. + * + * 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 { useSafeAreaInsets } from 'react-native-safe-area-context'; + +import { fonts, theme } from '../theme'; + +type IconName = React.ComponentProps['name']; + +interface Props { + tabs: readonly T[]; + icons: Record; + current: T; + onSelect: (tab: T) => void; +} + +/** Promien pierscienia: kolo 40 px wokol ikony 22 px. */ +const RING_R = 20; +const RING_TOP = 8; +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 = 74; +/** Odstep pigulki od dolu (nad inset). Eksport, zeby FAB zglaszania mogl wisiec wyzej. */ +export const BOTTOM_BAR_GAP = 10; + +export function BottomTabsBar({ tabs, icons, current, onSelect }: Props) { + const insets = useSafeAreaInsets(); + const activeIndex = Math.max(0, tabs.indexOf(current)); + + const [centers, setCenters] = React.useState>({}); + const ready = tabs.every((_, i) => centers[i] != null); + + // Skad i dokad jedzie pierscien. `from` to poprzednia aktywna, do pierwszego + // przelaczenia from == to i pierscien stoi. + const [trip, setTrip] = React.useState({ from: activeIndex, to: activeIndex }); + const lead = React.useRef(new Animated.Value(1)).current; + const trail = React.useRef(new Animated.Value(1)).current; + + React.useEffect(() => { + setTrip((prev) => { + if (prev.to === activeIndex) return prev; + return { from: prev.to, to: activeIndex }; + }); + }, [activeIndex]); + + React.useEffect(() => { + if (trip.from === trip.to) return; + lead.setValue(0); + trail.setValue(0); + Animated.parallel([ + Animated.timing(lead, { + toValue: 1, + duration: 260, + easing: Easing.out(Easing.cubic), + useNativeDriver: false, + }), + Animated.timing(trail, { + toValue: 1, + duration: 300, + delay: 110, + easing: Easing.out(Easing.cubic), + useNativeDriver: false, + }), + ]).start(); + }, [trip, lead, trail]); + + const onTabLayout = (i: number) => (e: LayoutChangeEvent) => { + const { x, width } = e.nativeEvent.layout; + const cx = x + width / 2; + setCenters((p) => (p[i] === cx ? p : { ...p, [i]: cx })); + }; + + // Krawedzie pierscienia. Przy ruchu w prawo przednia krawedz to prawa, tylna to + // lewa; przy ruchu w lewo odwrotnie. Szerokosc = prawa - lewa. + const ring = React.useMemo(() => { + if (!ready) return null; + const a = centers[trip.from]; + const b = centers[trip.to]; + if (a == null || b == null) return null; + const toRight = b >= a; + const leftDriver = toRight ? trail : lead; + const rightDriver = toRight ? lead : trail; + const left = leftDriver.interpolate({ inputRange: [0, 1], outputRange: [a - RING_R, b - RING_R] }); + const right = rightDriver.interpolate({ inputRange: [0, 1], outputRange: [a + RING_R, b + RING_R] }); + const width = Animated.subtract(right, left); + return { + left, + width, + glowLeft: Animated.subtract(left, GLOW_PAD), + glowWidth: Animated.add(width, GLOW_PAD * 2), + }; + }, [ready, centers, trip, lead, trail]); + + return ( + + + {ring ? ( + <> + + + + ) : null} + {tabs.map((t, i) => { + const active = i === activeIndex; + return ( + { + if (active) return; + Haptics.selectionAsync().catch(() => {}); + onSelect(t); + }} + > + + + + + {t} + + + ); + })} + + + ); +} + +const styles = StyleSheet.create({ + wrap: { + paddingHorizontal: 14, + paddingTop: 6, + backgroundColor: theme.bg, + }, + pill: { + height: BOTTOM_BAR_HEIGHT, + borderRadius: BOTTOM_BAR_HEIGHT / 2, + backgroundColor: theme.card, + borderWidth: 1, + borderColor: theme.border, + flexDirection: 'row', + alignItems: 'flex-start', + paddingHorizontal: 6, + overflow: 'hidden', + }, + tab: { + flex: 1, + alignItems: 'center', + paddingTop: RING_TOP, + }, + iconBox: { + width: RING_R * 2, + height: RING_R * 2, + alignItems: 'center', + justifyContent: 'center', + }, + label: { + marginTop: 2, + color: theme.muted, + fontSize: 11, + fontFamily: fonts.medium, + letterSpacing: 0.3, + }, + labelActive: { color: theme.accentText, fontFamily: fonts.display }, + ring: { + position: 'absolute', + top: RING_TOP, + height: RING_R * 2, + borderRadius: RING_R, + borderWidth: 1.5, + borderColor: theme.accentText, + }, + // Poswiata: ta sama geometria, szersza obwodka o niskiej przezroczystosci, o 3 px + // wieksza z kazdej strony. + glow: { + position: 'absolute', + top: RING_TOP - GLOW_PAD, + height: RING_R * 2 + GLOW_PAD * 2, + borderRadius: RING_R + GLOW_PAD, + borderWidth: 4, + borderColor: theme.accentText, + opacity: 0.16, + }, +}); diff --git a/mobile/src/components/BugReportFAB.tsx b/mobile/src/components/BugReportFAB.tsx index f5e76d1..41bb7b4 100644 --- a/mobile/src/components/BugReportFAB.tsx +++ b/mobile/src/components/BugReportFAB.tsx @@ -30,10 +30,12 @@ import { TouchableOpacity, View, } from 'react-native'; +import { useSafeAreaInsets } from 'react-native-safe-area-context'; 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 { formatLastPlayback } from '../lib/lastPlayback'; import { getSxyprnDiag } from '../lib/sxyprnResolver'; @@ -116,6 +118,11 @@ export function BugReportFAB({ client, appVersion, navRef }: Props) { return unsub; }, [navRef]); const hidden = currentRoute !== null && FAB_HIDDEN_SCREENS.has(currentRoute); + // Na ekranie-gospodarzu (trasa 'Scenes') na dole wisi pasek zakladek, FAB idzie + // nad niego; na pozostalych ekranach zostaje przy krawedzi. + const insets = useSafeAreaInsets(); + const liftAboveTabs = currentRoute === null || currentRoute === 'Scenes'; + const fabBottom = 24 + (liftAboveTabs ? insets.bottom + BOTTOM_BAR_GAP + BOTTOM_BAR_HEIGHT : 0); const onPress = useCallback(async () => { let captured: string | null = null; @@ -237,7 +244,7 @@ export function BugReportFAB({ client, appVersion, navRef }: Props) { return ( <> {!hidden ? ( - + ? {unseen > 0 ? ( diff --git a/mobile/src/navigation.tsx b/mobile/src/navigation.tsx index f6d4214..b70968f 100644 --- a/mobile/src/navigation.tsx +++ b/mobile/src/navigation.tsx @@ -4,7 +4,7 @@ import { useNavigationContainerRef, } from '@react-navigation/native'; import { BugReportFAB } from './components/BugReportFAB'; -import { TopTabsBar } from './components/TopTabsBar'; +import { BottomTabsBar } from './components/BottomTabsBar'; import { GoonMark } from './components/GoonWordmark'; import { GoonClient } from './api'; import { createNativeStackNavigator } from '@react-navigation/native-stack'; @@ -166,9 +166,17 @@ function HomeScreen({ > - - - + + {tab} + navigation.navigate('Donate')} hitSlop={12}> ♥ @@ -194,10 +202,21 @@ function HomeScreen({ )} + {/* Zakladki na dole (2026-09-07): pierscien swiatla wokol aktywnej ikony, kciuk + nie musi siegac gory ekranu. Nazwa sekcji przeszla do naglowka. */} + ); } +const TAB_ICONS = { + Scenes: 'view-grid-outline', + Movies: 'movie-open-outline', + Sites: 'web', + JAV: 'star-four-points-outline', + Anime: 'animation-play', +} as const; + const navTheme = { ...DefaultTheme, dark: true,