Settings hub with appearance, accounts, alerts and data pages; relay shown as 中转站; hint to set a quota on unlimited relay keys
This commit is contained in:
1 parent
dec7f5ddaa
commit
f6eedbbe7b
11 files changed
+460
-258
No files matched your search
+81
-256
@@ -1,79 +1,95 @@
|
||||
import Constants from 'expo-constants';
|
||||
import { useRouter } from 'expo-router';
|
||||
import { Alert, Linking, Platform, View } from 'react-native';
|
||||
import { useRouter, type Href } from 'expo-router';
|
||||
import { SymbolView, type SymbolViewProps } from 'expo-symbols';
|
||||
import { Linking, View } from 'react-native';
|
||||
import Animated from 'react-native-reanimated';
|
||||
|
||||
import { Screen } from '@/components/screen';
|
||||
import { Section, Row } from '@/components/section';
|
||||
import { Row, Section } from '@/components/section';
|
||||
import { ScreenHeader } from '@/components/screen-header';
|
||||
import { ThemedText } from '@/components/themed-text';
|
||||
import { Accents, Radius, Spacing, type AccentName } from '@/constants/theme';
|
||||
import {
|
||||
useAccountActions,
|
||||
useAccountViews,
|
||||
useAlertSettings,
|
||||
useBackgroundState,
|
||||
useNotificationPermission,
|
||||
} from '@/data/hooks';
|
||||
import { Radius, Spacing } from '@/constants/theme';
|
||||
import { useAccountViews, useAlertSettings } from '@/data/hooks';
|
||||
import { useTheme } from '@/hooks/use-theme';
|
||||
import { useT, type LocaleSetting, type ThemeSetting } from '@/i18n';
|
||||
import { useT } from '@/i18n';
|
||||
import { usePreferences } from '@/providers/preferences';
|
||||
import { Button, ColorSwatches, Segmented } from '@/ui/controls';
|
||||
import { haptics } from '@/ui/haptics';
|
||||
import { enterFade, enterItem, exitFade, layoutShift } from '@/ui/motion';
|
||||
import { ProviderIcon } from '@/ui/provider-icon';
|
||||
import { MultiChips, ToggleRow } from '@/ui/toggles';
|
||||
import { Button } from '@/ui/controls';
|
||||
import { enterItem } from '@/ui/motion';
|
||||
import { ACCENT_LABELS } from '@/ui/settings-labels';
|
||||
|
||||
const GITHUB_HANDLE = 'nextroad-dev';
|
||||
const GITHUB_URL = 'https://github.com/nextroad-dev';
|
||||
const SPONSOR_URL = 'https://afdian.com/a/nextroad';
|
||||
|
||||
/** Spoken and shown names of the theme colours (English keys, translated with t()). */
|
||||
const ACCENT_LABELS: Record<AccentName, string> = {
|
||||
blue: 'Blue',
|
||||
indigo: 'Indigo',
|
||||
violet: 'Violet',
|
||||
pink: 'Pink',
|
||||
orange: 'Orange',
|
||||
green: 'Green',
|
||||
teal: 'Teal',
|
||||
};
|
||||
/** Small filled square with a white symbol, the familiar settings-menu affordance. */
|
||||
function MenuIcon({ name }: { name: SymbolViewProps['name'] }) {
|
||||
const theme = useTheme();
|
||||
return (
|
||||
<View
|
||||
style={{
|
||||
width: 30,
|
||||
height: 30,
|
||||
borderRadius: Radius.control - 4,
|
||||
backgroundColor: theme.primary,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
}}
|
||||
>
|
||||
<SymbolView name={name} size={17} tintColor="#FFFFFF" />
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Settings hub: each area opens its own page, and the row shows its current state so most visits
|
||||
* need no tap at all.
|
||||
*/
|
||||
export default function SettingsScreen() {
|
||||
const router = useRouter();
|
||||
const t = useT();
|
||||
const theme = useTheme();
|
||||
const accounts = useAccountViews();
|
||||
const views = accounts.data ?? [];
|
||||
const actions = useAccountActions();
|
||||
const { settings, update } = useAlertSettings();
|
||||
const permission = useNotificationPermission();
|
||||
const background = useBackgroundState(settings.backgroundRefresh);
|
||||
const {
|
||||
localeSetting,
|
||||
themeSetting,
|
||||
accentSetting,
|
||||
setLocaleSetting,
|
||||
setThemeSetting,
|
||||
setAccentSetting,
|
||||
} = usePreferences();
|
||||
const { settings } = useAlertSettings();
|
||||
const { localeSetting, themeSetting, accentSetting } = usePreferences();
|
||||
|
||||
const confirmClear = () =>
|
||||
Alert.alert(
|
||||
t('Delete all data?'),
|
||||
t('This removes every account, credential and usage history from this device.'),
|
||||
[
|
||||
{ text: t('Cancel'), style: 'cancel' },
|
||||
{
|
||||
text: t('Delete everything'),
|
||||
style: 'destructive',
|
||||
onPress: () => {
|
||||
haptics.warning();
|
||||
actions.removeAll();
|
||||
},
|
||||
},
|
||||
],
|
||||
);
|
||||
const language =
|
||||
localeSetting === 'system'
|
||||
? t('Follow system')
|
||||
: localeSetting === 'zh'
|
||||
? t('Chinese')
|
||||
: t('English');
|
||||
const appearance =
|
||||
themeSetting === 'system'
|
||||
? t('Follow system')
|
||||
: themeSetting === 'light'
|
||||
? t('Light')
|
||||
: t('Dark');
|
||||
|
||||
const menu: { icon: SymbolViewProps['name']; title: string; value?: string; href: Href }[] = [
|
||||
{
|
||||
icon: { ios: 'paintbrush.fill', android: 'palette', web: 'palette' },
|
||||
title: t('Language & appearance'),
|
||||
value: `${language} · ${appearance} · ${t(ACCENT_LABELS[accentSetting])}`,
|
||||
href: '/settings/appearance',
|
||||
},
|
||||
{
|
||||
icon: { ios: 'person.2.fill', android: 'group', web: 'group' },
|
||||
title: t('Accounts'),
|
||||
value: accounts.data ? String(accounts.data.length) : undefined,
|
||||
href: '/settings/accounts',
|
||||
},
|
||||
{
|
||||
icon: { ios: 'bell.fill', android: 'notifications', web: 'notifications' },
|
||||
title: t('Alerts'),
|
||||
value: settings.enabled ? t('On') : t('Off'),
|
||||
href: '/settings/alerts',
|
||||
},
|
||||
{
|
||||
icon: { ios: 'externaldrive.fill', android: 'storage', web: 'storage' },
|
||||
title: t('Data'),
|
||||
href: '/settings/data',
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<Screen>
|
||||
@@ -98,208 +114,17 @@ export default function SettingsScreen() {
|
||||
<Button title={t('Support on Afdian')} onPress={() => Linking.openURL(SPONSOR_URL)} />
|
||||
</Animated.View>
|
||||
|
||||
<Section title={t('Language & appearance')} bandStyle={{ gap: Spacing.three }}>
|
||||
<View style={{ gap: Spacing.two }}>
|
||||
<ThemedText type="small" themeColor="textSecondary">
|
||||
{t('Language')}
|
||||
</ThemedText>
|
||||
<Segmented<LocaleSetting>
|
||||
options={[
|
||||
{ value: 'system', label: t('Follow system') },
|
||||
{ value: 'zh', label: t('Chinese') },
|
||||
{ value: 'en', label: t('English') },
|
||||
]}
|
||||
label={t('Language')}
|
||||
value={localeSetting}
|
||||
onChange={(v) => setLocaleSetting(v)}
|
||||
/>
|
||||
</View>
|
||||
<View style={{ gap: Spacing.two }}>
|
||||
<ThemedText type="small" themeColor="textSecondary">
|
||||
{t('Appearance')}
|
||||
</ThemedText>
|
||||
<Segmented<ThemeSetting>
|
||||
options={[
|
||||
{ value: 'system', label: t('Follow system') },
|
||||
{ value: 'light', label: t('Light') },
|
||||
{ value: 'dark', label: t('Dark') },
|
||||
]}
|
||||
label={t('Appearance')}
|
||||
value={themeSetting}
|
||||
onChange={(v) => setThemeSetting(v)}
|
||||
/>
|
||||
</View>
|
||||
<View style={{ gap: Spacing.two }}>
|
||||
<ThemedText type="small" themeColor="textSecondary">
|
||||
{t('Theme color')}
|
||||
</ThemedText>
|
||||
<ColorSwatches<AccentName>
|
||||
label={t('Theme color')}
|
||||
options={(Object.keys(Accents) as AccentName[]).map((name) => ({
|
||||
value: name,
|
||||
color: Accents[name],
|
||||
label: t(ACCENT_LABELS[name]),
|
||||
}))}
|
||||
value={accentSetting}
|
||||
onChange={setAccentSetting}
|
||||
/>
|
||||
</View>
|
||||
</Section>
|
||||
|
||||
<Section title={t('Accounts')}>
|
||||
{accounts.isLoading && !accounts.data ? (
|
||||
<ThemedText type="small" themeColor="textSecondary">
|
||||
{t('Loading…')}
|
||||
</ThemedText>
|
||||
) : views.length === 0 ? (
|
||||
<ThemedText type="small" themeColor="textSecondary">
|
||||
{t('No accounts yet')}
|
||||
</ThemedText>
|
||||
) : (
|
||||
views.map((v, i) => (
|
||||
<Animated.View key={v.account.id} entering={enterItem(i)} exiting={exitFade}>
|
||||
<Row
|
||||
leading={
|
||||
<ProviderIcon
|
||||
providerId={v.account.providerId}
|
||||
label={v.meta?.name ?? v.account.providerId}
|
||||
/>
|
||||
}
|
||||
title={v.meta?.name ?? v.account.providerId}
|
||||
hint={v.account.label !== v.meta?.name ? v.account.label : undefined}
|
||||
onPress={() => router.push(`/account/${v.account.id}`)}
|
||||
/>
|
||||
</Animated.View>
|
||||
))
|
||||
)}
|
||||
<Button title={t('Add account')} onPress={() => router.push('/add')} />
|
||||
</Section>
|
||||
|
||||
<Section title={t('Alerts')} bandStyle={{ gap: Spacing.three }}>
|
||||
<ToggleRow
|
||||
label={t('Usage alerts')}
|
||||
value={settings.enabled}
|
||||
onChange={(v) => update({ enabled: v })}
|
||||
/>
|
||||
{settings.enabled && (
|
||||
<Animated.View
|
||||
entering={enterFade}
|
||||
exiting={exitFade}
|
||||
layout={layoutShift}
|
||||
style={{ gap: Spacing.three }}
|
||||
>
|
||||
<View style={{ gap: Spacing.two }}>
|
||||
<ThemedText type="small" themeColor="textSecondary">
|
||||
{t('Notify when usage reaches')}
|
||||
</ThemedText>
|
||||
<MultiChips
|
||||
options={[0.7, 0.8, 0.9, 0.95].map((v) => ({
|
||||
value: v,
|
||||
label: `${Math.round(v * 100)}%`,
|
||||
}))}
|
||||
values={settings.thresholds}
|
||||
onChange={(thresholds) =>
|
||||
update({ thresholds: [...thresholds].sort((a, b) => a - b) })
|
||||
}
|
||||
/>
|
||||
</View>
|
||||
<ToggleRow
|
||||
label={t('Remind me before a reset')}
|
||||
value={settings.resetSoon.enabled}
|
||||
onChange={(v) => update({ resetSoon: { ...settings.resetSoon, enabled: v } })}
|
||||
/>
|
||||
{settings.resetSoon.enabled && (
|
||||
<Animated.View entering={enterFade} exiting={exitFade} layout={layoutShift}>
|
||||
<Segmented
|
||||
options={[3, 6, 12, 24].map((h) => ({ value: h, label: t('{n}h', { n: h }) }))}
|
||||
value={settings.resetSoon.hours}
|
||||
onChange={(hours) => update({ resetSoon: { ...settings.resetSoon, hours } })}
|
||||
/>
|
||||
</Animated.View>
|
||||
)}
|
||||
<Animated.View layout={layoutShift}>
|
||||
<ToggleRow
|
||||
label={t('Tell me when a window resets')}
|
||||
hint={t('Only for windows that were nearly used up.')}
|
||||
value={settings.windowReset}
|
||||
onChange={(v) => update({ windowReset: v })}
|
||||
/>
|
||||
</Animated.View>
|
||||
<Animated.View layout={layoutShift}>
|
||||
<ToggleRow
|
||||
label={t('Remind me before a plan ends')}
|
||||
value={settings.renewal.enabled}
|
||||
onChange={(v) => update({ renewal: { ...settings.renewal, enabled: v } })}
|
||||
/>
|
||||
</Animated.View>
|
||||
{settings.renewal.enabled && (
|
||||
<Animated.View entering={enterFade} exiting={exitFade} layout={layoutShift}>
|
||||
<Segmented
|
||||
label={t('Remind me before a plan ends')}
|
||||
options={[1, 3, 7].map((d) => ({
|
||||
value: d,
|
||||
label: t('{n} days before', { n: d }),
|
||||
}))}
|
||||
value={settings.renewal.days}
|
||||
onChange={(days) => update({ renewal: { ...settings.renewal, days } })}
|
||||
/>
|
||||
</Animated.View>
|
||||
)}
|
||||
<Animated.View layout={layoutShift}>
|
||||
<ToggleRow
|
||||
label={t('Tell me when a login expires')}
|
||||
value={settings.authExpired}
|
||||
onChange={(v) => update({ authExpired: v })}
|
||||
/>
|
||||
</Animated.View>
|
||||
</Animated.View>
|
||||
)}
|
||||
{permission.state !== 'granted' && (
|
||||
<Animated.View
|
||||
entering={enterFade}
|
||||
exiting={exitFade}
|
||||
layout={layoutShift}
|
||||
style={{ gap: Spacing.two }}
|
||||
>
|
||||
<ThemedText type="small" themeColor="textSecondary">
|
||||
{permission.state === 'denied'
|
||||
? Platform.OS === 'ios'
|
||||
? t('Notifications are off. Turn them on in iOS Settings.')
|
||||
: t('Notifications are off. Turn them on in system settings.')
|
||||
: t('Allow notifications to get alerts.')}
|
||||
</ThemedText>
|
||||
<Button
|
||||
title={
|
||||
permission.state === 'denied'
|
||||
? Platform.OS === 'ios'
|
||||
? t('Open iOS Settings')
|
||||
: t('Open system settings')
|
||||
: t('Allow notifications')
|
||||
}
|
||||
onPress={() =>
|
||||
permission.state === 'denied' ? Linking.openSettings() : permission.request()
|
||||
}
|
||||
<Section title={t('General')}>
|
||||
{menu.map((m, i) => (
|
||||
<Animated.View key={m.title} entering={enterItem(i + 1)}>
|
||||
<Row
|
||||
leading={<MenuIcon name={m.icon} />}
|
||||
title={m.title}
|
||||
value={m.value}
|
||||
onPress={() => router.push(m.href)}
|
||||
/>
|
||||
</Animated.View>
|
||||
)}
|
||||
<Animated.View layout={layoutShift} style={{ gap: Spacing.three }}>
|
||||
<ToggleRow
|
||||
label={t('Background refresh')}
|
||||
value={settings.backgroundRefresh}
|
||||
onChange={(v) => update({ backgroundRefresh: v })}
|
||||
/>
|
||||
{background.data === 'restricted' && (
|
||||
<Animated.View entering={enterFade}>
|
||||
<ThemedText type="small" themeColor="textSecondary">
|
||||
{t('Background refresh is restricted on this device.')}
|
||||
</ThemedText>
|
||||
</Animated.View>
|
||||
)}
|
||||
</Animated.View>
|
||||
</Section>
|
||||
|
||||
<Section title={t('Data')}>
|
||||
<Button title={t('Delete all data')} kind="destructive" onPress={confirmClear} />
|
||||
))}
|
||||
</Section>
|
||||
|
||||
<Section title={t('About')}>
|
||||
|
||||
@@ -27,6 +27,10 @@ function ThemedRoot() {
|
||||
<Stack.Screen name="add/index" options={{ title: t('Add account') }} />
|
||||
<Stack.Screen name="add/[providerId]" options={{ title: t('Add account') }} />
|
||||
<Stack.Screen name="add/relay" options={{ title: t('API relay') }} />
|
||||
<Stack.Screen name="settings/appearance" options={{ title: t('Language & appearance') }} />
|
||||
<Stack.Screen name="settings/accounts" options={{ title: t('Accounts') }} />
|
||||
<Stack.Screen name="settings/alerts" options={{ title: t('Alerts') }} />
|
||||
<Stack.Screen name="settings/data" options={{ title: t('Data') }} />
|
||||
</Stack>
|
||||
</ThemeProvider>
|
||||
);
|
||||
|
||||
@@ -26,6 +26,7 @@ import { meterTitle, renewalInfo } from '@/ui/account-card';
|
||||
import { Button, Field, Segmented } from '@/ui/controls';
|
||||
import { haptics } from '@/ui/haptics';
|
||||
import { MeterRow } from '@/ui/meter-row';
|
||||
import { unlimitedKeyHint } from '@/ui/relay-hints';
|
||||
import { RefreshButton } from '@/ui/refresh-button';
|
||||
import { enterFade, enterItem, exitFade, layoutShift } from '@/ui/motion';
|
||||
import { ProviderIcon } from '@/ui/provider-icon';
|
||||
@@ -236,7 +237,16 @@ export default function AccountDetailScreen() {
|
||||
</Band>
|
||||
|
||||
{meters.length > 0 && (
|
||||
<Section title={t('Meters')}>
|
||||
<Section
|
||||
title={t('Meters')}
|
||||
footer={
|
||||
// a relay key without a ceiling only reports spending: explain how to get the rest
|
||||
v.account.providerId === 'relay' &&
|
||||
meters.some((m) => m.kind.type === 'amount' && m.kind.limit === undefined)
|
||||
? unlimitedKeyHint(v.snapshot?.plan === 'New API' ? 'new-api' : 'unknown', t)
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
{meters.map((m, i) => (
|
||||
<Animated.View key={keyOf(m)} entering={enterItem(i)}>
|
||||
<MeterRow meter={m} now={now} view={v} />
|
||||
|
||||
@@ -21,6 +21,7 @@ import {
|
||||
type RelayInspection,
|
||||
} from '@/providers/relay/inspect';
|
||||
import { Button, Field } from '@/ui/controls';
|
||||
import { unlimitedKeyHint } from '@/ui/relay-hints';
|
||||
import { haptics } from '@/ui/haptics';
|
||||
import { enterFade, enterItem, exitFade, layoutShift } from '@/ui/motion';
|
||||
|
||||
@@ -219,6 +220,16 @@ export default function AddRelayScreen() {
|
||||
</ThemedText>
|
||||
)}
|
||||
|
||||
{/* an unlimited key has no ceiling to measure against: say how to set one */}
|
||||
{b?.supported && b.unlimited ? (
|
||||
<Animated.View entering={enterItem(2)}>
|
||||
<ThemedText type="small" style={{ color: theme.warn }}>
|
||||
{'▲ '}
|
||||
{unlimitedKeyHint(result.provider.type, t)}
|
||||
</ThemedText>
|
||||
</Animated.View>
|
||||
) : null}
|
||||
|
||||
<Animated.View entering={enterItem(2)}>
|
||||
<ThemedText
|
||||
type="small"
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
import { useRouter } from 'expo-router';
|
||||
import Animated from 'react-native-reanimated';
|
||||
|
||||
import { Screen } from '@/components/screen';
|
||||
import { Row, Section } from '@/components/section';
|
||||
import { ThemedText } from '@/components/themed-text';
|
||||
import { useAccountViews } from '@/data/hooks';
|
||||
import { useT } from '@/i18n';
|
||||
import { Button } from '@/ui/controls';
|
||||
import { enterItem, exitFade } from '@/ui/motion';
|
||||
import { ProviderIcon } from '@/ui/provider-icon';
|
||||
|
||||
export default function AccountsSettingsScreen() {
|
||||
const router = useRouter();
|
||||
const t = useT();
|
||||
const accounts = useAccountViews();
|
||||
const views = accounts.data ?? [];
|
||||
|
||||
return (
|
||||
<Screen safeTop={false}>
|
||||
<Section>
|
||||
{accounts.isLoading && !accounts.data ? (
|
||||
<ThemedText type="small" themeColor="textSecondary">
|
||||
{t('Loading…')}
|
||||
</ThemedText>
|
||||
) : views.length === 0 ? (
|
||||
<ThemedText type="small" themeColor="textSecondary">
|
||||
{t('No accounts yet')}
|
||||
</ThemedText>
|
||||
) : (
|
||||
views.map((v, i) => {
|
||||
const name =
|
||||
v.meta?.id === 'relay' ? v.account.label : (v.meta?.name ?? v.account.providerId);
|
||||
return (
|
||||
<Animated.View key={v.account.id} entering={enterItem(i)} exiting={exitFade}>
|
||||
<Row
|
||||
leading={<ProviderIcon providerId={v.account.providerId} label={name} />}
|
||||
title={name}
|
||||
hint={v.account.label !== name ? v.account.label : undefined}
|
||||
onPress={() => router.push(`/account/${v.account.id}`)}
|
||||
/>
|
||||
</Animated.View>
|
||||
);
|
||||
})
|
||||
)}
|
||||
<Button title={t('Add account')} onPress={() => router.push('/add')} />
|
||||
</Section>
|
||||
</Screen>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
import { Linking, Platform, View } from 'react-native';
|
||||
import Animated from 'react-native-reanimated';
|
||||
|
||||
import { Screen } from '@/components/screen';
|
||||
import { Section } from '@/components/section';
|
||||
import { ThemedText } from '@/components/themed-text';
|
||||
import { Spacing } from '@/constants/theme';
|
||||
import { useAlertSettings, useBackgroundState, useNotificationPermission } from '@/data/hooks';
|
||||
import { useT } from '@/i18n';
|
||||
import { Button, Segmented } from '@/ui/controls';
|
||||
import { enterFade, exitFade, layoutShift } from '@/ui/motion';
|
||||
import { MultiChips, ToggleRow } from '@/ui/toggles';
|
||||
|
||||
export default function AlertSettingsScreen() {
|
||||
const t = useT();
|
||||
const { settings, update } = useAlertSettings();
|
||||
const permission = useNotificationPermission();
|
||||
const background = useBackgroundState(settings.backgroundRefresh);
|
||||
|
||||
return (
|
||||
<Screen safeTop={false}>
|
||||
{permission.state !== 'granted' && (
|
||||
<Animated.View entering={enterFade} exiting={exitFade} layout={layoutShift}>
|
||||
<Section bandStyle={{ gap: Spacing.two }}>
|
||||
<ThemedText type="small" themeColor="textSecondary">
|
||||
{permission.state === 'denied'
|
||||
? Platform.OS === 'ios'
|
||||
? t('Notifications are off. Turn them on in iOS Settings.')
|
||||
: t('Notifications are off. Turn them on in system settings.')
|
||||
: t('Allow notifications to get alerts.')}
|
||||
</ThemedText>
|
||||
<Button
|
||||
title={
|
||||
permission.state === 'denied'
|
||||
? Platform.OS === 'ios'
|
||||
? t('Open iOS Settings')
|
||||
: t('Open system settings')
|
||||
: t('Allow notifications')
|
||||
}
|
||||
onPress={() =>
|
||||
permission.state === 'denied' ? Linking.openSettings() : permission.request()
|
||||
}
|
||||
/>
|
||||
</Section>
|
||||
</Animated.View>
|
||||
)}
|
||||
|
||||
<Section title={t('Usage')} bandStyle={{ gap: Spacing.three }}>
|
||||
<ToggleRow
|
||||
label={t('Usage alerts')}
|
||||
value={settings.enabled}
|
||||
onChange={(v) => update({ enabled: v })}
|
||||
/>
|
||||
{settings.enabled && (
|
||||
<Animated.View
|
||||
entering={enterFade}
|
||||
exiting={exitFade}
|
||||
layout={layoutShift}
|
||||
style={{ gap: Spacing.three }}
|
||||
>
|
||||
<View style={{ gap: Spacing.two }}>
|
||||
<ThemedText type="small" themeColor="textSecondary">
|
||||
{t('Notify when usage reaches')}
|
||||
</ThemedText>
|
||||
<MultiChips
|
||||
options={[0.7, 0.8, 0.9, 0.95].map((v) => ({
|
||||
value: v,
|
||||
label: `${Math.round(v * 100)}%`,
|
||||
}))}
|
||||
values={settings.thresholds}
|
||||
onChange={(thresholds) =>
|
||||
update({ thresholds: [...thresholds].sort((a, b) => a - b) })
|
||||
}
|
||||
/>
|
||||
</View>
|
||||
<ToggleRow
|
||||
label={t('Remind me before a reset')}
|
||||
value={settings.resetSoon.enabled}
|
||||
onChange={(v) => update({ resetSoon: { ...settings.resetSoon, enabled: v } })}
|
||||
/>
|
||||
{settings.resetSoon.enabled && (
|
||||
<Animated.View entering={enterFade} exiting={exitFade} layout={layoutShift}>
|
||||
<Segmented
|
||||
options={[3, 6, 12, 24].map((h) => ({ value: h, label: t('{n}h', { n: h }) }))}
|
||||
value={settings.resetSoon.hours}
|
||||
onChange={(hours) => update({ resetSoon: { ...settings.resetSoon, hours } })}
|
||||
/>
|
||||
</Animated.View>
|
||||
)}
|
||||
<Animated.View layout={layoutShift}>
|
||||
<ToggleRow
|
||||
label={t('Tell me when a window resets')}
|
||||
hint={t('Only for windows that were nearly used up.')}
|
||||
value={settings.windowReset}
|
||||
onChange={(v) => update({ windowReset: v })}
|
||||
/>
|
||||
</Animated.View>
|
||||
</Animated.View>
|
||||
)}
|
||||
</Section>
|
||||
|
||||
{settings.enabled && (
|
||||
<Animated.View entering={enterFade} exiting={exitFade} layout={layoutShift}>
|
||||
<Section title={t('Account')} bandStyle={{ gap: Spacing.three }}>
|
||||
<ToggleRow
|
||||
label={t('Remind me before a plan ends')}
|
||||
value={settings.renewal.enabled}
|
||||
onChange={(v) => update({ renewal: { ...settings.renewal, enabled: v } })}
|
||||
/>
|
||||
{settings.renewal.enabled && (
|
||||
<Animated.View entering={enterFade} exiting={exitFade} layout={layoutShift}>
|
||||
<Segmented
|
||||
label={t('Remind me before a plan ends')}
|
||||
options={[1, 3, 7].map((d) => ({
|
||||
value: d,
|
||||
label: t('{n} days before', { n: d }),
|
||||
}))}
|
||||
value={settings.renewal.days}
|
||||
onChange={(days) => update({ renewal: { ...settings.renewal, days } })}
|
||||
/>
|
||||
</Animated.View>
|
||||
)}
|
||||
<Animated.View layout={layoutShift}>
|
||||
<ToggleRow
|
||||
label={t('Tell me when a login expires')}
|
||||
value={settings.authExpired}
|
||||
onChange={(v) => update({ authExpired: v })}
|
||||
/>
|
||||
</Animated.View>
|
||||
</Section>
|
||||
</Animated.View>
|
||||
)}
|
||||
|
||||
<Animated.View layout={layoutShift}>
|
||||
<Section title={t('Background')} bandStyle={{ gap: Spacing.three }}>
|
||||
<ToggleRow
|
||||
label={t('Background refresh')}
|
||||
value={settings.backgroundRefresh}
|
||||
onChange={(v) => update({ backgroundRefresh: v })}
|
||||
/>
|
||||
{background.data === 'restricted' && (
|
||||
<Animated.View entering={enterFade}>
|
||||
<ThemedText type="small" themeColor="textSecondary">
|
||||
{t('Background refresh is restricted on this device.')}
|
||||
</ThemedText>
|
||||
</Animated.View>
|
||||
)}
|
||||
</Section>
|
||||
</Animated.View>
|
||||
</Screen>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
import { View } from 'react-native';
|
||||
|
||||
import { Screen } from '@/components/screen';
|
||||
import { Section } from '@/components/section';
|
||||
import { ThemedText } from '@/components/themed-text';
|
||||
import { Accents, Spacing, type AccentName } from '@/constants/theme';
|
||||
import { useT, type LocaleSetting, type ThemeSetting } from '@/i18n';
|
||||
import { usePreferences } from '@/providers/preferences';
|
||||
import { ColorSwatches, Segmented } from '@/ui/controls';
|
||||
import { ACCENT_LABELS } from '@/ui/settings-labels';
|
||||
|
||||
export default function AppearanceSettingsScreen() {
|
||||
const t = useT();
|
||||
const {
|
||||
localeSetting,
|
||||
themeSetting,
|
||||
accentSetting,
|
||||
setLocaleSetting,
|
||||
setThemeSetting,
|
||||
setAccentSetting,
|
||||
} = usePreferences();
|
||||
|
||||
return (
|
||||
<Screen safeTop={false}>
|
||||
<Section title={t('Language')}>
|
||||
<Segmented<LocaleSetting>
|
||||
options={[
|
||||
{ value: 'system', label: t('Follow system') },
|
||||
{ value: 'zh', label: t('Chinese') },
|
||||
{ value: 'en', label: t('English') },
|
||||
]}
|
||||
label={t('Language')}
|
||||
value={localeSetting}
|
||||
onChange={(v) => setLocaleSetting(v)}
|
||||
/>
|
||||
</Section>
|
||||
|
||||
<Section title={t('Appearance')} bandStyle={{ gap: Spacing.three }}>
|
||||
<Segmented<ThemeSetting>
|
||||
options={[
|
||||
{ value: 'system', label: t('Follow system') },
|
||||
{ value: 'light', label: t('Light') },
|
||||
{ value: 'dark', label: t('Dark') },
|
||||
]}
|
||||
label={t('Appearance')}
|
||||
value={themeSetting}
|
||||
onChange={(v) => setThemeSetting(v)}
|
||||
/>
|
||||
<View style={{ gap: Spacing.two }}>
|
||||
<ThemedText type="small" themeColor="textSecondary">
|
||||
{t('Theme color')}
|
||||
</ThemedText>
|
||||
<ColorSwatches<AccentName>
|
||||
label={t('Theme color')}
|
||||
options={(Object.keys(Accents) as AccentName[]).map((name) => ({
|
||||
value: name,
|
||||
color: Accents[name],
|
||||
label: t(ACCENT_LABELS[name]),
|
||||
}))}
|
||||
value={accentSetting}
|
||||
onChange={setAccentSetting}
|
||||
/>
|
||||
</View>
|
||||
</Section>
|
||||
</Screen>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
import { useRouter } from 'expo-router';
|
||||
import { Alert } from 'react-native';
|
||||
|
||||
import { Screen } from '@/components/screen';
|
||||
import { Section } from '@/components/section';
|
||||
import { useAccountActions } from '@/data/hooks';
|
||||
import { useT } from '@/i18n';
|
||||
import { Button } from '@/ui/controls';
|
||||
import { haptics } from '@/ui/haptics';
|
||||
|
||||
export default function DataSettingsScreen() {
|
||||
const router = useRouter();
|
||||
const t = useT();
|
||||
const actions = useAccountActions();
|
||||
|
||||
const confirmClear = () =>
|
||||
Alert.alert(
|
||||
t('Delete all data?'),
|
||||
t('This removes every account, credential and usage history from this device.'),
|
||||
[
|
||||
{ text: t('Cancel'), style: 'cancel' },
|
||||
{
|
||||
text: t('Delete everything'),
|
||||
style: 'destructive',
|
||||
onPress: async () => {
|
||||
haptics.warning();
|
||||
await actions.removeAll();
|
||||
router.back();
|
||||
},
|
||||
},
|
||||
],
|
||||
);
|
||||
|
||||
return (
|
||||
<Screen safeTop={false}>
|
||||
<Section
|
||||
footer={t(
|
||||
'Accounts, credentials and usage history live only on this device. Nothing is sent to any server of ours.',
|
||||
)}
|
||||
>
|
||||
<Button title={t('Delete all data')} kind="destructive" onPress={confirmClear} />
|
||||
</Section>
|
||||
</Screen>
|
||||
);
|
||||
}
|
||||
+11
-1
@@ -28,8 +28,18 @@ export const zh: Record<string, string> = {
|
||||
'Open this account to sign in again.': '请打开此账号重新登录。',
|
||||
'Tap refresh to load usage.': '点击刷新以加载用量。',
|
||||
Refresh: '刷新',
|
||||
General: '通用',
|
||||
On: '开启',
|
||||
Account: '账号',
|
||||
Background: '后台',
|
||||
'Accounts, credentials and usage history live only on this device. Nothing is sent to any server of ours.':
|
||||
'账号、凭据和用量历史只保存在这台设备上,不会发送到我们的任何服务器。',
|
||||
// API relays
|
||||
'API relay': 'API 中转站',
|
||||
'API relay': '中转站',
|
||||
'This key has no quota limit, so only spending can be shown: no remaining amount and no alerts. Set a quota for this key on the Tokens page of the relay site, then detect again.':
|
||||
'这个 Key 没有设置额度上限,只能显示已用金额:无法计算剩余额度,也无法在快用完时提醒你。请到中转站后台的「令牌」页面为该 Key 设置额度上限,然后重新识别。',
|
||||
'This key has no quota limit, so only spending can be shown: no remaining amount and no alerts. Set a quota limit for this key in the relay site, then detect again.':
|
||||
'这个 Key 没有设置额度上限,只能显示已用金额:无法计算剩余额度,也无法在快用完时提醒你。请到中转站后台为该 Key 设置额度上限,然后重新识别。',
|
||||
'Price currency': '月费货币',
|
||||
'New API, Sub2API, One API and other OpenAI-compatible relays: enter the site address and key, the rest is detected.':
|
||||
'支持 New API、Sub2API、One API 及其他 OpenAI 兼容中转站:只需填写站点地址和 Key,其余自动识别。',
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
import type { Translator } from '@/i18n';
|
||||
import type { RelayType } from '@/providers/relay/inspect';
|
||||
|
||||
/**
|
||||
* A relay key without a quota ceiling only reports what it has spent: there is no remaining
|
||||
* amount, no usage share and nothing to alert on. Tell the user where to set the ceiling.
|
||||
*/
|
||||
export function unlimitedKeyHint(type: RelayType, t: Translator): string {
|
||||
return type === 'new-api'
|
||||
? t(
|
||||
'This key has no quota limit, so only spending can be shown: no remaining amount and no alerts. Set a quota for this key on the Tokens page of the relay site, then detect again.',
|
||||
)
|
||||
: t(
|
||||
'This key has no quota limit, so only spending can be shown: no remaining amount and no alerts. Set a quota limit for this key in the relay site, then detect again.',
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import type { AccentName } from '@/constants/theme';
|
||||
|
||||
/** Spoken and shown names of the theme colours (English keys, translated with t()). */
|
||||
export const ACCENT_LABELS: Record<AccentName, string> = {
|
||||
blue: 'Blue',
|
||||
indigo: 'Indigo',
|
||||
violet: 'Violet',
|
||||
pink: 'Pink',
|
||||
orange: 'Orange',
|
||||
green: 'Green',
|
||||
teal: 'Teal',
|
||||
};
|
||||
Reference in new issue
Block a user