Refresh button reports the result (updated count or failed accounts with reasons); version line at the bottom of settings
This commit is contained in:
1 parent
e92c4191db
commit
3d5f98513a
8 files changed
+259
-14
No files matched your search
@@ -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 }}>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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' });
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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: '中转站',
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user