Add-account list grouped (subscriptions, open platforms, relays); no price hint on the overview; settings rows without current values
This commit is contained in:
1 parent
e167cd28fa
commit
e92c4191db
6 files changed
+123
-64
No files matched your search
+11
-12
@@ -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}', {
|
||||
|
||||
@@ -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
@@ -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
@@ -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.': '仅限即将用完的窗口。',
|
||||
|
||||
@@ -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']);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
Reference in new issue
Block a user