goon/mobile/src/components/OnboardingModal.tsx
goon-foss 0ce4675574 fix(mobile): onboarding pager — measure page width so last slide shows "Start browsing"
scrollTo/onScroll used the full screen width, but the ScrollView viewport is narrower
(card margins + padding), so the computed index desynced from the visible slide — the
last slide kept showing "Next"/"Skip" instead of "Start browsing". Measure the real
viewport width via onLayout and use it for paging, scrollTo and index. Caught on the
emulator (uiautomator dump — FLAG_SECURE blocks screenshots).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 10:21:19 +02:00

265 lines
8.8 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* Tutorial przy pierwszym odpaleniu — paged carousel po stronach, funkcjach,
* longpressach i gestach playera. Pokazuje się raz (flaga goon.onboarding_seen_v1
* w SecureStore); replay z ustawień przez onboardingBus. Montowany raz nad
* nawigacją (App.tsx), niezależny od ekranów — slajdy opisują UI ilustracyjnie,
* bez spotlightowania realnych elementów (odporne na zmiany layoutu).
*
* Treść trzymać ZGODNĄ z realnymi gestami/akcjami (patrz SceneActionsContext,
* PlayerScreen gesty, SceneDetail performer long-press, navigation TopTabs).
*/
import React from 'react';
import {
Modal,
NativeScrollEvent,
NativeSyntheticEvent,
Pressable,
ScrollView,
StyleSheet,
Text,
View,
} from 'react-native';
import { NEWEST_CHANGELOG_ID } from '../changelog';
import { onReplay } from '../lib/onboardingBus';
import { getOnboardingSeen, setLastSeenChangelog, setOnboardingSeen } from '../storage';
import { theme } from '../theme';
type Slide = { icon: string; title: string; lines: string[] };
const SLIDES: Slide[] = [
{
icon: '👋',
title: 'Welcome to Goon',
lines: [
'Scenes and movies pulled together from 30+ sites — browse and play in one place.',
'Quick tour, about 30 seconds. Swipe or tap Next.',
],
},
{
icon: '🗂️',
title: 'Three tabs up top',
lines: [
'Scenes — individual clips.',
'Movies — full-length, often multi-part.',
'Sites — browse by source, each rated 0–5★.',
],
},
{
icon: '🔎',
title: 'Find what you want',
lines: [
'Search, then Filter by length, tags, studio, performer or sort.',
'Tap ☆ Save next to the search box to reuse a query as a chip.',
'From Scenes you can jump to Performers, Tags and Favorites.',
],
},
{
icon: '👆',
title: 'Long-press for more',
lines: [
'Hold a scene tile → Hide it, or mark it as a duplicate.',
'Hold a performer on a scene → remove a wrong tag.',
'Hold a play link → open in browser (diagnostics) or mark it broken.',
],
},
{
icon: '▶️',
title: 'Player gestures',
lines: [
'Tap once → show or hide the controls.',
'Double-tap left / right → skip back / forward 15s.',
'Swipe across the video → scrub to any point.',
'It starts muted — tap the speaker for sound.',
],
},
{
icon: '⭐',
title: 'Make it yours',
lines: [
'Heart scenes, movies and performers — they collect in Favorites.',
'Settings ⚙ → Hidden content hides tags, performers or studios. Grid columns and a PIN lock live there too.',
'Hit a problem? The ? button sends a report — and you can read replies.',
],
},
{
icon: '✅',
title: "You're set",
lines: [
'On Sites, the ★ rating tells you what’s fresh and what actually plays — tap the stars for the breakdown.',
'Replay this tour anytime: Settings ⚙ → Replay tutorial.',
],
},
];
export function OnboardingModal() {
const [visible, setVisible] = React.useState(false);
const [index, setIndex] = React.useState(0);
const scrollRef = React.useRef<ScrollView>(null);
// Szerokość STRONY = realna szerokość viewportu ScrollView (mierzona onLayout),
// NIE szerokość ekranu — karta ma marginesy + padding, więc ekran ≠ strona.
// Użycie szerokości ekranu rozjeżdżało paging vs scrollTo vs index (ostatni
// slajd pokazywał "Next" zamiast "Start browsing").
const [pageW, setPageW] = React.useState(0);
// Pierwsze odpalenie: pokaż jeśli jeszcze nie widziany.
React.useEffect(() => {
let cancelled = false;
getOnboardingSeen().then((seen) => {
if (!cancelled && !seen) setVisible(true);
});
return () => {
cancelled = true;
};
}, []);
// Replay z ustawień.
React.useEffect(
() =>
onReplay(() => {
setIndex(0);
setVisible(true);
// reset pozycji scrolla po otwarciu
requestAnimationFrame(() => scrollRef.current?.scrollTo({ x: 0, animated: false }));
}),
[],
);
const finish = React.useCallback(() => {
setVisible(false);
setOnboardingSeen(true).catch(() => {});
// Nowy user nie powinien po tutorialu dostać od razu "What's new" — oznacz
// changelog jako widziany do najnowszego wpisu.
if (NEWEST_CHANGELOG_ID) setLastSeenChangelog(NEWEST_CHANGELOG_ID).catch(() => {});
}, []);
const goTo = React.useCallback(
(i: number) => {
const clamped = Math.max(0, Math.min(SLIDES.length - 1, i));
setIndex(clamped);
if (pageW > 0) scrollRef.current?.scrollTo({ x: clamped * pageW, animated: true });
},
[pageW],
);
const onScroll = (e: NativeSyntheticEvent<NativeScrollEvent>) => {
if (pageW <= 0) return;
const i = Math.round(e.nativeEvent.contentOffset.x / pageW);
if (i !== index) setIndex(i);
};
if (!visible) return null;
const isLast = index === SLIDES.length - 1;
return (
<Modal visible transparent animationType="fade" onRequestClose={finish}>
<View style={styles.backdrop}>
<View style={styles.card}>
<View style={styles.topRow}>
<Text style={styles.brand}>GOON</Text>
<Pressable onPress={finish} hitSlop={10}>
<Text style={styles.skip}>{isLast ? '' : 'Skip'}</Text>
</Pressable>
</View>
<ScrollView
ref={scrollRef}
horizontal
pagingEnabled
showsHorizontalScrollIndicator={false}
onMomentumScrollEnd={onScroll}
onLayout={(e) => setPageW(e.nativeEvent.layout.width)}
style={styles.pager}
>
{SLIDES.map((s, i) => (
<View key={i} style={[styles.slide, pageW > 0 && { width: pageW }]}>
<Text style={styles.icon}>{s.icon}</Text>
<Text style={styles.title}>{s.title}</Text>
<View style={styles.lines}>
{s.lines.map((ln, j) => (
<View key={j} style={styles.lineRow}>
<Text style={styles.bullet}>•</Text>
<Text style={styles.lineText}>{ln}</Text>
</View>
))}
</View>
</View>
))}
</ScrollView>
<View style={styles.dots}>
{SLIDES.map((_, i) => (
<View key={i} style={[styles.dot, i === index && styles.dotActive]} />
))}
</View>
<View style={styles.btnRow}>
<Pressable
onPress={() => goTo(index - 1)}
disabled={index === 0}
style={[styles.btnGhost, index === 0 && styles.btnHidden]}
>
<Text style={styles.btnGhostText}>Back</Text>
</Pressable>
<Pressable onPress={() => (isLast ? finish() : goTo(index + 1))} style={styles.btnPrimary}>
<Text style={styles.btnPrimaryText}>{isLast ? 'Start browsing' : 'Next'}</Text>
</Pressable>
</View>
</View>
</View>
</Modal>
);
}
const styles = StyleSheet.create({
backdrop: {
flex: 1,
backgroundColor: 'rgba(0,0,0,0.82)',
alignItems: 'center',
justifyContent: 'center',
padding: 24,
},
card: {
width: '100%',
backgroundColor: theme.bgElevated,
borderRadius: 18,
borderColor: theme.border,
borderWidth: 1,
paddingVertical: 18,
paddingHorizontal: 24,
},
topRow: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
marginBottom: 6,
},
brand: { color: theme.accent, fontWeight: '900', letterSpacing: 2, fontSize: 13 },
skip: { color: theme.muted, fontSize: 14, fontWeight: '600', minWidth: 36, textAlign: 'right' },
pager: { flexGrow: 0 },
slide: { paddingTop: 14, paddingBottom: 8, minHeight: 250 },
icon: { fontSize: 44, marginBottom: 12 },
title: { color: theme.fg, fontSize: 22, fontWeight: '800', marginBottom: 16 },
lines: { gap: 12 },
lineRow: { flexDirection: 'row', gap: 10 },
bullet: { color: theme.accent, fontSize: 16, lineHeight: 22 },
lineText: { color: theme.fg, fontSize: 15, lineHeight: 22, flex: 1 },
dots: { flexDirection: 'row', justifyContent: 'center', gap: 6, marginVertical: 16 },
dot: { width: 7, height: 7, borderRadius: 4, backgroundColor: theme.border },
dotActive: { backgroundColor: theme.accent, width: 18 },
btnRow: { flexDirection: 'row', alignItems: 'center', gap: 12 },
btnGhost: { paddingVertical: 12, paddingHorizontal: 18, borderRadius: 12 },
btnHidden: { opacity: 0 },
btnGhostText: { color: theme.muted, fontSize: 15, fontWeight: '700' },
btnPrimary: {
flex: 1,
backgroundColor: theme.accent,
borderRadius: 12,
paddingVertical: 13,
alignItems: 'center',
},
btnPrimaryText: { color: theme.fg, fontSize: 15, fontWeight: '800' },
});