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>
388 lines
12 KiB
TypeScript
388 lines
12 KiB
TypeScript
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' },
|
||
});
|