diff --git a/mobile/App.tsx b/mobile/App.tsx index 25110b0..20c67a0 100644 --- a/mobile/App.tsx +++ b/mobile/App.tsx @@ -25,6 +25,7 @@ import { GoonClient } from './src/api'; import { ClientProvider } from './src/ClientContext'; import { PreferencesProvider } from './src/PreferencesContext'; import { SceneActionsProvider } from './src/SceneActionsContext'; +import { OnboardingModal } from './src/components/OnboardingModal'; import { WhatsNewModal } from './src/components/WhatsNewModal'; import { ErrorBoundary } from './src/ErrorBoundary'; import { isAccepted as isAgeGateAccepted } from './src/lib/agegate'; @@ -415,6 +416,7 @@ export default function App() { }} /> + diff --git a/mobile/src/changelog.ts b/mobile/src/changelog.ts index 87ccaa3..86c8914 100644 --- a/mobile/src/changelog.ts +++ b/mobile/src/changelog.ts @@ -16,6 +16,13 @@ export type 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', date: 'June 2026', diff --git a/mobile/src/components/OnboardingModal.tsx b/mobile/src/components/OnboardingModal.tsx new file mode 100644 index 0000000..804ae87 --- /dev/null +++ b/mobile/src/components/OnboardingModal.tsx @@ -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(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) => { + 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 ( + + + + + GOON + + {isLast ? '' : 'Skip'} + + + + + {SLIDES.map((s, i) => ( + + {s.icon} + {s.title} + + {s.lines.map((ln, j) => ( + + + {ln} + + ))} + + + ))} + + + + {SLIDES.map((_, i) => ( + + ))} + + + + goTo(index - 1)} + disabled={index === 0} + style={[styles.btnGhost, index === 0 && styles.btnHidden]} + > + Back + + (isLast ? finish() : goTo(index + 1))} style={styles.btnPrimary}> + {isLast ? 'Start browsing' : 'Next'} + + + + + + ); +} + +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' }, +}); diff --git a/mobile/src/components/WhatsNewModal.tsx b/mobile/src/components/WhatsNewModal.tsx index d011425..cd9fccb 100644 --- a/mobile/src/components/WhatsNewModal.tsx +++ b/mobile/src/components/WhatsNewModal.tsx @@ -13,7 +13,7 @@ import { unseenEntries, type ChangelogEntry, } from '../changelog'; -import { getLastSeenChangelog, setLastSeenChangelog } from '../storage'; +import { getLastSeenChangelog, getOnboardingSeen, setLastSeenChangelog } from '../storage'; import { theme } from '../theme'; export function WhatsNewModal() { @@ -24,6 +24,10 @@ export function WhatsNewModal() { let cancelled = false; (async () => { 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 unseen = unseenEntries(lastSeen); if (!cancelled && unseen.length > 0) { diff --git a/mobile/src/lib/onboardingBus.ts b/mobile/src/lib/onboardingBus.ts new file mode 100644 index 0000000..44825df --- /dev/null +++ b/mobile/src/lib/onboardingBus.ts @@ -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(); + +export function onReplay(cb: Listener): () => void { + listeners.add(cb); + return () => listeners.delete(cb); +} + +export function replayOnboarding(): void { + listeners.forEach((cb) => cb()); +} diff --git a/mobile/src/screens/AppLockSettingsScreen.tsx b/mobile/src/screens/AppLockSettingsScreen.tsx index e735799..1095b18 100644 --- a/mobile/src/screens/AppLockSettingsScreen.tsx +++ b/mobile/src/screens/AppLockSettingsScreen.tsx @@ -25,6 +25,7 @@ import { import { useNavigation } from '@react-navigation/native'; import type { NativeStackNavigationProp } from '@react-navigation/native-stack'; import { APP_VERSION } from '../lib/appVersion'; +import { replayOnboarding } from '../lib/onboardingBus'; import { usePreferences } from '../PreferencesContext'; import type { RootStackParamList } from '../navigation'; import { theme } from '../theme'; @@ -315,6 +316,13 @@ export function AppLockSettingsScreen() { {REPO_LABEL} › + replayOnboarding()}> + + Replay tutorial + Show the first-launch tour again + + + ); diff --git a/mobile/src/storage.ts b/mobile/src/storage.ts index 7d0461d..64ec9e4 100644 --- a/mobile/src/storage.ts +++ b/mobile/src/storage.ts @@ -50,6 +50,28 @@ export async function setLastSeenChangelog(id: string): Promise { 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 { + 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 { + 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 // miniaturki na telefonie do rozpoznania performera. Default 2 (tablet-friendly). const GRID_COLS_KEY = 'goon.grid_columns';