Add-account list grouped (subscriptions, open platforms, relays); no price hint on the overview; settings rows without current values

This commit is contained in:
Jude.Chen committed 2026-10-07 03:59:46 +08:00
1 parent e167cd28fa
commit e92c4191db
6 files changed
+123 -64

No files matched your search

+11 -12
View File
@@ -68,20 +68,19 @@ export default function OverviewScreen() {
</Band>
) : null}
{list.length > 0 ? (
{/* the summary appears only with something to say: a priced plan or an upcoming plan end */}
{totals.length > 0 || renewal ? (
<Band entering={enterFade} layout={layoutShift} style={{ gap: Spacing.one }}>
<ThemedText type="small" themeColor="textSecondary">
{t('Subscriptions per month')}
</ThemedText>
{totals.length > 0 ? (
<ThemedText type="subtitle">
{totals.map(([currency, sum]) => formatAmount(sum, currency)).join(' + ')}
</ThemedText>
) : (
<ThemedText type="small" themeColor="textSecondary">
{t('Add monthly prices in account details to see your total.')}
</ThemedText>
)}
<>
<ThemedText type="small" themeColor="textSecondary">
{t('Subscriptions per month')}
</ThemedText>
<ThemedText type="subtitle">
{totals.map(([currency, sum]) => formatAmount(sum, currency)).join(' + ')}
</ThemedText>
</>
) : null}
{renewal ? (
<ThemedText type="small" themeColor="textSecondary">
{t('Next plan end: {name} · {date}', {
+3 -28
View File
@@ -9,14 +9,12 @@ import { Row, Section } from '@/components/section';
import { ScreenHeader } from '@/components/screen-header';
import { ThemedText } from '@/components/themed-text';
import { Radius, Spacing } from '@/constants/theme';
import { useAccountViews, useAlertSettings } from '@/data/hooks';
import { useTheme } from '@/hooks/use-theme';
import { useT } from '@/i18n';
import { usePreferences } from '@/providers/preferences';
import { Button } from '@/ui/controls';
import { haptics } from '@/ui/haptics';
import { enterFade, enterItem, exitFade, layoutShift, PressableScale } from '@/ui/motion';
import { ACCENT_LABELS } from '@/ui/settings-labels';
const GITHUB_HANDLE = 'nextroad-dev';
const GITHUB_URL = 'https://github.com/nextroad-dev';
@@ -41,49 +39,27 @@ function MenuIcon({ name }: { name: SymbolViewProps['name'] }) {
);
}
/**
* Settings hub: each area opens its own page, and the row shows its current state so most visits
* need no tap at all.
*/
/** Settings hub: each area opens its own page; the hub itself stays a short, quiet list. */
export default function SettingsScreen() {
const router = useRouter();
const t = useT();
const theme = useTheme();
const accounts = useAccountViews();
const { settings } = useAlertSettings();
const { localeSetting, themeSetting, accentSetting, sponsorDismissed, setSponsorDismissed } =
usePreferences();
const { sponsorDismissed, setSponsorDismissed } = usePreferences();
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 }[] = [
const menu: { icon: SymbolViewProps['name']; title: 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',
},
{
@@ -152,7 +128,6 @@ export default function SettingsScreen() {
<Row
leading={<MenuIcon name={m.icon} />}
title={m.title}
value={m.value}
onPress={() => router.push(m.href)}
/>
</Animated.View>
+25 -23
View File
@@ -3,11 +3,11 @@ import { useState } from 'react';
import Animated from 'react-native-reanimated';
import { Screen } from '@/components/screen';
import { Band, Row } from '@/components/section';
import { Row, Section } from '@/components/section';
import { ThemedText } from '@/components/themed-text';
import { Spacing } from '@/constants/theme';
import { useT } from '@/i18n';
import { isConnectableProvider, providers, type ProviderMeta } from '@/providers/registry';
import { groupProviders } from '@/providers/groups';
import { matchProviders } from '@/providers/search';
import { enterFade, enterItem } from '@/ui/motion';
import { ProviderIcon } from '@/ui/provider-icon';
@@ -33,27 +33,29 @@ export default function AddAccountScreen() {
},
}}
/>
{/* flush the first row against the header: cancel the screen gutter and the band's own top pad */}
<Band style={{ marginTop: -Spacing.three, paddingTop: Spacing.two }}>
{shown.map((p, i) => (
<Animated.View key={p.id} entering={enterItem(i)}>
<Row
leading={<ProviderIcon providerId={p.id} label={p.name} />}
title={t(p.name)}
hint={connectHint(p.connect)}
accessibilityHint={connectHint(p.connect)}
onPress={() => router.push(`/add/${p.id}`)}
/>
</Animated.View>
))}
{shown.length === 0 ? (
<Animated.View entering={enterFade}>
<ThemedText type="small" themeColor="textSecondary">
{t('No provider matches "{query}".', { query: query.trim() })}
</ThemedText>
</Animated.View>
) : null}
</Band>
{/* grouped by kind; a search keeps only the groups with matches */}
{groupProviders(shown).map((g, gi) => (
<Section key={g.key} title={t(g.title)}>
{g.providers.map((p, i) => (
<Animated.View key={p.id} entering={enterItem(gi * 3 + i)}>
<Row
leading={<ProviderIcon providerId={p.id} label={p.name} />}
title={t(p.name)}
hint={connectHint(p.connect)}
accessibilityHint={connectHint(p.connect)}
onPress={() => router.push(`/add/${p.id}`)}
/>
</Animated.View>
))}
</Section>
))}
{shown.length === 0 ? (
<Animated.View entering={enterFade}>
<ThemedText type="small" themeColor="textSecondary">
{t('No provider matches "{query}".', { query: query.trim() })}
</ThemedText>
</Animated.View>
) : null}
</Screen>
);
}
+4 -1
View File
@@ -36,6 +36,10 @@ export const zh: Record<string, string> = {
'账号、凭据和用量历史只保存在这台设备上,不会发送到我们的任何服务器。',
// API relays
'API relay': '中转站',
Subscriptions: '订阅套餐',
'Open platforms': '开放平台',
Relays: '中转站',
Other: '其他',
'New v{version}': '新版本 v{version}',
'New version {version} available': '有新版本 {version}',
'Dismiss this version': '不再提示此版本',
@@ -353,7 +357,6 @@ export const zh: Record<string, string> = {
'{name}: {meter} has reset': '{name}:{meter}已重置',
'The quota is available again.': '额度已恢复,可以继续使用。',
'Subscriptions per month': '每月订阅合计',
'Add monthly prices in account details to see your total.': '在账号详情中填写月费即可查看合计。',
'Next plan end: {name} · {date}': '最近到期:{name} · {date}',
'Tell me when a window resets': '窗口重置时通知我',
'Only for windows that were nearly used up.': '仅限即将用完的窗口。',
+23
View File
@@ -0,0 +1,23 @@
import { groupProviders } from '@/providers/groups';
import { isConnectableProvider, providers } from '@/providers/registry';
import { matchProviders } from '@/providers/search';
const connectable = providers.filter(isConnectableProvider);
describe('add-account groups', () => {
it('places every connectable provider in a named group, in order', () => {
const groups = groupProviders(connectable);
expect(groups.map((g) => g.key)).toEqual(['subscriptions', 'platforms', 'relays']);
expect(groups.flatMap((g) => g.providers)).toHaveLength(connectable.length);
expect(groups[0].providers[0].id).toBe('codex');
expect(groups[1].providers.map((p) => p.id)).toContain('deepseek');
});
it('keeps only the groups with matches when searching, and never hides an unknown provider', () => {
expect(groupProviders(matchProviders(connectable, 'open platform')).map((g) => g.key)).toEqual([
'platforms',
]);
const stray = { ...connectable[0], id: 'brand-new' };
expect(groupProviders([stray]).map((g) => g.key)).toEqual(['other']);
});
});
+57
View File
@@ -0,0 +1,57 @@
import type { ProviderMeta } from '@/providers/registry';
/**
* How the add-account list is organised: plans you subscribe to, pay-as-you-go API platforms,
* then relays. Within a group, the most widely used come first. A provider missing here still
* shows up, in a trailing "Other" group, so adding one never hides it.
*/
export const PROVIDER_GROUPS: { key: string; title: string; ids: string[] }[] = [
{
key: 'subscriptions',
title: 'Subscriptions',
ids: [
'codex',
'copilot',
'cline',
'kimi-code',
'zai',
'minimax',
'command-goat',
'opencode-go',
'poe',
],
},
{
key: 'platforms',
title: 'Open platforms',
ids: [
'openai-platform',
'anthropic-platform',
'openrouter',
'deepseek',
'kimi-balance',
'runway',
],
},
{ key: 'relays', title: 'Relays', ids: ['relay'] },
];
export function groupProviders(
list: ProviderMeta[],
): { key: string; title: string; providers: ProviderMeta[] }[] {
const byId = new Map(list.map((p) => [p.id, p]));
const placed = new Set<string>();
const groups = PROVIDER_GROUPS.map((g) => ({
key: g.key,
title: g.title,
providers: g.ids.flatMap((id) => {
const p = byId.get(id);
if (!p) return [];
placed.add(id);
return [p];
}),
}));
const rest = list.filter((p) => !placed.has(p.id));
if (rest.length) groups.push({ key: 'other', title: 'Other', providers: rest });
return groups.filter((g) => g.providers.length > 0);
}