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
This commit is contained in:
goon-foss 2026-09-07 11:23:49 +02:00
parent 7fe9a11136
commit b847b300bf
4 changed files with 253 additions and 5 deletions

View file

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

View file

@ -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
* <View> 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<typeof MaterialCommunityIcons>['name'];
interface Props<T extends string> {
tabs: readonly T[];
icons: Record<T, IconName>;
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<T extends string>({ tabs, icons, current, onSelect }: Props<T>) {
const insets = useSafeAreaInsets();
const activeIndex = Math.max(0, tabs.indexOf(current));
const [centers, setCenters] = React.useState<Record<number, number>>({});
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 (
<View style={[styles.wrap, { paddingBottom: insets.bottom + BOTTOM_BAR_GAP }]}>
<View style={styles.pill}>
{ring ? (
<>
<Animated.View style={[styles.glow, { left: ring.glowLeft, width: ring.glowWidth }]} />
<Animated.View style={[styles.ring, { left: ring.left, width: ring.width }]} />
</>
) : null}
{tabs.map((t, i) => {
const active = i === activeIndex;
return (
<Pressable
key={t}
style={styles.tab}
hitSlop={6}
accessibilityRole="tab"
accessibilityLabel={t}
accessibilityState={{ selected: active }}
onLayout={onTabLayout(i)}
onPress={() => {
if (active) return;
Haptics.selectionAsync().catch(() => {});
onSelect(t);
}}
>
<View style={styles.iconBox}>
<MaterialCommunityIcons
name={icons[t]}
size={ICON_SIZE}
color={active ? theme.accentText : theme.muted}
/>
</View>
<Text numberOfLines={1} style={[styles.label, active ? styles.labelActive : null]}>
{t}
</Text>
</Pressable>
);
})}
</View>
</View>
);
}
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,
},
});

View file

@ -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 ? (
<TouchableOpacity style={styles.fab} onPress={onPress} activeOpacity={0.7}>
<TouchableOpacity style={[styles.fab, { bottom: fabBottom }]} onPress={onPress} activeOpacity={0.7}>
<Text style={styles.fabIcon}>?</Text>
{unseen > 0 ? (
<View style={styles.fabDot}>

View file

@ -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({
>
<GoonMark size={28} />
<View style={{ width: 1, height: 16, backgroundColor: theme.border }} />
<View style={{ flex: 1, minWidth: 170 }}>
<TopTabsBar tabs={tabs} current={tab} onSelect={setTab} />
</View>
<Text
style={{
flex: 1,
color: theme.fg,
fontFamily: fonts.display,
fontSize: 17,
letterSpacing: 0.2,
}}
>
{tab}
</Text>
<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>
@ -194,10 +202,21 @@ function HomeScreen({
<ScenesScreen />
)}
</View>
{/* Zakladki na dole (2026-09-07): pierscien swiatla wokol aktywnej ikony, kciuk
nie musi siegac gory ekranu. Nazwa sekcji przeszla do naglowka. */}
<BottomTabsBar tabs={tabs} icons={TAB_ICONS} current={tab} onSelect={setTab} />
</View>
);
}
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,