| 1 | import { ScreenScrollView as ScrollView } from "../../components/ScreenScrollView"; |
| 2 | import Constants from "expo-constants"; |
| 3 | import { useCallback, useEffect, useRef, useState } from "react"; |
| 4 | import { Alert, Pressable, View } from "react-native"; |
| 5 | import { useSafeAreaInsets } from "react-native-safe-area-context"; |
| 6 | |
| 7 | import { AppText as Text } from "../../components/AppText"; |
| 8 | import { SymbolView } from "../../components/AppSymbol"; |
| 9 | import { |
| 10 | type AppUpdateCheckState, |
| 11 | isAppUpdateCheckAvailable, |
| 12 | registerHiddenUpdateTap, |
| 13 | runAppUpdateCheck, |
| 14 | } from "../updates/app-updates"; |
| 15 | import { SettingsRow } from "./components/SettingsRow"; |
| 16 | import { SettingsSection } from "./components/SettingsSection"; |
| 17 | import { SettingsScreen } from "./components/SettingsScreen"; |
| 18 | |
| 19 | export function SettingsAboutRouteScreen() { |
| 20 | const insets = useSafeAreaInsets(); |
| 21 | |
| 22 | return ( |
| 23 | <SettingsScreen title="About The Knife"> |
| 24 | <ScrollView |
| 25 | contentInsetAdjustmentBehavior="automatic" |
| 26 | showsVerticalScrollIndicator={false} |
| 27 | className="flex-1" |
| 28 | contentContainerClassName="gap-6 px-5 pt-4" |
| 29 | contentContainerStyle={{ paddingBottom: Math.max(insets.bottom, 18) + 18 }} |
| 30 | > |
| 31 | <AppSettingsSection /> |
| 32 | </ScrollView> |
| 33 | </SettingsScreen> |
| 34 | ); |
| 35 | } |
| 36 | |
| 37 | function AppSettingsSection() { |
| 38 | const [updateState, setUpdateState] = useState<AppUpdateCheckState>("idle"); |
| 39 | const updateInFlight = useRef(false); |
| 40 | const hiddenUpdateTapCount = useRef(0); |
| 41 | |
| 42 | const version = Constants.expoConfig?.version ?? "0.0.0"; |
| 43 | // Fall back to "production" to match resolveAppVariant in app.config.ts, so a |
| 44 | // missing variant never mislabels a production build as development. |
| 45 | const variant = (Constants.expoConfig?.extra?.appVariant as string | undefined) ?? "production"; |
| 46 | const variantLabel = variant === "production" ? "" : capitalize(variant); |
| 47 | const versionLabel = variantLabel ? `${version} · ${variantLabel}` : version; |
| 48 | const updateCheckAvailable = isAppUpdateCheckAvailable(); |
| 49 | const busy = |
| 50 | updateState === "checking" || updateState === "downloading" || updateState === "restarting"; |
| 51 | |
| 52 | // "Up to date" is a transient acknowledgement, not a state worth persisting — |
| 53 | // return the version row to its normal, deliberately quiet state. |
| 54 | useEffect(() => { |
| 55 | if (updateState !== "current") return; |
| 56 | const timer = setTimeout(() => setUpdateState("idle"), 3000); |
| 57 | return () => clearTimeout(timer); |
| 58 | }, [updateState]); |
| 59 | |
| 60 | const checkForUpdate = useCallback(async () => { |
| 61 | // `disabled={busy}` only takes effect on the next render, so two taps in the |
| 62 | // same frame would both get through. The ref closes that window. |
| 63 | if (updateInFlight.current) return; |
| 64 | updateInFlight.current = true; |
| 65 | try { |
| 66 | // The user asked for this restart by tapping the version row, so it may |
| 67 | // apply immediately instead of prompting. |
| 68 | await runAppUpdateCheck({ |
| 69 | applyMode: "immediate", |
| 70 | onFailure: (message) => Alert.alert("Update failed", message), |
| 71 | onStateChange: setUpdateState, |
| 72 | }); |
| 73 | } finally { |
| 74 | updateInFlight.current = false; |
| 75 | } |
| 76 | }, []); |
| 77 | |
| 78 | const handleVersionPress = useCallback(() => { |
| 79 | if (!updateCheckAvailable || updateInFlight.current) return; |
| 80 | const tap = registerHiddenUpdateTap(hiddenUpdateTapCount.current); |
| 81 | hiddenUpdateTapCount.current = tap.nextCount; |
| 82 | if (tap.shouldCheck) { |
| 83 | void checkForUpdate(); |
| 84 | } |
| 85 | }, [checkForUpdate, updateCheckAvailable]); |
| 86 | |
| 87 | const statusLabel = |
| 88 | updateState === "checking" |
| 89 | ? "Checking…" |
| 90 | : updateState === "downloading" |
| 91 | ? "Downloading…" |
| 92 | : // "ready" appears only when this check joined an in-flight background-mode |
| 93 | // check; that download installs at the next backgrounding. |
| 94 | updateState === "ready" |
| 95 | ? "Update ready" |
| 96 | : updateState === "restarting" |
| 97 | ? "Restarting…" |
| 98 | : updateState === "current" |
| 99 | ? "Up to date" |
| 100 | : null; |
| 101 | |
| 102 | const versionRow = ( |
| 103 | <View className="flex-row items-center gap-4 p-4"> |
| 104 | <SymbolView |
| 105 | name="info.circle" |
| 106 | size={22} |
| 107 | tintColorClassName="accent-icon" |
| 108 | type="monochrome" |
| 109 | weight="regular" |
| 110 | /> |
| 111 | <Text className="flex-1 text-lg text-foreground">Version</Text> |
| 112 | <View className="items-end"> |
| 113 | <Text className="text-lg text-foreground-muted">{versionLabel}</Text> |
| 114 | {statusLabel ? ( |
| 115 | <Text className="text-xs text-foreground-muted/70">{statusLabel}</Text> |
| 116 | ) : null} |
| 117 | </View> |
| 118 | </View> |
| 119 | ); |
| 120 | |
| 121 | return ( |
| 122 | <SettingsSection title="App"> |
| 123 | <SettingsRow icon="internaldrive" label="Client Storage" target="SettingsClientStorage" /> |
| 124 | <SettingsRow icon="stethoscope" label="Diagnostics" target="SettingsDiagnostics" /> |
| 125 | <SettingsRow |
| 126 | icon="doc.on.doc" |
| 127 | label="Open source licenses" |
| 128 | target="SettingsOpenSourceLicenses" |
| 129 | /> |
| 130 | <SettingsRow icon="doc.text" label="Legal" fullScreenTarget="SettingsLegal" /> |
| 131 | {updateCheckAvailable ? ( |
| 132 | <Pressable |
| 133 | accessibilityLabel={`Version ${versionLabel}`} |
| 134 | accessibilityRole="text" |
| 135 | disabled={busy} |
| 136 | onPress={handleVersionPress} |
| 137 | > |
| 138 | {versionRow} |
| 139 | </Pressable> |
| 140 | ) : ( |
| 141 | versionRow |
| 142 | )} |
| 143 | </SettingsSection> |
| 144 | ); |
| 145 | } |
| 146 | |
| 147 | function capitalize(value: string): string { |
| 148 | return value.length > 0 ? value.charAt(0).toUpperCase() + value.slice(1) : value; |
| 149 | } |