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>
265 lines
8.8 KiB
TypeScript
265 lines
8.8 KiB
TypeScript
/**
|
||
* 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' },
|
||
});
|