goon/mobile/src/screens/AppLockSettingsScreen.tsx
jtrzupek db23b63e46 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>
2026-06-22 10:08:56 +02:00

388 lines
12 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.

import React, { useEffect, useState } from 'react';
import {
ActivityIndicator,
Alert,
Linking,
Pressable,
ScrollView,
StyleSheet,
Switch,
Text,
View,
} from 'react-native';
import {
AppLockSettings,
biometricAvailable,
clearPin,
DEFAULT_TIMEOUT_SECONDS,
getSettings,
setBioEnabled,
setEnabled,
setPin,
setTimeoutSeconds,
verifyPin,
} from '../lib/applock';
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';
import { PinEntry } from './PinEntry';
const TIMEOUT_OPTIONS: { label: string; seconds: number }[] = [
{ label: 'Immediately', seconds: 0 },
{ label: '30 sec', seconds: 30 },
{ label: '1 min', seconds: 60 },
{ label: '5 min', seconds: 300 },
{ label: '15 min', seconds: 900 },
];
type Stage = 'menu' | 'enter-current' | 'set-new' | 'confirm-new' | 'disable-confirm';
export function AppLockSettingsScreen() {
const [settings, setSettings] = useState<AppLockSettings | null>(null);
const [bioAvailable, setBioAvailable] = useState(false);
const [stage, setStage] = useState<Stage>('menu');
const [newPin, setNewPin] = useState('');
const [errorText, setErrorText] = useState<string | null>(null);
const { gridColumns, setGridColumns } = usePreferences();
const navigation = useNavigation<NativeStackNavigationProp<RootStackParamList>>();
async function refresh() {
const [s, bio] = await Promise.all([getSettings(), biometricAvailable()]);
setSettings(s);
setBioAvailable(bio);
}
useEffect(() => {
refresh();
}, []);
if (!settings) {
return (
<View style={styles.center}>
<ActivityIndicator color={theme.muted} />
</View>
);
}
if (stage === 'enter-current') {
return (
<PinEntry
title="Enter current PIN"
error={errorText}
onCancel={() => {
setStage('menu');
setErrorText(null);
}}
onSubmit={async (candidate) => {
const ok = await verifyPin(candidate);
if (!ok) {
setErrorText('Incorrect PIN');
return;
}
setErrorText(null);
setStage('set-new');
}}
/>
);
}
if (stage === 'set-new') {
return (
<PinEntry
title="Enter new PIN (4-8 digits)"
error={errorText}
onCancel={() => {
setStage('menu');
setErrorText(null);
setNewPin('');
}}
onSubmit={async (candidate) => {
if (candidate.length < 4) {
setErrorText('Min. 4 digits');
return;
}
setNewPin(candidate);
setErrorText(null);
setStage('confirm-new');
}}
/>
);
}
if (stage === 'confirm-new') {
return (
<PinEntry
title="Confirm PIN"
error={errorText}
onCancel={() => {
setStage('menu');
setErrorText(null);
setNewPin('');
}}
onSubmit={async (candidate) => {
if (candidate !== newPin) {
setErrorText('PINs do not match');
return;
}
await setPin(newPin);
await setEnabled(true);
setNewPin('');
setErrorText(null);
setStage('menu');
await refresh();
Alert.alert('Done', 'PIN saved. App lock enabled.');
}}
/>
);
}
if (stage === 'disable-confirm') {
return (
<PinEntry
title="Enter PIN to disable lock"
error={errorText}
onCancel={() => {
setStage('menu');
setErrorText(null);
}}
onSubmit={async (candidate) => {
const ok = await verifyPin(candidate);
if (!ok) {
setErrorText('Incorrect PIN');
return;
}
await setEnabled(false);
await setBioEnabled(false);
await clearPin();
setErrorText(null);
setStage('menu');
await refresh();
}}
/>
);
}
return (
<ScrollView style={styles.root} contentContainerStyle={{ paddingBottom: 40 }}>
<View style={styles.section}>
<Text style={styles.sectionTitle}>App lock</Text>
<View style={styles.row}>
<View style={{ flex: 1 }}>
<Text style={styles.label}>Enabled</Text>
<Text style={styles.hint}>
{settings.enabled ? 'Requires PIN at startup and after a break' : 'Disabled'}
</Text>
</View>
<Switch
value={settings.enabled}
onValueChange={(v) => {
if (v) {
setStage('set-new');
} else if (settings.hasPin) {
setStage('disable-confirm');
} else {
setEnabled(false).then(refresh);
}
}}
trackColor={{ true: theme.accent, false: theme.border }}
thumbColor={theme.fg}
/>
</View>
{settings.enabled && settings.hasPin ? (
<Pressable
style={styles.actionBtn}
onPress={() => {
setStage('enter-current');
setErrorText(null);
}}
>
<Text style={styles.actionText}>Change PIN</Text>
</Pressable>
) : null}
</View>
{settings.enabled ? (
<>
<View style={styles.section}>
<Text style={styles.sectionTitle}>Biometrics</Text>
<View style={styles.row}>
<View style={{ flex: 1 }}>
<Text style={styles.label}>Fingerprint / Face Unlock</Text>
<Text style={styles.hint}>
{bioAvailable
? 'Faster unlocking, PIN as fallback'
: 'No biometrics on this device'}
</Text>
</View>
<Switch
value={settings.bioEnabled && bioAvailable}
disabled={!bioAvailable}
onValueChange={async (v) => {
await setBioEnabled(v);
await refresh();
}}
trackColor={{ true: theme.accent, false: theme.border }}
thumbColor={theme.fg}
/>
</View>
</View>
<View style={styles.section}>
<Text style={styles.sectionTitle}>Time until lock</Text>
<Text style={styles.hint}>How long to wait after leaving the app before locking</Text>
<View style={styles.chipRow}>
{TIMEOUT_OPTIONS.map((opt) => {
const active = settings.timeoutSeconds === opt.seconds;
return (
<Pressable
key={opt.seconds}
style={[styles.chip, active && styles.chipActive]}
onPress={async () => {
await setTimeoutSeconds(opt.seconds);
await refresh();
}}
>
<Text style={[styles.chipText, active && styles.chipTextActive]}>
{opt.label}
</Text>
</Pressable>
);
})}
</View>
</View>
</>
) : null}
<View style={styles.section}>
<Text style={styles.hint}>
The app is also hidden in the recent apps list and blocks screenshots.
</Text>
</View>
<View style={styles.section}>
<Text style={styles.sectionTitle}>Content</Text>
<Pressable style={styles.row} onPress={() => navigation.navigate('Blacklist')}>
<View style={{ flex: 1 }}>
<Text style={styles.label}>Hidden content</Text>
<Text style={styles.hint}>Hide tags, performers or studios you never want to see</Text>
</View>
<Text style={styles.linkValue}></Text>
</Pressable>
</View>
<View style={styles.section}>
<Text style={styles.sectionTitle}>Grid</Text>
<Text style={styles.hint}>Thumbnails per row in scene lists</Text>
<View style={styles.chipRow}>
{[1, 2, 3].map((n) => {
const active = gridColumns === n;
return (
<Pressable
key={n}
style={[styles.chip, active && styles.chipActive]}
onPress={() => setGridColumns(n)}
>
<Text style={[styles.chipText, active && styles.chipTextActive]}>
{n === 1 ? '1 column' : `${n} columns`}
</Text>
</Pressable>
);
})}
</View>
</View>
<View style={styles.section}>
<Text style={styles.sectionTitle}>About</Text>
<View style={styles.row}>
<View style={{ flex: 1 }}>
<Text style={styles.label}>Version</Text>
<Text style={styles.hint}>Current JS bundle (OTA-updated)</Text>
</View>
<Text style={styles.versionValue}>{APP_VERSION}</Text>
</View>
<Pressable
style={styles.row}
onPress={() => Linking.openURL(REPO_URL).catch(() => {})}
>
<View style={{ flex: 1 }}>
<Text style={styles.label}>Source code</Text>
<Text style={styles.hint}>Open source (MIT) audit it or self-host</Text>
</View>
<Text style={styles.linkValue}>{REPO_LABEL} </Text>
</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>
</ScrollView>
);
}
// Publiczne repo OSS (zgłoszenie usera 4c5066b8: "no source code repo linked").
// Sygnał zaufania dla sideloadowanej apki 18+: audyt kodu / self-host / kontrybucja.
const REPO_URL = 'https://github.com/goon-foss/goon';
const REPO_LABEL = 'goon-foss/goon';
const styles = StyleSheet.create({
root: { flex: 1, backgroundColor: theme.bg },
center: { flex: 1, backgroundColor: theme.bg, alignItems: 'center', justifyContent: 'center' },
section: {
backgroundColor: theme.card,
marginHorizontal: 14,
marginTop: 14,
padding: 16,
borderRadius: 14,
borderWidth: 1,
borderColor: theme.border,
},
sectionTitle: {
color: theme.fg,
fontSize: 13,
fontWeight: '700',
textTransform: 'uppercase',
letterSpacing: 0.5,
marginBottom: 10,
},
row: {
flexDirection: 'row',
alignItems: 'center',
paddingVertical: 8,
},
label: { color: theme.fg, fontSize: 15, fontWeight: '600' },
hint: { color: theme.muted, fontSize: 12, marginTop: 2 },
actionBtn: {
marginTop: 12,
paddingVertical: 12,
paddingHorizontal: 16,
borderRadius: 10,
backgroundColor: theme.bgElevated,
borderWidth: 1,
borderColor: theme.border,
alignItems: 'center',
},
actionText: { color: theme.accent, fontSize: 14, fontWeight: '600' },
chipRow: { flexDirection: 'row', flexWrap: 'wrap', gap: 8, marginTop: 10 },
chip: {
paddingVertical: 8,
paddingHorizontal: 14,
borderRadius: 999,
backgroundColor: theme.bgElevated,
borderWidth: 1,
borderColor: theme.border,
},
chipActive: { backgroundColor: theme.accent, borderColor: theme.accent },
versionValue: { color: theme.fg, fontSize: 15, fontWeight: '700', fontVariant: ['tabular-nums'] },
linkValue: { color: theme.accent, fontSize: 15, fontWeight: '700' },
chipText: { color: theme.muted, fontSize: 13 },
chipTextActive: { color: '#fff', fontWeight: '700' },
});