diff --git a/frontend/src/app/AppRoot.tsx b/frontend/src/app/AppRoot.tsx
index 9ea5847..dcde474 100644
--- a/frontend/src/app/AppRoot.tsx
+++ b/frontend/src/app/AppRoot.tsx
@@ -210,67 +210,50 @@ function AuthenticatedScheduleRoute({
return (
-
-
- 账号:{username}
-
- void handleSignOut()}
- style={({ pressed }) => [
- styles.signOutButton,
- pressed && !isSigningOut && styles.signOutButtonPressed,
- ]}
- >
- {isSigningOut ? '正在退出…' : '退出登录'}
-
-
-
- {currentLoadState?.status === 'ready' &&
- scheduleService &&
- pushToTalkApplication &&
- continuousApplication ? (
-
- ) : (
-
-
- {currentLoadState?.status === 'error' ? '本地日程存储初始化失败' : '正在准备日程'}
+ {currentLoadState?.status === 'ready' &&
+ scheduleService &&
+ pushToTalkApplication &&
+ continuousApplication ? (
+
+ ) : (
+
+
+ {currentLoadState?.status === 'error' ? '本地日程存储初始化失败' : '正在准备日程'}
+
+ {currentLoadState?.status === 'error' ? (
+
+ 重试
+
+ ) : null}
+ void handleSignOut()}
+ style={styles.loadStateSignOut}
+ >
+
+ {isSigningOut ? '正在退出…' : '退出登录'}
- {currentLoadState?.status === 'error' ? (
-
- 重试
-
- ) : null}
-
- )}
-
+
+
+ )}
);
}
const styles = StyleSheet.create({
account: { color: colors.mutedText, fontSize: 16, marginTop: spacing.sm },
- accountBar: {
- alignItems: 'center',
- backgroundColor: colors.surface,
- borderBottomColor: colors.border,
- borderBottomWidth: 1,
- flexDirection: 'row',
- gap: spacing.md,
- justifyContent: 'space-between',
- paddingHorizontal: spacing.lg,
- paddingVertical: spacing.sm,
- },
- accountIdentity: { color: colors.mutedText, flex: 1, fontSize: 14 },
- authenticatedContent: { flex: 1 },
authenticatedRoute: { backgroundColor: colors.background, flex: 1 },
authenticatedScreen: {
alignItems: 'center',
@@ -279,6 +262,16 @@ const styles = StyleSheet.create({
justifyContent: 'center',
padding: spacing.xl,
},
+ loadStateSignOut: {
+ backgroundColor: colors.surface,
+ borderColor: colors.border,
+ borderRadius: 8,
+ borderWidth: 1,
+ marginTop: spacing.md,
+ paddingHorizontal: spacing.lg,
+ paddingVertical: spacing.sm,
+ },
+ loadStateSignOutText: { color: colors.mutedText, fontWeight: '600' },
retry: {
backgroundColor: colors.text,
borderRadius: 8,
@@ -287,14 +280,5 @@ const styles = StyleSheet.create({
paddingVertical: spacing.sm,
},
retryText: { color: colors.onPrimary, fontWeight: '700' },
- signOutButton: {
- borderColor: colors.border,
- borderRadius: 8,
- borderWidth: 1,
- paddingHorizontal: spacing.md,
- paddingVertical: spacing.sm,
- },
- signOutButtonPressed: { opacity: 0.7 },
- signOutText: { color: colors.text, fontSize: 14, fontWeight: '600' },
title: { color: colors.text, fontSize: 22, fontWeight: '700' },
});
diff --git a/frontend/src/features/schedule/presentation/ScheduleCalendarScreen.tsx b/frontend/src/features/schedule/presentation/ScheduleCalendarScreen.tsx
index ef3059b..30ad8ef 100644
--- a/frontend/src/features/schedule/presentation/ScheduleCalendarScreen.tsx
+++ b/frontend/src/features/schedule/presentation/ScheduleCalendarScreen.tsx
@@ -1,5 +1,6 @@
import { useState } from 'react';
import { ActivityIndicator, Pressable, ScrollView, StyleSheet, Text, View } from 'react-native';
+import Svg, { Path } from 'react-native-svg';
import { colors, spacing } from '../../../shared/ui/theme';
import type {
@@ -20,22 +21,32 @@ const SELECTED_DATE_FORMATTER = new Intl.DateTimeFormat('zh-CN', {
weekday: 'long',
});
-export function ScheduleCalendarScreen({
- service,
- accountId,
- timezone,
- refreshSignal,
-}: {
+interface ScheduleCalendarScreenProps {
service: ScheduleCalendarReadService;
accountId: string;
timezone: string;
+ username: string;
+ onSignOut: () => void | Promise;
+ isSigningOut?: boolean;
/** 外部触发刷新用(比如语音写完一条日程);变化即重取,不用管具体数值。 */
refreshSignal?: number;
-}) {
+}
+
+export function ScheduleCalendarScreen({
+ service,
+ accountId,
+ timezone,
+ username,
+ onSignOut,
+ isSigningOut = false,
+ refreshSignal,
+}: ScheduleCalendarScreenProps) {
const calendar = useScheduleCalendar(service, accountId, timezone, undefined, refreshSignal);
const [selectedOccurrence, setSelectedOccurrence] = useState(null);
const [selectedLocation, setSelectedLocation] = useState(null);
const selectedLabel = SELECTED_DATE_FORMATTER.format(calendar.selectedDate);
+ const displayUsername = username.trim() || '用户';
+ const avatarInitial = Array.from(displayUsername)[0]?.toLocaleUpperCase() ?? '用';
return (
@@ -46,8 +57,45 @@ export function ScheduleCalendarScreen({
>
- 我的日程
- {selectedLabel}
+
+ 我的日程
+
+
+
+ {avatarInitial}
+
+
+ {displayUsername}
+
+
+ void onSignOut()}
+ style={({ pressed }) => [
+ styles.signOutButton,
+ pressed && !isSigningOut && styles.signOutButtonPressed,
+ ]}
+ >
+ {isSigningOut ? (
+
+ ) : (
+
+ )}
+
+
+
+
+ {selectedLabel}
+
+
+
+ );
+}
+
const styles = StyleSheet.create({
+ accountActions: {
+ alignItems: 'center',
+ flex: 1,
+ flexDirection: 'row',
+ gap: spacing.sm,
+ justifyContent: 'flex-end',
+ marginLeft: spacing.sm,
+ maxWidth: 240,
+ minWidth: 0,
+ },
agenda: { paddingHorizontal: spacing.md, paddingTop: spacing.xl },
+ avatar: {
+ alignItems: 'center',
+ backgroundColor: colors.accent,
+ borderRadius: 999,
+ height: 28,
+ justifyContent: 'center',
+ width: 28,
+ },
+ avatarText: { color: colors.text, fontSize: 12, fontWeight: '800' },
center: {
alignItems: 'center',
backgroundColor: colors.surface,
@@ -175,6 +256,12 @@ const styles = StyleSheet.create({
paddingHorizontal: spacing.lg,
paddingTop: spacing.xl,
},
+ headerTop: {
+ alignItems: 'center',
+ flexDirection: 'row',
+ justifyContent: 'space-between',
+ minWidth: 0,
+ },
locationSection: {
borderTopColor: colors.border,
borderTopWidth: StyleSheet.hairlineWidth,
@@ -199,6 +286,34 @@ const styles = StyleSheet.create({
paddingBottom: 14,
},
sectionTitle: { color: colors.text, fontSize: 20, fontWeight: '800' },
+ signOutButton: {
+ alignItems: 'center',
+ backgroundColor: colors.surface,
+ borderColor: colors.border,
+ borderRadius: 18,
+ borderWidth: 1,
+ flexShrink: 0,
+ height: 36,
+ justifyContent: 'center',
+ width: 36,
+ },
+ signOutButtonPressed: { opacity: 0.62 },
stateText: { color: colors.mutedText },
title: { color: colors.text, fontSize: 28, fontWeight: '800', lineHeight: 34, marginTop: 4 },
+ userPill: {
+ alignItems: 'center',
+ backgroundColor: colors.surface,
+ borderColor: colors.border,
+ borderRadius: 18,
+ borderWidth: 1,
+ flexDirection: 'row',
+ flexShrink: 1,
+ gap: spacing.sm,
+ maxWidth: 196,
+ minWidth: 0,
+ paddingHorizontal: 4,
+ paddingRight: 10,
+ paddingVertical: 3,
+ },
+ username: { color: colors.text, flexShrink: 1, fontSize: 13, fontWeight: '700', minWidth: 0 },
});
diff --git a/frontend/src/screens/HomeScreen.tsx b/frontend/src/screens/HomeScreen.tsx
index cf3725d..018344b 100644
--- a/frontend/src/screens/HomeScreen.tsx
+++ b/frontend/src/screens/HomeScreen.tsx
@@ -12,7 +12,10 @@ interface HomeScreenProps {
continuousApplication: AssistantApplicationPort;
scheduleService: ScheduleCalendarReadService;
accountId: string;
+ isSigningOut: boolean;
+ onSignOut: () => Promise;
timezone: string;
+ username: string;
}
/**
@@ -25,7 +28,10 @@ export function HomeScreen({
continuousApplication,
scheduleService,
accountId,
+ isSigningOut,
+ onSignOut,
timezone,
+ username,
}: HomeScreenProps) {
const { lastAppliedCommand: pttCommand } = useAssistantConversation(pushToTalkApplication);
const { lastAppliedCommand: callCommand } = useAssistantConversation(continuousApplication);
@@ -54,9 +60,12 @@ export function HomeScreen({
({
SqliteScheduleClientService: jest.fn(),
}));
jest.mock('../../../src/features/schedule/presentation/ScheduleCalendarScreen', () => ({
- ScheduleCalendarScreen: () => {
- const { Text } = jest.requireActual('react-native') as typeof import('react-native');
- return 日程日历;
+ ScheduleCalendarScreen: ({
+ isSigningOut,
+ onSignOut,
+ username,
+ }: {
+ isSigningOut: boolean;
+ onSignOut: () => Promise;
+ username: string;
+ }) => {
+ const { Pressable, Text, View } = jest.requireActual(
+ 'react-native',
+ ) as typeof import('react-native');
+ return (
+
+ 日程日历
+ {username}
+ void onSignOut()}
+ />
+
+ );
},
}));
// 语音助手这几个真实实现都要接原生模块(麦克风/播放/定位),测试环境没有对应的
@@ -95,7 +116,8 @@ describe('AppRoot', () => {
});
await waitFor(() => expect(screen.getByText('日程日历')).toBeTruthy());
- expect(screen.getByText('账号:timeflow_user')).toBeTruthy();
+ expect(screen.getByText('timeflow_user')).toBeTruthy();
+ expect(screen.queryByText(/账号:/)).toBeNull();
expect(screen.queryByText(/acc_001/)).toBeNull();
expect(screen.queryByText('登录或注册')).toBeNull();
expect(screen.queryByText('opaque-token')).toBeNull();
@@ -110,7 +132,8 @@ describe('AppRoot', () => {
});
render();
- await screen.findByText('账号:restored_user');
+ await screen.findByText('restored_user');
+ expect(screen.queryByText(/账号:/)).toBeNull();
expect(screen.queryByText(/acc_internal_001/)).toBeNull();
expect(screen.queryByText('opaque-token')).toBeNull();
});
@@ -132,6 +155,42 @@ describe('AppRoot', () => {
await waitFor(() => expect(mockedOpenTimeflowDatabase).toHaveBeenCalledTimes(2));
await waitFor(() => expect(screen.getByText('日程日历')).toBeTruthy());
+ expect(screen.getByText('timeflow_user')).toBeTruthy();
+ expect(screen.queryByText(/账号:/)).toBeNull();
+ });
+
+ it('can sign out when SQLite initialization fails', async () => {
+ mockedOpenTimeflowDatabase.mockRejectedValue(new Error('database unavailable'));
+ const controller = createController({
+ accountId: 'acc_001',
+ accessToken: 'opaque-token',
+ expiresAt: 200_000,
+ username: 'timeflow_user',
+ });
+ render();
+
+ await screen.findByText('本地日程存储初始化失败');
+ fireEvent.press(screen.getByRole('button', { name: '退出登录' }));
+
+ await waitFor(() => expect(screen.getByText('登录或注册')).toBeTruthy());
+ expect(controller.getViewState()).toEqual({ status: 'unauthenticated' });
+ });
+
+ it('can sign out while SQLite initialization is pending', async () => {
+ mockedOpenTimeflowDatabase.mockImplementation(() => new Promise(() => undefined));
+ const controller = createController({
+ accountId: 'acc_001',
+ accessToken: 'opaque-token',
+ expiresAt: 200_000,
+ username: 'timeflow_user',
+ });
+ render();
+
+ await screen.findByText('正在准备日程');
+ fireEvent.press(screen.getByRole('button', { name: '退出登录' }));
+
+ await waitFor(() => expect(screen.getByText('登录或注册')).toBeTruthy());
+ expect(controller.getViewState()).toEqual({ status: 'unauthenticated' });
});
it('signs out from the authenticated account shell', async () => {
diff --git a/frontend/tests/unit/features/schedule/presentation/ScheduleCalendarScreen.test.tsx b/frontend/tests/unit/features/schedule/presentation/ScheduleCalendarScreen.test.tsx
index 685214a..7c2f698 100644
--- a/frontend/tests/unit/features/schedule/presentation/ScheduleCalendarScreen.test.tsx
+++ b/frontend/tests/unit/features/schedule/presentation/ScheduleCalendarScreen.test.tsx
@@ -1,5 +1,6 @@
import { fireEvent, render, screen, waitFor } from '@testing-library/react-native';
import { describe, expect, it, jest } from '@jest/globals';
+import { StyleSheet } from 'react-native';
import type { ScheduleCalendarReadService } from '../../../../../src/features/schedule/application';
import { ScheduleCalendarScreen } from '../../../../../src/features/schedule/presentation/ScheduleCalendarScreen';
@@ -33,7 +34,13 @@ describe('ScheduleCalendarScreen location schedules', () => {
const service = createService();
const accountId = 'internal-account-id-not-for-display';
render(
- ,
+ {}}
+ service={service}
+ timezone="Asia/Shanghai"
+ username="Sarah"
+ />,
);
await waitFor(() => expect(service.getSchedulesByRange).toHaveBeenCalled());
@@ -44,10 +51,74 @@ describe('ScheduleCalendarScreen location schedules', () => {
expect(screen.queryByText(accountId)).toBeNull();
});
+ it('reloads calendar queries when refreshSignal changes', async () => {
+ const service = createService();
+ const props = {
+ accountId: 'account-a',
+ onSignOut: () => {},
+ service,
+ timezone: 'Asia/Shanghai',
+ username: 'Sarah',
+ };
+ const view = render();
+
+ await waitFor(() => expect(service.getSchedulesByRange).toHaveBeenCalledTimes(1));
+ await waitFor(() => expect(service.getLocationSchedules).toHaveBeenCalledTimes(1));
+
+ view.rerender();
+
+ await waitFor(() => expect(service.getSchedulesByRange).toHaveBeenCalledTimes(2));
+ await waitFor(() => expect(service.getLocationSchedules).toHaveBeenCalledTimes(2));
+ expect(service.getSchedulesByRange).toHaveBeenLastCalledWith(
+ expect.objectContaining({ accountId: 'account-a' }),
+ );
+ expect(service.getLocationSchedules).toHaveBeenLastCalledWith({ accountId: 'account-a' });
+ });
+
+ it('renders compact account controls, truncates a long username, and signs out', async () => {
+ const service = createService();
+ const onSignOut = jest.fn<() => void>();
+ const username = 'zhangsan-with-an-extremely-long-account-name';
+ render(
+ ,
+ );
+
+ await waitFor(() => expect(service.getSchedulesByRange).toHaveBeenCalled());
+ expect(screen.getByText('我的日程')).toBeTruthy();
+ expect(screen.getByText('Z')).toBeTruthy();
+ expect(screen.getByText(username)).toBeTruthy();
+ expect(screen.queryByText(/账号:/)).toBeNull();
+ expect(screen.getByTestId('schedule-account-username').props).toMatchObject({
+ ellipsizeMode: 'tail',
+ numberOfLines: 1,
+ });
+ expect(
+ StyleSheet.flatten(screen.getByTestId('schedule-account-username').props.style),
+ ).toMatchObject({ flexShrink: 1, minWidth: 0 });
+ expect(
+ StyleSheet.flatten(screen.getByTestId('schedule-account-actions').props.style),
+ ).toMatchObject({ maxWidth: 240, minWidth: 0 });
+
+ fireEvent.press(screen.getByRole('button', { name: '退出登录' }));
+ expect(onSignOut).toHaveBeenCalledTimes(1);
+ });
+
it('shows a location section that stays visible after selecting a day and changing month', async () => {
const service = createService();
render(
- ,
+ {}}
+ service={service}
+ timezone="Asia/Shanghai"
+ username="Sarah"
+ />,
);
await waitFor(() => expect(screen.getByText('地点提醒')).toBeTruthy());
@@ -70,7 +141,13 @@ describe('ScheduleCalendarScreen location schedules', () => {
it('opens a read-only location detail with the configured fields', async () => {
const service = createService();
render(
- ,
+ {}}
+ service={service}
+ timezone="Asia/Shanghai"
+ username="Sarah"
+ />,
);
await waitFor(() => expect(screen.getByLabelText('公司 到公司提醒我打卡')).toBeTruthy());
diff --git a/frontend/tests/unit/screens/HomeScreen.test.tsx b/frontend/tests/unit/screens/HomeScreen.test.tsx
new file mode 100644
index 0000000..7883e75
--- /dev/null
+++ b/frontend/tests/unit/screens/HomeScreen.test.tsx
@@ -0,0 +1,90 @@
+import { act, render, waitFor } from '@testing-library/react-native';
+import { describe, expect, it, jest } from '@jest/globals';
+
+import type { AssistantApplicationPort } from '../../../src/features/assistant/application/AssistantApplication';
+import type {
+ AppliedCommand,
+ ConversationTurnState,
+} from '../../../src/features/assistant/domain/ConversationTurn';
+import type { ScheduleCalendarReadService } from '../../../src/features/schedule/application';
+import { HomeScreen } from '../../../src/screens/HomeScreen';
+
+jest.mock('../../../src/features/assistant/presentation/AssistantVoiceOverlay', () => ({
+ AssistantVoiceOverlay: () => null,
+}));
+
+class FakeAssistantApplication implements AssistantApplicationPort {
+ private command: AppliedCommand | null = null;
+ private readonly listeners = new Set<(state: ConversationTurnState) => void>();
+
+ apply(command: AppliedCommand) {
+ this.command = command;
+ for (const listener of this.listeners) listener({ phase: 'idle' });
+ }
+
+ dismissReply = async () => {};
+ dispose = () => {};
+ endTurn = async () => {};
+ getLastAppliedCommand = () => this.command;
+ getReplyText = () => null;
+ getSoundLevel = () => null;
+ getState = (): ConversationTurnState => ({ phase: 'idle' });
+ startTurn = async () => {};
+ subscribe = (listener: (state: ConversationTurnState) => void) => {
+ this.listeners.add(listener);
+ return () => this.listeners.delete(listener);
+ };
+}
+
+describe('HomeScreen calendar refresh', () => {
+ it('reloads account-scoped calendar data after a voice command is applied', async () => {
+ const pushToTalkApplication = new FakeAssistantApplication();
+ const continuousApplication = new FakeAssistantApplication();
+ const scheduleService: ScheduleCalendarReadService = {
+ getLocationSchedules: jest
+ .fn()
+ .mockResolvedValue([]),
+ getSchedulesByDay: jest
+ .fn()
+ .mockResolvedValue([]),
+ getSchedulesByRange: jest
+ .fn()
+ .mockResolvedValue([]),
+ };
+ render(
+ {}}
+ pushToTalkApplication={pushToTalkApplication}
+ scheduleService={scheduleService}
+ timezone="Asia/Shanghai"
+ username="Sarah"
+ />,
+ );
+
+ await waitFor(() => expect(scheduleService.getSchedulesByRange).toHaveBeenCalledTimes(1));
+ await waitFor(() => expect(scheduleService.getLocationSchedules).toHaveBeenCalledTimes(1));
+
+ act(() => {
+ pushToTalkApplication.apply({ operation: 'update_schedule', status: 'applied' });
+ });
+
+ await waitFor(() => expect(scheduleService.getSchedulesByRange).toHaveBeenCalledTimes(2));
+ await waitFor(() => expect(scheduleService.getLocationSchedules).toHaveBeenCalledTimes(2));
+
+ act(() => {
+ continuousApplication.apply({ operation: 'create_schedule', status: 'applied' });
+ });
+
+ await waitFor(() => expect(scheduleService.getSchedulesByRange).toHaveBeenCalledTimes(3));
+ await waitFor(() => expect(scheduleService.getLocationSchedules).toHaveBeenCalledTimes(3));
+ expect(scheduleService.getSchedulesByRange).toHaveBeenLastCalledWith(
+ expect.objectContaining({ accountId: 'account-a' }),
+ );
+ expect(scheduleService.getLocationSchedules).toHaveBeenLastCalledWith({
+ accountId: 'account-a',
+ });
+ });
+});