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:
parent
7fe9a11136
commit
b847b300bf
4 changed files with 253 additions and 5 deletions
|
|
@ -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',
|
||||
|
|
|
|||
214
mobile/src/components/BottomTabsBar.tsx
Normal file
214
mobile/src/components/BottomTabsBar.tsx
Normal 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,
|
||||
},
|
||||
});
|
||||
|
|
@ -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}>
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue