feat(mobile): first-launch tutorial (pages, features, long-presses, player gestures)
A 7-slide carousel shown once on first launch: - the three tabs (Scenes/Movies/Sites) - search, filters, saved searches, Performers/Tags/Favorites - long-press actions (hide/duplicate a scene, remove a wrong performer, link diagnostics) - player gestures (tap controls, double-tap ±15s, swipe to scrub, unmute) - favorites, Hidden content, PIN lock, the ? report button, Sites ★ ratings Gated by a SecureStore flag; replayable from Settings ⚙ → Replay tutorial (via a tiny onboarding bus). Suppresses the What's-new popup for brand-new users (the tour covers it) and marks the changelog seen on finish. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
c154deab37
commit
db23b63e46
7 changed files with 321 additions and 1 deletions
|
|
@ -25,6 +25,7 @@ import { GoonClient } from './src/api';
|
||||||
import { ClientProvider } from './src/ClientContext';
|
import { ClientProvider } from './src/ClientContext';
|
||||||
import { PreferencesProvider } from './src/PreferencesContext';
|
import { PreferencesProvider } from './src/PreferencesContext';
|
||||||
import { SceneActionsProvider } from './src/SceneActionsContext';
|
import { SceneActionsProvider } from './src/SceneActionsContext';
|
||||||
|
import { OnboardingModal } from './src/components/OnboardingModal';
|
||||||
import { WhatsNewModal } from './src/components/WhatsNewModal';
|
import { WhatsNewModal } from './src/components/WhatsNewModal';
|
||||||
import { ErrorBoundary } from './src/ErrorBoundary';
|
import { ErrorBoundary } from './src/ErrorBoundary';
|
||||||
import { isAccepted as isAgeGateAccepted } from './src/lib/agegate';
|
import { isAccepted as isAgeGateAccepted } from './src/lib/agegate';
|
||||||
|
|
@ -415,6 +416,7 @@ export default function App() {
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<WhatsNewModal />
|
<WhatsNewModal />
|
||||||
|
<OnboardingModal />
|
||||||
</SceneActionsProvider>
|
</SceneActionsProvider>
|
||||||
</PreferencesProvider>
|
</PreferencesProvider>
|
||||||
</ClientProvider>
|
</ClientProvider>
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,13 @@ export type ChangelogEntry = {
|
||||||
};
|
};
|
||||||
|
|
||||||
export const CHANGELOG: ChangelogEntry[] = [
|
export const CHANGELOG: ChangelogEntry[] = [
|
||||||
|
{
|
||||||
|
id: '2026-06-22b',
|
||||||
|
date: 'June 2026',
|
||||||
|
items: [
|
||||||
|
'New: a quick tutorial on first launch — tabs, search, long-press actions and player gestures. Replay it anytime from Settings ⚙.',
|
||||||
|
],
|
||||||
|
},
|
||||||
{
|
{
|
||||||
id: '2026-06-22',
|
id: '2026-06-22',
|
||||||
date: 'June 2026',
|
date: 'June 2026',
|
||||||
|
|
|
||||||
260
mobile/src/components/OnboardingModal.tsx
Normal file
260
mobile/src/components/OnboardingModal.tsx
Normal file
|
|
@ -0,0 +1,260 @@
|
||||||
|
/**
|
||||||
|
* 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 {
|
||||||
|
Dimensions,
|
||||||
|
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);
|
||||||
|
const width = Dimensions.get('window').width;
|
||||||
|
|
||||||
|
// 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);
|
||||||
|
scrollRef.current?.scrollTo({ x: clamped * width, animated: true });
|
||||||
|
},
|
||||||
|
[width],
|
||||||
|
);
|
||||||
|
|
||||||
|
const onScroll = (e: NativeSyntheticEvent<NativeScrollEvent>) => {
|
||||||
|
const i = Math.round(e.nativeEvent.contentOffset.x / width);
|
||||||
|
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}
|
||||||
|
style={styles.pager}
|
||||||
|
>
|
||||||
|
{SLIDES.map((s, i) => (
|
||||||
|
<View key={i} style={[styles.slide, { width: width - 48 }]}>
|
||||||
|
<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' },
|
||||||
|
});
|
||||||
|
|
@ -13,7 +13,7 @@ import {
|
||||||
unseenEntries,
|
unseenEntries,
|
||||||
type ChangelogEntry,
|
type ChangelogEntry,
|
||||||
} from '../changelog';
|
} from '../changelog';
|
||||||
import { getLastSeenChangelog, setLastSeenChangelog } from '../storage';
|
import { getLastSeenChangelog, getOnboardingSeen, setLastSeenChangelog } from '../storage';
|
||||||
import { theme } from '../theme';
|
import { theme } from '../theme';
|
||||||
|
|
||||||
export function WhatsNewModal() {
|
export function WhatsNewModal() {
|
||||||
|
|
@ -24,6 +24,10 @@ export function WhatsNewModal() {
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
(async () => {
|
(async () => {
|
||||||
try {
|
try {
|
||||||
|
// Nowy user (jeszcze nie przeszedł tutoriala) → tutorial ma pierwszeństwo,
|
||||||
|
// nie wyskakuj changelogiem. Onboarding po sobie oznaczy changelog widziany.
|
||||||
|
const onboarded = await getOnboardingSeen();
|
||||||
|
if (!onboarded) return;
|
||||||
const lastSeen = await getLastSeenChangelog();
|
const lastSeen = await getLastSeenChangelog();
|
||||||
const unseen = unseenEntries(lastSeen);
|
const unseen = unseenEntries(lastSeen);
|
||||||
if (!cancelled && unseen.length > 0) {
|
if (!cancelled && unseen.length > 0) {
|
||||||
|
|
|
||||||
17
mobile/src/lib/onboardingBus.ts
Normal file
17
mobile/src/lib/onboardingBus.ts
Normal file
|
|
@ -0,0 +1,17 @@
|
||||||
|
/**
|
||||||
|
* Mini event-bus do ręcznego odpalenia tutoriala ("Replay tutorial" w ustawieniach).
|
||||||
|
* OnboardingModal subskrybuje `onReplay`, ekran ustawień woła `replayOnboarding()`.
|
||||||
|
* Zero zależności — lista listenerów w module scope.
|
||||||
|
*/
|
||||||
|
type Listener = () => void;
|
||||||
|
|
||||||
|
const listeners = new Set<Listener>();
|
||||||
|
|
||||||
|
export function onReplay(cb: Listener): () => void {
|
||||||
|
listeners.add(cb);
|
||||||
|
return () => listeners.delete(cb);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function replayOnboarding(): void {
|
||||||
|
listeners.forEach((cb) => cb());
|
||||||
|
}
|
||||||
|
|
@ -25,6 +25,7 @@ import {
|
||||||
import { useNavigation } from '@react-navigation/native';
|
import { useNavigation } from '@react-navigation/native';
|
||||||
import type { NativeStackNavigationProp } from '@react-navigation/native-stack';
|
import type { NativeStackNavigationProp } from '@react-navigation/native-stack';
|
||||||
import { APP_VERSION } from '../lib/appVersion';
|
import { APP_VERSION } from '../lib/appVersion';
|
||||||
|
import { replayOnboarding } from '../lib/onboardingBus';
|
||||||
import { usePreferences } from '../PreferencesContext';
|
import { usePreferences } from '../PreferencesContext';
|
||||||
import type { RootStackParamList } from '../navigation';
|
import type { RootStackParamList } from '../navigation';
|
||||||
import { theme } from '../theme';
|
import { theme } from '../theme';
|
||||||
|
|
@ -315,6 +316,13 @@ export function AppLockSettingsScreen() {
|
||||||
</View>
|
</View>
|
||||||
<Text style={styles.linkValue}>{REPO_LABEL} ›</Text>
|
<Text style={styles.linkValue}>{REPO_LABEL} ›</Text>
|
||||||
</Pressable>
|
</Pressable>
|
||||||
|
<Pressable style={styles.row} onPress={() => replayOnboarding()}>
|
||||||
|
<View style={{ flex: 1 }}>
|
||||||
|
<Text style={styles.label}>Replay tutorial</Text>
|
||||||
|
<Text style={styles.hint}>Show the first-launch tour again</Text>
|
||||||
|
</View>
|
||||||
|
<Text style={styles.linkValue}>›</Text>
|
||||||
|
</Pressable>
|
||||||
</View>
|
</View>
|
||||||
</ScrollView>
|
</ScrollView>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -50,6 +50,28 @@ export async function setLastSeenChangelog(id: string): Promise<void> {
|
||||||
await SecureStore.setItemAsync(CHANGELOG_SEEN_KEY, id);
|
await SecureStore.setItemAsync(CHANGELOG_SEEN_KEY, id);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Onboarding/tutorial — pokazany raz przy pierwszym odpaleniu (carousel: tabs,
|
||||||
|
// funkcje, longpressy, gesty playera). Wersjonowany klucz → bump gdy tutorial
|
||||||
|
// istotnie się zmieni i chcemy pokazać ponownie. Replay z ustawień przez bus.
|
||||||
|
const ONBOARDING_SEEN_KEY = 'goon.onboarding_seen_v1';
|
||||||
|
|
||||||
|
export async function getOnboardingSeen(): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
return (await SecureStore.getItemAsync(ONBOARDING_SEEN_KEY)) === '1';
|
||||||
|
} catch {
|
||||||
|
return true; // brak SecureStore → nie blokuj UI tutorialem w kółko
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function setOnboardingSeen(seen: boolean): Promise<void> {
|
||||||
|
try {
|
||||||
|
if (seen) await SecureStore.setItemAsync(ONBOARDING_SEEN_KEY, '1');
|
||||||
|
else await SecureStore.deleteItemAsync(ONBOARDING_SEEN_KEY);
|
||||||
|
} catch {
|
||||||
|
// ignore — keychain quirks
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Liczba kolumn w siatkach scen (1/2/3). Feedback mobilism: domyślne 2 kol = za małe
|
// Liczba kolumn w siatkach scen (1/2/3). Feedback mobilism: domyślne 2 kol = za małe
|
||||||
// miniaturki na telefonie do rozpoznania performera. Default 2 (tablet-friendly).
|
// miniaturki na telefonie do rozpoznania performera. Default 2 (tablet-friendly).
|
||||||
const GRID_COLS_KEY = 'goon.grid_columns';
|
const GRID_COLS_KEY = 'goon.grid_columns';
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue