import { useCallback, useEffect, useState } from "react" import { View, Text, ScrollView, TouchableOpacity, Switch, StyleSheet, useColorScheme, Linking, Alert, } from "react-native" import { Ionicons } from "@expo/vector-icons" import { useTranslation } from "react-i18next" import { useAuth } from "../../src/stores/auth" import { useSettings } from "../../src/stores/settings" import { categories, categoryMeta, setup as setupNotifications, granted as notificationsGranted, } from "../../src/lib/notifications" import type { Category } from "../../src/lib/notifications" import { hasTelemetryConsent, setTelemetryConsent } from "../../src/lib/telemetry" import { PRIVACY_POLICY_URL } from "../../src/lib/links" import { CURRENT_VERSION, checkForUpdate, type AvailableUpdate } from "../../src/lib/update-check" import type { LocalePreference } from "../../src/lib/i18n/locale-resolve" function SettingRow({ icon, label, description, isDark, right, onPress, }: { icon: keyof typeof Ionicons.glyphMap label: string description?: string isDark: boolean right?: React.ReactNode onPress?: () => void }) { const content = ( {label} {description && {description}} {right} ) if (onPress) { return {content} } return content } function SettingSection({ title, children, isDark }: { title: string; children: React.ReactNode; isDark: boolean }) { return ( {title} {children} ) } export default function SettingsScreen() { const colorScheme = useColorScheme() const isDark = colorScheme === "dark" const { t } = useTranslation() const { settings, hasBiometrics, updateSettings, lock } = useAuth() const { notifications, setNotification, locale, setLocale } = useSettings() const [osGranted, setOsGranted] = useState(null) const [telemetryUpdating, setTelemetryUpdating] = useState(false) // Settings is where a user goes to ask "what am I running?". Answer it, and if // a newer build exists say so here too โ€” the banner on the sessions list is // dismissible, this row is not (AGE-110). Uses the same 24h-throttled check, // so opening Settings repeatedly costs no extra requests. const [updateAvailable, setUpdateAvailable] = useState(null) useEffect(() => { let cancelled = false checkForUpdate({ ignoreDismissed: true }) .then((result) => { if (!cancelled) setUpdateAvailable(result) }) .catch(() => undefined) return () => { cancelled = true } }, []) // Telemetry consent: hasTelemetryConsent() returns null (unknown), true, or false. // We initialise local state from in-memory value; updates call setTelemetryConsent(). const [crashReporting, setCrashReporting] = useState(hasTelemetryConsent() ?? false) const handleCrashReportingToggle = useCallback( async (value: boolean) => { setTelemetryUpdating(true) try { await setTelemetryConsent(value) setCrashReporting(value) } catch { setCrashReporting(hasTelemetryConsent() ?? false) Alert.alert(t("settings.alerts.privacyNotSavedTitle"), t("settings.alerts.privacyNotSavedMessage")) } finally { setTelemetryUpdating(false) } }, [t], ) // Check OS permission state on first toggle attempt const handleToggle = useCallback( async (category: Category, enabled: boolean) => { if (enabled) { const ok = await setupNotifications() setOsGranted(ok) if (!ok) { Alert.alert(t("settings.alerts.notificationsDisabledTitle"), t("settings.alerts.notificationsDisabledMessage")) return } } setNotification(category, enabled) }, [setNotification, t], ) // Lazy-check OS permission for status display if (osGranted === null) { notificationsGranted() .then(setOsGranted) .catch(() => setOsGranted(false)) } const localeLabels: Record = { system: t("settings.language.system"), en: t("settings.language.en"), "zh-Hans": t("settings.language.zhHans"), } const handleLanguagePress = useCallback(() => { Alert.alert(t("settings.language.title"), undefined, [ { text: localeLabels.system, onPress: () => setLocale("system") }, { text: localeLabels.en, onPress: () => setLocale("en") }, { text: localeLabels["zh-Hans"], onPress: () => setLocale("zh-Hans") }, { text: t("common.cancel"), style: "cancel" }, ]) }, [t, setLocale, localeLabels]) return ( updateSettings({ requireBiometric: value })} disabled={!hasBiometrics} trackColor={{ false: "#767577", true: "#22c55e" }} /> } /> updateSettings({ requireBiometricForMessages: value })} disabled={!hasBiometrics || !settings.requireBiometric} trackColor={{ false: "#767577", true: "#22c55e" }} /> } /> {settings.requireBiometric && ( } /> )} {categories.map((category) => { const meta = categoryMeta[category] return ( handleToggle(category, value)} trackColor={{ false: "#767577", true: "#22c55e" }} /> } /> ) })} {osGranted === false && ( {t("settings.notifications.disabledNotice")} )} } /> Linking.openURL(PRIVACY_POLICY_URL)} right={} /> } /> Linking.openURL(updateAvailable.url) : undefined} right={ updateAvailable ? ( ) : undefined } /> Linking.openURL("https://github.com/anomalyco/opencode")} right={} /> Linking.openURL("https://opencode.ai/docs")} right={} /> {t("settings.footer.appName")} {t("settings.footer.tagline")} ) } const styles = StyleSheet.create({ container: { flex: 1, backgroundColor: "#f5f5f5", }, containerDark: { backgroundColor: "#0a0a0a", }, content: { paddingBottom: 32, }, section: { marginTop: 24, }, sectionTitle: { fontSize: 13, fontWeight: "600", color: "#666666", marginLeft: 16, marginBottom: 8, textTransform: "uppercase", }, sectionTitleDark: { color: "#888888", }, sectionContent: { backgroundColor: "#ffffff", borderTopWidth: 1, borderBottomWidth: 1, borderColor: "#e5e5e5", }, sectionContentDark: { backgroundColor: "#1a1a1a", borderColor: "#2a2a2a", }, settingRow: { flexDirection: "row", alignItems: "center", paddingVertical: 12, paddingHorizontal: 16, borderBottomWidth: 1, borderBottomColor: "#e5e5e5", }, settingRowDark: { borderBottomColor: "#2a2a2a", }, settingIcon: { width: 36, height: 36, borderRadius: 8, backgroundColor: "#f5f5f5", justifyContent: "center", alignItems: "center", marginRight: 12, }, settingIconDark: { backgroundColor: "#2a2a2a", }, settingContent: { flex: 1, }, settingLabel: { fontSize: 16, color: "#0a0a0a", }, textDark: { color: "#ffffff", }, settingDescription: { fontSize: 13, color: "#666666", marginTop: 2, }, metaDark: { color: "#888888", }, footer: { alignItems: "center", padding: 32, }, footerText: { fontSize: 13, color: "#999999", textAlign: "center", }, })