1import { ScreenScrollView as ScrollView } from "../../components/ScreenScrollView";
2import Constants from "expo-constants";
3import { useCallback, useEffect, useRef, useState } from "react";
4import { Alert, Pressable, View } from "react-native";
5import { useSafeAreaInsets } from "react-native-safe-area-context";
6
7import { AppText as Text } from "../../components/AppText";
8import { SymbolView } from "../../components/AppSymbol";
9import {
10 type AppUpdateCheckState,
11 isAppUpdateCheckAvailable,
12 registerHiddenUpdateTap,
13 runAppUpdateCheck,
14} from "../updates/app-updates";
15import { SettingsRow } from "./components/SettingsRow";
16import { SettingsSection } from "./components/SettingsSection";
17import { SettingsScreen } from "./components/SettingsScreen";
18
19export 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
37function 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
147function capitalize(value: string): string {
148 return value.length > 0 ? value.charAt(0).toUpperCase() + value.slice(1) : value;
149}