* Recommend Claude Opus 5.5 by bumping the Pi model catalog The pinned pi-ai 0.85.1 had no Claude Opus 5.5, so the connect flow fell back to Opus 5. pi-ai and pi-agent-core 0.87.1 add it, and the keyless recommendation now preselects it. The new catalog names router aliases "Qwen Max Latest (Qwen3.8 Max)"; those now read "(auto-updates)" like the other alias labels, so no picker entry claims to be "latest". Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KWEAjTw26eRf3ZXTXfRjmD * Name HTTPS as the cause when dictation is unavailable, and translate it Over plain HTTP on a LAN address, Safari hides the microphone and speech recognition, so voice mode said the browser had no dictation, in English inside a German UI. Dictation now checks for a secure context first and says to open the address with https://. Its other errors go through Lingui too, and Safari's blocked recognizer gets a clear message instead of "Dictation failed: not-allowed". Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KWEAjTw26eRf3ZXTXfRjmD * Serve a fake Kokoro voice server in the E2E harness Onboarding's voice step connects to the local Kokoro server on its default port, so the shell E2E failed on any machine where nothing (or a misbehaving server) listened there: the failed request shows up as a browser console error. The harness now starts an offline stand-in and points KOKORO_BASE_URL at it, so the suite no longer depends on the runner host's own voice server. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KWEAjTw26eRf3ZXTXfRjmD * Show only the recommended sign-in until a model is connected With no model connected, "Connect model" opened the full model settings: provider list, tiers and a model picker, even though the recommendation was already preselected. Until something is connected, the Models panel now shows just the preselected model with its sign-in and API key field; "Other providers" reveals the full settings. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KWEAjTw26eRf3ZXTXfRjmD * Give the second chart-links test the same cold-load allowance as the first Each Playwright test runs in a fresh browser context, so the second test's first navigation re-fetches the lazily imported Plot chunk just like the first test's warm-up does. On a loaded CI runner that exceeded the default 20 s expect timeout. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KWEAjTw26eRf3ZXTXfRjmD * Use the recommended model as the Anthropic deployment default A deployment that sets only ANTHROPIC_API_KEY and PI_DEFAULT_PROVIDER=anthropic now runs Claude Opus 5.5, the same model the connect flow preselects, instead of an older default. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KWEAjTw26eRf3ZXTXfRjmD * Run every space on the deployment's local models without a connect step A deployment with PI_DEFAULT_PROVIDER=local and RAKAZO_LOCAL_MODELS set still asked every user to connect a model, because only OpenRouter and Anthropic counted as a deployment model. Configured local models now do, keylessly. RAKAZO_LOCAL_TIERS assigns local models to the tiers the agent delegates to, for spaces that have not mapped their own, and a RAKAZO_LOCAL_MODELS entry can name its own server (id=url) when the models run behind different endpoints. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KWEAjTw26eRf3ZXTXfRjmD * Hide model choices when the deployment routes models itself When the server provides its own models, the system picks and routes them per task, so Settings no longer offers Models and bot settings no longer offer Model and Thinking, on web and mobile. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KWEAjTw26eRf3ZXTXfRjmD * Wait for the workspace pane's width transition before measuring it The pane animates its width over 300 ms, so a single boundingBox read right after it opens can land mid-transition on a slow runner. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KWEAjTw26eRf3ZXTXfRjmD * Allow chart re-renders inside the chart-links loops to take longer Each navigation in the loops renders Observable Plot again. On a loaded CI runner that exceeded the default expect timeout even after the warm-up, so the per-iteration visibility checks get 30 s. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KWEAjTw26eRf3ZXTXfRjmD --------- Co-authored-by: Claude <noreply@anthropic.com>
708 lines
22 KiB
TypeScript
708 lines
22 KiB
TypeScript
import { useLocalSearchParams, useRouter } from "expo-router";
|
||
import { useEffect, useState } from "react";
|
||
import {
|
||
ActivityIndicator,
|
||
Alert,
|
||
Button,
|
||
Platform,
|
||
Pressable,
|
||
ScrollView,
|
||
StyleSheet,
|
||
Switch,
|
||
Text,
|
||
TextInput,
|
||
View,
|
||
} from "react-native";
|
||
import { SafeAreaView } from "react-native-safe-area-context";
|
||
import type { MobileBot, MobileMe } from "../lib/api";
|
||
import {
|
||
currentApiBase,
|
||
deleteAccount,
|
||
loadSessionToken,
|
||
rpc,
|
||
selectedSpaceId,
|
||
signOut,
|
||
} from "../lib/api";
|
||
import { formatUpdateLabel, getAppVersionInfo } from "../lib/app-version";
|
||
import {
|
||
getCachedAppearancePreference,
|
||
mobileTokens,
|
||
setAppearancePreference,
|
||
} from "../lib/appearance";
|
||
import { explicitSignInRoute } from "../lib/auth-routing";
|
||
import { confirmDeleteBot } from "../lib/bot-lifecycle";
|
||
import { setUiLocale, useI18n } from "../lib/i18n";
|
||
import type { LiveNotificationSettings } from "../lib/live-notifications";
|
||
import {
|
||
canPostPromotedNotifications,
|
||
DEFAULT_LIVE_NOTIFICATION_SETTINGS,
|
||
getLiveNotificationSettings,
|
||
openLiveNotificationSettings,
|
||
openPromotedNotificationSettings,
|
||
setLiveNotificationSettings,
|
||
} from "../lib/live-notifications";
|
||
import { presentMessageActionSheet } from "../lib/message-action-sheet";
|
||
import { native, useResolvedAppearance, useThemedStyles } from "../lib/native";
|
||
import { registerPushToken } from "../lib/push";
|
||
import type { AccountUiLocale } from "../lib/ui-locale";
|
||
import { ACCOUNT_UI_LOCALES, UI_LOCALE_LABELS } from "../lib/ui-locale";
|
||
import { alignVoiceWithLocale } from "../lib/voice";
|
||
|
||
/** Render account settings, including the entry point for voice configuration. */
|
||
export default function Account() {
|
||
const { t, locale } = useI18n();
|
||
const colorScheme = useResolvedAppearance();
|
||
const router = useRouter();
|
||
const { focus } = useLocalSearchParams<{ focus?: string }>();
|
||
const [me, setMe] = useState<MobileMe | null>(null);
|
||
const [password, setPassword] = useState("");
|
||
const [localeSaving, setLocaleSaving] = useState(false);
|
||
const [localeError, setLocaleError] = useState<string | null>(null);
|
||
const [pending, setPending] = useState(false);
|
||
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 [archivedBots, setArchivedBots] = useState<MobileBot[]>([]);
|
||
const [usage, setUsage] = useState<{
|
||
runs: number;
|
||
inputTokens: number;
|
||
outputTokens: number;
|
||
} | null>(null);
|
||
const appearance = getCachedAppearancePreference();
|
||
const styles = useThemedStyles(createAccountStyles);
|
||
const versionInfo = getAppVersionInfo();
|
||
const updateLabel = formatUpdateLabel(versionInfo.update, t);
|
||
const versionAccessibility = [versionInfo.nativeLabel, updateLabel].filter(Boolean).join(". ");
|
||
|
||
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={t("Usage")} style={styles.profile}>
|
||
<Text style={styles.settingsTitle}>{t("Usage")}</Text>
|
||
{usage ? (
|
||
<Text style={styles.email}>
|
||
{t("{runs} runs · {tokens} tokens", {
|
||
runs: usage.runs,
|
||
tokens: usage.inputTokens + usage.outputTokens,
|
||
})}
|
||
</Text>
|
||
) : null}
|
||
</View>
|
||
);
|
||
|
||
async function restoreBot(botId: string) {
|
||
try {
|
||
await rpc("bots/restore", { botId });
|
||
setArchivedBots((bots) => bots.filter((bot) => bot.id !== botId));
|
||
} catch (restoreError) {
|
||
Alert.alert(
|
||
t("Could not restore bot"),
|
||
restoreError instanceof Error ? restoreError.message : t("Try again."),
|
||
);
|
||
}
|
||
}
|
||
|
||
async function handleSignOut() {
|
||
setPending(true);
|
||
setError(null);
|
||
try {
|
||
await signOut();
|
||
router.dismissAll();
|
||
router.replace(explicitSignInRoute);
|
||
} catch (err) {
|
||
setError(err instanceof Error ? err.message : t("Could not sign out"));
|
||
setPending(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 : t("Could not update notifications"),
|
||
);
|
||
} finally {
|
||
setNotificationPending(false);
|
||
}
|
||
}
|
||
|
||
function confirmDeletion() {
|
||
setError(null);
|
||
Alert.alert(
|
||
t("Delete your account?"),
|
||
t(
|
||
"This permanently deletes your account, bots, conversations, memories, files, and saved connections. This cannot be undone.",
|
||
),
|
||
[
|
||
{ text: t("Cancel"), style: "cancel" },
|
||
{
|
||
text: t("Delete account"),
|
||
style: "destructive",
|
||
onPress: () => void handleDeletion(),
|
||
},
|
||
],
|
||
);
|
||
}
|
||
|
||
function applyLocale(code: AccountUiLocale) {
|
||
if (code === locale || localeSaving) return;
|
||
setLocaleSaving(true);
|
||
setLocaleError(null);
|
||
void setUiLocale(code)
|
||
.then((activated) => alignVoiceWithLocale(activated).catch(() => undefined))
|
||
.catch(() => {
|
||
setLocaleError(t("Could not change language"));
|
||
})
|
||
.finally(() => setLocaleSaving(false));
|
||
}
|
||
|
||
function openLanguagePicker() {
|
||
if (localeSaving) return;
|
||
presentMessageActionSheet({
|
||
title: t("Language"),
|
||
actions: ACCOUNT_UI_LOCALES.map((code) => ({
|
||
text: UI_LOCALE_LABELS[code],
|
||
onPress: () => applyLocale(code),
|
||
})),
|
||
colorScheme,
|
||
cancel: t("Cancel"),
|
||
more: t("More"),
|
||
});
|
||
}
|
||
|
||
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 : t("Could not delete account"));
|
||
} finally {
|
||
setPending(false);
|
||
}
|
||
}
|
||
|
||
return (
|
||
<SafeAreaView edges={["bottom"]} style={styles.screen}>
|
||
<ScrollView contentContainerStyle={styles.content}>
|
||
<Button
|
||
color={mobileTokens().primary}
|
||
title="AI data sharing"
|
||
onPress={() => router.push("/ai-data-sharing")}
|
||
/>
|
||
{focus === "usage" ? usageBlock : null}
|
||
<View style={styles.profile}>
|
||
<Text style={styles.name}>{me?.name || t("Your account")}</Text>
|
||
{me?.email ? <Text style={styles.email}>{me.email}</Text> : null}
|
||
</View>
|
||
{focus !== "usage" ? usageBlock : null}
|
||
|
||
<Pressable
|
||
accessibilityRole="button"
|
||
onPress={() => router.push("/change-password")}
|
||
style={({ pressed }) => [styles.settingsButton, pressed && styles.pressed]}
|
||
>
|
||
<Text style={styles.settingsTitle}>{t("Change password")}</Text>
|
||
<Text style={styles.chevron}>›</Text>
|
||
</Pressable>
|
||
|
||
<View accessibilityLabel={t("Appearance")} style={styles.appearanceSection}>
|
||
<Text style={styles.settingsTitle}>{t("Appearance")}</Text>
|
||
<View style={styles.appearanceOptions}>
|
||
{(
|
||
[
|
||
["system", "System"],
|
||
["light", "Light"],
|
||
["dark", "Dark"],
|
||
] as const
|
||
).map(([value, label]) => {
|
||
const selected = appearance === value;
|
||
const translated = t(label);
|
||
return (
|
||
<Pressable
|
||
key={value}
|
||
accessibilityLabel={translated}
|
||
accessibilityRole="button"
|
||
accessibilityState={{ selected }}
|
||
onPress={() => void setAppearancePreference(value)}
|
||
style={({ pressed }) => [
|
||
styles.appearanceOption,
|
||
selected && styles.appearanceOptionSelected,
|
||
pressed && styles.pressed,
|
||
]}
|
||
>
|
||
<Text style={styles.appearanceLabel}>{translated}</Text>
|
||
</Pressable>
|
||
);
|
||
})}
|
||
</View>
|
||
</View>
|
||
|
||
<Pressable
|
||
accessibilityRole="button"
|
||
accessibilityLabel={t("Language")}
|
||
accessibilityValue={{ text: UI_LOCALE_LABELS[locale] }}
|
||
accessibilityState={{ disabled: localeSaving }}
|
||
disabled={localeSaving}
|
||
onPress={openLanguagePicker}
|
||
style={({ pressed }) => [
|
||
styles.settingsButton,
|
||
pressed && styles.pressed,
|
||
localeSaving && { opacity: 0.6 },
|
||
]}
|
||
>
|
||
<Text style={styles.settingsTitle}>{t("Language")}</Text>
|
||
<View style={styles.settingsTrailing}>
|
||
<Text style={styles.settingsValue}>{UI_LOCALE_LABELS[locale]}</Text>
|
||
<Text style={styles.chevron}>›</Text>
|
||
</View>
|
||
</Pressable>
|
||
{localeError ? <Text style={styles.error}>{localeError}</Text> : null}
|
||
|
||
{Platform.OS === "android" ? (
|
||
<View accessibilityLabel={t("Notifications")} style={styles.profile}>
|
||
<Text style={styles.settingsTitle}>{t("Notifications")}</Text>
|
||
<NotificationSwitch
|
||
label={t("Live working status")}
|
||
detail={t("While agents are working")}
|
||
value={notifications.liveConnection}
|
||
disabled={notificationPending || !notificationsReady}
|
||
onChange={(liveConnection) =>
|
||
void updateNotifications({ ...notifications, liveConnection })
|
||
}
|
||
/>
|
||
<NotificationSwitch
|
||
label={t("Agent messages")}
|
||
detail={t("Replies and completed work")}
|
||
value={notifications.messages}
|
||
disabled={notificationPending || !notificationsReady}
|
||
onChange={(messages) => void updateNotifications({ ...notifications, messages })}
|
||
/>
|
||
<NotificationSwitch
|
||
label={t("Scheduled tasks")}
|
||
detail={t("Alerts from routines")}
|
||
value={notifications.scheduledTasks}
|
||
disabled={notificationPending || !notificationsReady}
|
||
onChange={(scheduledTasks) =>
|
||
void updateNotifications({ ...notifications, scheduledTasks })
|
||
}
|
||
/>
|
||
<NotificationSwitch
|
||
label={t("Needs attention")}
|
||
detail={t("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: native.label, fontSize: 14 }}>{t("Live update settings")}</Text>
|
||
</Pressable>
|
||
<Pressable
|
||
accessibilityRole="button"
|
||
onPress={() => void openLiveNotificationSettings()}
|
||
style={{ minHeight: 44, justifyContent: "center" }}
|
||
>
|
||
<Text style={{ color: native.label, fontSize: 14 }}>
|
||
{t("Notification settings")}
|
||
</Text>
|
||
</Pressable>
|
||
{notificationError ? <Text style={styles.error}>{notificationError}</Text> : null}
|
||
</View>
|
||
) : null}
|
||
|
||
{me?.modelsManaged ? null : (
|
||
<Pressable
|
||
accessibilityRole="button"
|
||
disabled={pending}
|
||
onPress={() => router.push("/models")}
|
||
style={({ pressed }) => [styles.settingsButton, pressed && styles.pressed]}
|
||
>
|
||
<Text style={styles.settingsTitle}>{t("Models")}</Text>
|
||
<Text style={styles.chevron}>›</Text>
|
||
</Pressable>
|
||
)}
|
||
|
||
<Pressable
|
||
accessibilityRole="button"
|
||
disabled={pending}
|
||
onPress={() => router.push("/voice")}
|
||
style={({ pressed }) => [styles.settingsButton, pressed && styles.pressed]}
|
||
>
|
||
<Text style={styles.settingsTitle}>{t("Voice")}</Text>
|
||
<Text style={styles.chevron}>›</Text>
|
||
</Pressable>
|
||
|
||
<Pressable
|
||
accessibilityRole="button"
|
||
disabled={pending}
|
||
onPress={() => router.push("/integrations")}
|
||
style={({ pressed }) => [styles.settingsButton, pressed && styles.pressed]}
|
||
>
|
||
<Text style={styles.settingsTitle}>{t("Integrations")}</Text>
|
||
<Text style={styles.chevron}>›</Text>
|
||
</Pressable>
|
||
|
||
{me?.isDeploymentOwner ? (
|
||
<Pressable
|
||
accessibilityRole="button"
|
||
onPress={() => router.push("/integration-setup")}
|
||
style={styles.settingsButton}
|
||
>
|
||
<Text style={styles.settingsTitle}>{t("Server integrations")}</Text>
|
||
</Pressable>
|
||
) : null}
|
||
|
||
<Pressable
|
||
accessibilityRole="button"
|
||
disabled={pending}
|
||
onPress={() => void handleSignOut()}
|
||
style={({ pressed }) => [styles.button, pressed && styles.pressed]}
|
||
>
|
||
<Text style={styles.buttonLabel}>{t("Sign out")}</Text>
|
||
</Pressable>
|
||
|
||
{archivedBots.length > 0 ? (
|
||
<View style={styles.archivedSection}>
|
||
<Text style={styles.sectionTitle}>{t("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}>{t("Restore")}</Text>
|
||
</Pressable>
|
||
<Pressable
|
||
onPress={() =>
|
||
confirmDeleteBot(bot, () =>
|
||
setArchivedBots((bots) => bots.filter((item) => item.id !== bot.id)),
|
||
)
|
||
}
|
||
hitSlop={8}
|
||
>
|
||
<Text style={styles.archivedDeleteLabel}>{t("Delete")}</Text>
|
||
</Pressable>
|
||
</View>
|
||
))}
|
||
</View>
|
||
) : null}
|
||
|
||
{versionInfo.nativeLabel || updateLabel ? (
|
||
<View
|
||
accessibilityLabel={versionAccessibility}
|
||
accessibilityRole="summary"
|
||
style={styles.versionFooter}
|
||
>
|
||
{versionInfo.nativeLabel ? (
|
||
<Text style={styles.versionLine}>{versionInfo.nativeLabel}</Text>
|
||
) : null}
|
||
{updateLabel ? <Text style={styles.versionLine}>{updateLabel}</Text> : null}
|
||
</View>
|
||
) : null}
|
||
|
||
<View style={styles.dangerZone}>
|
||
<Text style={styles.dangerTitle}>{t("Delete account")}</Text>
|
||
<TextInput
|
||
accessibilityLabel={t("Current password")}
|
||
autoCapitalize="none"
|
||
autoCorrect={false}
|
||
editable={!pending}
|
||
onChangeText={(value) => {
|
||
setPassword(value);
|
||
setError(null);
|
||
}}
|
||
placeholder={t("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={mobileTokens().destructiveForeground} />
|
||
) : (
|
||
<Text style={styles.deleteLabel}>{t("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 createAccountStyles() {
|
||
const tokens = mobileTokens();
|
||
return 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: tokens.destructive,
|
||
fontSize: 14,
|
||
},
|
||
settingsButton: {
|
||
minHeight: 62,
|
||
borderRadius: 14,
|
||
backgroundColor: native.fill,
|
||
paddingHorizontal: 16,
|
||
paddingVertical: 12,
|
||
flexDirection: "row",
|
||
alignItems: "center",
|
||
justifyContent: "space-between",
|
||
},
|
||
appearanceSection: {
|
||
borderRadius: 16,
|
||
backgroundColor: native.fill,
|
||
padding: 18,
|
||
gap: 14,
|
||
},
|
||
appearanceOptions: {
|
||
flexDirection: "row",
|
||
gap: 8,
|
||
},
|
||
appearanceOption: {
|
||
flex: 1,
|
||
minHeight: 44,
|
||
borderRadius: 12,
|
||
borderWidth: StyleSheet.hairlineWidth,
|
||
borderColor: native.tertiaryLabel,
|
||
alignItems: "center",
|
||
justifyContent: "center",
|
||
},
|
||
appearanceOptionSelected: {
|
||
borderColor: native.label,
|
||
backgroundColor: native.fillPressed,
|
||
},
|
||
appearanceLabel: {
|
||
color: native.label,
|
||
fontSize: 14,
|
||
fontWeight: "600",
|
||
},
|
||
settingsTitle: {
|
||
color: native.label,
|
||
fontSize: 17,
|
||
fontWeight: "600",
|
||
},
|
||
settingsTrailing: {
|
||
flexDirection: "row",
|
||
alignItems: "center",
|
||
gap: 6,
|
||
minWidth: 0,
|
||
},
|
||
settingsValue: {
|
||
color: native.secondaryLabel,
|
||
fontSize: 15,
|
||
},
|
||
chevron: {
|
||
color: native.secondaryLabel,
|
||
fontSize: 28,
|
||
fontWeight: "300",
|
||
},
|
||
versionFooter: {
|
||
marginTop: 4,
|
||
alignItems: "center",
|
||
gap: 2,
|
||
},
|
||
versionLine: {
|
||
color: native.tertiaryLabel,
|
||
fontSize: 12,
|
||
textAlign: "center",
|
||
},
|
||
dangerZone: {
|
||
marginTop: 12,
|
||
borderRadius: 16,
|
||
borderWidth: StyleSheet.hairlineWidth,
|
||
borderColor: tokens.destructive,
|
||
padding: 18,
|
||
},
|
||
dangerTitle: {
|
||
color: tokens.destructive,
|
||
fontSize: 17,
|
||
fontWeight: "600",
|
||
},
|
||
password: {
|
||
height: 48,
|
||
borderRadius: 12,
|
||
backgroundColor: native.fill,
|
||
color: native.label,
|
||
paddingHorizontal: 14,
|
||
marginTop: 16,
|
||
fontSize: 16,
|
||
},
|
||
error: {
|
||
color: tokens.destructive,
|
||
fontSize: 14,
|
||
marginTop: 10,
|
||
},
|
||
deleteButton: {
|
||
minHeight: 50,
|
||
borderRadius: 12,
|
||
alignItems: "center",
|
||
justifyContent: "center",
|
||
backgroundColor: tokens.destructive,
|
||
marginTop: 14,
|
||
},
|
||
deleteLabel: {
|
||
color: tokens.destructiveForeground,
|
||
fontSize: 16,
|
||
fontWeight: "700",
|
||
},
|
||
disabled: {
|
||
opacity: 0.45,
|
||
},
|
||
pressed: {
|
||
opacity: 0.7,
|
||
},
|
||
});
|
||
}
|