736 lines
22 KiB
TypeScript
736 lines
22 KiB
TypeScript
|
|
import type { AvatarStyle } from "@rakazo/contracts";
|
|||
|
|
import { useLocalSearchParams, useRouter } from "expo-router";
|
|||
|
|
import { useEffect, useState } from "react";
|
|||
|
|
import {
|
|||
|
|
ActivityIndicator,
|
|||
|
|
Alert,
|
|||
|
|
Platform,
|
|||
|
|
Pressable,
|
|||
|
|
ScrollView,
|
|||
|
|
StyleSheet,
|
|||
|
|
Switch,
|
|||
|
|
Text,
|
|||
|
|
TextInput,
|
|||
|
|
View,
|
|||
|
|
} from "react-native";
|
|||
|
|
import { SafeAreaView } from "react-native-safe-area-context";
|
|||
|
|
import { useAvatarStyle } from "../components/avatar-style";
|
|||
|
|
import { BotAvatar } from "../components/bot-avatar";
|
|||
|
|
import type { MobileBot } from "../lib/api";
|
|||
|
|
import {
|
|||
|
|
changePassword as changeAccountPassword,
|
|||
|
|
currentApiBase,
|
|||
|
|
deleteAccount,
|
|||
|
|
loadSessionToken,
|
|||
|
|
type MobileMe,
|
|||
|
|
rpc,
|
|||
|
|
selectedSpaceId,
|
|||
|
|
signOut,
|
|||
|
|
} from "../lib/api";
|
|||
|
|
import { confirmDeleteBot } from "../lib/bot-lifecycle";
|
|||
|
|
import {
|
|||
|
|
canPostPromotedNotifications,
|
|||
|
|
DEFAULT_LIVE_NOTIFICATION_SETTINGS,
|
|||
|
|
getLiveNotificationSettings,
|
|||
|
|
type LiveNotificationSettings,
|
|||
|
|
openLiveNotificationSettings,
|
|||
|
|
openPromotedNotificationSettings,
|
|||
|
|
setLiveNotificationSettings,
|
|||
|
|
} from "../lib/live-notifications";
|
|||
|
|
import { native } from "../lib/native";
|
|||
|
|
import { registerPushToken } from "../lib/push";
|
|||
|
|
|
|||
|
|
export default function Account() {
|
|||
|
|
const router = useRouter();
|
|||
|
|
const { focus } = useLocalSearchParams<{ focus?: string }>();
|
|||
|
|
const [me, setMe] = useState<MobileMe | null>(null);
|
|||
|
|
const [password, setPassword] = useState("");
|
|||
|
|
const [pending, setPending] = useState(false);
|
|||
|
|
const [avatarPending, setAvatarPending] = useState(false);
|
|||
|
|
const [avatarError, setAvatarError] = useState<string | null>(null);
|
|||
|
|
const [notifications, setNotifications] = useState<LiveNotificationSettings>(
|
|||
|
|
DEFAULT_LIVE_NOTIFICATION_SETTINGS,
|
|||
|
|
);
|
|||
|
|
const [notificationsReady, setNotificationsReady] = useState(Platform.OS !== "android");
|
|||
|
|
const [notificationPending, setNotificationPending] = useState(false);
|
|||
|
|
const [notificationError, setNotificationError] = useState<string | null>(null);
|
|||
|
|
const [error, setError] = useState<string | null>(null);
|
|||
|
|
const [currentPassword, setCurrentPassword] = useState("");
|
|||
|
|
const [newPassword, setNewPassword] = useState("");
|
|||
|
|
const [passwordConfirmation, setPasswordConfirmation] = useState("");
|
|||
|
|
const [passwordPending, setPasswordPending] = useState(false);
|
|||
|
|
const [passwordMessage, setPasswordMessage] = useState<string | null>(null);
|
|||
|
|
const [archivedBots, setArchivedBots] = useState<MobileBot[]>([]);
|
|||
|
|
const [usage, setUsage] = useState<{
|
|||
|
|
runs: number;
|
|||
|
|
inputTokens: number;
|
|||
|
|
outputTokens: number;
|
|||
|
|
} | null>(null);
|
|||
|
|
const { avatarStyle, updateAvatarStyle } = useAvatarStyle();
|
|||
|
|
|
|||
|
|
useEffect(() => {
|
|||
|
|
void rpc<MobileMe>("me")
|
|||
|
|
.then(setMe)
|
|||
|
|
.catch(() => undefined);
|
|||
|
|
void rpc<MobileBot[]>("bots/listArchived")
|
|||
|
|
.then(setArchivedBots)
|
|||
|
|
.catch(() => undefined);
|
|||
|
|
void rpc<{ runs: number; inputTokens: number; outputTokens: number }>("usage/summary")
|
|||
|
|
.then(setUsage)
|
|||
|
|
.catch(() => undefined);
|
|||
|
|
if (Platform.OS === "android") {
|
|||
|
|
void getLiveNotificationSettings()
|
|||
|
|
.then(setNotifications)
|
|||
|
|
.catch(() => undefined)
|
|||
|
|
.finally(() => setNotificationsReady(true));
|
|||
|
|
}
|
|||
|
|
}, []);
|
|||
|
|
|
|||
|
|
const usageBlock = (
|
|||
|
|
<View accessibilityLabel="Usage" style={styles.profile}>
|
|||
|
|
<Text style={styles.settingsTitle}>Usage</Text>
|
|||
|
|
{usage ? (
|
|||
|
|
<Text style={styles.email}>
|
|||
|
|
{usage.runs} runs · {usage.inputTokens + usage.outputTokens} tokens
|
|||
|
|
</Text>
|
|||
|
|
) : null}
|
|||
|
|
<Text style={styles.settingsExplanation}>Model spend uses your provider keys.</Text>
|
|||
|
|
</View>
|
|||
|
|
);
|
|||
|
|
|
|||
|
|
async function restoreBot(botId: string) {
|
|||
|
|
try {
|
|||
|
|
await rpc("bots/restore", { botId });
|
|||
|
|
setArchivedBots((bots) => bots.filter((bot) => bot.id !== botId));
|
|||
|
|
} catch (restoreError) {
|
|||
|
|
Alert.alert(
|
|||
|
|
"Could not restore bot",
|
|||
|
|
restoreError instanceof Error ? restoreError.message : "Try again.",
|
|||
|
|
);
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
async function selectAvatarStyle(next: AvatarStyle) {
|
|||
|
|
if (next === avatarStyle) return;
|
|||
|
|
setAvatarPending(true);
|
|||
|
|
setAvatarError(null);
|
|||
|
|
try {
|
|||
|
|
await updateAvatarStyle(next);
|
|||
|
|
} catch {
|
|||
|
|
setAvatarError("Couldn't update avatars");
|
|||
|
|
} finally {
|
|||
|
|
setAvatarPending(false);
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
async function handleSignOut() {
|
|||
|
|
setPending(true);
|
|||
|
|
setError(null);
|
|||
|
|
try {
|
|||
|
|
await signOut();
|
|||
|
|
router.dismissAll();
|
|||
|
|
router.replace("/sign-in");
|
|||
|
|
} catch (err) {
|
|||
|
|
setError(err instanceof Error ? err.message : "Could not sign out");
|
|||
|
|
setPending(false);
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
async function handlePasswordChange() {
|
|||
|
|
if (newPassword !== passwordConfirmation) {
|
|||
|
|
setPasswordMessage("Passwords do not match");
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
setPasswordPending(true);
|
|||
|
|
setPasswordMessage(null);
|
|||
|
|
try {
|
|||
|
|
await changeAccountPassword(currentPassword, newPassword);
|
|||
|
|
setCurrentPassword("");
|
|||
|
|
setNewPassword("");
|
|||
|
|
setPasswordConfirmation("");
|
|||
|
|
setPasswordMessage("Password updated");
|
|||
|
|
} catch (cause) {
|
|||
|
|
setPasswordMessage(cause instanceof Error ? cause.message : "Could not change password");
|
|||
|
|
} finally {
|
|||
|
|
setPasswordPending(false);
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
async function updateNotifications(next: LiveNotificationSettings) {
|
|||
|
|
const previous = notifications;
|
|||
|
|
setNotifications(next);
|
|||
|
|
setNotificationPending(true);
|
|||
|
|
setNotificationError(null);
|
|||
|
|
try {
|
|||
|
|
await setLiveNotificationSettings(
|
|||
|
|
next,
|
|||
|
|
currentApiBase(),
|
|||
|
|
await loadSessionToken(),
|
|||
|
|
selectedSpaceId() ?? "",
|
|||
|
|
);
|
|||
|
|
if (next.liveConnection && !(await canPostPromotedNotifications())) {
|
|||
|
|
await openPromotedNotificationSettings();
|
|||
|
|
}
|
|||
|
|
await registerPushToken();
|
|||
|
|
} catch (cause) {
|
|||
|
|
setNotifications(previous);
|
|||
|
|
setNotificationError(
|
|||
|
|
cause instanceof Error ? cause.message : "Could not update notifications",
|
|||
|
|
);
|
|||
|
|
} finally {
|
|||
|
|
setNotificationPending(false);
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function confirmDeletion() {
|
|||
|
|
setError(null);
|
|||
|
|
Alert.alert(
|
|||
|
|
"Delete your account?",
|
|||
|
|
"This permanently deletes your account, bots, conversations, memories, files, and saved connections. This cannot be undone.",
|
|||
|
|
[
|
|||
|
|
{ text: "Cancel", style: "cancel" },
|
|||
|
|
{
|
|||
|
|
text: "Delete account",
|
|||
|
|
style: "destructive",
|
|||
|
|
onPress: () => void handleDeletion(),
|
|||
|
|
},
|
|||
|
|
],
|
|||
|
|
);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
async function handleDeletion() {
|
|||
|
|
setPending(true);
|
|||
|
|
setError(null);
|
|||
|
|
try {
|
|||
|
|
await deleteAccount(password);
|
|||
|
|
router.dismissAll();
|
|||
|
|
router.replace("/sign-in");
|
|||
|
|
} catch (err) {
|
|||
|
|
setError(err instanceof Error ? err.message : "Could not delete account");
|
|||
|
|
} finally {
|
|||
|
|
setPending(false);
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
return (
|
|||
|
|
<SafeAreaView edges={["bottom"]} style={styles.screen}>
|
|||
|
|
<ScrollView contentContainerStyle={styles.content}>
|
|||
|
|
{focus === "usage" ? usageBlock : null}
|
|||
|
|
<View style={styles.profile}>
|
|||
|
|
<Text style={styles.name}>{me?.name || "Your account"}</Text>
|
|||
|
|
{me?.email ? <Text style={styles.email}>{me.email}</Text> : null}
|
|||
|
|
</View>
|
|||
|
|
{focus !== "usage" ? usageBlock : null}
|
|||
|
|
|
|||
|
|
<View accessibilityLabel="Password" style={styles.profile}>
|
|||
|
|
<Text style={styles.settingsTitle}>Password</Text>
|
|||
|
|
<AccountPasswordInput
|
|||
|
|
label="Current password"
|
|||
|
|
value={currentPassword}
|
|||
|
|
onChange={setCurrentPassword}
|
|||
|
|
autoComplete="current-password"
|
|||
|
|
/>
|
|||
|
|
<AccountPasswordInput
|
|||
|
|
label="New password"
|
|||
|
|
value={newPassword}
|
|||
|
|
onChange={setNewPassword}
|
|||
|
|
autoComplete="new-password"
|
|||
|
|
/>
|
|||
|
|
<AccountPasswordInput
|
|||
|
|
label="Confirm password"
|
|||
|
|
value={passwordConfirmation}
|
|||
|
|
onChange={setPasswordConfirmation}
|
|||
|
|
autoComplete="new-password"
|
|||
|
|
/>
|
|||
|
|
{passwordMessage ? <Text style={styles.passwordMessage}>{passwordMessage}</Text> : null}
|
|||
|
|
<Pressable
|
|||
|
|
accessibilityRole="button"
|
|||
|
|
disabled={passwordPending || !currentPassword || newPassword.length < 8}
|
|||
|
|
onPress={() => void handlePasswordChange()}
|
|||
|
|
style={({ pressed }) => [
|
|||
|
|
styles.changePasswordButton,
|
|||
|
|
(passwordPending || !currentPassword || newPassword.length < 8) && styles.disabled,
|
|||
|
|
pressed && styles.pressed,
|
|||
|
|
]}
|
|||
|
|
>
|
|||
|
|
{passwordPending ? (
|
|||
|
|
<ActivityIndicator color={native.label} />
|
|||
|
|
) : (
|
|||
|
|
<Text style={styles.changePasswordLabel}>Change password</Text>
|
|||
|
|
)}
|
|||
|
|
</Pressable>
|
|||
|
|
</View>
|
|||
|
|
|
|||
|
|
<View accessibilityLabel="Avatar style" style={styles.avatarSection}>
|
|||
|
|
<Text style={styles.settingsTitle}>Avatars</Text>
|
|||
|
|
<View style={styles.avatarOptions}>
|
|||
|
|
{(["robot", "organic"] as const).map((style) => {
|
|||
|
|
const selected = avatarStyle === style;
|
|||
|
|
return (
|
|||
|
|
<Pressable
|
|||
|
|
key={style}
|
|||
|
|
accessibilityLabel={`${style === "robot" ? "Robot" : "Organic"} avatars`}
|
|||
|
|
accessibilityRole="button"
|
|||
|
|
accessibilityState={{ selected, disabled: avatarPending }}
|
|||
|
|
disabled={avatarPending}
|
|||
|
|
onPress={() => void selectAvatarStyle(style)}
|
|||
|
|
style={({ pressed }) => [
|
|||
|
|
styles.avatarOption,
|
|||
|
|
selected && styles.avatarOptionSelected,
|
|||
|
|
pressed && styles.pressed,
|
|||
|
|
]}
|
|||
|
|
>
|
|||
|
|
<BotAvatar
|
|||
|
|
color={style === "robot" ? "#8B5CF6" : "#D62F8B"}
|
|||
|
|
identity="avatar-preview"
|
|||
|
|
size={42}
|
|||
|
|
variant={style}
|
|||
|
|
/>
|
|||
|
|
<Text style={styles.avatarLabel}>{style === "robot" ? "Robot" : "Organic"}</Text>
|
|||
|
|
</Pressable>
|
|||
|
|
);
|
|||
|
|
})}
|
|||
|
|
</View>
|
|||
|
|
{avatarError ? <Text style={styles.error}>{avatarError}</Text> : null}
|
|||
|
|
</View>
|
|||
|
|
|
|||
|
|
{Platform.OS === "android" ? (
|
|||
|
|
<View accessibilityLabel="Notifications" style={styles.profile}>
|
|||
|
|
<Text style={styles.settingsTitle}>Notifications</Text>
|
|||
|
|
<NotificationSwitch
|
|||
|
|
label="Live working status"
|
|||
|
|
detail="While agents are working"
|
|||
|
|
value={notifications.liveConnection}
|
|||
|
|
disabled={notificationPending || !notificationsReady}
|
|||
|
|
onChange={(liveConnection) =>
|
|||
|
|
void updateNotifications({ ...notifications, liveConnection })
|
|||
|
|
}
|
|||
|
|
/>
|
|||
|
|
<NotificationSwitch
|
|||
|
|
label="Agent messages"
|
|||
|
|
detail="Replies and completed work"
|
|||
|
|
value={notifications.messages}
|
|||
|
|
disabled={notificationPending || !notificationsReady}
|
|||
|
|
onChange={(messages) => void updateNotifications({ ...notifications, messages })}
|
|||
|
|
/>
|
|||
|
|
<NotificationSwitch
|
|||
|
|
label="Scheduled tasks"
|
|||
|
|
detail="Alerts from routines"
|
|||
|
|
value={notifications.scheduledTasks}
|
|||
|
|
disabled={notificationPending || !notificationsReady}
|
|||
|
|
onChange={(scheduledTasks) =>
|
|||
|
|
void updateNotifications({ ...notifications, scheduledTasks })
|
|||
|
|
}
|
|||
|
|
/>
|
|||
|
|
<NotificationSwitch
|
|||
|
|
label="Needs attention"
|
|||
|
|
detail="Questions, approvals, takeover"
|
|||
|
|
value={notifications.needsAttention}
|
|||
|
|
disabled={notificationPending || !notificationsReady}
|
|||
|
|
onChange={(needsAttention) =>
|
|||
|
|
void updateNotifications({ ...notifications, needsAttention })
|
|||
|
|
}
|
|||
|
|
/>
|
|||
|
|
<Pressable
|
|||
|
|
accessibilityRole="button"
|
|||
|
|
onPress={() => void openPromotedNotificationSettings()}
|
|||
|
|
style={{ minHeight: 44, justifyContent: "center" }}
|
|||
|
|
>
|
|||
|
|
<Text style={{ color: "#4C8DFF", fontSize: 14 }}>Live update settings</Text>
|
|||
|
|
</Pressable>
|
|||
|
|
<Pressable
|
|||
|
|
accessibilityRole="button"
|
|||
|
|
onPress={() => void openLiveNotificationSettings()}
|
|||
|
|
style={{ minHeight: 44, justifyContent: "center" }}
|
|||
|
|
>
|
|||
|
|
<Text style={{ color: "#4C8DFF", fontSize: 14 }}>Notification settings</Text>
|
|||
|
|
</Pressable>
|
|||
|
|
{notificationError ? <Text style={styles.error}>{notificationError}</Text> : null}
|
|||
|
|
</View>
|
|||
|
|
) : null}
|
|||
|
|
|
|||
|
|
<Pressable
|
|||
|
|
accessibilityRole="button"
|
|||
|
|
disabled={pending}
|
|||
|
|
onPress={() => router.push("/models")}
|
|||
|
|
style={({ pressed }) => [styles.settingsButton, pressed && styles.pressed]}
|
|||
|
|
>
|
|||
|
|
<View>
|
|||
|
|
<Text style={styles.settingsTitle}>Models</Text>
|
|||
|
|
<Text style={styles.settingsExplanation}>Choose your provider and active model</Text>
|
|||
|
|
</View>
|
|||
|
|
<Text style={styles.chevron}>›</Text>
|
|||
|
|
</Pressable>
|
|||
|
|
|
|||
|
|
<Pressable
|
|||
|
|
accessibilityRole="button"
|
|||
|
|
disabled={pending}
|
|||
|
|
onPress={() => router.push("/voice")}
|
|||
|
|
style={({ pressed }) => [styles.settingsButton, pressed && styles.pressed]}
|
|||
|
|
>
|
|||
|
|
<View>
|
|||
|
|
<Text style={styles.settingsTitle}>Voice</Text>
|
|||
|
|
<Text style={styles.settingsExplanation}>
|
|||
|
|
Speak replies aloud with ElevenLabs, OpenAI, or Cartesia
|
|||
|
|
</Text>
|
|||
|
|
</View>
|
|||
|
|
<Text style={styles.chevron}>›</Text>
|
|||
|
|
</Pressable>
|
|||
|
|
|
|||
|
|
<Pressable
|
|||
|
|
accessibilityRole="button"
|
|||
|
|
disabled={pending}
|
|||
|
|
onPress={() => router.push("/integrations")}
|
|||
|
|
style={({ pressed }) => [styles.settingsButton, pressed && styles.pressed]}
|
|||
|
|
>
|
|||
|
|
<View>
|
|||
|
|
<Text style={styles.settingsTitle}>Integrations</Text>
|
|||
|
|
<Text style={styles.settingsExplanation}>Connect apps.</Text>
|
|||
|
|
</View>
|
|||
|
|
<Text style={styles.chevron}>›</Text>
|
|||
|
|
</Pressable>
|
|||
|
|
|
|||
|
|
<Pressable
|
|||
|
|
accessibilityRole="button"
|
|||
|
|
disabled={pending}
|
|||
|
|
onPress={() => void handleSignOut()}
|
|||
|
|
style={({ pressed }) => [styles.button, pressed && styles.pressed]}
|
|||
|
|
>
|
|||
|
|
<Text style={styles.buttonLabel}>Sign out</Text>
|
|||
|
|
</Pressable>
|
|||
|
|
|
|||
|
|
{archivedBots.length > 0 ? (
|
|||
|
|
<View style={styles.archivedSection}>
|
|||
|
|
<Text style={styles.sectionTitle}>Archived bots</Text>
|
|||
|
|
{archivedBots.map((bot) => (
|
|||
|
|
<View key={bot.id} style={styles.archivedRow}>
|
|||
|
|
<Text numberOfLines={1} style={styles.archivedName}>
|
|||
|
|
{bot.name}
|
|||
|
|
</Text>
|
|||
|
|
<Pressable onPress={() => void restoreBot(bot.id)} hitSlop={8}>
|
|||
|
|
<Text style={styles.restoreLabel}>Restore</Text>
|
|||
|
|
</Pressable>
|
|||
|
|
<Pressable
|
|||
|
|
onPress={() =>
|
|||
|
|
confirmDeleteBot(bot, () =>
|
|||
|
|
setArchivedBots((bots) => bots.filter((item) => item.id !== bot.id)),
|
|||
|
|
)
|
|||
|
|
}
|
|||
|
|
hitSlop={8}
|
|||
|
|
>
|
|||
|
|
<Text style={styles.archivedDeleteLabel}>Delete</Text>
|
|||
|
|
</Pressable>
|
|||
|
|
</View>
|
|||
|
|
))}
|
|||
|
|
</View>
|
|||
|
|
) : null}
|
|||
|
|
|
|||
|
|
<View style={styles.dangerZone}>
|
|||
|
|
<Text style={styles.dangerTitle}>Delete account</Text>
|
|||
|
|
<Text style={styles.explanation}>
|
|||
|
|
Enter your current password, then confirm permanent deletion of your account and all
|
|||
|
|
associated data.
|
|||
|
|
</Text>
|
|||
|
|
<TextInput
|
|||
|
|
accessibilityLabel="Current password"
|
|||
|
|
autoCapitalize="none"
|
|||
|
|
autoCorrect={false}
|
|||
|
|
editable={!pending}
|
|||
|
|
onChangeText={(value) => {
|
|||
|
|
setPassword(value);
|
|||
|
|
setError(null);
|
|||
|
|
}}
|
|||
|
|
placeholder="Current password"
|
|||
|
|
placeholderTextColor={native.tertiaryLabel}
|
|||
|
|
secureTextEntry
|
|||
|
|
style={styles.password}
|
|||
|
|
textContentType="password"
|
|||
|
|
value={password}
|
|||
|
|
/>
|
|||
|
|
{error ? <Text style={styles.error}>{error}</Text> : null}
|
|||
|
|
<Pressable
|
|||
|
|
accessibilityRole="button"
|
|||
|
|
disabled={pending || !password}
|
|||
|
|
onPress={confirmDeletion}
|
|||
|
|
style={({ pressed }) => [
|
|||
|
|
styles.deleteButton,
|
|||
|
|
(pending || !password) && styles.disabled,
|
|||
|
|
pressed && styles.pressed,
|
|||
|
|
]}
|
|||
|
|
>
|
|||
|
|
{pending ? (
|
|||
|
|
<ActivityIndicator color="#FFFFFF" />
|
|||
|
|
) : (
|
|||
|
|
<Text style={styles.deleteLabel}>Delete account</Text>
|
|||
|
|
)}
|
|||
|
|
</Pressable>
|
|||
|
|
</View>
|
|||
|
|
</ScrollView>
|
|||
|
|
</SafeAreaView>
|
|||
|
|
);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function NotificationSwitch({
|
|||
|
|
label,
|
|||
|
|
detail,
|
|||
|
|
value,
|
|||
|
|
disabled,
|
|||
|
|
onChange,
|
|||
|
|
}: {
|
|||
|
|
label: string;
|
|||
|
|
detail: string;
|
|||
|
|
value: boolean;
|
|||
|
|
disabled: boolean;
|
|||
|
|
onChange: (value: boolean) => void;
|
|||
|
|
}) {
|
|||
|
|
return (
|
|||
|
|
<View
|
|||
|
|
style={{
|
|||
|
|
minHeight: 54,
|
|||
|
|
flexDirection: "row",
|
|||
|
|
alignItems: "center",
|
|||
|
|
gap: 12,
|
|||
|
|
}}
|
|||
|
|
>
|
|||
|
|
<View style={{ flex: 1 }}>
|
|||
|
|
<Text style={{ color: native.label, fontSize: 15 }}>{label}</Text>
|
|||
|
|
<Text style={{ color: native.secondaryLabel, fontSize: 12.5, marginTop: 2 }}>{detail}</Text>
|
|||
|
|
</View>
|
|||
|
|
<Switch
|
|||
|
|
accessibilityLabel={label}
|
|||
|
|
accessibilityHint={detail}
|
|||
|
|
disabled={disabled}
|
|||
|
|
value={value}
|
|||
|
|
onValueChange={onChange}
|
|||
|
|
/>
|
|||
|
|
</View>
|
|||
|
|
);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function AccountPasswordInput({
|
|||
|
|
label,
|
|||
|
|
value,
|
|||
|
|
onChange,
|
|||
|
|
autoComplete,
|
|||
|
|
}: {
|
|||
|
|
label: string;
|
|||
|
|
value: string;
|
|||
|
|
onChange: (value: string) => void;
|
|||
|
|
autoComplete: "current-password" | "new-password";
|
|||
|
|
}) {
|
|||
|
|
return (
|
|||
|
|
<TextInput
|
|||
|
|
accessibilityLabel={label}
|
|||
|
|
autoCapitalize="none"
|
|||
|
|
autoComplete={autoComplete}
|
|||
|
|
autoCorrect={false}
|
|||
|
|
onChangeText={onChange}
|
|||
|
|
placeholder={label}
|
|||
|
|
placeholderTextColor={native.tertiaryLabel}
|
|||
|
|
secureTextEntry
|
|||
|
|
style={styles.accountPassword}
|
|||
|
|
value={value}
|
|||
|
|
/>
|
|||
|
|
);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const styles = StyleSheet.create({
|
|||
|
|
screen: {
|
|||
|
|
flex: 1,
|
|||
|
|
backgroundColor: native.page,
|
|||
|
|
},
|
|||
|
|
content: {
|
|||
|
|
flexGrow: 1,
|
|||
|
|
padding: 20,
|
|||
|
|
gap: 20,
|
|||
|
|
},
|
|||
|
|
profile: {
|
|||
|
|
borderRadius: 16,
|
|||
|
|
backgroundColor: native.fill,
|
|||
|
|
padding: 18,
|
|||
|
|
gap: 4,
|
|||
|
|
},
|
|||
|
|
name: {
|
|||
|
|
color: native.label,
|
|||
|
|
fontSize: 20,
|
|||
|
|
fontWeight: "600",
|
|||
|
|
},
|
|||
|
|
email: {
|
|||
|
|
color: native.secondaryLabel,
|
|||
|
|
fontSize: 15,
|
|||
|
|
},
|
|||
|
|
button: {
|
|||
|
|
minHeight: 50,
|
|||
|
|
borderRadius: 14,
|
|||
|
|
alignItems: "center",
|
|||
|
|
justifyContent: "center",
|
|||
|
|
backgroundColor: native.fill,
|
|||
|
|
},
|
|||
|
|
buttonLabel: {
|
|||
|
|
color: native.label,
|
|||
|
|
fontSize: 17,
|
|||
|
|
fontWeight: "600",
|
|||
|
|
},
|
|||
|
|
archivedSection: {
|
|||
|
|
borderRadius: 16,
|
|||
|
|
backgroundColor: native.fill,
|
|||
|
|
padding: 18,
|
|||
|
|
gap: 14,
|
|||
|
|
},
|
|||
|
|
sectionTitle: {
|
|||
|
|
color: native.secondaryLabel,
|
|||
|
|
fontSize: 14,
|
|||
|
|
fontWeight: "600",
|
|||
|
|
},
|
|||
|
|
archivedRow: {
|
|||
|
|
flexDirection: "row",
|
|||
|
|
alignItems: "center",
|
|||
|
|
gap: 14,
|
|||
|
|
},
|
|||
|
|
archivedName: {
|
|||
|
|
flex: 1,
|
|||
|
|
color: native.label,
|
|||
|
|
fontSize: 16,
|
|||
|
|
},
|
|||
|
|
restoreLabel: {
|
|||
|
|
color: native.label,
|
|||
|
|
fontSize: 14,
|
|||
|
|
fontWeight: "600",
|
|||
|
|
},
|
|||
|
|
archivedDeleteLabel: {
|
|||
|
|
color: "#FF6961",
|
|||
|
|
fontSize: 14,
|
|||
|
|
},
|
|||
|
|
settingsButton: {
|
|||
|
|
minHeight: 62,
|
|||
|
|
borderRadius: 14,
|
|||
|
|
backgroundColor: native.fill,
|
|||
|
|
paddingHorizontal: 16,
|
|||
|
|
paddingVertical: 12,
|
|||
|
|
flexDirection: "row",
|
|||
|
|
alignItems: "center",
|
|||
|
|
justifyContent: "space-between",
|
|||
|
|
},
|
|||
|
|
avatarSection: {
|
|||
|
|
borderRadius: 16,
|
|||
|
|
backgroundColor: native.fill,
|
|||
|
|
padding: 18,
|
|||
|
|
gap: 14,
|
|||
|
|
},
|
|||
|
|
avatarOptions: {
|
|||
|
|
flexDirection: "row",
|
|||
|
|
gap: 12,
|
|||
|
|
},
|
|||
|
|
avatarOption: {
|
|||
|
|
flex: 1,
|
|||
|
|
minHeight: 86,
|
|||
|
|
borderRadius: 14,
|
|||
|
|
borderWidth: StyleSheet.hairlineWidth,
|
|||
|
|
borderColor: native.tertiaryLabel,
|
|||
|
|
alignItems: "center",
|
|||
|
|
justifyContent: "center",
|
|||
|
|
gap: 8,
|
|||
|
|
},
|
|||
|
|
avatarOptionSelected: {
|
|||
|
|
borderColor: native.label,
|
|||
|
|
backgroundColor: native.fillPressed,
|
|||
|
|
},
|
|||
|
|
avatarLabel: {
|
|||
|
|
color: native.label,
|
|||
|
|
fontSize: 14,
|
|||
|
|
fontWeight: "600",
|
|||
|
|
},
|
|||
|
|
settingsTitle: {
|
|||
|
|
color: native.label,
|
|||
|
|
fontSize: 17,
|
|||
|
|
fontWeight: "600",
|
|||
|
|
},
|
|||
|
|
settingsExplanation: {
|
|||
|
|
color: native.secondaryLabel,
|
|||
|
|
fontSize: 13,
|
|||
|
|
marginTop: 3,
|
|||
|
|
},
|
|||
|
|
accountPassword: {
|
|||
|
|
minHeight: 46,
|
|||
|
|
borderRadius: 12,
|
|||
|
|
backgroundColor: native.fillPressed,
|
|||
|
|
color: native.label,
|
|||
|
|
paddingHorizontal: 14,
|
|||
|
|
marginTop: 8,
|
|||
|
|
},
|
|||
|
|
passwordMessage: {
|
|||
|
|
color: native.secondaryLabel,
|
|||
|
|
fontSize: 13,
|
|||
|
|
marginTop: 8,
|
|||
|
|
},
|
|||
|
|
changePasswordButton: {
|
|||
|
|
minHeight: 44,
|
|||
|
|
borderRadius: 12,
|
|||
|
|
alignItems: "center",
|
|||
|
|
justifyContent: "center",
|
|||
|
|
backgroundColor: native.fillPressed,
|
|||
|
|
marginTop: 10,
|
|||
|
|
},
|
|||
|
|
changePasswordLabel: {
|
|||
|
|
color: native.label,
|
|||
|
|
fontSize: 15,
|
|||
|
|
fontWeight: "600",
|
|||
|
|
},
|
|||
|
|
chevron: {
|
|||
|
|
color: native.secondaryLabel,
|
|||
|
|
fontSize: 28,
|
|||
|
|
fontWeight: "300",
|
|||
|
|
},
|
|||
|
|
dangerZone: {
|
|||
|
|
marginTop: 12,
|
|||
|
|
borderRadius: 16,
|
|||
|
|
borderWidth: StyleSheet.hairlineWidth,
|
|||
|
|
borderColor: "#5A2426",
|
|||
|
|
padding: 18,
|
|||
|
|
},
|
|||
|
|
dangerTitle: {
|
|||
|
|
color: "#FF6961",
|
|||
|
|
fontSize: 17,
|
|||
|
|
fontWeight: "600",
|
|||
|
|
},
|
|||
|
|
explanation: {
|
|||
|
|
color: native.secondaryLabel,
|
|||
|
|
fontSize: 14,
|
|||
|
|
lineHeight: 20,
|
|||
|
|
marginTop: 8,
|
|||
|
|
},
|
|||
|
|
password: {
|
|||
|
|
height: 48,
|
|||
|
|
borderRadius: 12,
|
|||
|
|
backgroundColor: native.fill,
|
|||
|
|
color: native.label,
|
|||
|
|
paddingHorizontal: 14,
|
|||
|
|
marginTop: 16,
|
|||
|
|
fontSize: 16,
|
|||
|
|
},
|
|||
|
|
error: {
|
|||
|
|
color: "#FF6961",
|
|||
|
|
fontSize: 14,
|
|||
|
|
marginTop: 10,
|
|||
|
|
},
|
|||
|
|
deleteButton: {
|
|||
|
|
minHeight: 50,
|
|||
|
|
borderRadius: 12,
|
|||
|
|
alignItems: "center",
|
|||
|
|
justifyContent: "center",
|
|||
|
|
backgroundColor: "#C9363E",
|
|||
|
|
marginTop: 14,
|
|||
|
|
},
|
|||
|
|
deleteLabel: {
|
|||
|
|
color: "#FFFFFF",
|
|||
|
|
fontSize: 16,
|
|||
|
|
fontWeight: "700",
|
|||
|
|
},
|
|||
|
|
disabled: {
|
|||
|
|
opacity: 0.45,
|
|||
|
|
},
|
|||
|
|
pressed: {
|
|||
|
|
opacity: 0.7,
|
|||
|
|
},
|
|||
|
|
});
|