Refresh button reports the result (updated count or failed accounts with reasons); version line at the bottom of settings

This commit is contained in:
Jude.Chen committed 2026-10-07 04:01:51 +08:00
1 parent e92c4191db
commit 3d5f98513a
8 files changed
+259 -14

No files matched your search

+26 -7
View File
@@ -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<RefreshSummary>();
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() {
<UpdatePill release={update.notice} onDismiss={update.dismiss} />
) : undefined
}
action={
<RefreshButton
refreshing={refresh.isPending}
onPress={() => refresh.mutate({ force: true })}
/>
}
action={<RefreshButton refreshing={refresh.isPending} onPress={refreshNow} />}
/>
{notice ? <RefreshNotice summary={notice} onClose={closeNotice} /> : null}
{services.error || views.error ? (
<Band entering={enterFade} exiting={exitFade} layout={layoutShift}>
<ThemedText type="small" style={{ color: theme.bad }}>
+9 -1
View File
@@ -137,7 +137,6 @@ export default function SettingsScreen() {
<Animated.View layout={layoutShift}>
<Section title={t('About')}>
<Row title={t('Version {version}', { version: Constants.expoConfig?.version ?? '—' })} />
<Row
title="GitHub"
value={GITHUB_HANDLE}
@@ -156,6 +155,15 @@ export default function SettingsScreen() {
) : null}
</Section>
</Animated.View>
{/* the build, quietly at the very end of the page */}
<ThemedText
type="small"
themeColor="textSecondary"
style={{ textAlign: 'center', marginTop: Spacing.two }}
>
{`AI Usage ${t('Version {version}', { version: Constants.expoConfig?.version ?? '—' })}`}
</ThemedText>
</Screen>
);
}
+23 -5
View File
@@ -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<RefreshSummary>();
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 (
<Screen
safeTop={false}
onRefresh={v.source === 'auto' ? () => refresh.mutate() : undefined}
onRefresh={v.source === 'auto' ? refreshNow : undefined}
refreshing={refresh.isPending}
>
<Stack.Screen
@@ -186,13 +204,13 @@ export default function AccountDetailScreen() {
title: t('Details'),
headerRight:
v.source === 'auto'
? () => (
<RefreshButton refreshing={refresh.isPending} onPress={() => refresh.mutate()} />
)
? () => <RefreshButton refreshing={refresh.isPending} onPress={refreshNow} />
: undefined,
}}
/>
{notice ? <RefreshNotice summary={notice} onClose={closeNotice} /> : null}
<Band entering={enterFade} layout={layoutShift}>
<View style={{ flexDirection: 'row', alignItems: 'center', gap: Spacing.three }}>
<ProviderIcon providerId={v.account.providerId} label={providerName} size={32} />
@@ -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' });
});
});
+40
View File
@@ -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<string, RefreshOutcome>,
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);
}
+7
View File
@@ -36,6 +36,13 @@ export const zh: Record<string, string> = {
'账号、凭据和用量历史只保存在这台设备上,不会发送到我们的任何服务器。',
// 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: '中转站',
+5 -1
View File
@@ -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);
+94
View File
@@ -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 (
<Animated.View
entering={FadeInDown.duration(Motion.base).easing(Motion.easing)}
exiting={exitFade}
layout={layoutShift}
>
<PressableScale
accessibilityRole="button"
accessibilityLabel={t('Dismiss')}
accessibilityLiveRegion="polite"
scaleTo={0.98}
onPress={onClose}
style={{
backgroundColor: theme.backgroundElement,
borderColor: color,
borderWidth: 1,
borderRadius: Radius.control,
paddingHorizontal: Spacing.three,
paddingVertical: Spacing.two,
gap: Spacing.one,
}}
>
{summary.kind === 'ok' ? (
<ThemedText type="small" style={{ color }}>
{'✓ '}
{summary.updated === 1
? t('Updated 1 account')
: t('Updated {n} accounts', { n: summary.updated })}
</ThemedText>
) : summary.kind === 'timeout' ? (
<ThemedText type="small" style={{ color }}>
{'▲ '}
{t('Refresh took too long. Some accounts may update a little later.')}
</ThemedText>
) : (
<>
<ThemedText type="smallBold" style={{ color }}>
{'▲ '}
{summary.failed.length === 1
? t('1 account could not be refreshed')
: t('{n} accounts could not be refreshed', { n: summary.failed.length })}
</ThemedText>
{summary.failed.map((f) => (
<View key={f.name} style={{ flexDirection: 'row', gap: Spacing.one }}>
<ThemedText type="small" style={{ flexShrink: 0 }}>
{f.name}
</ThemedText>
<ThemedText type="small" themeColor="textSecondary" style={{ flexShrink: 1 }}>
{t(f.message)}
</ThemedText>
</View>
))}
</>
)}
</PressableScale>
</Animated.View>
);
}