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[] = [
|
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',
|
id: '2026-09-07a',
|
||||||
date: 'September 2026',
|
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,
|
TouchableOpacity,
|
||||||
View,
|
View,
|
||||||
} from 'react-native';
|
} from 'react-native';
|
||||||
|
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||||
import { captureScreen } from 'react-native-view-shot';
|
import { captureScreen } from 'react-native-view-shot';
|
||||||
|
|
||||||
import { GoonClient } from '../api';
|
import { GoonClient } from '../api';
|
||||||
import { theme } from '../theme';
|
import { theme } from '../theme';
|
||||||
|
import { BOTTOM_BAR_GAP, BOTTOM_BAR_HEIGHT } from './BottomTabsBar';
|
||||||
import { formatLastPlayback } from '../lib/lastPlayback';
|
import { formatLastPlayback } from '../lib/lastPlayback';
|
||||||
import { getSxyprnDiag } from '../lib/sxyprnResolver';
|
import { getSxyprnDiag } from '../lib/sxyprnResolver';
|
||||||
|
|
||||||
|
|
@ -116,6 +118,11 @@ export function BugReportFAB({ client, appVersion, navRef }: Props) {
|
||||||
return unsub;
|
return unsub;
|
||||||
}, [navRef]);
|
}, [navRef]);
|
||||||
const hidden = currentRoute !== null && FAB_HIDDEN_SCREENS.has(currentRoute);
|
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 () => {
|
const onPress = useCallback(async () => {
|
||||||
let captured: string | null = null;
|
let captured: string | null = null;
|
||||||
|
|
@ -237,7 +244,7 @@ export function BugReportFAB({ client, appVersion, navRef }: Props) {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{!hidden ? (
|
{!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>
|
<Text style={styles.fabIcon}>?</Text>
|
||||||
{unseen > 0 ? (
|
{unseen > 0 ? (
|
||||||
<View style={styles.fabDot}>
|
<View style={styles.fabDot}>
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,7 @@ import {
|
||||||
useNavigationContainerRef,
|
useNavigationContainerRef,
|
||||||
} from '@react-navigation/native';
|
} from '@react-navigation/native';
|
||||||
import { BugReportFAB } from './components/BugReportFAB';
|
import { BugReportFAB } from './components/BugReportFAB';
|
||||||
import { TopTabsBar } from './components/TopTabsBar';
|
import { BottomTabsBar } from './components/BottomTabsBar';
|
||||||
import { GoonMark } from './components/GoonWordmark';
|
import { GoonMark } from './components/GoonWordmark';
|
||||||
import { GoonClient } from './api';
|
import { GoonClient } from './api';
|
||||||
import { createNativeStackNavigator } from '@react-navigation/native-stack';
|
import { createNativeStackNavigator } from '@react-navigation/native-stack';
|
||||||
|
|
@ -166,9 +166,17 @@ function HomeScreen({
|
||||||
>
|
>
|
||||||
<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 }}>
|
<Text
|
||||||
<TopTabsBar tabs={tabs} current={tab} onSelect={setTab} />
|
style={{
|
||||||
</View>
|
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 }}>
|
<View style={{ flexDirection: 'row', gap: 14, alignItems: 'center', marginLeft: 6 }}>
|
||||||
<Pressable onPress={() => navigation.navigate('Donate')} hitSlop={12}>
|
<Pressable onPress={() => navigation.navigate('Donate')} hitSlop={12}>
|
||||||
<Text style={{ color: theme.accentText, fontSize: 18 }}>♥</Text>
|
<Text style={{ color: theme.accentText, fontSize: 18 }}>♥</Text>
|
||||||
|
|
@ -194,10 +202,21 @@ function HomeScreen({
|
||||||
<ScenesScreen />
|
<ScenesScreen />
|
||||||
)}
|
)}
|
||||||
</View>
|
</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>
|
</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 = {
|
const navTheme = {
|
||||||
...DefaultTheme,
|
...DefaultTheme,
|
||||||
dark: true,
|
dark: true,
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue