Add 'apps/mobile/' from commit '5fa30f365f21531094cd4d2045042bb4f1370ac3'

git-subtree-dir: apps/mobile
git-subtree-mainline: 86f8987dff
git-subtree-split: 5fa30f365f
This commit is contained in:
2026-08-16 21:42:59 -04:00
222 changed files with 23436 additions and 0 deletions
+700
View File
@@ -0,0 +1,700 @@
import { useState } from "react";
import Constants from "expo-constants";
import { Ionicons } from "@expo/vector-icons";
import { router } from "expo-router";
import {
Alert,
Platform,
Pressable,
StyleSheet,
Switch,
Text,
View,
} from "react-native";
import { TabPage } from "@/components/TabPage";
import { TabScrollView } from "@/components/TabScrollView";
import { AppBackground } from "@/components/AppBackground";
import { InstanceUrlField } from "@/components/InstanceUrlField";
import { LoadingScreen } from "@/components/LoadingScreen";
import { PageHeader } from "@/components/PageHeader";
import { PinPrompt } from "@/components/PinPrompt";
import { ShortcutsSetupCard } from "@/components/ShortcutsSetupCard";
import { Button } from "@/components/ui/Button";
import { Card } from "@/components/ui/Card";
import { fonts, spacing } from "@/constants/theme";
import { useAccounts } from "@/contexts/AccountsContext";
import { useAppLock } from "@/contexts/AppLockContext";
import { useAuthClient, useSession } from "@/contexts/AuthContext";
import { type ColorMode, useAppTheme } from "@/contexts/ThemeContext";
import { startAdditionalAccountSignIn } from "@/lib/add-account";
import {
confirmRemoveAccount,
finishAccountRemoval,
} from "@/lib/account-actions";
import { performAuthReset } from "@/lib/auth-session";
import { api } from "@/lib/trpc";
const THEME_OPTIONS: { value: ColorMode; label: string }[] = [
{ value: "system", label: "System" },
{ value: "light", label: "Light" },
{ value: "dark", label: "Dark" },
];
export default function SettingsScreen() {
const authClient = useAuthClient();
const { data: session } = useSession();
const {
accounts,
activeAccount,
activeAccountId,
apiUrl,
switchAccount,
removeAccount,
refreshAccounts,
clearActiveAccount,
} = useAccounts();
const { colors, colorMode, setColorMode } = useAppTheme();
const switchProps = {
trackColor: { false: colors.switchTrackOff, true: colors.switchTrackOn },
thumbColor: Platform.OS === "android" ? colors.switchThumb : undefined,
ios_backgroundColor: colors.switchIosBackground,
};
const {
enabled: lockEnabled,
biometricEnabled,
biometricAvailable,
biometricLabel,
enableLock,
disableLock,
changePin,
setUseBiometric,
lock,
} = useAppLock();
const profileQuery = api.settings.getProfile.useQuery();
const deleteAccountMutation = api.settings.deleteAccount.useMutation();
const [pinPrompt, setPinPrompt] = useState<
| { mode: "create" }
| { mode: "confirm-disable" }
| { mode: "change-current" }
| { mode: "change-next" }
| null
>(null);
const [pendingPin, setPendingPin] = useState("");
const [showAdvanced, setShowAdvanced] = useState(false);
const [refreshingAccounts, setRefreshingAccounts] = useState(false);
async function handleRefreshAccounts() {
setRefreshingAccounts(true);
try {
await refreshAccounts();
await profileQuery.refetch();
} finally {
setRefreshingAccounts(false);
}
}
function handleRemoveAccount(accountId: string, label: string) {
confirmRemoveAccount(
label,
() => removeAccount(accountId),
async (result) => {
await finishAccountRemoval({
result,
authClient,
clearActiveAccount,
activeAccountId,
});
},
);
}
async function handleSignOut() {
await performAuthReset({
authClient,
clearActiveAccount,
activeAccountId,
});
router.replace("/(auth)/sign-in");
}
function confirmSignOut() {
Alert.alert("Sign out", "Sign out of this account on this device?", [
{ text: "Cancel", style: "cancel" },
{
text: "Sign out",
style: "destructive",
onPress: () => void handleSignOut(),
},
]);
}
async function handleDeleteAccount() {
if (!activeAccountId) return;
try {
await deleteAccountMutation.mutateAsync({
confirmText: "DELETE MY ACCOUNT",
});
const result = await removeAccount(activeAccountId);
await finishAccountRemoval({
result,
authClient,
clearActiveAccount,
activeAccountId,
});
if (result.remainingCount > 0) {
router.replace("/(auth)/select-account");
}
} catch (error) {
Alert.alert(
"Could not delete account",
error instanceof Error ? error.message : "Please try again.",
);
}
}
function confirmDeleteAccount() {
Alert.alert(
"Permanently delete account?",
"This deletes your account, invoices, clients, businesses, expenses, time entries, uploaded files, and sign-in data. This cannot be undone.",
[
{ text: "Cancel", style: "cancel" },
{
text: "Delete Account",
style: "destructive",
onPress: () => void handleDeleteAccount(),
},
],
);
}
function confirmInstanceChange() {
Alert.alert(
"Server updated",
"You may need to sign in again if you switched to a different instance.",
[{ text: "OK" }],
);
}
function handleLockToggle(next: boolean) {
if (next) {
setPinPrompt({ mode: "create" });
return;
}
setPinPrompt({ mode: "confirm-disable" });
}
function handleChangePin() {
setPendingPin("");
setPinPrompt({ mode: "change-current" });
}
function handleBiometricToggle(next: boolean) {
void setUseBiometric(next);
}
async function handlePinPromptSubmit(pin: string) {
if (pinPrompt?.mode === "create") {
try {
await enableLock(pin);
setPinPrompt(null);
} catch (err) {
Alert.alert(
"Could not enable lock",
err instanceof Error ? err.message : "Try again",
);
}
return;
}
if (pinPrompt?.mode === "confirm-disable") {
const success = await disableLock(pin);
if (!success) {
Alert.alert("Incorrect PIN", "Could not disable app lock.");
return;
}
setPinPrompt(null);
return;
}
if (pinPrompt?.mode === "change-current") {
setPendingPin(pin);
setPinPrompt({ mode: "change-next" });
return;
}
if (pinPrompt?.mode === "change-next") {
const success = await changePin(pendingPin, pin);
if (!success) {
Alert.alert(
"Could not change PIN",
"Check your current PIN and try again.",
);
return;
}
setPendingPin("");
setPinPrompt(null);
Alert.alert("PIN updated", "Your app lock PIN has been changed.");
}
}
if (profileQuery.isLoading) {
return <LoadingScreen message="Loading profile…" />;
}
const profile = profileQuery.data;
const appVersion = Constants.expoConfig?.version ?? "1.0.0";
return (
<AppBackground>
<TabPage showMoreBack>
<PinPrompt
visible={pinPrompt !== null}
title={
pinPrompt?.mode === "create"
? "Create PIN"
: pinPrompt?.mode === "confirm-disable"
? "Disable app lock"
: pinPrompt?.mode === "change-current"
? "Current PIN"
: "New PIN"
}
message={
pinPrompt?.mode === "create" || pinPrompt?.mode === "change-next"
? "Choose a 46 digit PIN."
: pinPrompt?.mode === "confirm-disable"
? "Enter your PIN to turn off app lock."
: "Enter your current PIN."
}
confirmLabel={
pinPrompt?.mode === "create" || pinPrompt?.mode === "change-next"
? "Save"
: "Continue"
}
requireConfirmation={
pinPrompt?.mode === "create" || pinPrompt?.mode === "change-next"
}
onCancel={() => {
setPendingPin("");
setPinPrompt(null);
}}
onSubmit={(pin) => void handlePinPromptSubmit(pin)}
/>
<TabScrollView
header={
<PageHeader
title="Settings"
subtitle="Account and app preferences"
/>
}
keyboardShouldPersistTaps="handled"
>
<Card title="Account">
<Text style={[styles.name, { color: colors.foreground }]}>
{profile?.name ?? session?.user.name ?? "User"}
</Text>
<Text style={[styles.email, { color: colors.mutedForeground }]}>
{profile?.email ?? session?.user.email}
</Text>
{profile?.role ? (
<Text style={[styles.meta, { color: colors.mutedForeground }]}>
Role: {profile.role}
</Text>
) : null}
</Card>
<Card title="Accounts">
{accounts.map((account) => {
const isActive = account.id === activeAccountId;
return (
<View
key={account.id}
style={[
styles.accountRow,
{
borderColor: colors.border,
backgroundColor: isActive ? colors.muted : "transparent",
},
]}
>
<Pressable
accessibilityRole="button"
onPress={() => void switchAccount(account.id)}
style={({ pressed }) => [
styles.accountMain,
pressed && styles.pressed,
]}
>
<View style={styles.accountMeta}>
<Text
style={[
styles.accountName,
{ color: colors.foreground },
]}
>
{account.name || account.email}
</Text>
<Text
style={[
styles.accountSub,
{ color: colors.mutedForeground },
]}
>
{account.email}
</Text>
<Text
style={[
styles.accountSub,
{ color: colors.mutedForeground },
]}
>
{account.instanceUrl.replace(/^https?:\/\//, "")}
</Text>
</View>
{isActive ? (
<Text
style={[styles.activeBadge, { color: colors.primary }]}
>
Active
</Text>
) : null}
</Pressable>
<Pressable
accessibilityRole="button"
accessibilityLabel={`Remove ${account.name || account.email}`}
hitSlop={8}
onPress={() =>
handleRemoveAccount(
account.id,
account.name || account.email,
)
}
style={({ pressed }) => [
styles.removeButton,
pressed && styles.pressed,
]}
>
<Ionicons
name="trash-outline"
size={18}
color={colors.destructive}
/>
</Pressable>
</View>
);
})}
<Button
title={refreshingAccounts ? "Refreshing…" : "Refresh accounts"}
variant="secondary"
disabled={refreshingAccounts}
onPress={() => void handleRefreshAccounts()}
/>
<Button
title="Add another account"
variant="secondary"
onPress={() =>
void startAdditionalAccountSignIn(clearActiveAccount)
}
/>
<Text style={[styles.meta, { color: colors.mutedForeground }]}>
Tap an account to switch. Refresh updates names from saved sign-in
data.
</Text>
</Card>
{Platform.OS === "ios" ? (
<Card title="Shortcuts & Siri">
<ShortcutsSetupCard />
</Card>
) : null}
<Card title="Security">
<View style={styles.settingRow}>
<View style={styles.settingCopy}>
<Text
style={[styles.settingTitle, { color: colors.foreground }]}
>
App lock
</Text>
<Text style={[styles.meta, { color: colors.mutedForeground }]}>
Require a PIN when reopening the app
</Text>
</View>
<Switch
value={lockEnabled}
onValueChange={handleLockToggle}
{...switchProps}
/>
</View>
{lockEnabled && biometricAvailable ? (
<View style={styles.settingRow}>
<View style={styles.settingCopy}>
<Text
style={[styles.settingTitle, { color: colors.foreground }]}
>
{biometricLabel}
</Text>
<Text
style={[styles.meta, { color: colors.mutedForeground }]}
>
Unlock with {biometricLabel.toLowerCase()} when available
</Text>
</View>
<Switch
value={biometricEnabled}
onValueChange={handleBiometricToggle}
{...switchProps}
/>
</View>
) : null}
{lockEnabled ? (
<>
<Button
title="Change PIN"
variant="secondary"
onPress={handleChangePin}
/>
<Button title="Lock now" variant="secondary" onPress={lock} />
</>
) : null}
</Card>
<Card title="Appearance">
<View style={styles.themeRow}>
{THEME_OPTIONS.map((option) => {
const selected = colorMode === option.value;
return (
<Pressable
key={option.value}
accessibilityRole="button"
onPress={() => void setColorMode(option.value)}
style={[
styles.themeChip,
{
borderColor: selected ? colors.primary : colors.border,
backgroundColor: selected
? colors.muted
: "transparent",
},
]}
>
<Text
style={[
styles.themeChipLabel,
{
color: selected
? colors.foreground
: colors.mutedForeground,
},
]}
>
{option.label}
</Text>
</Pressable>
);
})}
</View>
</Card>
<Card title="App">
<View style={styles.appRow}>
<Text style={[styles.meta, { color: colors.mutedForeground }]}>
Version
</Text>
<Text style={[styles.appValue, { color: colors.foreground }]}>
{appVersion}
</Text>
</View>
<View style={styles.appRow}>
<Text style={[styles.meta, { color: colors.mutedForeground }]}>
Platform
</Text>
<Text style={[styles.appValue, { color: colors.foreground }]}>
{Constants.platform?.ios ? "iOS" : "Other"}
</Text>
</View>
</Card>
<Card title="Delete account">
<Text style={[styles.meta, { color: colors.mutedForeground }]}>
Permanently delete this account and all of its data from the
server. This cannot be undone.
</Text>
<Button
title={
deleteAccountMutation.isPending
? "Deleting account…"
: "Delete Account"
}
variant="danger"
loading={deleteAccountMutation.isPending}
disabled={deleteAccountMutation.isPending}
onPress={confirmDeleteAccount}
/>
</Card>
<Pressable
accessibilityRole="button"
accessibilityState={{ expanded: showAdvanced }}
onPress={() => setShowAdvanced((open) => !open)}
style={styles.advancedToggle}
>
<Text
style={[styles.advancedLabel, { color: colors.mutedForeground }]}
>
Advanced
</Text>
<Ionicons
name={showAdvanced ? "chevron-up" : "chevron-down"}
size={16}
color={colors.mutedForeground}
/>
</Pressable>
{showAdvanced ? (
<Card title="Server instance">
<InstanceUrlField onSaved={confirmInstanceChange} />
<Text
style={[
styles.currentServer,
{ color: colors.mutedForeground },
]}
>
Connected to {activeAccount?.instanceUrl ?? apiUrl}
</Text>
</Card>
) : null}
<View style={styles.actions}>
<Button
title="Sign Out"
variant="danger"
onPress={confirmSignOut}
/>
</View>
</TabScrollView>
</TabPage>
</AppBackground>
);
}
const styles = StyleSheet.create({
name: {
fontSize: 20,
fontFamily: fonts.heading,
},
email: {
fontSize: 15,
fontFamily: fonts.body,
},
meta: {
fontSize: 13,
fontFamily: fonts.bodyMedium,
},
currentServer: {
fontSize: 12,
fontFamily: fonts.mono,
},
advancedToggle: {
flexDirection: "row",
alignItems: "center",
justifyContent: "center",
gap: spacing.xs,
minHeight: 36,
},
advancedLabel: {
fontSize: 13,
fontFamily: fonts.bodyMedium,
},
appRow: {
flexDirection: "row",
justifyContent: "space-between",
alignItems: "center",
},
appValue: {
fontSize: 14,
fontFamily: fonts.bodyMedium,
},
accountRow: {
borderWidth: 1,
borderRadius: 12,
paddingLeft: spacing.md,
paddingRight: spacing.sm,
flexDirection: "row",
alignItems: "center",
gap: spacing.sm,
},
accountMain: {
flex: 1,
flexDirection: "row",
alignItems: "center",
justifyContent: "space-between",
gap: spacing.md,
paddingVertical: spacing.md,
},
removeButton: {
alignItems: "center",
justifyContent: "center",
minWidth: 36,
minHeight: 36,
},
pressed: {
opacity: 0.92,
},
accountMeta: {
flex: 1,
gap: 2,
},
accountName: {
fontSize: 15,
fontFamily: fonts.bodySemiBold,
},
accountSub: {
fontSize: 12,
fontFamily: fonts.body,
},
activeBadge: {
fontSize: 12,
fontFamily: fonts.bodySemiBold,
},
themeRow: {
flexDirection: "row",
gap: spacing.sm,
},
themeChip: {
flex: 1,
borderWidth: 1,
borderRadius: 10,
minHeight: 40,
alignItems: "center",
justifyContent: "center",
paddingHorizontal: spacing.sm,
},
themeChipLabel: {
fontSize: 13,
fontFamily: fonts.bodyMedium,
lineHeight: 18,
...(Platform.OS === "android" ? { includeFontPadding: false } : null),
},
settingRow: {
flexDirection: "row",
alignItems: "center",
justifyContent: "space-between",
gap: spacing.md,
},
settingCopy: {
flex: 1,
gap: 2,
},
settingTitle: {
fontSize: 15,
fontFamily: fonts.bodySemiBold,
},
actions: {
marginTop: spacing.sm,
},
});