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_*"
]
}