Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
106 changes: 14 additions & 92 deletions apps/mobile/src/features/settings/SettingsAboutRouteScreen.tsx
Original file line number Diff line number Diff line change
@@ -1,19 +1,11 @@
import * as Dialog from "../../components/ui/Dialog";
import { ScreenScrollView as ScrollView } from "../../components/ui/ScreenScrollView";
import Constants from "expo-constants";
import { settlePromise } from "@t3tools/client-runtime/state/runtime";
import { useEffect, useRef, useState } from "react";
import { Pressable, View } from "react-native";
import { View } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";

import { AppText as Text } from "../../components/ui/AppText";
import { SymbolView } from "../../components/ui/AppSymbol";
import {
type AppUpdateCheckState,
isAppUpdateCheckAvailable,
registerHiddenUpdateTap,
runAppUpdateCheck,
} from "../updates/app-updates";
import { AppUpdateCheckRow } from "../updates/AppUpdateCheckRow";
import { SettingsRow } from "./components/SettingsRow";
import { SettingsSection } from "./components/SettingsSection";
import { SettingsScreen } from "./components/SettingsScreen";
Expand All @@ -37,82 +29,12 @@ export function SettingsAboutRouteScreen() {
}

function AppSettingsSection() {
const [updateState, setUpdateState] = useState<AppUpdateCheckState>("idle");
const updateInFlight = useRef(false);
const hiddenUpdateTapCount = useRef(0);

const version = Constants.expoConfig?.version ?? "0.0.0";
// Fall back to "production" to match resolveAppVariant in app.config.ts, so a
// missing variant never mislabels a production build as development.
const variant = (Constants.expoConfig?.extra?.appVariant as string | undefined) ?? "production";
const variantLabel = variant === "production" ? "" : capitalize(variant);
const versionLabel = variantLabel ? `${version} · ${variantLabel}` : version;
const updateCheckAvailable = isAppUpdateCheckAvailable();
const busy =
updateState === "checking" || updateState === "downloading" || updateState === "restarting";

useEffect(() => {
if (updateState !== "current") return;
const timer = setTimeout(() => setUpdateState("idle"), 3000);
return () => clearTimeout(timer);
}, [updateState]);

async function checkForUpdate() {
// `disabled={busy}` only takes effect on the next render, so two taps in the
// same frame would both get through. The ref closes that window.
if (updateInFlight.current) return;
updateInFlight.current = true;
// Failures arrive through onFailure; the settled result only matters for unlocking.
await settlePromise(() =>
runAppUpdateCheck({
applyMode: "immediate",
onFailure: (message) => Dialog.alert("Update failed", message),
onStateChange: setUpdateState,
}),
);
updateInFlight.current = false;
}

function handleVersionPress() {
if (!updateCheckAvailable || updateInFlight.current) return;
const tap = registerHiddenUpdateTap(hiddenUpdateTapCount.current);
hiddenUpdateTapCount.current = tap.nextCount;
if (tap.shouldCheck) {
void checkForUpdate();
}
}

const statusLabel =
updateState === "checking"
? "Checking…"
: updateState === "downloading"
? "Downloading…"
: updateState === "ready"
? "Update ready"
: updateState === "restarting"
? "Restarting…"
: updateState === "current"
? "Up to date"
: null;

const versionRow = (
<View className="flex-row items-center gap-4 p-4">
<SymbolView
name="info.circle"
size={22}
tintColorClassName="accent-icon"
type="monochrome"
weight="regular"
/>
<Text className="flex-1 text-lg text-foreground">Version</Text>
<View className="items-end">
<Text className="text-lg text-foreground-muted">{versionLabel}</Text>
{statusLabel ? (
<Text className="text-xs text-foreground-muted/70">{statusLabel}</Text>
) : null}
</View>
</View>
);

return (
<SettingsSection title="App">
Expand All @@ -124,18 +46,18 @@ function AppSettingsSection() {
target="SettingsOpenSourceLicenses"
/>
<SettingsRow icon="doc.text" label="Legal" fullScreenTarget="SettingsLegal" />
{updateCheckAvailable ? (
<Pressable
accessibilityLabel={`Version ${versionLabel}`}
accessibilityRole="text"
disabled={busy}
onPress={handleVersionPress}
>
{versionRow}
</Pressable>
) : (
versionRow
)}
<View className="flex-row items-center gap-4 p-4">
<SymbolView
name="info.circle"
size={22}
tintColorClassName="accent-icon"
type="monochrome"
weight="regular"
/>
<Text className="flex-1 text-lg text-foreground">Version</Text>
<Text className="text-lg text-foreground-muted">{versionLabel}</Text>
</View>
<AppUpdateCheckRow />
</SettingsSection>
);
}
Expand Down
2 changes: 2 additions & 0 deletions apps/mobile/src/features/settings/SettingsRouteScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import { hasCloudPublicConfig } from "../cloud/publicConfig";
import { useAdaptiveWorkspaceLayout } from "../layout/AdaptiveWorkspaceLayout";
import { NativeStackScreenOptions } from "../../native/StackHeader";
import { useSavedRemoteConnections } from "../../state/use-remote-environment-registry";
import { AppUpdateCheckRow } from "../updates/AppUpdateCheckRow";
import { SettingsRow } from "./components/SettingsRow";
import { SettingsSection } from "./components/SettingsSection";
import { SettingsScreen } from "./components/SettingsScreen";
Expand Down Expand Up @@ -227,6 +228,7 @@ function SettingsIndexSections() {
<SettingsSection title="App">
<SettingsRow icon="chart.bar.xaxis" label="Usage" target="SettingsUsage" />
<SettingsRow icon="info.circle" label="About T3 Code" target="SettingsAbout" />
<AppUpdateCheckRow />
</SettingsSection>
</>
);
Expand Down
65 changes: 65 additions & 0 deletions apps/mobile/src/features/updates/AppUpdateCheckRow.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
import * as Dialog from "../../components/ui/Dialog";
import { settlePromise } from "@t3tools/client-runtime/state/runtime";
import { useEffect, useRef, useState } from "react";

import { SettingsActionRow } from "../settings/components/SettingsActionRow";
import {
type AppUpdateCheckState,
isAppUpdateCheckAvailable,
runAppUpdateCheck,
} from "./app-updates";

/**
* Visible check control for fork APK / OTA updates. About used to hide this behind
* five taps on the version row, which Android testers never found.
*/
export function AppUpdateCheckRow() {
const [updateState, setUpdateState] = useState<AppUpdateCheckState>("idle");
const updateInFlight = useRef(false);

useEffect(() => {
if (updateState !== "current") return;
const timer = setTimeout(() => setUpdateState("idle"), 3000);
return () => clearTimeout(timer);
}, [updateState]);

if (!isAppUpdateCheckAvailable()) return null;

const busy =
updateState === "checking" || updateState === "downloading" || updateState === "restarting";
const statusLabel =
updateState === "checking"
? "Checking…"
: updateState === "downloading"
? "Downloading…"
: updateState === "ready"
? "Update ready"
: updateState === "restarting"
? "Restarting…"
: updateState === "current"
? "Up to date"
: null;

return (
<SettingsActionRow
icon="arrow.clockwise"
label="Check for updates"
description={statusLabel ?? undefined}
disabled={busy}
loading={busy}
onPress={() => {
if (updateInFlight.current) return;
updateInFlight.current = true;
void settlePromise(() =>
runAppUpdateCheck({
applyMode: "immediate",
onFailure: (message) => Dialog.alert("Update failed", message),
onStateChange: setUpdateState,
}),
).finally(() => {
updateInFlight.current = false;
});
}}
/>
);
}
Loading