Files
beenvoice/apps/mobile/app/(app)/more/settings.tsx
T

704 lines
20 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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}
<Text style={[styles.meta, { color: colors.mutedForeground }]}>
Time zone: {profile?.timeZone ?? "America/New_York"}
</Text>
</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,
},
});