diff --git a/src/app/(tabs)/index.tsx b/src/app/(tabs)/index.tsx index 9e17ee3..61d0b6c 100644 --- a/src/app/(tabs)/index.tsx +++ b/src/app/(tabs)/index.tsx @@ -68,20 +68,19 @@ export default function OverviewScreen() { ) : null} - {list.length > 0 ? ( + {/* the summary appears only with something to say: a priced plan or an upcoming plan end */} + {totals.length > 0 || renewal ? ( - - {t('Subscriptions per month')} - {totals.length > 0 ? ( - - {totals.map(([currency, sum]) => formatAmount(sum, currency)).join(' + ')} - - ) : ( - - {t('Add monthly prices in account details to see your total.')} - - )} + <> + + {t('Subscriptions per month')} + + + {totals.map(([currency, sum]) => formatAmount(sum, currency)).join(' + ')} + + + ) : null} {renewal ? ( {t('Next plan end: {name} · {date}', { diff --git a/src/app/(tabs)/settings.tsx b/src/app/(tabs)/settings.tsx index 414d88f..c5403dd 100644 --- a/src/app/(tabs)/settings.tsx +++ b/src/app/(tabs)/settings.tsx @@ -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() { } title={m.title} - value={m.value} onPress={() => router.push(m.href)} /> diff --git a/src/app/add/index.tsx b/src/app/add/index.tsx index f9c0fca..c54831a 100644 --- a/src/app/add/index.tsx +++ b/src/app/add/index.tsx @@ -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 */} - - {shown.map((p, i) => ( - - } - title={t(p.name)} - hint={connectHint(p.connect)} - accessibilityHint={connectHint(p.connect)} - onPress={() => router.push(`/add/${p.id}`)} - /> - - ))} - {shown.length === 0 ? ( - - - {t('No provider matches "{query}".', { query: query.trim() })} - - - ) : null} - + {/* grouped by kind; a search keeps only the groups with matches */} + {groupProviders(shown).map((g, gi) => ( +
+ {g.providers.map((p, i) => ( + + } + title={t(p.name)} + hint={connectHint(p.connect)} + accessibilityHint={connectHint(p.connect)} + onPress={() => router.push(`/add/${p.id}`)} + /> + + ))} +
+ ))} + {shown.length === 0 ? ( + + + {t('No provider matches "{query}".', { query: query.trim() })} + + + ) : null} ); } diff --git a/src/i18n/zh.ts b/src/i18n/zh.ts index ffba123..c2670ee 100644 --- a/src/i18n/zh.ts +++ b/src/i18n/zh.ts @@ -36,6 +36,10 @@ export const zh: Record = { '账号、凭据和用量历史只保存在这台设备上,不会发送到我们的任何服务器。', // 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 = { '{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.': '仅限即将用完的窗口。', diff --git a/src/providers/__tests__/groups.test.ts b/src/providers/__tests__/groups.test.ts new file mode 100644 index 0000000..7ea102e --- /dev/null +++ b/src/providers/__tests__/groups.test.ts @@ -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']); + }); +}); diff --git a/src/providers/groups.ts b/src/providers/groups.ts new file mode 100644 index 0000000..7239876 --- /dev/null +++ b/src/providers/groups.ts @@ -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(); + 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); +}