From 3d5f98513a5ef4d255e039aa19099f40e93117ce Mon Sep 17 00:00:00 2001 From: "Jude.Chen" Date: Wed, 7 Oct 2026 04:01:51 +0800 Subject: [PATCH] Refresh button reports the result (updated count or failed accounts with reasons); version line at the bottom of settings --- src/app/(tabs)/index.tsx | 33 ++++++-- src/app/(tabs)/settings.tsx | 10 ++- src/app/account/[id].tsx | 28 +++++-- src/data/__tests__/refresh-summary.test.ts | 55 +++++++++++++ src/data/refresh-summary.ts | 40 +++++++++ src/i18n/zh.ts | 7 ++ src/refresh/refresh-account.ts | 6 +- src/ui/refresh-notice.tsx | 94 ++++++++++++++++++++++ 8 files changed, 259 insertions(+), 14 deletions(-) create mode 100644 src/data/__tests__/refresh-summary.test.ts create mode 100644 src/data/refresh-summary.ts create mode 100644 src/ui/refresh-notice.tsx diff --git a/src/app/(tabs)/index.tsx b/src/app/(tabs)/index.tsx index 61d0b6c..73d402a 100644 --- a/src/app/(tabs)/index.tsx +++ b/src/app/(tabs)/index.tsx @@ -1,5 +1,5 @@ import { useRouter } from 'expo-router'; -import { useEffect } from 'react'; +import { useCallback, useEffect, useState } from 'react'; import Animated from 'react-native-reanimated'; import { Band } from '@/components/section'; @@ -18,6 +18,9 @@ import { Button } from '@/ui/controls'; import { RefreshButton } from '@/ui/refresh-button'; import { UpdatePill } from '@/ui/update-pill'; import { useUpdateNotice } from '@/data/update-check'; +import { summarizeCycle, type RefreshSummary } from '@/data/refresh-summary'; +import { haptics } from '@/ui/haptics'; +import { RefreshNotice } from '@/ui/refresh-notice'; import { enterFade, enterItem, exitFade, layoutShift } from '@/ui/motion'; export default function OverviewScreen() { @@ -29,6 +32,25 @@ export default function OverviewScreen() { const views = useAccountViews(); const refresh = useRefreshAll(); const update = useUpdateNotice(); + const [notice, setNotice] = useState(); + const closeNotice = useCallback(() => setNotice(undefined), []); + + // the refresh button reports what it did; the automatic refresh on open stays silent + const refreshNow = () => { + setNotice(undefined); + refresh.mutate( + { force: true }, + { + onSuccess: (result) => { + const s = summarizeCycle(result, views.data ?? []); + if (s.kind === 'ok' && s.updated === 0) return; + if (s.kind === 'ok') haptics.success(); + else haptics.warning(); + setNotice(s); + }, + }, + ); + }; // Refresh once when the app opens; the rate limiter makes this cheap if data is recent. const ready = !!services.data; @@ -52,14 +74,11 @@ export default function OverviewScreen() { ) : undefined } - action={ - refresh.mutate({ force: true })} - /> - } + action={} /> + {notice ? : null} + {services.error || views.error ? ( diff --git a/src/app/(tabs)/settings.tsx b/src/app/(tabs)/settings.tsx index c5403dd..5f60fbc 100644 --- a/src/app/(tabs)/settings.tsx +++ b/src/app/(tabs)/settings.tsx @@ -137,7 +137,6 @@ export default function SettingsScreen() {
- + + {/* the build, quietly at the very end of the page */} + + {`AI Usage ${t('Version {version}', { version: Constants.expoConfig?.version ?? '—' })}`} + ); } diff --git a/src/app/account/[id].tsx b/src/app/account/[id].tsx index 0fadc77..4c45185 100644 --- a/src/app/account/[id].tsx +++ b/src/app/account/[id].tsx @@ -1,5 +1,5 @@ import { Stack, useLocalSearchParams, useRouter } from 'expo-router'; -import { useState } from 'react'; +import { useCallback, useState } from 'react'; import { Alert, View } from 'react-native'; import Animated from 'react-native-reanimated'; @@ -28,6 +28,8 @@ import { haptics } from '@/ui/haptics'; import { MeterRow } from '@/ui/meter-row'; import { unlimitedKeyHint } from '@/ui/relay-hints'; import { RefreshButton } from '@/ui/refresh-button'; +import { RefreshNotice } from '@/ui/refresh-notice'; +import { summarizeOutcomes, type RefreshSummary } from '@/data/refresh-summary'; import { enterFade, enterItem, exitFade, layoutShift } from '@/ui/motion'; import { ProviderIcon } from '@/ui/provider-icon'; import { StatusBadge } from '@/ui/status-badge'; @@ -133,6 +135,8 @@ export default function AccountDetailScreen() { const now = useNow(); const view = useAccountView(accountId); const refresh = useRefreshAccount(accountId); + const [notice, setNotice] = useState(); + const closeNotice = useCallback(() => setNotice(undefined), []); const actions = useAccountActions(); const [renaming, setRenaming] = useState(false); const [name, setName] = useState(''); @@ -175,10 +179,24 @@ export default function AccountDetailScreen() { setRenaming(false); }; + // a manual refresh reports its outcome; the pull-to-refresh spinner already shows progress + const refreshNow = () => { + setNotice(undefined); + refresh.mutate(undefined, { + onSuccess: (outcome) => { + const s = summarizeOutcomes({ [accountId]: outcome }, [v]); + if (s.kind === 'ok' && s.updated === 0) return; + if (s.kind === 'ok') haptics.success(); + else haptics.warning(); + setNotice(s); + }, + }); + }; + return ( refresh.mutate() : undefined} + onRefresh={v.source === 'auto' ? refreshNow : undefined} refreshing={refresh.isPending} > ( - refresh.mutate()} /> - ) + ? () => : undefined, }} /> + {notice ? : null} + diff --git a/src/data/__tests__/refresh-summary.test.ts b/src/data/__tests__/refresh-summary.test.ts new file mode 100644 index 0000000..9f52832 --- /dev/null +++ b/src/data/__tests__/refresh-summary.test.ts @@ -0,0 +1,55 @@ +import { summarizeCycle, summarizeOutcomes } from '@/data/refresh-summary'; +import type { AccountView } from '@/data/summary'; + +const view = (id: string, providerId: string, name: string, label = name): AccountView => ({ + account: { id, providerId, label, authMethod: 'apiKey', sortOrder: 0, createdAt: 0 }, + meta: { id: providerId, name, automation: 'spec', auth: ['apiKey'] }, + snapshot: null, + health: null, + source: 'auto', +}); + +const views = [ + view('a', 'deepseek', 'DeepSeek Open Platform'), + view('b', 'relay', 'API relay', '小猫 API'), + view('c', 'cline', 'Cline / ClinePass'), +]; +const ok = { type: 'ok' as const, snapshot: {} as never }; + +describe('refresh summary', () => { + it('counts updated accounts and ignores ones that never refresh', () => { + expect( + summarizeOutcomes({ a: ok, c: ok, b: { type: 'skipped', reason: 'legacy-account' } }, views), + ).toEqual({ kind: 'ok', updated: 2 }); + }); + + it('names each failed account (relays by their site name) with its reason', () => { + expect( + summarizeOutcomes( + { + a: ok, + b: { type: 'failed', kind: 'network', message: 'The request timed out.' }, + c: { + type: 'failed', + kind: 'invalid-credential', + message: 'The credential was rejected.', + }, + }, + views, + ), + ).toEqual({ + kind: 'partial', + updated: 1, + failed: [ + { name: '小猫 API', message: 'The request timed out.' }, + { name: 'Cline / ClinePass', message: 'The credential was rejected.' }, + ], + }); + }); + + it('reports a cycle that ran out of time', () => { + expect( + summarizeCycle({ outcomes: {}, alertsSent: 0, pruned: false, timedOut: true }, views), + ).toEqual({ kind: 'timeout' }); + }); +}); diff --git a/src/data/refresh-summary.ts b/src/data/refresh-summary.ts new file mode 100644 index 0000000..2b89c89 --- /dev/null +++ b/src/data/refresh-summary.ts @@ -0,0 +1,40 @@ +import type { AccountView } from '@/data/summary'; +import type { CycleResult } from '@/refresh/coordinator'; +import type { RefreshOutcome } from '@/refresh/refresh-account'; + +/** What a manual refresh achieved, for the notice shown after the refresh button. */ +export type RefreshSummary = + | { kind: 'ok'; updated: number } + | { kind: 'partial'; updated: number; failed: { name: string; message: string }[] } + | { kind: 'timeout' }; + +export function accountName(v: AccountView): string { + return v.meta?.id === 'relay' ? v.account.label : (v.meta?.name ?? v.account.providerId); +} + +/** + * Successes and failures of one refresh. Accounts skipped as legacy or read-only are neither: they + * were never meant to refresh. A failure keeps the account's name and the short, translatable + * reason the refresh recorded. + */ +export function summarizeOutcomes( + outcomes: Record, + views: AccountView[], +): RefreshSummary { + const byId = new Map(views.map((v) => [v.account.id, v])); + let updated = 0; + const failed: { name: string; message: string }[] = []; + for (const [id, o] of Object.entries(outcomes)) { + if (o.type === 'ok') updated++; + else if (o.type === 'failed') { + const v = byId.get(id); + failed.push({ name: v ? accountName(v) : id, message: o.message }); + } + } + return failed.length ? { kind: 'partial', updated, failed } : { kind: 'ok', updated }; +} + +export function summarizeCycle(result: CycleResult, views: AccountView[]): RefreshSummary { + if (result.timedOut) return { kind: 'timeout' }; + return summarizeOutcomes(result.outcomes, views); +} diff --git a/src/i18n/zh.ts b/src/i18n/zh.ts index c2670ee..ed801a2 100644 --- a/src/i18n/zh.ts +++ b/src/i18n/zh.ts @@ -36,6 +36,13 @@ export const zh: Record = { '账号、凭据和用量历史只保存在这台设备上,不会发送到我们的任何服务器。', // API relays 'API relay': '中转站', + 'Updated 1 account': '已刷新 1 个账号', + 'Updated {n} accounts': '已刷新 {n} 个账号', + '1 account could not be refreshed': '1 个账号刷新失败', + '{n} accounts could not be refreshed': '{n} 个账号刷新失败', + 'Refresh took too long. Some accounts may update a little later.': + '刷新超时,部分账号可能稍后才会更新。', + Dismiss: '关闭', Subscriptions: '订阅套餐', 'Open platforms': '开放平台', Relays: '中转站', diff --git a/src/refresh/refresh-account.ts b/src/refresh/refresh-account.ts index 7e13e5d..d63a4c7 100644 --- a/src/refresh/refresh-account.ts +++ b/src/refresh/refresh-account.ts @@ -83,7 +83,11 @@ export async function refreshAccount( message: 'The response was not recognized. The service may have changed.', countsAsFailure: false, }); - return { type: 'failed', kind: 'unsupported', message: 'unsupported' }; + return { + type: 'failed', + kind: 'unsupported', + message: 'The response was not recognized. The service may have changed.', + }; } await repos.snapshots.save(snapshot); await repos.health.recordSuccess(accountId, nowMs); diff --git a/src/ui/refresh-notice.tsx b/src/ui/refresh-notice.tsx new file mode 100644 index 0000000..bb8ca2b --- /dev/null +++ b/src/ui/refresh-notice.tsx @@ -0,0 +1,94 @@ +import { useEffect } from 'react'; +import { View } from 'react-native'; +import Animated, { FadeInDown } from 'react-native-reanimated'; + +import { ThemedText } from '@/components/themed-text'; +import { Radius, Spacing } from '@/constants/theme'; +import type { RefreshSummary } from '@/data/refresh-summary'; +import { useTheme } from '@/hooks/use-theme'; +import { useT } from '@/i18n'; +import { exitFade, layoutShift, Motion, PressableScale } from '@/ui/motion'; + +const SHOW_OK_MS = 2500; +const SHOW_FAILED_MS = 7000; + +/** + * Result of a manual refresh, shown under the page header: a short confirmation when everything + * updated, or the accounts that failed with their reasons. It hides by itself (later when + * something failed) and on tap. + */ +export function RefreshNotice({ + summary, + onClose, +}: { + summary: RefreshSummary; + onClose: () => void; +}) { + const theme = useTheme(); + const t = useT(); + const failed = summary.kind !== 'ok'; + + useEffect(() => { + const timer = setTimeout(onClose, failed ? SHOW_FAILED_MS : SHOW_OK_MS); + return () => clearTimeout(timer); + }, [summary, failed, onClose]); + + const color = failed ? theme.warn : theme.good; + return ( + + + {summary.kind === 'ok' ? ( + + {'✓ '} + {summary.updated === 1 + ? t('Updated 1 account') + : t('Updated {n} accounts', { n: summary.updated })} + + ) : summary.kind === 'timeout' ? ( + + {'▲ '} + {t('Refresh took too long. Some accounts may update a little later.')} + + ) : ( + <> + + {'▲ '} + {summary.failed.length === 1 + ? t('1 account could not be refreshed') + : t('{n} accounts could not be refreshed', { n: summary.failed.length })} + + {summary.failed.map((f) => ( + + + {f.name} + + + {t(f.message)} + + + ))} + + )} + + + ); +}