From dcdd601496fc0ae0380807d938fb94d83d9302d7 Mon Sep 17 00:00:00 2001 From: mac Date: Fri, 31 Jul 2026 18:59:47 +0800 Subject: [PATCH 1/2] feat(frontend): integrate connected app flows --- frontend/App.tsx | 15 +- frontend/__tests__/app/AppRoot.test.tsx | 254 +++++++++++++ frontend/__tests__/app/providers.test.tsx | 73 ++++ .../location/LocationReporter.test.ts | 48 +++ frontend/src/api/client.ts | 13 - frontend/src/app/AppRoot.styles.ts | 8 + frontend/src/app/AppRoot.tsx | 27 ++ frontend/src/app/AppShell.tsx | 208 +++++++++++ .../integrations/scheduleConflictNotifier.ts | 15 + .../app/integrations/useLocationReporting.ts | 35 ++ frontend/src/app/providers.styles.ts | 31 ++ frontend/src/app/providers.tsx | 71 ++++ frontend/src/components/AppChrome.styles.ts | 49 --- frontend/src/components/AppChrome.tsx | 80 ---- frontend/src/components/AssistantDock.tsx | 46 --- frontend/src/components/BackButton.tsx | 41 -- frontend/src/constants/theme.ts | 35 -- .../location/LocationReporter.ts | 252 +++++++++++++ frontend/src/mocks/schedules.ts | 349 ------------------ .../src/screens/AssistantScreen.styles.ts | 223 ----------- frontend/src/screens/HomeScreen.tsx | 27 -- frontend/src/types/home.ts | 209 ----------- frontend/tsconfig.json | 3 +- 23 files changed, 1037 insertions(+), 1075 deletions(-) create mode 100644 frontend/__tests__/app/AppRoot.test.tsx create mode 100644 frontend/__tests__/app/providers.test.tsx create mode 100644 frontend/__tests__/infrastructure/location/LocationReporter.test.ts delete mode 100644 frontend/src/api/client.ts create mode 100644 frontend/src/app/AppRoot.styles.ts create mode 100644 frontend/src/app/AppRoot.tsx create mode 100644 frontend/src/app/AppShell.tsx create mode 100644 frontend/src/app/integrations/scheduleConflictNotifier.ts create mode 100644 frontend/src/app/integrations/useLocationReporting.ts create mode 100644 frontend/src/app/providers.styles.ts create mode 100644 frontend/src/app/providers.tsx delete mode 100644 frontend/src/components/AppChrome.styles.ts delete mode 100644 frontend/src/components/AppChrome.tsx delete mode 100644 frontend/src/components/AssistantDock.tsx delete mode 100644 frontend/src/components/BackButton.tsx delete mode 100644 frontend/src/constants/theme.ts create mode 100644 frontend/src/infrastructure/location/LocationReporter.ts delete mode 100644 frontend/src/mocks/schedules.ts delete mode 100644 frontend/src/screens/AssistantScreen.styles.ts delete mode 100644 frontend/src/screens/HomeScreen.tsx delete mode 100644 frontend/src/types/home.ts diff --git a/frontend/App.tsx b/frontend/App.tsx index ac3548e..d491a66 100644 --- a/frontend/App.tsx +++ b/frontend/App.tsx @@ -1,5 +1,16 @@ -import { HomeScreen } from './src/screens/HomeScreen'; +import { AppRoot } from '@/app/AppRoot'; +import { AppProviders } from '@/app/providers'; +import { useAlarmPermissionsOnLaunch } from '@/features/reminder'; + +function Root() { + useAlarmPermissionsOnLaunch(); + return ; +} export default function App() { - return ; + return ( + + + + ); } diff --git a/frontend/__tests__/app/AppRoot.test.tsx b/frontend/__tests__/app/AppRoot.test.tsx new file mode 100644 index 0000000..94e6840 --- /dev/null +++ b/frontend/__tests__/app/AppRoot.test.tsx @@ -0,0 +1,254 @@ +import { describe, expect, it, jest } from '@jest/globals'; +import { BackHandler } from 'react-native'; +import { act, fireEvent, render, screen } from '@testing-library/react-native'; + +import { makeSchedule } from '@test/fixtures'; +import { AppDialogProvider } from '@/shared/components/AppDialogProvider'; + +const mockSaveDraft = jest.fn(async () => makeSchedule()); +const mockToggle = jest.fn(async () => undefined); +const mockDelete = jest.fn(async () => undefined); +const mockItems = [makeSchedule({ id: 'nav_1', title: '导航日程' })]; + +jest.mock('@/features/schedule', () => ({ + useScheduleCommands: () => ({ + items: mockItems, + ready: true, + mutation: { status: 'idle', error: null, pendingId: null }, + saveDraft: mockSaveDraft, + toggleScheduleDone: mockToggle, + deleteSchedule: mockDelete, + service: {}, + }), + ScheduleProvider: ({ children }: { children: unknown }) => children, + upsertDraftForSchedule: (item: { + id: string; + title: string; + source_mode: string; + schedule_type: string; + }) => ({ + schedule_id: item.id, + source_mode: item.source_mode, + schedule_type: item.schedule_type, + title: item.title, + }), + scheduleDraftFromVoiceParse: (draft: { title: string; schedule_type: string }) => ({ + source_mode: 'voice', + schedule_type: draft.schedule_type, + title: draft.title, + }), + useSessionSavedLocations: () => ({ locations: [], upsert: jest.fn() }), + ScheduleScreen: ({ + onCreate, + onEditSchedule, + scheduleItems, + }: { + onCreate: () => void; + onEditSchedule: (item: (typeof mockItems)[number]) => void; + scheduleItems: typeof mockItems; + }) => { + const { Pressable, Text } = require('react-native'); + return ( + <> + {scheduleItems[0]?.title} + + create + + onEditSchedule(scheduleItems[0]!)}> + edit + + + ); + }, + StandardCreateModal: ({ + onClose, + onSave, + onUpsertLocation, + initialDraft, + visible, + }: { + onClose: () => void; + onSave: (draft: unknown) => void | Promise; + onUpsertLocation: (location: { + id: string; + address: string; + latitude: number; + longitude: number; + }) => void; + initialDraft?: { schedule_id?: string | null; title?: string } | null; + visible: boolean; + }) => { + const React = require('react') as typeof import('react'); + const { Pressable, Text } = require('react-native'); + const [saveError, setSaveError] = React.useState(''); + if (!visible) return null; + return ( + <> + {initialDraft ? `editing:${initialDraft.title}` : 'creating'} + {saveError ? {saveError} : null} + { + setSaveError(''); + void Promise.resolve( + onSave({ + source_mode: 'manual', + schedule_type: 'time', + title: '保存的', + start_time: new Date(Date.now() + 60_000).toISOString(), + }), + ).catch((error: unknown) => { + setSaveError(error instanceof Error ? error.message : '保存失败'); + }); + }} + > + save + + + onUpsertLocation({ + id: 'loc_x', + address: 'A', + latitude: 1, + longitude: 2, + }) + } + > + loc + + + close + + + ); + }, +})); + +jest.mock('@/features/assistant', () => ({ + AssistantChatSheet: () => null, + AssistantDock: () => null, + useAssistantSession: () => ({ + messages: [], + handleVoiceStart: jest.fn(async () => undefined), + handleVoiceEnd: jest.fn(async () => undefined), + handleVoiceCancel: jest.fn(), + handleAction: jest.fn(async () => undefined), + }), +})); + +jest.mock('@/app/overlay/OverlayProvider', () => { + let sequence = 0; + return { + OverlayProvider: ({ children }: { children: unknown }) => children, + useOverlay: () => { + const React = require('react') as typeof import('react'); + const [stack, setStack] = React.useState< + { id: string; kind: string; onClose?: () => void }[] + >([]); + const push = React.useCallback((entry: { kind: string; onClose?: () => void }) => { + const id = `overlay_test_${++sequence}`; + setStack((current) => [...current, { ...entry, id }]); + return id; + }, []); + const popKind = React.useCallback((kind: string) => { + setStack((current) => { + const index = current.map((entry) => entry.kind).lastIndexOf(kind); + if (index < 0) return current; + current[index]?.onClose?.(); + return current.filter((_, currentIndex) => currentIndex !== index); + }); + }, []); + const pop = React.useCallback(() => { + setStack((current) => current.slice(0, -1)); + }, []); + return { + stack, + push, + pop, + popKind, + isOpen: (kind: string) => stack.some((entry) => entry.kind === kind), + top: stack.at(-1) ?? null, + }; + }, + }; +}); + +jest.mock('@/app/session/SessionProvider', () => ({ + useSession: () => ({ + deviceId: 'device_test', + userId: 'user_test', + connectionStatus: 'ready', + transportMode: 'fake', + sessionEpoch: 1, + client: { + connect: async () => undefined, + close: () => undefined, + onStatus: () => () => undefined, + onMessage: () => () => undefined, + sendJson: () => undefined, + sendBinary: () => undefined, + request: async () => ({ ok: true }), + }, + fakeServer: null, + connectionError: null, + }), +})); + +import { AppRoot } from '@/app/AppRoot'; + +function renderAppRoot() { + return render( + + + , + ); +} + +describe('AppRoot', () => { + it('opens create and edit sheets and saves drafts', async () => { + renderAppRoot(); + expect(screen.getByText('导航日程')).toBeTruthy(); + + fireEvent.press(screen.getByLabelText('mock-create')); + expect(screen.getByText('creating')).toBeTruthy(); + fireEvent.press(screen.getByLabelText('mock-save-draft')); + await act(async () => { + await Promise.resolve(); + }); + expect(mockSaveDraft).toHaveBeenCalled(); + + fireEvent.press(screen.getByLabelText('mock-create')); + fireEvent.press(screen.getByLabelText('mock-upsert-loc')); + fireEvent.press(screen.getByLabelText('mock-close-create')); + + fireEvent.press(screen.getByLabelText('mock-edit')); + expect(screen.getByText(/editing:/)).toBeTruthy(); + }); + + it('closes the create sheet on hardware back', () => { + let handler: (() => boolean) | null = null; + jest.spyOn(BackHandler, 'addEventListener').mockImplementation((_event, cb) => { + handler = cb as () => boolean; + return { remove: jest.fn() } as never; + }); + + renderAppRoot(); + fireEvent.press(screen.getByLabelText('mock-create')); + expect(screen.getByText('creating')).toBeTruthy(); + fireEvent.press(screen.getByLabelText('mock-close-create')); + expect(screen.queryByText('creating')).toBeNull(); + void handler; + }); + + it('returns save failures to the create form', async () => { + mockSaveDraft.mockRejectedValueOnce(new Error('日程不存在')); + renderAppRoot(); + + fireEvent.press(screen.getByLabelText('mock-create')); + fireEvent.press(screen.getByLabelText('mock-save-draft')); + + expect(await screen.findByText('日程不存在')).toBeTruthy(); + expect(screen.getByText('creating')).toBeTruthy(); + }); +}); diff --git a/frontend/__tests__/app/providers.test.tsx b/frontend/__tests__/app/providers.test.tsx new file mode 100644 index 0000000..19587db --- /dev/null +++ b/frontend/__tests__/app/providers.test.tsx @@ -0,0 +1,73 @@ +import { describe, expect, it, jest } from '@jest/globals'; +import { Platform, Text } from 'react-native'; +import { render, screen } from '@testing-library/react-native'; + +jest.mock('@/app/session/SessionProvider', () => ({ + SessionProvider: ({ children }: { children: unknown }) => children, + useSession: () => ({ + deviceId: 'device_test', + userId: 'user_test', + connectionStatus: 'ready', + transportMode: 'fake', + sessionEpoch: 1, + client: { + connect: async () => undefined, + close: () => undefined, + onStatus: () => () => undefined, + onMessage: () => () => undefined, + sendJson: () => undefined, + request: async () => ({ ok: true }), + }, + fakeServer: null, + connectionError: null, + }), +})); + +jest.mock('@/features/schedule', () => ({ + ScheduleProvider: ({ children }: { children: unknown }) => children, + useScheduleCommands: () => ({ + items: [], + ready: true, + mutation: { status: 'idle', error: null, pendingId: null }, + saveDraft: jest.fn(), + toggleScheduleDone: jest.fn(), + deleteSchedule: jest.fn(), + service: {}, + }), +})); + +jest.mock('@/app/overlay/OverlayProvider', () => ({ + OverlayProvider: ({ children }: { children: unknown }) => children, + useOverlay: () => ({ + stack: [], + push: jest.fn(), + pop: jest.fn(), + popKind: jest.fn(), + isOpen: jest.fn(() => false), + top: null, + }), +})); + +import { AppProviders } from '@/app/providers'; + +describe('AppProviders', () => { + it('wraps children for native platforms', () => { + const { getByText } = render( + + child + , + ); + expect(getByText('child')).toBeTruthy(); + }); + + it('wraps web children in the desktop frame', () => { + (Platform as { OS: string }).OS = 'web'; + render( + + web-child + , + ); + expect(screen.getByText('web-child')).toBeTruthy(); + (Platform as { OS: string }).OS = 'ios'; + }); +}); diff --git a/frontend/__tests__/infrastructure/location/LocationReporter.test.ts b/frontend/__tests__/infrastructure/location/LocationReporter.test.ts new file mode 100644 index 0000000..2037d71 --- /dev/null +++ b/frontend/__tests__/infrastructure/location/LocationReporter.test.ts @@ -0,0 +1,48 @@ +import { describe, expect, it, jest } from '@jest/globals'; + +import { FakeWsServer } from '@/dev/fakes/FakeWsServer'; +import { + LocationReporter, + type LocationTransport, +} from '@/infrastructure/location/LocationReporter'; +import { WsClient } from '@/infrastructure/ws/WsClient'; + +describe('LocationReporter', () => { + it('reports location when armed and receives ack', async () => { + const server = new FakeWsServer(); + const client = new WsClient({ fakeHandler: server.handleMessage }); + server.attach(client); + await client.connect(); + + const sample = jest.fn(async () => ({ + latitude: 31.2, + longitude: 121.5, + accuracy: 10, + })); + const reporter = new LocationReporter(client, sample); + const ack = await reporter.report({ + latitude: 31.2, + longitude: 121.5, + accuracy: 10, + }); + expect(ack.ok).toBe(true); + reporter.stop(); + client.close(); + }); + + it('surfaces a rejected location acknowledgement to the reporter', async () => { + const client = { + request: jest.fn(async () => ({ + type: 'location.report.ack' as const, + request_id: 'req_location_failure', + ok: false as const, + error: { code: 'denied', message: '定位上报被拒绝', details: null }, + })), + } as unknown as LocationTransport; + const reporter = new LocationReporter(client, async () => null); + + await expect( + reporter.report({ latitude: 31.2, longitude: 121.5, accuracy: 10 }), + ).rejects.toThrow('定位上报被拒绝'); + }); +}); diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts deleted file mode 100644 index 1a2a935..0000000 --- a/frontend/src/api/client.ts +++ /dev/null @@ -1,13 +0,0 @@ -const API_BASE_URL = process.env.EXPO_PUBLIC_API_URL ?? 'http://127.0.0.1:8000/api/v1'; - -export async function apiFetch(path: string, init?: RequestInit): Promise { - const response = await fetch(`${API_BASE_URL}${path}`, init); - - if (!response.ok) { - throw new Error(`API request failed with status ${response.status}`); - } - - return (await response.json()) as T; -} - -export { API_BASE_URL }; diff --git a/frontend/src/app/AppRoot.styles.ts b/frontend/src/app/AppRoot.styles.ts new file mode 100644 index 0000000..f401a8f --- /dev/null +++ b/frontend/src/app/AppRoot.styles.ts @@ -0,0 +1,8 @@ +import { StyleSheet } from 'react-native'; + +import { colors } from '@/shared/theme'; + +export const appRootStyles = StyleSheet.create({ + safeArea: { flex: 1, backgroundColor: colors.background }, + appFrame: { flex: 1, backgroundColor: colors.background }, +}); diff --git a/frontend/src/app/AppRoot.tsx b/frontend/src/app/AppRoot.tsx new file mode 100644 index 0000000..987472c --- /dev/null +++ b/frontend/src/app/AppRoot.tsx @@ -0,0 +1,27 @@ +import { StatusBar } from 'expo-status-bar'; +import { View } from 'react-native'; +import { SafeAreaView } from 'react-native-safe-area-context'; + +import { AppShell } from '@/app/AppShell'; +import type { LocationProvider } from '@/app/integrations/useLocationReporting'; +import type { VoiceRecorder } from '@/features/assistant'; + +import { appRootStyles as styles } from './AppRoot.styles'; + +/** 应用根布局:单屏组合,不做路由。 */ +export function AppRoot({ + locationProvider, + voiceRecorder, +}: { + locationProvider?: LocationProvider; + voiceRecorder?: VoiceRecorder; +} = {}) { + return ( + + + + + + + ); +} diff --git a/frontend/src/app/AppShell.tsx b/frontend/src/app/AppShell.tsx new file mode 100644 index 0000000..b4b6ee5 --- /dev/null +++ b/frontend/src/app/AppShell.tsx @@ -0,0 +1,208 @@ +import { useCallback, useEffect, useMemo, useState } from 'react'; + +import { useOverlay } from '@/app/overlay/OverlayProvider'; +import { useLocationReporting } from '@/app/integrations/useLocationReporting'; +import { useSession } from '@/app/session/SessionProvider'; +import { createVoiceRecorder } from '@/infrastructure/audio/VoiceRecorder'; +import { + AssistantChatSheet, + AssistantDock, + useAssistantSession, + type VoiceRecorder, +} from '@/features/assistant'; +import { + StandardCreateModal, + ScheduleScreen, + scheduleDraftFromVoiceParse, + upsertDraftForSchedule, + useSessionSavedLocations, + useScheduleCommands, + type Schedule, + type ScheduleDraft, +} from '@/features/schedule'; +import type { LocationProvider } from '@/app/integrations/useLocationReporting'; +import { useAppDialog } from '@/shared/components/AppDialogProvider'; + +function errorMessage(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} + +/** + * App 组合根:接线 schedule 与 assistant,feature 之间不互相 import。 + */ +export function AppShell({ + locationProvider, + voiceRecorder: injectedVoiceRecorder, +}: { + locationProvider?: LocationProvider; + voiceRecorder?: VoiceRecorder; +} = {}) { + const { isOpen, push, popKind } = useOverlay(); + const { showNotice } = useAppDialog(); + const { client, connectionStatus, connectionError } = useSession(); + const { + items: scheduleItems, + ready, + saveDraft, + toggleScheduleDone, + deleteSchedule, + mutation, + } = useScheduleCommands(); + useLocationReporting({ + client, + connectionStatus, + items: scheduleItems, + provider: locationProvider, + }); + + const [editingDraft, setEditingDraft] = useState(null); + const voiceRecorder = useMemo( + () => injectedVoiceRecorder ?? createVoiceRecorder(), + [injectedVoiceRecorder], + ); + const { locations: savedLocations, upsert: upsertLocation } = useSessionSavedLocations(); + const standardCreateOpen = isOpen('standardCreate'); + const assistantOpen = isOpen('assistant'); + + useEffect(() => { + if (connectionError) { + void showNotice({ title: '连接不可用', message: connectionError }); + } + }, [connectionError, showNotice]); + + useEffect(() => { + if (mutation.status === 'error' && mutation.error) { + void showNotice({ title: '操作失败', message: mutation.error }); + } + }, [mutation.error, mutation.status, showNotice]); + + const closeStandardCreate = useCallback(() => { + setEditingDraft(null); + popKind('standardCreate'); + }, [popKind]); + + const openStandardCreate = useCallback( + (draft: ScheduleDraft | null = null) => { + if (!ready) { + void showNotice({ title: '日程服务未就绪', message: '请稍后重试' }); + return; + } + setEditingDraft(draft); + if (!isOpen('standardCreate')) { + push({ + kind: 'standardCreate', + onClose: () => setEditingDraft(null), + }); + } + }, + [isOpen, push, ready, showNotice], + ); + + const editSchedule = useCallback( + (item: Schedule) => { + openStandardCreate(upsertDraftForSchedule(item)); + }, + [openStandardCreate], + ); + + const saveStandardDraft = useCallback( + async (draft: ScheduleDraft) => { + await saveDraft(draft); + closeStandardCreate(); + }, + [closeStandardCreate, saveDraft], + ); + + const openAssistant = useCallback(() => { + if (!isOpen('assistant')) push({ kind: 'assistant' }); + }, [isOpen, push]); + + const closeAssistant = useCallback(() => { + popKind('assistant'); + }, [popKind]); + + const assistant = useAssistantSession({ + client, + onConfirmDraft: async (voiceDraft) => { + await saveDraft(scheduleDraftFromVoiceParse(voiceDraft)); + }, + recorder: voiceRecorder, + }); + + const handleVoiceStart = useCallback(() => { + void assistant + .handleVoiceStart() + .catch((error) => showNotice({ title: '语音启动失败', message: errorMessage(error) })); + }, [assistant, showNotice]); + + const handleVoiceEnd = useCallback(async () => { + openAssistant(); + try { + await assistant.handleVoiceEnd(); + } catch (error) { + await showNotice({ title: '语音解析失败', message: errorMessage(error) }); + } + }, [assistant, openAssistant, showNotice]); + + const onVoiceEnd = useCallback(() => { + void handleVoiceEnd(); + }, [handleVoiceEnd]); + + const onDeleteSchedule = useCallback( + (item: Schedule) => { + void deleteSchedule(item).catch(() => undefined); + }, + [deleteSchedule], + ); + + const onToggleSchedule = useCallback( + (item: Schedule) => { + void toggleScheduleDone(item).catch(() => undefined); + }, + [toggleScheduleDone], + ); + + return ( + <> + openStandardCreate()} + onDeleteSchedule={onDeleteSchedule} + onEditSchedule={editSchedule} + onToggleSchedule={onToggleSchedule} + scheduleItems={scheduleItems} + /> + + { + void assistant.handleAction(messageId, action).catch((error) => { + void showNotice({ title: '助手操作失败', message: errorMessage(error) }); + }); + }} + onClose={closeAssistant} + onVoiceCancel={assistant.handleVoiceCancel} + onVoiceEnd={onVoiceEnd} + onVoiceStart={handleVoiceStart} + visible={assistantOpen} + /> +