diff --git a/frontend/.env.example b/frontend/.env.example index e646a25..29e7b6f 100644 --- a/frontend/.env.example +++ b/frontend/.env.example @@ -4,9 +4,14 @@ EXPO_PUBLIC_API_URL=http://10.0.2.2:8000/api/v1 # Baidu Maps browser-side AK with JavaScript API v4 enabled EXPO_PUBLIC_BAIDU_MAP_AK=your-baidu-map-browser-ak -# Real backend WebSocket URL. Leave empty to use the in-process fake transport in development. -# Example: ws://10.0.2.2:8000/ws/v1 +# Real backend WebSocket URL (required for release / production builds). +# The client appends its persisted device_id query parameter automatically. +# Local device example: ws://192.168.1.10:8000/ws +# Emulator example: ws://10.0.2.2:8000/ws/v1 +# Production example: wss://api.example.com/ws +# Release builds require wss://. Plain ws:// is accepted only in development. +# Leave empty to use the in-process fake transport in development. EXPO_PUBLIC_WS_URL= # Force the fake WebSocket even if EXPO_PUBLIC_WS_URL is set (dev only). -# EXPO_PUBLIC_USE_FAKE_WS=true \ No newline at end of file +# EXPO_PUBLIC_USE_FAKE_WS=true diff --git a/frontend/__tests__/app/AppRoot.test.tsx b/frontend/__tests__/app/AppRoot.test.tsx index cac6d00..94e6840 100644 --- a/frontend/__tests__/app/AppRoot.test.tsx +++ b/frontend/__tests__/app/AppRoot.test.tsx @@ -1,16 +1,254 @@ import { describe, expect, it, jest } from '@jest/globals'; -import { render, screen } from '@testing-library/react-native'; +import { BackHandler } from 'react-native'; +import { act, fireEvent, render, screen } from '@testing-library/react-native'; -jest.mock('@/app/AppShell', () => { - const { Text } = require('react-native'); - return { AppShell: () => connected-app-shell }; +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('mounts the connected application shell', () => { - render(); - expect(screen.getByText('connected-app-shell')).toBeTruthy(); + 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__/app/session/sessionEndpoint.test.ts b/frontend/__tests__/app/session/sessionEndpoint.test.ts index 457c936..eeeab4c 100644 --- a/frontend/__tests__/app/session/sessionEndpoint.test.ts +++ b/frontend/__tests__/app/session/sessionEndpoint.test.ts @@ -4,9 +4,9 @@ import { buildSessionWebSocketUrl, resolveSessionUserId } from '@/app/session/se describe('session endpoint compatibility', () => { it('adds the persisted device id to the backend WebSocket URL', () => { - expect(buildSessionWebSocketUrl('ws://127.0.0.1:8000/ws', 'device 1')).toBe( - 'ws://127.0.0.1:8000/ws?device_id=device+1', - ); + expect( + buildSessionWebSocketUrl('ws://127.0.0.1:8000/ws', 'device 1', { allowInsecure: true }), + ).toBe('ws://127.0.0.1:8000/ws?device_id=device+1'); }); it('replaces a stale device id while preserving other query parameters', () => { @@ -21,6 +21,14 @@ describe('session endpoint compatibility', () => { ); }); + it('rejects plaintext WebSocket endpoints in release mode', () => { + expect(() => + buildSessionWebSocketUrl('ws://api.example.com/ws', 'device_1', { + allowInsecure: false, + }), + ).toThrow('发布构建的 EXPO_PUBLIC_WS_URL 必须使用 wss://'); + }); + it('uses the MVP backend user when session.ready omits user_id', () => { expect(resolveSessionUserId(undefined)).toBe('default_user'); expect(resolveSessionUserId(' user_1 ')).toBe('user_1'); 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/__tests__/infrastructure/ws/WsClient.test.ts b/frontend/__tests__/infrastructure/ws/WsClient.test.ts index 0de8916..b8af096 100644 --- a/frontend/__tests__/infrastructure/ws/WsClient.test.ts +++ b/frontend/__tests__/infrastructure/ws/WsClient.test.ts @@ -97,7 +97,7 @@ describe('WsClient + FakeWsServer', () => { client.close(); }); - it('uses the production location report and uncorrelated ack shapes', async () => { + it('uses the production location report and correlated ack shapes', async () => { const server = new FakeWsServer({ userId: 'user_test' }); const client = new WsClient({ fakeHandler: server.handleMessage }); server.attach(client); @@ -112,14 +112,22 @@ describe('WsClient + FakeWsServer', () => { }); client.sendJson({ type: 'location.report', - schedule_scope: 'current', - latitude: 31.236305, - longitude: 121.480237, - accuracy: 12, - timestamp: '2026-07-31T10:00:00Z', + request_id: 'req_location_1', + payload: { + schedule_scope: 'current', + latitude: 31.236305, + longitude: 121.480237, + accuracy: 12, + timestamp: '2026-07-31T10:00:00Z', + }, }); - await expect(ack).resolves.toEqual({ type: 'location.report.ack', ok: true }); + await expect(ack).resolves.toEqual({ + type: 'location.report.ack', + request_id: 'req_location_1', + ok: true, + payload: null, + }); client.close(); }); diff --git a/frontend/app.json b/frontend/app.json index 2a0a481..a8e622e 100644 --- a/frontend/app.json +++ b/frontend/app.json @@ -9,9 +9,7 @@ "ios": { "infoPlist": { "NSLocationWhenInUseUsageDescription": "允许 Timeflow 获取当前位置,以便在地图选点和地点提醒时使用。", - "NSLocationAlwaysAndWhenInUseUsageDescription": "允许 Timeflow 在后台获取当前位置,以便触发地点提醒。", - "NSMicrophoneUsageDescription": "允许 Timeflow 录制语音,以便将语音整理成日程。", - "UIBackgroundModes": ["location"] + "NSMicrophoneUsageDescription": "允许 Timeflow 录制语音,以便将语音整理成日程。" }, "supportsTablet": true }, @@ -20,7 +18,6 @@ "permissions": [ "ACCESS_COARSE_LOCATION", "ACCESS_FINE_LOCATION", - "ACCESS_BACKGROUND_LOCATION", "RECORD_AUDIO", "SCHEDULE_EXACT_ALARM", "POST_NOTIFICATIONS", @@ -28,7 +25,6 @@ "SYSTEM_ALERT_WINDOW", "REQUEST_IGNORE_BATTERY_OPTIMIZATIONS", "FOREGROUND_SERVICE", - "FOREGROUND_SERVICE_LOCATION", "FOREGROUND_SERVICE_MEDIA_PLAYBACK", "VIBRATE" ], @@ -44,6 +40,12 @@ "plugins": [ "./plugins/withTimeflowAlarm", "./plugins/withTimeflowVoiceRecorder", + [ + "expo-location", + { + "locationWhenInUsePermission": "允许 Timeflow 在应用使用期间获取当前位置,以便触发地点提醒。" + } + ], [ "expo-image-picker", { diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 5bb031a..339aaaf 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -13,6 +13,7 @@ "expo": "~57.0.8", "expo-file-system": "~57.0.1", "expo-image-picker": "~57.0.6", + "expo-location": "~57.0.7", "expo-status-bar": "~57.0.1", "lucide-react-native": "^1.27.0", "react": "19.2.3", @@ -6454,6 +6455,18 @@ "expo": "*" } }, + "node_modules/expo-location": { + "version": "57.0.7", + "resolved": "https://registry.npmjs.org/expo-location/-/expo-location-57.0.7.tgz", + "integrity": "sha512-HPsS6Sse8GgMv9QiENXUEp9awl0O6iX0mFKJkxsHtC1HiouDXKCDlSnW66/E6U1ykwZ2M9ymJnZLRpWu0ua+/g==", + "license": "MIT", + "dependencies": { + "@expo/image-utils": "^0.11.4" + }, + "peerDependencies": { + "expo": "*" + } + }, "node_modules/expo-modules-autolinking": { "version": "57.0.9", "resolved": "https://registry.npmjs.org/expo-modules-autolinking/-/expo-modules-autolinking-57.0.9.tgz", diff --git a/frontend/package.json b/frontend/package.json index 31a939d..bb4965c 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -13,6 +13,7 @@ "expo": "~57.0.8", "expo-file-system": "~57.0.1", "expo-image-picker": "~57.0.6", + "expo-location": "~57.0.7", "expo-status-bar": "~57.0.1", "lucide-react-native": "^1.27.0", "react": "19.2.3", diff --git a/frontend/plugins/withTimeflowVoiceRecorder.js b/frontend/plugins/withTimeflowVoiceRecorder.js index c1b6c9f..100b0ef 100644 --- a/frontend/plugins/withTimeflowVoiceRecorder.js +++ b/frontend/plugins/withTimeflowVoiceRecorder.js @@ -3,7 +3,7 @@ const { AndroidConfig, createRunOncePlugin, withAndroidManifest } = require('exp const PACKAGE_NAME = 'timeflow-voice-recorder'; const RECORD_AUDIO = 'android.permission.RECORD_AUDIO'; -/** Keeps microphone and LAN ws:// support in generated release manifests. */ +/** Keeps the microphone permission in generated native manifests. */ function withTimeflowVoiceRecorder(config) { config = AndroidConfig.Permissions.withPermissions(config, [RECORD_AUDIO]); config = withAndroidManifest(config, (config) => { @@ -16,8 +16,6 @@ function withTimeflowVoiceRecorder(config) { } } - const application = AndroidConfig.Manifest.getMainApplicationOrThrow(manifest); - application.$['android:usesCleartextTraffic'] = 'true'; return config; }); return config; 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.tsx b/frontend/src/app/AppRoot.tsx index 48fd05e..987472c 100644 --- a/frontend/src/app/AppRoot.tsx +++ b/frontend/src/app/AppRoot.tsx @@ -2,18 +2,25 @@ 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'; -import { AppShell } from './AppShell'; -/** Application root layout for the connected single-screen experience. */ -export function AppRoot({ voiceRecorder }: { voiceRecorder?: VoiceRecorder } = {}) { +/** 应用根布局:单屏组合,不做路由。 */ +export function AppRoot({ + locationProvider, + voiceRecorder, +}: { + locationProvider?: LocationProvider; + voiceRecorder?: VoiceRecorder; +} = {}) { return ( - + ); diff --git a/frontend/src/app/AppShell.tsx b/frontend/src/app/AppShell.tsx index 75561ca..b4b6ee5 100644 --- a/frontend/src/app/AppShell.tsx +++ b/frontend/src/app/AppShell.tsx @@ -1,7 +1,9 @@ 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, @@ -9,31 +11,35 @@ import { type VoiceRecorder, } from '@/features/assistant'; import { - ScheduleScreen, StandardCreateModal, + ScheduleScreen, scheduleDraftFromVoiceParse, upsertDraftForSchedule, - useScheduleCommands, useSessionSavedLocations, + useScheduleCommands, type Schedule, type ScheduleDraft, } from '@/features/schedule'; -import { createVoiceRecorder } from '@/infrastructure/audio/VoiceRecorder'; +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); } -/** Connects schedule and assistant features without coupling the feature packages. */ +/** + * 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, connectionError } = useSession(); + const { client, connectionStatus, connectionError } = useSession(); const { items: scheduleItems, ready, @@ -42,6 +48,13 @@ export function AppShell({ deleteSchedule, mutation, } = useScheduleCommands(); + useLocationReporting({ + client, + connectionStatus, + items: scheduleItems, + provider: locationProvider, + }); + const [editingDraft, setEditingDraft] = useState(null); const voiceRecorder = useMemo( () => injectedVoiceRecorder ?? createVoiceRecorder(), @@ -76,14 +89,19 @@ export function AppShell({ } setEditingDraft(draft); if (!isOpen('standardCreate')) { - push({ kind: 'standardCreate', onClose: () => setEditingDraft(null) }); + push({ + kind: 'standardCreate', + onClose: () => setEditingDraft(null), + }); } }, [isOpen, push, ready, showNotice], ); const editSchedule = useCallback( - (item: Schedule) => openStandardCreate(upsertDraftForSchedule(item)), + (item: Schedule) => { + openStandardCreate(upsertDraftForSchedule(item)); + }, [openStandardCreate], ); @@ -99,7 +117,10 @@ export function AppShell({ if (!isOpen('assistant')) push({ kind: 'assistant' }); }, [isOpen, push]); - const closeAssistant = useCallback(() => popKind('assistant'), [popKind]); + const closeAssistant = useCallback(() => { + popKind('assistant'); + }, [popKind]); + const assistant = useAssistantSession({ client, onConfirmDraft: async (voiceDraft) => { @@ -123,18 +144,35 @@ export function AppShell({ } }, [assistant, openAssistant, showNotice]); - const onVoiceEnd = useCallback(() => void handleVoiceEnd(), [handleVoiceEnd]); + 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={(item) => void deleteSchedule(item).catch(() => undefined)} + onDeleteSchedule={onDeleteSchedule} onEditSchedule={editSchedule} - onToggleSchedule={(item) => void toggleScheduleDone(item).catch(() => undefined)} + onToggleSchedule={onToggleSchedule} scheduleItems={scheduleItems} /> + + void | Promise, +): ScheduleConflictNotifier { + return (conflicts) => { + if (conflicts.length === 0) return; + void showNotice({ + title: '当前时段已有日程', + message: conflicts.map((conflict) => conflict.title).join('、'), + }); + }; +} diff --git a/frontend/src/app/integrations/useLocationReporting.ts b/frontend/src/app/integrations/useLocationReporting.ts new file mode 100644 index 0000000..9170cbe --- /dev/null +++ b/frontend/src/app/integrations/useLocationReporting.ts @@ -0,0 +1,46 @@ +import { useEffect, useMemo } from 'react'; +import { AppState, type AppStateStatus } from 'react-native'; + +import type { ConnectionStatus } from '@/contracts'; +import type { Schedule } from '@/features/schedule'; +import { + createLocationProvider, + LocationReporter, + type LocationProvider, + type LocationTransport, +} from '@/infrastructure/location/LocationReporter'; + +export type { LocationProvider } from '@/infrastructure/location/LocationReporter'; + +export function useLocationReporting(options: { + client: LocationTransport | null; + connectionStatus: ConnectionStatus; + items: Schedule[]; + /** Tests and native hosts may provide a concrete foreground provider. */ + provider?: LocationProvider; +}) { + const { client, connectionStatus, items, provider } = options; + const reporter = useMemo( + () => (client ? new LocationReporter(client, provider ?? createLocationProvider()) : null), + [client, provider], + ); + + useEffect(() => { + if (!reporter) return; + + const syncForState = (state: AppStateStatus) => { + if (connectionStatus !== 'ready' || state !== 'active') { + reporter.stop(); + return; + } + reporter.syncArmedSchedules(items); + }; + + syncForState(AppState.currentState); + const subscription = AppState.addEventListener('change', syncForState); + return () => { + subscription.remove(); + reporter.stop(); + }; + }, [connectionStatus, items, reporter]); +} diff --git a/frontend/src/app/providers.tsx b/frontend/src/app/providers.tsx index 00220f3..79888fc 100644 --- a/frontend/src/app/providers.tsx +++ b/frontend/src/app/providers.tsx @@ -2,24 +2,28 @@ import { useMemo, type ReactNode } from 'react'; import { Platform, useWindowDimensions, View } from 'react-native'; import { SafeAreaProvider } from 'react-native-safe-area-context'; -import { createReminderAlarmAdapter } from '@/app/integrations/reminderAlarmAdapter'; import { OverlayProvider } from '@/app/overlay/OverlayProvider'; +import { createReminderAlarmAdapter } from '@/app/integrations/reminderAlarmAdapter'; +import { createScheduleConflictNotifier } from '@/app/integrations/scheduleConflictNotifier'; import { SessionProvider, useSession } from '@/app/session/SessionProvider'; -import { ScheduleProvider } from '@/features/schedule'; import type { DeviceIdStore } from '@/infrastructure/storage/deviceIdStore'; +import { ScheduleProvider } from '@/features/schedule'; import { AppDialogProvider, useAppDialog } from '@/shared/components/AppDialogProvider'; import { providerStyles as styles } from './providers.styles'; +/** 将 session 注入 schedule,避免 feature 反向依赖 app。 */ function ScheduleSessionBridge({ children }: { children: ReactNode }) { const { client, connectionStatus, userId, sessionEpoch } = useSession(); const { showNotice } = useAppDialog(); const alarmAdapter = useMemo(() => createReminderAlarmAdapter(showNotice), [showNotice]); + const notifyConflicts = useMemo(() => createScheduleConflictNotifier(showNotice), [showNotice]); return ( diff --git a/frontend/src/app/session/sessionEndpoint.ts b/frontend/src/app/session/sessionEndpoint.ts index 6b49fd8..23a7dee 100644 --- a/frontend/src/app/session/sessionEndpoint.ts +++ b/frontend/src/app/session/sessionEndpoint.ts @@ -1,10 +1,22 @@ const LEGACY_BACKEND_USER_ID = 'default_user'; -export function buildSessionWebSocketUrl(baseUrl: string, deviceId: string): string { +function isDevelopmentBuild(): boolean { + return typeof __DEV__ !== 'undefined' && __DEV__; +} + +export function buildSessionWebSocketUrl( + baseUrl: string, + deviceId: string, + options: { allowInsecure?: boolean } = {}, +): string { const url = new URL(baseUrl); if (url.protocol !== 'ws:' && url.protocol !== 'wss:') { throw new Error('EXPO_PUBLIC_WS_URL 必须使用 ws:// 或 wss://'); } + const allowInsecure = options.allowInsecure ?? isDevelopmentBuild(); + if (url.protocol === 'ws:' && !allowInsecure) { + throw new Error('发布构建的 EXPO_PUBLIC_WS_URL 必须使用 wss://'); + } url.searchParams.set('device_id', deviceId); return url.toString(); } diff --git a/frontend/src/components/AppChrome.styles.ts b/frontend/src/components/AppChrome.styles.ts deleted file mode 100644 index 5ef43ef..0000000 --- a/frontend/src/components/AppChrome.styles.ts +++ /dev/null @@ -1,49 +0,0 @@ -import { StyleSheet } from 'react-native'; - -import { colors, spacing } from '../constants/theme'; - -export const commonStyles = StyleSheet.create({ - safeArea: { flex: 1, backgroundColor: colors.background }, - appFrame: { flex: 1, backgroundColor: colors.background }, - screen: { flex: 1, paddingHorizontal: spacing.lg, paddingTop: spacing.md }, - header: { - alignItems: 'center', - flexDirection: 'row', - justifyContent: 'space-between', - marginBottom: spacing.md, - }, - eyebrow: { color: colors.sub, fontSize: 9, marginBottom: 5 }, - pageTitle: { color: colors.ink, fontSize: 25, fontWeight: '800', letterSpacing: 0 }, - headerActions: { alignItems: 'center', flexDirection: 'row', gap: 8 }, - addButton: { - alignItems: 'center', - backgroundColor: '#EAE7DF', - borderRadius: 13, - height: 37, - justifyContent: 'center', - width: 37, - }, - addButtonText: { color: colors.deep, fontSize: 20, lineHeight: 23 }, - avatar: { - alignItems: 'center', - backgroundColor: colors.deep, - borderColor: colors.lime, - borderRadius: 15, - borderWidth: 2, - height: 37, - justifyContent: 'center', - width: 37, - }, - avatarLight: { backgroundColor: colors.lime, borderColor: colors.lime, height: 53, width: 53 }, - viewSwitch: { backgroundColor: '#EBE8E1', borderRadius: 13, flexDirection: 'row', padding: 4 }, - switchOption: { - alignItems: 'center', - borderRadius: 10, - flex: 1, - height: 32, - justifyContent: 'center', - }, - switchOptionActive: { backgroundColor: colors.surface, elevation: 2 }, - switchText: { color: colors.sub, fontSize: 10 }, - switchTextActive: { color: colors.ink, fontWeight: '800' }, -}); diff --git a/frontend/src/components/AppChrome.tsx b/frontend/src/components/AppChrome.tsx deleted file mode 100644 index 160f4f7..0000000 --- a/frontend/src/components/AppChrome.tsx +++ /dev/null @@ -1,80 +0,0 @@ -import type { ReactNode } from 'react'; -import { UserRound } from 'lucide-react-native'; -import { Pressable, Text, View } from 'react-native'; - -import { colors } from '../constants/theme'; -import type { CalendarView } from '../types/home'; -import { commonStyles as styles } from './AppChrome.styles'; - -export function Avatar({ - onPress, - variant = 'dark', -}: { - onPress?: () => void; - variant?: 'dark' | 'light'; -}) { - const avatar = ( - - - - ); - - return onPress ? ( - - {avatar} - - ) : ( - avatar - ); -} - -export function ViewSwitch({ - value, - onChange, -}: { - value: CalendarView; - onChange: (view: CalendarView) => void; -}) { - return ( - - {(['day', 'week', 'month'] as CalendarView[]).map((item) => ( - onChange(item)} - style={[styles.switchOption, value === item && styles.switchOptionActive]} - > - - {item === 'day' ? '日' : item === 'week' ? '周' : '月'} - - - ))} - - ); -} - -export function Header({ - eyebrow, - title, - action, -}: { - eyebrow: string; - title: string; - action?: ReactNode; -}) { - return ( - - - {eyebrow} - {title} - - {action ?? } - - ); -} diff --git a/frontend/src/components/AssistantDock.tsx b/frontend/src/components/AssistantDock.tsx deleted file mode 100644 index 15328f3..0000000 --- a/frontend/src/components/AssistantDock.tsx +++ /dev/null @@ -1,46 +0,0 @@ -import { Pressable, View } from 'react-native'; -import Svg, { Path } from 'react-native-svg'; - -import { assistantStyles as styles } from '../screens/AssistantScreen.styles'; - -export function TempoAssistantIcon({ - color = '#D9F65A', - size = 22, -}: { - color?: string; - size?: number; -}) { - return ( - - - - - ); -} - -export function AssistantDock({ onPress }: { onPress: () => void }) { - return ( - - [ - styles.assistantDockButton, - pressed && styles.assistantDockButtonPressed, - ]} - > - - - - ); -} diff --git a/frontend/src/components/BackButton.tsx b/frontend/src/components/BackButton.tsx deleted file mode 100644 index e57c644..0000000 --- a/frontend/src/components/BackButton.tsx +++ /dev/null @@ -1,41 +0,0 @@ -import { ChevronLeft } from 'lucide-react-native'; -import { Pressable, StyleSheet } from 'react-native'; - -import { colors } from '../constants/theme'; - -type BackButtonProps = { - accessibilityLabel?: string; - onPress: () => void; -}; - -/** 统一页面层级返回入口的视觉和交互。 */ -export function BackButton({ accessibilityLabel = '返回', onPress }: BackButtonProps) { - return ( - [styles.button, pressed && styles.pressed]} - > - - - ); -} - -const styles = StyleSheet.create({ - button: { - alignItems: 'center', - backgroundColor: colors.surface, - borderColor: colors.line, - borderRadius: 12, - borderWidth: 1, - height: 40, - justifyContent: 'center', - width: 40, - }, - pressed: { - backgroundColor: colors.surfaceTint, - transform: [{ scale: 0.94 }], - }, -}); diff --git a/frontend/src/components/BaiduMapWebView.ts b/frontend/src/components/BaiduMapWebView.ts deleted file mode 100644 index 89cf0b4..0000000 --- a/frontend/src/components/BaiduMapWebView.ts +++ /dev/null @@ -1,190 +0,0 @@ -import type { MapLocation } from './MapPicker.types'; - -export type BaiduMapBridgeMessage = - | { type: 'map-ready' } - | { message: string; type: 'map-error' } - | { latitude: number; longitude: number; type: 'selecting' } - | { location: MapLocation; type: 'selected' } - | { message: string; type: 'location-error' } - | { results: MapLocation[]; type: 'search-results' } - | { type: 'search-error' }; - -function serializeForInlineScript(value: unknown) { - return JSON.stringify(value) - .replace(//g, '\\u003e') - .replace(/&/g, '\\u0026') - .replace(/\u2028/g, '\\u2028') - .replace(/\u2029/g, '\\u2029'); -} - -export function buildBaiduMapDocument(ak: string, initialLocation: MapLocation | null) { - const center = initialLocation ?? { - address: '上海市 · 默认地图中心', - latitude: 31.236305, - longitude: 121.480237, - }; - const initialJson = serializeForInlineScript(initialLocation); - const centerJson = serializeForInlineScript(center); - - return ` - - - - - - - - -
- - -`; -} diff --git a/frontend/src/components/MapPicker.native.tsx b/frontend/src/components/MapPicker.native.tsx deleted file mode 100644 index ed40812..0000000 --- a/frontend/src/components/MapPicker.native.tsx +++ /dev/null @@ -1,180 +0,0 @@ -import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; -import { View } from 'react-native'; -import { WebView, WebViewMessageEvent } from 'react-native-webview'; - -import { BaiduMapBridgeMessage, buildBaiduMapDocument } from './BaiduMapWebView'; -import { BAIDU_MAP_AK, createCoordinateLocation } from './MapPicker.services'; -import { mapPickerStyles as styles } from './MapPicker.styles'; -import { MapPickerOverlay } from './MapPickerOverlay'; -import type { MapLocation, MapPickerProps } from './MapPicker.types'; - -const SEARCH_TIMEOUT_MS = 8000; - -type PendingSearch = { - reject: (error: Error) => void; - resolve: (locations: MapLocation[]) => void; - timeout: ReturnType; -}; - -export function MapPicker({ initialLocation, onCancel, onConfirm }: MapPickerProps) { - const webViewRef = useRef(null); - const pendingSearchRef = useRef(null); - const [selection, setSelection] = useState(initialLocation); - const [locating, setLocating] = useState(false); - const [locationError, setLocationError] = useState(null); - const [mapReady, setMapReady] = useState(false); - const [mapError, setMapError] = useState( - BAIDU_MAP_AK ? null : '缺少百度地图浏览器端密钥,请完成地图服务配置。', - ); - const document = useMemo( - () => buildBaiduMapDocument(BAIDU_MAP_AK, initialLocation), - [initialLocation], - ); - - useEffect(() => { - return () => { - if (pendingSearchRef.current) { - clearTimeout(pendingSearchRef.current.timeout); - pendingSearchRef.current = null; - } - }; - }, []); - - const failPendingSearch = useCallback((message: string) => { - const pending = pendingSearchRef.current; - if (!pending) return; - clearTimeout(pending.timeout); - pending.reject(new Error(message)); - pendingSearchRef.current = null; - }, []); - - const handleMessage = useCallback( - ({ nativeEvent }: WebViewMessageEvent) => { - let message: BaiduMapBridgeMessage; - try { - message = JSON.parse(nativeEvent.data) as BaiduMapBridgeMessage; - } catch { - return; - } - - if (message.type === 'map-ready') { - setMapReady(true); - setMapError(null); - if (!initialLocation) { - webViewRef.current?.injectJavaScript('window.__timeflowLocate(); true;'); - } - return; - } - if (message.type === 'map-error') { - setMapReady(false); - setMapError(message.message); - failPendingSearch(message.message); - return; - } - if (message.type === 'selecting') { - setLocationError(null); - setSelection(createCoordinateLocation(message.latitude, message.longitude)); - setLocating(true); - return; - } - if (message.type === 'selected') { - setSelection(message.location); - setLocating(false); - setLocationError(null); - return; - } - if (message.type === 'location-error') { - setLocating(false); - setLocationError(message.message); - return; - } - if (message.type === 'search-results') { - const pending = pendingSearchRef.current; - if (!pending) return; - clearTimeout(pending.timeout); - pending.resolve(message.results); - pendingSearchRef.current = null; - return; - } - if (message.type === 'search-error') { - failPendingSearch('Baidu place search failed'); - } - }, - [failPendingSearch, initialLocation], - ); - - const searchLocations = useCallback( - (query: string) => { - return new Promise((resolve, reject) => { - if (!mapReady || !webViewRef.current) { - reject(new Error('Baidu map is not ready')); - return; - } - - failPendingSearch('A newer search replaced this request'); - const timeout = setTimeout(() => { - failPendingSearch('Baidu place search timed out'); - }, SEARCH_TIMEOUT_MS); - pendingSearchRef.current = { reject, resolve, timeout }; - webViewRef.current.injectJavaScript( - `window.__timeflowSearch(${JSON.stringify(query)}); true;`, - ); - }); - }, - [failPendingSearch, mapReady], - ); - - const selectSearchResult = (location: MapLocation) => { - setLocationError(null); - setLocating(false); - setSelection(location); - webViewRef.current?.injectJavaScript( - `window.__timeflowSelect(${location.longitude}, ${location.latitude}); true;`, - ); - }; - - const locateCurrentPosition = () => { - setLocationError(null); - setLocating(true); - webViewRef.current?.injectJavaScript('window.__timeflowLocate(); true;'); - }; - - return ( - - {BAIDU_MAP_AK ? ( - { - setMapReady(false); - setMapError('地图加载失败,请检查网络或百度地图密钥配置。'); - }} - onMessage={handleMessage} - originWhitelist={['https://*']} - ref={webViewRef} - scrollEnabled={false} - setSupportMultipleWindows={false} - source={{ baseUrl: 'https://timeflow.local/', html: document }} - style={styles.mapCanvas} - /> - ) : ( - - )} - selection && onConfirm(selection)} - onSearch={searchLocations} - onSelectSearchResult={selectSearchResult} - selection={selection} - /> - - ); -} diff --git a/frontend/src/components/MapPicker.services.ts b/frontend/src/components/MapPicker.services.ts deleted file mode 100644 index 633f016..0000000 --- a/frontend/src/components/MapPicker.services.ts +++ /dev/null @@ -1,26 +0,0 @@ -import type { MapLocation } from './MapPicker.types'; - -export const BAIDU_MAP_AK = process.env.EXPO_PUBLIC_BAIDU_MAP_AK?.trim() ?? ''; -export const BAIDU_COORDINATE_SYSTEM = 'bd09ll' as const; - -export const SHANGHAI_CENTER: MapLocation = { - address: '上海市 · 默认地图中心', - latitude: 31.236305, - longitude: 121.480237, -}; - -export function coordinateAddress(latitude: number, longitude: number) { - return `百度地图选点 · ${latitude.toFixed(5)}, ${longitude.toFixed(5)}`; -} - -export function createCoordinateLocation(latitude: number, longitude: number): MapLocation { - return { - address: coordinateAddress(latitude, longitude), - latitude, - longitude, - }; -} - -export function readablePoiAddress(title: string, address?: string) { - return address?.trim() ? `${title} · ${address.trim()}` : title; -} diff --git a/frontend/src/components/MapPicker.styles.ts b/frontend/src/components/MapPicker.styles.ts deleted file mode 100644 index 9949cf7..0000000 --- a/frontend/src/components/MapPicker.styles.ts +++ /dev/null @@ -1,150 +0,0 @@ -import { StyleSheet } from 'react-native'; - -import { colors } from '../constants/theme'; - -export const mapPickerStyles = StyleSheet.create({ - screen: { backgroundColor: '#D8E4DE', flex: 1 }, - mapCanvas: { flex: 1 }, - mapError: { - alignItems: 'center', - backgroundColor: 'rgba(247, 248, 245, 0.96)', - borderColor: colors.line, - borderRadius: 16, - borderWidth: 1, - left: 44, - paddingHorizontal: 20, - paddingVertical: 18, - position: 'absolute', - right: 44, - top: '38%', - zIndex: 900, - }, - mapErrorTitle: { color: colors.ink, fontSize: 14, fontWeight: '800', marginTop: 10 }, - mapErrorText: { - color: colors.sub, - fontSize: 11, - lineHeight: 17, - marginTop: 5, - textAlign: 'center', - }, - topArea: { - left: 14, - position: 'absolute', - right: 14, - top: 14, - zIndex: 1000, - }, - toolbar: { alignItems: 'center', flexDirection: 'row', gap: 8 }, - searchBox: { - alignItems: 'center', - backgroundColor: colors.surface, - borderColor: colors.line, - borderRadius: 14, - borderWidth: 1, - elevation: 4, - flex: 1, - flexDirection: 'row', - height: 48, - paddingHorizontal: 12, - }, - searchInput: { - borderWidth: 0, - color: colors.ink, - flex: 1, - fontSize: 13, - height: 46, - marginHorizontal: 8, - outlineColor: 'transparent', - outlineStyle: 'solid', - outlineWidth: 0, - paddingVertical: 0, - }, - searchButton: { - alignItems: 'center', - height: 34, - justifyContent: 'center', - width: 30, - }, - locateButton: { - alignItems: 'center', - backgroundColor: colors.surface, - borderColor: colors.line, - borderRadius: 14, - borderWidth: 1, - elevation: 4, - height: 48, - justifyContent: 'center', - width: 42, - }, - locateButtonActive: { backgroundColor: colors.limeSoft }, - searchResults: { - backgroundColor: colors.surface, - borderColor: colors.line, - borderRadius: 14, - borderWidth: 1, - marginLeft: 50, - marginTop: 7, - overflow: 'hidden', - }, - searchResult: { - alignItems: 'center', - borderBottomColor: colors.line, - borderBottomWidth: 1, - flexDirection: 'row', - minHeight: 48, - paddingHorizontal: 12, - paddingVertical: 9, - }, - searchResultLast: { borderBottomWidth: 0 }, - searchResultText: { color: colors.ink, flex: 1, fontSize: 12, lineHeight: 17, marginLeft: 9 }, - searchMessage: { color: colors.sub, fontSize: 11, padding: 14, textAlign: 'center' }, - locationError: { - alignSelf: 'center', - backgroundColor: 'rgba(247, 248, 245, 0.96)', - borderColor: colors.line, - borderRadius: 11, - borderWidth: 1, - marginTop: 7, - paddingHorizontal: 10, - paddingVertical: 7, - }, - locationErrorText: { color: colors.sub, fontSize: 11 }, - selectionCard: { - backgroundColor: colors.surface, - borderColor: colors.line, - borderRadius: 18, - borderWidth: 1, - bottom: 16, - elevation: 6, - left: 14, - padding: 14, - position: 'absolute', - right: 14, - zIndex: 1000, - }, - selectionHeading: { alignItems: 'center', flexDirection: 'row' }, - selectionIcon: { - alignItems: 'center', - backgroundColor: colors.limeSoft, - borderRadius: 12, - height: 38, - justifyContent: 'center', - marginRight: 10, - width: 38, - }, - selectionCopy: { flex: 1 }, - selectionKicker: { color: '#728456', fontSize: 10, fontWeight: '800' }, - selectionAddress: { color: colors.ink, fontSize: 13, lineHeight: 19, marginTop: 4 }, - selectionHint: { color: colors.sub, fontSize: 11, lineHeight: 17, marginTop: 4 }, - selectionMeta: { color: colors.muted, fontSize: 10, marginTop: 8 }, - confirmButton: { - alignItems: 'center', - backgroundColor: colors.deep, - borderRadius: 12, - height: 48, - justifyContent: 'center', - marginTop: 12, - }, - confirmButtonDisabled: { opacity: 0.38 }, - confirmButtonText: { color: colors.surface, fontSize: 13, fontWeight: '800' }, -}); diff --git a/frontend/src/components/MapPicker.tsx b/frontend/src/components/MapPicker.tsx deleted file mode 100644 index 798d2be..0000000 --- a/frontend/src/components/MapPicker.tsx +++ /dev/null @@ -1 +0,0 @@ -export { MapPicker } from './MapPicker.web'; diff --git a/frontend/src/components/MapPicker.types.ts b/frontend/src/components/MapPicker.types.ts deleted file mode 100644 index 0f31c19..0000000 --- a/frontend/src/components/MapPicker.types.ts +++ /dev/null @@ -1,12 +0,0 @@ -export type MapLocation = { - address: string; - latitude: number; - longitude: number; - name?: string; -}; - -export type MapPickerProps = { - initialLocation: MapLocation | null; - onCancel: () => void; - onConfirm: (location: MapLocation) => void; -}; diff --git a/frontend/src/components/MapPicker.web.tsx b/frontend/src/components/MapPicker.web.tsx deleted file mode 100644 index 30e7489..0000000 --- a/frontend/src/components/MapPicker.web.tsx +++ /dev/null @@ -1,262 +0,0 @@ -import { useCallback, useEffect, useRef, useState } from 'react'; -import { load as loadBaiduMap } from '@baidumap/jsapi-loader'; -import { View } from 'react-native'; - -import { - BAIDU_MAP_AK, - createCoordinateLocation, - readablePoiAddress, - SHANGHAI_CENTER, -} from './MapPicker.services'; -import { mapPickerStyles as styles } from './MapPicker.styles'; -import { MapPickerOverlay } from './MapPickerOverlay'; -import type { MapLocation, MapPickerProps } from './MapPicker.types'; - -const MAP_REQUEST_TIMEOUT_MS = 6000; -const MAP_LOAD_TIMEOUT_MS = 5000; -const MARKER_SVG = encodeURIComponent( - '', -); - -export function MapPicker({ initialLocation, onCancel, onConfirm }: MapPickerProps) { - const mapHostRef = useRef(null); - const bmapRef = useRef(null); - const mapRef = useRef(null); - const markerRef = useRef(null); - const requestRef = useRef(0); - const [selection, setSelection] = useState(initialLocation); - const [locating, setLocating] = useState(false); - const [locationError, setLocationError] = useState(null); - const [mapReady, setMapReady] = useState(false); - const [mapError, setMapError] = useState( - BAIDU_MAP_AK ? null : '缺少百度地图浏览器端密钥,请完成地图服务配置。', - ); - - const moveMarker = useCallback((location: MapLocation) => { - const BMapApi = bmapRef.current; - const map = mapRef.current; - if (!BMapApi || !map) return; - - const point = new BMapApi.Point(location.longitude, location.latitude); - if (markerRef.current) { - markerRef.current.setPosition(point); - return; - } - - const icon = new BMapApi.Icon( - `data:image/svg+xml;charset=utf-8,${MARKER_SVG}`, - new BMapApi.Size(28, 28), - { anchor: new BMapApi.Size(14, 14) }, - ); - markerRef.current = new BMapApi.Marker(point, { icon }); - map.addOverlay(markerRef.current); - }, []); - - const selectCoordinates = useCallback( - (latitude: number, longitude: number) => { - const BMapApi = bmapRef.current; - if (!BMapApi) return; - - const requestId = requestRef.current + 1; - requestRef.current = requestId; - const pendingLocation = createCoordinateLocation(latitude, longitude); - let completed = false; - - setLocationError(null); - setSelection(pendingLocation); - moveMarker(pendingLocation); - setLocating(true); - - const finish = (address?: string) => { - if (completed || requestRef.current !== requestId) return; - completed = true; - window.clearTimeout(timeout); - if (address?.trim()) setSelection({ ...pendingLocation, address: address.trim() }); - setLocating(false); - }; - - const timeout = window.setTimeout(() => finish(), MAP_REQUEST_TIMEOUT_MS); - const geocoder = new BMapApi.Geocoder({ language: 'zh-CN' }); - geocoder.getLocation( - new BMapApi.Point(longitude, latitude), - (result: BMap.GeocoderResult | null) => finish(result?.address), - ); - }, - [moveMarker], - ); - - const locateCurrentPosition = useCallback(() => { - const BMapApi = bmapRef.current; - const map = mapRef.current; - if (!BMapApi || !map) return; - - setLocationError(null); - setLocating(true); - const geolocation = new BMapApi.Geolocation(); - geolocation.getCurrentPosition( - (result) => { - if (geolocation.getStatus() !== 0 || !result?.point) { - setLocating(false); - setLocationError('无法获取当前位置,请允许定位权限后重试。'); - return; - } - - map.setCenter(result.point, { noAnimation: false }); - map.setZoom(17, { noAnimation: false }); - selectCoordinates(result.point.lat, result.point.lng); - }, - { enableHighAccuracy: true }, - ); - }, [selectCoordinates]); - - useEffect(() => { - const host = mapHostRef.current as unknown as HTMLElement | null; - if (!host) return; - - if (!BAIDU_MAP_AK) return; - - let disposed = false; - const loadTimeout = window.setTimeout(() => { - if (!disposed) { - setMapError('请在百度地图控制台为此 Key 开通 JavaScript API 服务后重试。'); - } - }, MAP_LOAD_TIMEOUT_MS); - - void loadBaiduMap({ - ak: BAIDU_MAP_AK, - globalConfig: { coordType: 'bd09ll' }, - timeout: 10000, - version: '4.0', - }) - .then((namespace: typeof BMap) => { - if (disposed) return; - window.clearTimeout(loadTimeout); - - const center = initialLocation ?? SHANGHAI_CENTER; - const point = new namespace.Point(center.longitude, center.latitude); - const map = new namespace.Map(host, { - center: point, - enablePinchZoom: true, - enableWheelZoom: true, - fixCenterWhenResize: true, - zoom: initialLocation ? 17 : 14, - }); - - bmapRef.current = namespace; - mapRef.current = map; - if (initialLocation) moveMarker(initialLocation); - map.addEventListener('click', (event) => { - selectCoordinates(event.point.lat, event.point.lng); - }); - setMapError(null); - setMapReady(true); - if (!initialLocation) locateCurrentPosition(); - }) - .catch(() => { - if (!disposed) { - window.clearTimeout(loadTimeout); - setMapError('地图加载失败,请检查网络或百度地图密钥配置。'); - } - }); - - return () => { - disposed = true; - window.clearTimeout(loadTimeout); - requestRef.current += 1; - markerRef.current = null; - const map = mapRef.current; - if (map) { - try { - const destroy = (map as unknown as { destroy?: () => void }).destroy; - if (typeof destroy === 'function') { - destroy.call(map); - } else { - (map as unknown as { clearOverlays?: () => void }).clearOverlays?.(); - } - } catch { - // Baidu may have already torn down the map while the overlay closes. - } - } - mapRef.current = null; - bmapRef.current = null; - }; - }, [initialLocation, locateCurrentPosition, moveMarker, selectCoordinates]); - - const searchLocations = useCallback((query: string) => { - return new Promise((resolve, reject) => { - const BMapApi = bmapRef.current; - const map = mapRef.current; - if (!BMapApi || !map) { - reject(new Error('Baidu map is not ready')); - return; - } - - let completed = false; - const finish = (locations?: MapLocation[]) => { - if (completed) return; - completed = true; - window.clearTimeout(timeout); - if (locations) resolve(locations); - else reject(new Error('Baidu place search failed')); - }; - const timeout = window.setTimeout(() => finish(), MAP_REQUEST_TIMEOUT_MS); - const localSearch = new BMapApi.LocalSearch(map, { - onSearchComplete: (rawResults) => { - const result = Array.isArray(rawResults) ? rawResults[0] : rawResults; - if (!result) { - finish([]); - return; - } - - const locations: MapLocation[] = []; - const count = Math.min(result.getCurrentNumPois(), 5); - for (let index = 0; index < count; index += 1) { - const poi = result.getPoi(index); - if (!poi?.point) continue; - locations.push({ - address: readablePoiAddress(poi.title, poi.address), - latitude: poi.point.lat, - longitude: poi.point.lng, - }); - } - finish(locations); - }, - pageCapacity: 5, - renderOptions: { autoViewport: false }, - }); - localSearch.search(query); - }); - }, []); - - const selectSearchResult = (location: MapLocation) => { - const BMapApi = bmapRef.current; - const map = mapRef.current; - if (!BMapApi || !map) return; - - setLocationError(null); - requestRef.current += 1; - setLocating(false); - setSelection(location); - moveMarker(location); - map.setCenter(new BMapApi.Point(location.longitude, location.latitude), { noAnimation: false }); - map.setZoom(17, { noAnimation: false }); - }; - - return ( - - - selection && onConfirm(selection)} - onSearch={searchLocations} - onSelectSearchResult={selectSearchResult} - selection={selection} - /> - - ); -} diff --git a/frontend/src/components/MapPickerOverlay.tsx b/frontend/src/components/MapPickerOverlay.tsx deleted file mode 100644 index 555b72e..0000000 --- a/frontend/src/components/MapPickerOverlay.tsx +++ /dev/null @@ -1,216 +0,0 @@ -import { useCallback, useEffect, useRef, useState } from 'react'; -import { LocateFixed, MapPin, Search } from 'lucide-react-native'; -import { Pressable, Text, TextInput, View } from 'react-native'; - -import { colors } from '../constants/theme'; -import { BackButton } from './BackButton'; -import { mapPickerStyles as styles } from './MapPicker.styles'; -import type { MapLocation } from './MapPicker.types'; - -type MapPickerOverlayProps = { - mapError: string | null; - mapReady: boolean; - locating: boolean; - locationError: string | null; - onCancel: () => void; - onLocate: () => void; - onConfirm: () => void; - onSearch: (query: string) => Promise; - onSelectSearchResult: (location: MapLocation) => void; - selection: MapLocation | null; -}; - -export function MapPickerOverlay({ - mapError, - mapReady, - locating, - locationError, - onCancel, - onLocate, - onConfirm, - onSearch, - onSelectSearchResult, - selection, -}: MapPickerOverlayProps) { - const [query, setQuery] = useState(''); - const [results, setResults] = useState([]); - const [searching, setSearching] = useState(false); - const [searched, setSearched] = useState(false); - const [searchFailed, setSearchFailed] = useState(false); - const searchRequestRef = useRef(0); - - const search = useCallback( - async (value: string) => { - const nextQuery = value.trim(); - if (!nextQuery || !mapReady) return; - - const requestId = searchRequestRef.current + 1; - searchRequestRef.current = requestId; - setSearching(true); - setSearched(false); - setSearchFailed(false); - try { - const nextResults = await onSearch(nextQuery); - if (requestId !== searchRequestRef.current) return; - setResults(nextResults); - setSearched(true); - } catch { - if (requestId !== searchRequestRef.current) return; - setResults([]); - setSearchFailed(true); - } finally { - if (requestId === searchRequestRef.current) setSearching(false); - } - }, - [mapReady, onSearch], - ); - - useEffect(() => { - const nextQuery = query.trim(); - if (!nextQuery || !mapReady) return; - - const timer = setTimeout(() => { - void search(nextQuery); - }, 320); - return () => clearTimeout(timer); - }, [mapReady, query, search]); - - const chooseResult = (location: MapLocation) => { - searchRequestRef.current += 1; - setSearching(false); - setQuery(''); - setResults([]); - setSearched(false); - onSelectSearchResult(location); - }; - - return ( - <> - - - - - - { - setQuery(value); - searchRequestRef.current += 1; - setSearching(false); - setSearchFailed(false); - setSearched(false); - if (!value.trim()) { - setResults([]); - } - }} - onSubmitEditing={() => void search(query)} - placeholder="搜索地点或地址" - placeholderTextColor="#909892" - returnKeyType="search" - style={styles.searchInput} - value={query} - /> - void search(query)} - style={styles.searchButton} - > - - - - - - - - - {(searching || searchFailed || searched) && ( - - {searching ? ( - 正在搜索... - ) : searchFailed ? ( - 搜索暂时不可用,请直接在地图上选点 - ) : results.length === 0 ? ( - 没有找到相关地点 - ) : ( - results.map((result, index) => ( - chooseResult(result)} - style={[ - styles.searchResult, - index === results.length - 1 && styles.searchResultLast, - ]} - > - - - {result.address} - - - )) - )} - - )} - {locationError ? ( - - {locationError} - - ) : null} - - - {mapError && ( - - - 百度地图暂时不可用 - {mapError} - - )} - - - - - - - - 选中的地点 - {selection ? ( - - {locating ? '正在获取详细地址...' : selection.address} - - ) : locating ? ( - 正在获取当前位置... - ) : ( - 点击地图,或搜索后选择一个地点 - )} - - - {selection && ( - - {selection.latitude.toFixed(5)}, {selection.longitude.toFixed(5)} · 百度地图 · BD-09 - - )} - - 确认这个地点 - - - - ); -} diff --git a/frontend/src/constants/theme.ts b/frontend/src/constants/theme.ts deleted file mode 100644 index 40aae72..0000000 --- a/frontend/src/constants/theme.ts +++ /dev/null @@ -1,35 +0,0 @@ -export const colors = { - // Only still here so the outgoing HomeScreen keeps compiling. Dropped once - // the new screens replace it. - text: '#17212B', - background: '#F4F5F1', - surface: '#FFFFFF', - surfaceTint: '#E9ECE7', - deep: '#15352B', - ink: '#1B2923', - sub: '#6C7972', - muted: '#98A29C', - line: '#DDE4DE', - lime: '#D7F36A', - limeSoft: '#EEF5D6', - mint: '#DDEFE5', - coral: '#E98C70', - purple: '#8E86D7', - peach: '#FFE1D6', - violet: '#E8E4FF', -} as const; - -export const spacing = { - xs: 4, - sm: 8, - md: 16, - lg: 20, - xl: 24, -} as const; - -export const radii = { - sm: 10, - md: 14, - lg: 19, - xl: 24, -} as const; diff --git a/frontend/src/contracts/session.ts b/frontend/src/contracts/session.ts index 98bdf44..1029e6d 100644 --- a/frontend/src/contracts/session.ts +++ b/frontend/src/contracts/session.ts @@ -1,4 +1,4 @@ -import type { ApiError } from './envelope'; +import type { ApiError, WsFailure, WsRequest, WsSuccess } from './envelope'; export type SessionHello = { type: 'session.hello'; @@ -20,8 +20,7 @@ export type SessionError = { error: ApiError; }; -export type LocationReport = { - type: 'location.report'; +export type LocationReportPayload = { schedule_scope: 'current'; latitude: number; longitude: number; @@ -29,6 +28,7 @@ export type LocationReport = { timestamp: string; }; +export type LocationReport = WsRequest<'location.report', LocationReportPayload>; + export type LocationReportAck = - | { type: 'location.report.ack'; ok: true } - | { type: 'location.report.ack'; ok: false; error: ApiError }; + WsSuccess<'location.report.ack', null> | WsFailure<'location.report.ack'>; diff --git a/frontend/src/contracts/voice.ts b/frontend/src/contracts/voice.ts index 07c8ad1..730d98e 100644 --- a/frontend/src/contracts/voice.ts +++ b/frontend/src/contracts/voice.ts @@ -17,6 +17,18 @@ export type VoiceStreamEndCommand = WsRequest<'voice.stream.end', VoiceStreamEnd export type VoiceStreamError = WsFailure<'voice.stream.error'>; +export type VoiceStreamCancelPayload = { + stream_id: string; + job_id: string | null; +}; + +export type VoiceStreamCancelCommand = WsRequest<'voice.stream.cancel', VoiceStreamCancelPayload>; + +export type VoiceStreamCancelAck = + | WsSuccess<'voice.stream.cancelled', { stream_id: string }> + | VoiceStreamError + | WsFailure<'voice.stream.cancel'>; + export type VoiceStreamStarted = WsSuccess< 'voice.stream.started', { stream_id: string; job_id: string } diff --git a/frontend/src/dev/fakes/FakeWsServer.ts b/frontend/src/dev/fakes/FakeWsServer.ts index 1b0d82e..8c25123 100644 --- a/frontend/src/dev/fakes/FakeWsServer.ts +++ b/frontend/src/dev/fakes/FakeWsServer.ts @@ -13,6 +13,7 @@ import type { SessionHello, SessionReady, VoiceParseResultMessage, + VoiceStreamCancelCommand, VoiceStreamEndCommand, VoiceStreamStartCommand, VoiceStreamStartResponse, @@ -88,6 +89,9 @@ export class FakeWsServer { case 'voice.stream.end': this.handleVoiceEnd(message as VoiceStreamEndCommand); return; + case 'voice.stream.cancel': + this.handleVoiceCancel(message as VoiceStreamCancelCommand); + return; default: return; } @@ -202,7 +206,9 @@ export class FakeWsServer { private handleLocationReport(_message: LocationReport): void { const ack: LocationReportAck = { type: 'location.report.ack', + request_id: _message.request_id, ok: true, + payload: null, }; this.reply(ack); } @@ -251,6 +257,7 @@ export class FakeWsServer { const parseResult: VoiceParseResultMessage = { type: 'voice.parse.result', + // Backend correlates parse results to voice.stream.start, not end. request_id: stream.startRequestId, job_id: stream.jobId, status: 'ready_for_confirmation', diff --git a/frontend/src/features/assistant/hooks/useAssistantSession.ts b/frontend/src/features/assistant/hooks/useAssistantSession.ts index d950120..70ffe13 100644 --- a/frontend/src/features/assistant/hooks/useAssistantSession.ts +++ b/frontend/src/features/assistant/hooks/useAssistantSession.ts @@ -46,6 +46,14 @@ function formatClarificationLabel(result: VoiceParseOutcome): string | undefined return parts.length > 0 ? parts.join(';') : undefined; } +const unavailableRecorder: VoiceRecorder = { + async start() { + throw new Error('录音适配器未注入'); + }, + async stop() {}, + async cancel() {}, +}; + type ActiveVoiceStream = { streamId: string; jobId: string; @@ -67,14 +75,14 @@ async function cancelVoiceStream(stream: ActiveVoiceStream): Promise { export function useAssistantSession(options: { client: VoiceTransport | null; onConfirmDraft: (draft: VoiceParseDraft) => Promise; - /** The app host must inject a production PCM recorder for its platform. */ - recorder: VoiceRecorder; + /** App/native hosts may inject an Expo Audio backed recorder. */ + recorder?: VoiceRecorder; }) { const voice = useMemo( () => (options.client ? new WsVoiceStreamPort(options.client) : null), [options.client], ); - const recorder = options.recorder; + const recorder = useMemo(() => options.recorder ?? unavailableRecorder, [options.recorder]); const [messages, setMessages] = useState([]); const [isProcessing, setIsProcessing] = useState(false); const activeStreamRef = useRef(null); diff --git a/frontend/src/features/schedule/screens/ScheduleScreen.tsx b/frontend/src/features/schedule/screens/ScheduleScreen.tsx index 37fb44a..4b644db 100644 --- a/frontend/src/features/schedule/screens/ScheduleScreen.tsx +++ b/frontend/src/features/schedule/screens/ScheduleScreen.tsx @@ -29,8 +29,8 @@ export function ScheduleScreen({ scheduleItems: Schedule[]; }) { const now = useCurrentDate(); - const [visibleMonth, setVisibleMonth] = useState(() => startOfMonth(new Date())); - const [selectedDate, setSelectedDate] = useState(() => new Date()); + const [visibleMonth, setVisibleMonth] = useState(() => startOfMonth(now)); + const [selectedDate, setSelectedDate] = useState(() => now); const [selectedScheduleId, setSelectedScheduleId] = useState(null); const [datePickerOpen, setDatePickerOpen] = useState(false); diff --git a/frontend/src/infrastructure/location/LocationReporter.ts b/frontend/src/infrastructure/location/LocationReporter.ts new file mode 100644 index 0000000..d2e5232 --- /dev/null +++ b/frontend/src/infrastructure/location/LocationReporter.ts @@ -0,0 +1,250 @@ +import * as ExpoLocation from 'expo-location'; +import { Platform } from 'react-native'; + +import type { LocationReport, LocationReportAck, Schedule, WsJsonMessage } from '@/contracts'; +import { nextRequestId } from '@/shared/utils/requestId'; + +export type LocationSample = { + latitude: number; + longitude: number; + accuracy: number; + timestamp?: string; +}; + +export type LocationProvider = { + getCurrentSample(): Promise; +}; + +export type LocationTransport = { + request( + message: WsJsonMessage & { request_id: string }, + isMatch?: (response: WsJsonMessage) => boolean, + ): Promise; +}; + +export class LocationUnavailableError extends Error { + constructor(message = '当前位置服务不可用') { + super(message); + this.name = 'LocationUnavailableError'; + } +} + +type ExpoLocationPosition = { + coords?: { + latitude?: number; + longitude?: number; + accuracy?: number | null; + }; + timestamp?: number; +}; + +type ExpoLocationPermission = { + status?: string; + granted?: boolean; +}; + +type ExpoLocationModule = { + getForegroundPermissionsAsync?: () => Promise; + requestForegroundPermissionsAsync?: () => Promise; + getCurrentPositionAsync?: (options?: Record) => Promise; +}; + +// Expo Location's public enum maps Balanced accuracy to 3. Keep the numeric +// value at this adapter boundary so the feature does not depend on the SDK. +const EXPO_BALANCED_ACCURACY = 3; + +function assertSample(sample: LocationSample): LocationSample { + if ( + !Number.isFinite(sample.latitude) || + sample.latitude < -90 || + sample.latitude > 90 || + !Number.isFinite(sample.longitude) || + sample.longitude < -180 || + sample.longitude > 180 || + !Number.isFinite(sample.accuracy) || + sample.accuracy < 0 + ) { + throw new LocationUnavailableError('定位 SDK 返回了无效坐标'); + } + return sample; +} + +function isGranted(permission: ExpoLocationPermission | null | undefined): boolean { + return permission?.granted === true || permission?.status === 'granted'; +} + +/** Native foreground location provider backed by the linked Expo module. */ +export class ExpoLocationProvider implements LocationProvider { + constructor( + private readonly module: ExpoLocationModule | null = ExpoLocation as unknown as ExpoLocationModule, + ) {} + + async getCurrentSample(): Promise { + const location = this.module; + if (!location?.getCurrentPositionAsync) { + throw new LocationUnavailableError( + 'ExpoLocation 原生模块未链接;请在宿主中注入 LocationProvider', + ); + } + + const currentPermission = await location.getForegroundPermissionsAsync?.(); + const permission = isGranted(currentPermission) + ? currentPermission + : await location.requestForegroundPermissionsAsync?.(); + if (!isGranted(permission)) { + throw new LocationUnavailableError('未授予前台定位权限'); + } + + const position = await location.getCurrentPositionAsync({ + accuracy: EXPO_BALANCED_ACCURACY, + }); + const latitude = position.coords?.latitude; + const longitude = position.coords?.longitude; + if (latitude == null || longitude == null) { + throw new LocationUnavailableError('定位 SDK 未返回坐标'); + } + return assertSample({ + latitude, + longitude, + accuracy: Math.max(0, position.coords?.accuracy ?? 0), + timestamp: + position.timestamp != null + ? new Date(position.timestamp).toISOString() + : new Date().toISOString(), + }); + } +} + +/** Browser provider used by RN Web and desktop development. */ +export class BrowserLocationProvider implements LocationProvider { + async getCurrentSample(): Promise { + const geolocation = globalThis.navigator?.geolocation; + if (!geolocation) { + throw new LocationUnavailableError('当前浏览器不支持定位'); + } + return new Promise((resolve, reject) => { + geolocation.getCurrentPosition( + (position) => { + try { + resolve( + assertSample({ + latitude: position.coords.latitude, + longitude: position.coords.longitude, + accuracy: Math.max(0, position.coords.accuracy ?? 0), + timestamp: new Date(position.timestamp).toISOString(), + }), + ); + } catch (error) { + reject(error); + } + }, + (error) => reject(new LocationUnavailableError(`定位失败: ${error.message}`)), + { enableHighAccuracy: true, maximumAge: 15_000, timeout: 10_000 }, + ); + }); + } +} + +/** + * Resolve the platform provider at the composition boundary. No fixed + * coordinates are ever returned: an unavailable native module is surfaced as + * an error and the reporter skips that tick. + */ +export function createLocationProvider(): LocationProvider { + return Platform.OS === 'web' ? new BrowserLocationProvider() : new ExpoLocationProvider(); +} + +type LocationSampleSource = LocationProvider | (() => Promise); + +function readSample(source: LocationSampleSource): Promise { + return typeof source === 'function' ? source() : source.getCurrentSample(); +} + +/** + * 地点提醒位置上报器:客户端只上报位置,触发判定留给服务端。 + * 当前 timer 是前台轮询;后台 task/围栏应由宿主注入更合适的 provider。 + */ +export class LocationReporter { + private timer: ReturnType | null = null; + private armed = false; + private tickInFlight = false; + private lastError: Error | null = null; + + constructor( + private readonly client: LocationTransport, + private readonly source: LocationSampleSource, + private readonly onError?: (error: Error) => void, + ) {} + + getLastError(): Error | null { + return this.lastError; + } + + syncArmedSchedules(schedules: Schedule[]): void { + this.armed = schedules.some( + (item) => + item.status === 'scheduled' && item.schedule_type === 'location' && item.geofence_armed, + ); + if (this.armed) { + this.start(30_000); + } else { + this.stop(); + } + } + + start(intervalMs = 30_000): void { + if (this.timer) return; + this.armed = true; + void this.tick(); + this.timer = setInterval(() => { + void this.tick(); + }, intervalMs); + } + + stop(): void { + this.armed = false; + if (this.timer) { + clearInterval(this.timer); + this.timer = null; + } + } + + async report(sample: LocationSample): Promise { + const validated = assertSample(sample); + const message: LocationReport = { + type: 'location.report', + request_id: nextRequestId('req_location'), + payload: { + schedule_scope: 'current', + latitude: validated.latitude, + longitude: validated.longitude, + accuracy: validated.accuracy, + timestamp: validated.timestamp ?? new Date().toISOString(), + }, + }; + const isMatch = (incoming: WsJsonMessage) => + incoming.type === 'location.report.ack' && incoming.request_id === message.request_id; + + const ack = await this.client.request(message, isMatch); + if (!ack.ok) { + throw new Error(ack.error.message); + } + return ack; + } + + private async tick(): Promise { + if (!this.armed || this.tickInFlight) return; + this.tickInFlight = true; + try { + const sample = await readSample(this.source); + if (!sample || !this.armed) return; + await this.report(sample); + this.lastError = null; + } catch (error) { + this.lastError = error instanceof Error ? error : new Error(String(error)); + this.onError?.(this.lastError); + } finally { + this.tickInFlight = false; + } + } +} diff --git a/frontend/src/mocks/schedules.ts b/frontend/src/mocks/schedules.ts deleted file mode 100644 index ff24159..0000000 --- a/frontend/src/mocks/schedules.ts +++ /dev/null @@ -1,349 +0,0 @@ -import type { - Schedule, - ScheduleConflict, - ScheduleListQuery, - ScheduleListResult, - ScheduleUpsertCommand, - ScheduleUpsertError, - ScheduleUpsertResult, -} from '../types/home'; - -export const scheduleListMock: ScheduleListResult = { - type: 'schedule.list.result', - request_id: 'req_schedule_list_mock', - ok: true, - payload: { - schedules: [ - { - id: 'schedule_001', - user_id: 'default_user', - source_mode: 'voice', - schedule_type: 'time', - status: 'done', - title: '复习 Java 集合基础', - notes: '复习 List、Set 与 Map 的常见实现。', - start_time: '2026-07-27T08:40:00+08:00', - end_time: '2026-07-27T09:15:00+08:00', - timezone: 'Asia/Shanghai', - location_name: null, - location_address: null, - latitude: null, - longitude: null, - geofence_radius_meters: 100, - geofence_armed: true, - time_remind_offset_minutes: 15, - time_triggered_at: '2026-07-27T08:25:00+08:00', - geo_triggered_at: null, - system_schedule_ref_id: 'system_schedule_001', - system_alarm_ref_id: null, - created_at: '2026-07-26T20:10:00+08:00', - updated_at: '2026-07-27T09:15:00+08:00', - }, - { - id: 'schedule_002', - user_id: 'default_user', - source_mode: 'manual', - schedule_type: 'time', - status: 'scheduled', - title: '需求分析讨论', - notes: null, - start_time: '2026-07-28T14:00:00+08:00', - end_time: '2026-07-28T14:45:00+08:00', - timezone: 'Asia/Shanghai', - location_name: '张江办公室', - location_address: '上海市浦东新区张江路 88 号', - latitude: 31.2015, - longitude: 121.5871, - geofence_radius_meters: 100, - geofence_armed: true, - time_remind_offset_minutes: 15, - time_triggered_at: null, - geo_triggered_at: null, - system_schedule_ref_id: 'system_schedule_002', - system_alarm_ref_id: null, - created_at: '2026-07-27T18:30:00+08:00', - updated_at: '2026-07-27T18:30:00+08:00', - }, - { - id: 'schedule_003', - user_id: 'default_user', - source_mode: 'voice', - schedule_type: 'time', - status: 'done', - title: '完成需求分析初稿', - notes: null, - start_time: '2026-07-29T09:25:00+08:00', - end_time: '2026-07-29T09:50:00+08:00', - timezone: 'Asia/Shanghai', - location_name: null, - location_address: null, - latitude: null, - longitude: null, - geofence_radius_meters: 100, - geofence_armed: true, - time_remind_offset_minutes: 15, - time_triggered_at: '2026-07-29T09:10:00+08:00', - geo_triggered_at: null, - system_schedule_ref_id: 'system_schedule_003', - system_alarm_ref_id: null, - created_at: '2026-07-28T21:30:00+08:00', - updated_at: '2026-07-29T09:50:00+08:00', - }, - { - id: 'schedule_004', - user_id: 'default_user', - source_mode: 'manual', - schedule_type: 'time', - status: 'scheduled', - title: '项目周会', - notes: '同步本周进展与风险。', - start_time: '2026-07-29T10:30:00+08:00', - end_time: '2026-07-29T11:20:00+08:00', - timezone: 'Asia/Shanghai', - location_name: '3 号会议室', - location_address: '上海市浦东新区张江路 88 号 5 楼', - latitude: 31.2016, - longitude: 121.587, - geofence_radius_meters: 100, - geofence_armed: true, - time_remind_offset_minutes: 15, - time_triggered_at: null, - geo_triggered_at: null, - system_schedule_ref_id: 'system_schedule_004', - system_alarm_ref_id: null, - created_at: '2026-07-25T10:00:00+08:00', - updated_at: '2026-07-25T10:00:00+08:00', - }, - { - id: 'schedule_005', - user_id: 'default_user', - source_mode: 'voice', - schedule_type: 'time', - status: 'scheduled', - title: '复习 JVM 内存模型', - notes: null, - start_time: '2026-07-29T19:30:00+08:00', - end_time: '2026-07-29T20:05:00+08:00', - timezone: 'Asia/Shanghai', - location_name: null, - location_address: null, - latitude: null, - longitude: null, - geofence_radius_meters: 100, - geofence_armed: true, - time_remind_offset_minutes: 15, - time_triggered_at: null, - geo_triggered_at: null, - system_schedule_ref_id: 'system_schedule_005', - system_alarm_ref_id: null, - created_at: '2026-07-28T22:00:00+08:00', - updated_at: '2026-07-28T22:00:00+08:00', - }, - { - id: 'schedule_006', - user_id: 'default_user', - source_mode: 'manual', - schedule_type: 'time', - status: 'scheduled', - title: '整理项目介绍提纲', - notes: null, - start_time: '2026-07-29T20:15:00+08:00', - end_time: '2026-07-29T20:35:00+08:00', - timezone: 'Asia/Shanghai', - location_name: null, - location_address: null, - latitude: null, - longitude: null, - geofence_radius_meters: 100, - geofence_armed: true, - time_remind_offset_minutes: 15, - time_triggered_at: null, - geo_triggered_at: null, - system_schedule_ref_id: 'system_schedule_006', - system_alarm_ref_id: null, - created_at: '2026-07-28T22:10:00+08:00', - updated_at: '2026-07-28T22:10:00+08:00', - }, - { - id: 'schedule_007', - user_id: 'default_user', - source_mode: 'voice', - schedule_type: 'time', - status: 'scheduled', - title: '整理面试问题清单', - notes: '优先整理并发与数据库问题。', - start_time: '2026-07-31T19:30:00+08:00', - end_time: '2026-07-31T20:20:00+08:00', - timezone: 'Asia/Shanghai', - location_name: null, - location_address: null, - latitude: null, - longitude: null, - geofence_radius_meters: 100, - geofence_armed: true, - time_remind_offset_minutes: 15, - time_triggered_at: null, - geo_triggered_at: null, - system_schedule_ref_id: 'system_schedule_007', - system_alarm_ref_id: null, - created_at: '2026-07-28T22:20:00+08:00', - updated_at: '2026-07-28T22:20:00+08:00', - }, - { - id: 'schedule_008', - user_id: 'default_user', - source_mode: 'voice', - schedule_type: 'location', - status: 'scheduled', - title: '到公司后提交实习简历', - notes: '提交前再次检查附件命名。', - start_time: null, - end_time: null, - timezone: null, - location_name: '张江办公室', - location_address: '上海市浦东新区张江路 88 号', - latitude: 31.2015, - longitude: 121.5871, - geofence_radius_meters: 100, - geofence_armed: true, - time_remind_offset_minutes: 15, - time_triggered_at: null, - geo_triggered_at: null, - system_schedule_ref_id: 'system_schedule_008', - system_alarm_ref_id: null, - created_at: '2026-07-29T08:00:00+08:00', - updated_at: '2026-07-29T08:00:00+08:00', - }, - ], - }, -}; - -export const scheduleListQueryMock: ScheduleListQuery = { - type: 'schedule.list.query', - request_id: 'req_schedule_list_001', - payload: { - status: null, - include_deleted: false, - }, -}; - -export const scheduleUpsertMock: ScheduleUpsertCommand = { - type: 'schedule.upsert.command', - request_id: 'req_schedule_001', - payload: { - schedule_id: null, - source_mode: 'voice', - schedule_type: 'time', - title: '开会', - notes: null, - start_time: '2026-07-29T15:00:00+08:00', - end_time: null, - timezone: 'Asia/Shanghai', - location_name: '陆家嘴', - location_address: null, - latitude: 31.2451, - longitude: 121.5067, - geofence_radius_meters: 100, - geofence_armed: true, - time_remind_offset_minutes: 15, - }, -}; - -export const scheduleUpsertResultMock: ScheduleUpsertResult = { - type: 'schedule.upsert.result', - request_id: 'req_schedule_001', - ok: true, - payload: { - schedule_id: 'schedule_001', - schedule_type: 'time', - status: 'scheduled', - conflicts: [], - geofence_armed: true, - }, -}; - -function findScheduleConflicts( - command: ScheduleUpsertCommand, - schedules: Schedule[], - currentScheduleId: string, -): ScheduleConflict[] { - const { end_time: endTime, start_time: startTime } = command.payload; - if (!startTime) return []; - - const start = new Date(startTime).getTime(); - const end = endTime ? new Date(endTime).getTime() : start; - if (!Number.isFinite(start) || !Number.isFinite(end)) return []; - - return schedules - .filter((item) => item.id !== currentScheduleId && item.status !== 'deleted' && item.start_time) - .filter((item) => { - const itemStart = new Date(item.start_time!).getTime(); - const itemEnd = item.end_time ? new Date(item.end_time).getTime() : itemStart; - return ( - Number.isFinite(itemStart) && - Number.isFinite(itemEnd) && - start <= itemEnd && - itemStart <= end - ); - }) - .map((item) => ({ - schedule_id: item.id, - title: item.title, - start_time: item.start_time!, - end_time: item.end_time, - })); -} - -export function createScheduleUpsertResultMock( - command: ScheduleUpsertCommand, - schedules: Schedule[], - scheduleId: string, -): ScheduleUpsertResult { - const existingSchedule = schedules.find((item) => item.id === scheduleId); - - return { - ...scheduleUpsertResultMock, - request_id: command.request_id, - payload: { - ...scheduleUpsertResultMock.payload, - schedule_id: scheduleId, - schedule_type: command.payload.schedule_type, - conflicts: findScheduleConflicts(command, schedules, scheduleId), - geofence_armed: command.payload.geofence_armed ?? existingSchedule?.geofence_armed ?? true, - }, - }; -} - -export const scheduleUpsertConflictMock: ScheduleUpsertResult = { - type: 'schedule.upsert.result', - request_id: 'req_schedule_001', - ok: true, - payload: { - schedule_id: 'schedule_001', - schedule_type: 'time', - status: 'scheduled', - conflicts: [ - { - schedule_id: 'schedule_older', - title: '已有日程', - start_time: '2026-07-28T15:00:00+08:00', - end_time: '2026-07-28T16:00:00+08:00', - }, - ], - geofence_armed: true, - }, -}; - -export const scheduleUpsertErrorMock: ScheduleUpsertError = { - type: 'schedule.upsert.error', - request_id: 'req_schedule_001', - ok: false, - error: { - code: 'VALIDATION_ERROR', - message: '请求参数不合法', - details: { - field: 'schedule_type', - reason: 'schedule_type 为 time 时 start_time 必填;为 location 时 latitude 和 longitude 必填', - }, - }, -}; diff --git a/frontend/src/screens/AssistantScreen.styles.ts b/frontend/src/screens/AssistantScreen.styles.ts deleted file mode 100644 index b00f15d..0000000 --- a/frontend/src/screens/AssistantScreen.styles.ts +++ /dev/null @@ -1,223 +0,0 @@ -import { StyleSheet } from 'react-native'; - -import { colors } from '../constants/theme'; - -export const assistantStyles = StyleSheet.create({ - assistantScreen: { backgroundColor: colors.background, flex: 1, padding: 20 }, - assistantHeader: { paddingTop: 8 }, - assistantNavRow: { - alignItems: 'center', - flexDirection: 'row', - }, - assistantEyebrow: { - color: colors.muted, - fontSize: 10, - fontWeight: '700', - letterSpacing: 0, - marginLeft: 11, - }, - assistantTitle: { color: colors.ink, fontSize: 28, fontWeight: '800', marginTop: 18 }, - assistantSubtitle: { color: colors.sub, fontSize: 13, lineHeight: 20, marginTop: 8 }, - assistantMessageRow: { alignItems: 'flex-start', flexDirection: 'row', marginTop: 28 }, - assistantMark: { - backgroundColor: colors.deep, - borderColor: 'rgba(215,243,106,0.34)', - borderRadius: 12, - borderWidth: 1, - elevation: 4, - height: 38, - alignItems: 'center', - justifyContent: 'center', - width: 38, - }, - assistantBubble: { - backgroundColor: colors.surface, - borderColor: '#ECE9E2', - borderRadius: 12, - borderWidth: 1, - flex: 1, - marginLeft: 10, - padding: 16, - }, - assistantBubbleText: { color: colors.ink, fontSize: 14, lineHeight: 21 }, - assistantComposer: { flexShrink: 0, marginTop: 'auto' }, - selectedImageRow: { - alignItems: 'center', - alignSelf: 'flex-start', - backgroundColor: '#FFFFFF', - borderColor: 'rgba(20,40,33,0.09)', - borderRadius: 12, - borderWidth: 1, - elevation: 2, - flexDirection: 'row', - marginBottom: 8, - maxWidth: '78%', - padding: 5, - }, - selectedImageThumbnail: { borderRadius: 8, height: 34, width: 34 }, - selectedImageName: { - color: colors.ink, - flexShrink: 1, - fontSize: 12, - fontWeight: '600', - marginHorizontal: 8, - }, - selectedImageRemove: { - alignItems: 'center', - borderRadius: 9, - height: 28, - justifyContent: 'center', - outlineColor: 'transparent', - outlineStyle: 'solid', - outlineWidth: 0, - width: 28, - }, - commandInputRow: { - alignItems: 'center', - backgroundColor: 'rgba(255,255,255,0.96)', - borderColor: 'rgba(20,40,33,0.09)', - borderRadius: 18, - borderWidth: 1, - elevation: 4, - flexDirection: 'row', - gap: 5, - minHeight: 60, - padding: 8, - }, - commandInputRowCompact: { gap: 3, paddingHorizontal: 6, paddingVertical: 7 }, - commandMedia: { - alignItems: 'center', - backgroundColor: '#F5F6F2', - borderRadius: 14, - height: 40, - justifyContent: 'center', - outlineColor: 'transparent', - outlineStyle: 'solid', - outlineWidth: 0, - width: 40, - }, - commandHold: { - alignItems: 'center', - backgroundColor: '#FFFFFF', - borderRadius: 12, - flex: 1, - height: 44, - justifyContent: 'center', - minWidth: 0, - outlineColor: 'transparent', - outlineStyle: 'solid', - outlineWidth: 0, - }, - commandHoldActive: { - backgroundColor: colors.limeSoft, - transform: [{ scale: 0.985 }], - }, - commandHoldCompact: { height: 42 }, - commandHoldContent: { - alignItems: 'center', - justifyContent: 'center', - width: '100%', - }, - commandHoldText: { - color: '#1C2420', - fontSize: 15, - fontWeight: '700', - letterSpacing: 0, - }, - commandHoldTextCompact: { fontSize: 14 }, - commandWave: { - alignItems: 'center', - flexDirection: 'row', - gap: 3, - height: 24, - left: 14, - position: 'absolute', - }, - commandWaveBar: { - backgroundColor: '#142E26', - borderRadius: 2, - width: 3, - }, - commandTextPanel: { - alignItems: 'center', - flex: 1, - flexDirection: 'row', - gap: 5, - minWidth: 0, - }, - commandInput: { - backgroundColor: '#F0F3EF', - borderRadius: 12, - borderWidth: 0, - color: colors.ink, - flex: 1, - fontSize: 14, - height: 44, - minWidth: 0, - outlineColor: 'transparent', - outlineStyle: 'solid', - outlineWidth: 0, - paddingHorizontal: 13, - }, - commandInputCompact: { height: 42 }, - commandInputFocused: { - backgroundColor: '#FFFFFF', - borderColor: '#AEBBB4', - borderWidth: 1, - }, - commandSend: { - alignItems: 'center', - backgroundColor: colors.deep, - borderRadius: 12, - height: 40, - justifyContent: 'center', - minWidth: 48, - outlineColor: 'transparent', - outlineStyle: 'solid', - outlineWidth: 0, - paddingHorizontal: 11, - }, - commandSendCompact: { minWidth: 44, paddingHorizontal: 9 }, - commandSendText: { color: colors.surface, fontSize: 12, fontWeight: '700' }, - commandMode: { - alignItems: 'center', - backgroundColor: 'transparent', - borderRadius: 14, - height: 40, - justifyContent: 'center', - outlineColor: 'transparent', - outlineStyle: 'solid', - outlineWidth: 0, - width: 40, - }, - commandModeCompact: { height: 38, width: 36 }, - commandControlPressed: { - backgroundColor: '#E7EBE4', - transform: [{ scale: 0.94 }], - }, - assistantDock: { - alignItems: 'center', - backgroundColor: 'rgba(255,254,250,0.94)', - borderTopColor: 'rgba(30,40,35,0.08)', - borderTopWidth: 1, - bottom: 0, - height: 64, - justifyContent: 'center', - left: 0, - paddingBottom: 8, - position: 'absolute', - right: 0, - zIndex: 20, - }, - assistantDockButton: { - alignItems: 'center', - backgroundColor: '#142821', - borderRadius: 15, - boxShadow: '0 7px 18px rgba(20,40,33,0.25)', - elevation: 6, - height: 46, - justifyContent: 'center', - width: 46, - }, - assistantDockButtonPressed: { opacity: 0.9, transform: [{ scale: 0.95 }] }, -}); diff --git a/frontend/src/screens/HomeScreen.tsx b/frontend/src/screens/HomeScreen.tsx deleted file mode 100644 index 4fcf946..0000000 --- a/frontend/src/screens/HomeScreen.tsx +++ /dev/null @@ -1,122 +0,0 @@ -import { useState } from 'react'; -import { StatusBar } from 'expo-status-bar'; -import { MapPin } from 'lucide-react-native'; -import { Pressable, StyleSheet, Text, View } from 'react-native'; - -import { MapPicker } from '../components/MapPicker'; -import type { MapLocation } from '../components/MapPicker.types'; -import { colors, radii, spacing } from '../constants/theme'; - -export function HomeScreen() { - const [location, setLocation] = useState(null); - const [pickingLocation, setPickingLocation] = useState(false); - - if (pickingLocation) { - return ( - setPickingLocation(false)} - onConfirm={(nextLocation) => { - setLocation(nextLocation); - setPickingLocation(false); - }} - /> - ); - } - - return ( - - Timeflow - 为日程选择提醒地点 - {location ? ( - - - - - {location.address} - - - {location.latitude.toFixed(5)}, {location.longitude.toFixed(5)} - - - - ) : null} - setPickingLocation(true)} - style={({ pressed }) => [styles.mapButton, pressed && styles.mapButtonPressed]} - > - - {location ? '重新选择地点' : '打开地图选点'} - - - - ); -} - -const styles = StyleSheet.create({ - container: { - alignItems: 'center', - backgroundColor: colors.background, - flex: 1, - justifyContent: 'center', - padding: spacing.xl, - }, - title: { - color: colors.text, - fontSize: 28, - fontWeight: '700', - }, - subtitle: { - color: colors.sub, - fontSize: 15, - marginBottom: spacing.lg, - marginTop: spacing.xs, - }, - locationSummary: { - alignItems: 'center', - backgroundColor: colors.surface, - borderColor: colors.line, - borderRadius: radii.sm, - borderWidth: 1, - flexDirection: 'row', - gap: spacing.sm, - marginBottom: spacing.md, - maxWidth: 420, - padding: spacing.md, - width: '100%', - }, - locationCopy: { - flex: 1, - }, - locationAddress: { - color: colors.ink, - fontSize: 15, - fontWeight: '600', - }, - locationCoordinates: { - color: colors.sub, - fontSize: 12, - marginTop: spacing.xs, - }, - mapButton: { - alignItems: 'center', - backgroundColor: colors.lime, - borderRadius: radii.sm, - flexDirection: 'row', - gap: spacing.sm, - justifyContent: 'center', - maxWidth: 420, - minHeight: 48, - paddingHorizontal: spacing.lg, - width: '100%', - }, - mapButtonPressed: { - opacity: 0.78, - }, - mapButtonText: { - color: colors.deep, - fontSize: 15, - fontWeight: '700', - }, -}); diff --git a/frontend/src/types/home.ts b/frontend/src/types/home.ts deleted file mode 100644 index 7e6c686..0000000 --- a/frontend/src/types/home.ts +++ /dev/null @@ -1,209 +0,0 @@ -export type Tab = 'today' | 'create' | 'me'; -export type CalendarView = 'day' | 'week' | 'month'; - -export type ScheduleSourceMode = 'manual' | 'voice'; -export type ScheduleType = 'time' | 'location'; -export type ScheduleStatus = 'scheduled' | 'done' | 'deleted'; - -export type ApiError = { - code: string; - message: string; - details: Record | null; -}; - -export type WsRequest = { - type: TType; - request_id: string; - payload: TPayload; -}; - -export type WsSuccess = { - type: TType; - request_id: string; - ok: true; - payload: TPayload; -}; - -export type WsFailure = { - type: TType; - request_id: string; - ok: false; - error: ApiError; -}; - -export type Schedule = { - id: string; - user_id: string; - source_mode: ScheduleSourceMode; - schedule_type: ScheduleType; - status: ScheduleStatus; - title: string; - notes: string | null; - start_time: string | null; - end_time: string | null; - timezone: string | null; - location_name: string | null; - location_address: string | null; - latitude: number | null; - longitude: number | null; - geofence_radius_meters: number; - geofence_armed: boolean; - time_remind_offset_minutes: number; - time_triggered_at: string | null; - geo_triggered_at: string | null; - system_schedule_ref_id: string | null; - system_alarm_ref_id: string | null; - created_at: string; - updated_at: string; -}; - -export type ScheduleListQueryPayload = { - status: ScheduleStatus | null; - include_deleted: boolean; -}; - -export type ScheduleListQuery = WsRequest<'schedule.list.query', ScheduleListQueryPayload>; - -export type ScheduleListResultPayload = { - schedules: Schedule[]; -}; - -export type ScheduleListResult = WsSuccess<'schedule.list.result', ScheduleListResultPayload>; - -export type ScheduleListError = WsFailure<'schedule.list.error'>; -export type ScheduleListResponse = ScheduleListResult | ScheduleListError; - -export type ScheduleConflict = { - schedule_id: string; - title: string; - start_time: string; - end_time: string | null; -}; - -export type ScheduleUpsertPayload = { - schedule_id?: string | null; - source_mode: ScheduleSourceMode; - schedule_type: ScheduleType; - title: string; - notes?: string | null; - start_time?: string | null; - end_time?: string | null; - timezone?: string | null; - location_name?: string | null; - location_address?: string | null; - latitude?: number | null; - longitude?: number | null; - geofence_radius_meters?: number | null; - geofence_armed?: boolean | null; - time_remind_offset_minutes?: number | null; -}; - -export type ScheduleUpsertCommand = WsRequest<'schedule.upsert.command', ScheduleUpsertPayload>; - -export type ScheduleUpsertResultPayload = { - schedule_id: string; - schedule_type: ScheduleType; - status: ScheduleStatus; - conflicts: ScheduleConflict[]; - geofence_armed: boolean; -}; - -export type ScheduleUpsertResult = WsSuccess<'schedule.upsert.result', ScheduleUpsertResultPayload>; - -export type ScheduleUpsertError = WsFailure<'schedule.upsert.error'>; -export type ScheduleUpsertResponse = ScheduleUpsertResult | ScheduleUpsertError; - -export type VoiceStreamStartPayload = { - audio_format: 'pcm_s16le'; - sample_rate_hz: number; - channels: number; -}; - -export type VoiceStreamStartCommand = WsRequest<'voice.stream.start', VoiceStreamStartPayload>; - -export type VoiceStreamEndPayload = { - stream_id: string; -}; - -export type VoiceStreamEndCommand = WsRequest<'voice.stream.end', VoiceStreamEndPayload>; - -export type VoiceStreamStarted = WsSuccess< - 'voice.stream.started', - { stream_id: string; job_id: string } ->; - -export type VoiceStreamEnded = WsSuccess< - 'voice.stream.ended', - { stream_id: string; job_id: string; status: 'processing' } ->; - -export type VoiceStreamError = WsFailure<'voice.stream.error'>; -export type VoiceStreamStartResponse = VoiceStreamStarted | VoiceStreamError; -export type VoiceStreamEndResponse = VoiceStreamEnded | VoiceStreamError; - -export type VoiceParseDraft = { - schedule_type: ScheduleType; - title: string; - notes?: string | null; - start_time?: string | null; - end_time?: string | null; - timezone?: string | null; - location_name?: string | null; - location_address?: string | null; - latitude?: number | null; - longitude?: number | null; - geofence_radius_meters?: number | null; - time_remind_offset_minutes?: number | null; -}; - -export type VoiceParseReadyResult = { - type: 'voice.parse.result'; - request_id: string; - job_id: string; - status: 'ready_for_confirmation'; - draft: VoiceParseDraft; - missing_fields: string[]; - ambiguous_fields: string[]; - needs_confirmation: true; -}; - -export type VoiceParseFailedResult = { - type: 'voice.parse.result'; - request_id: string; - job_id: string; - status: 'failed'; - error: ApiError; -}; - -export type VoiceParseResultMessage = VoiceParseReadyResult | VoiceParseFailedResult; - -export type SessionHello = { - type: 'session.hello'; - device_id: string; - app_version: string; -}; - -export type SessionReady = { - type: 'session.ready'; - device_id: string; - server_time: string; -}; - -export type SessionError = { - type: 'session.error'; - ok: false; - error: ApiError; -}; - -export type LocationReport = { - type: 'location.report'; - schedule_scope: 'current'; - latitude: number; - longitude: number; - accuracy: number; - timestamp: string; -}; - -export type LocationReportAck = - | { type: 'location.report.ack'; ok: true } - | { type: 'location.report.ack'; ok: false; error: ApiError }; diff --git a/frontend/tsconfig.json b/frontend/tsconfig.json index 6a7437c..622a74c 100644 --- a/frontend/tsconfig.json +++ b/frontend/tsconfig.json @@ -14,6 +14,7 @@ "${configDir}/jest.config.js", "${configDir}/dist", "${configDir}/android", - "${configDir}/ios" + "${configDir}/ios", + "${configDir}/_backup_*" ] }