goon/mobile/src/components/OnboardingModal.tsx
goon-foss 7deda639bf fix(theme): rozdziel role akcentu i dodaj ramke, ktora cokolwiek oddziela
Pomiar WCAG na realnych parach z apki (2026-08-13):

    accent #B23A48 jako tekst:  3,21 na bg | 2,96 na bgElevated | 2,75 na card
    border #3A3128 jako granica: 1,48 na bg | 1,27 na card

Czyli kolor MARKI byl ponizej nawet progu 3:1 dla elementow interfejsu,
a nie tylko ponizej 4,5 dla tekstu. Dotyczylo to aktywnych zakladek,
gwiazdek ulubionych, linkow i chipow. Ramki przy 1,27 nie byly granica,
tylko cieniem.

NIE zmieniam koloru marki. Podniesienie samego `accent` do progu tekstu
wymagaloby zejscia w roz (#CE6975), co likwiduje tozsamosc, ktorej broni
naglowek theme.ts. Zamiast tego rozdzielam ROLE tego samego koloru:

  accent      zostaje do WYPELNIEN — jasny tekst na nim ma 5,03, przechodzi
  accentText  #C75260 dla tekstu i obwodek — 4,30 / 3,96 / 3,68

62 podmiany w 24 plikach (34 color, 26 borderColor, 2 thumbColor).
38 wypelnien nietknietych. Sprawdzone skryptem, ze zaden zmieniony tekst
nie stoi na tle akcentu.

borderStrong #7B7065 dla granic NIOSACYCH ZNACZENIE (pola tekstowe, 12
miejsc); dekoracyjne wloski zostaja na `border`. Wartosc dobrana pod obie
powierzchnie, bo pola leza i na bg, i na card: #6B6157 dawal na karcie
2,66, a #73695E dokladnie 2,999, czyli wciaz ponizej progu.
2026-08-14 09:50:08 +02:00

265 lines
8.7 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.accentText, 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.accentText, 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' },
});