diff --git a/frontend/.env.example b/frontend/.env.example index 877160b..58135df 100644 --- a/frontend/.env.example +++ b/frontend/.env.example @@ -1,2 +1,14 @@ -# Android emulator: 10.0.2.2 reaches the host machine. -EXPO_PUBLIC_API_URL=http://10.0.2.2:8000/api/v1 +# Baidu Maps browser-side AK with JavaScript API enabled. +# Allow localhost for web development and https://timeflow.local/* for the native WebView. +EXPO_PUBLIC_BAIDU_MAP_AK=replace-with-your-baidu-map-ak + +# 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 +# Production example: wss://api.example.com/ws +EXPO_PUBLIC_WS_URL= + +# Use in-process FakeWsServer when EXPO_PUBLIC_WS_URL is empty. +# In __DEV__, Fake is allowed by default if this is unset. +# Release builds always ignore this flag and fail fast without a URL. +# EXPO_PUBLIC_USE_FAKE_WS=true diff --git a/frontend/__tests__/app/overlay/OverlayProvider.test.tsx b/frontend/__tests__/app/overlay/OverlayProvider.test.tsx new file mode 100644 index 0000000..b65fedc --- /dev/null +++ b/frontend/__tests__/app/overlay/OverlayProvider.test.tsx @@ -0,0 +1,61 @@ +import { describe, expect, it, jest } from '@jest/globals'; +import { fireEvent, render, screen } from '@testing-library/react-native'; +import { StrictMode } from 'react'; +import { Pressable, Text, View } from 'react-native'; + +import { OverlayProvider, useOverlay, type OverlayKind } from '@/app/overlay/OverlayProvider'; + +function Harness({ onClose }: { onClose: () => void }) { + const { pop, popKind, push, stack } = useOverlay(); + + const add = (kind: OverlayKind) => { + push({ kind, onClose }); + }; + + return ( + <> + {stack.map((entry) => entry.kind).join(',')} + + add('standardCreate')} /> + add('assistant')} /> + + popKind('standardCreate')} /> + + + ); +} + +function renderHarness(onClose: () => void) { + return render( + + + + + , + ); +} + +describe('OverlayProvider', () => { + it('invokes onClose once when the top overlay is popped', () => { + const onClose = jest.fn(); + renderHarness(onClose); + + fireEvent.press(screen.getByLabelText('push-standard')); + fireEvent.press(screen.getByLabelText('pop')); + + expect(onClose).toHaveBeenCalledTimes(1); + }); + + it('removes and closes only the latest matching overlay', () => { + const onClose = jest.fn(); + renderHarness(onClose); + + fireEvent.press(screen.getByLabelText('push-standard')); + fireEvent.press(screen.getByLabelText('push-assistant')); + fireEvent.press(screen.getByLabelText('push-standard')); + fireEvent.press(screen.getByLabelText('pop-standard')); + + expect(onClose).toHaveBeenCalledTimes(1); + expect(screen.getByText('standardCreate,assistant')).toBeTruthy(); + }); +}); diff --git a/frontend/__tests__/app/session/sessionEndpoint.test.ts b/frontend/__tests__/app/session/sessionEndpoint.test.ts new file mode 100644 index 0000000..457c936 --- /dev/null +++ b/frontend/__tests__/app/session/sessionEndpoint.test.ts @@ -0,0 +1,28 @@ +import { describe, expect, it } from '@jest/globals'; + +import { buildSessionWebSocketUrl, resolveSessionUserId } from '@/app/session/sessionEndpoint'; + +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', + ); + }); + + it('replaces a stale device id while preserving other query parameters', () => { + expect( + buildSessionWebSocketUrl('wss://api.example.com/ws?token=test&device_id=stale', 'current'), + ).toBe('wss://api.example.com/ws?token=test&device_id=current'); + }); + + it('rejects non-WebSocket URLs', () => { + expect(() => buildSessionWebSocketUrl('http://127.0.0.1:8000/ws', 'device_1')).toThrow( + '必须使用 ws:// 或 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__/dev/fakes/schedule/scheduleConflicts.test.ts b/frontend/__tests__/dev/fakes/schedule/scheduleConflicts.test.ts new file mode 100644 index 0000000..1a66b56 --- /dev/null +++ b/frontend/__tests__/dev/fakes/schedule/scheduleConflicts.test.ts @@ -0,0 +1,135 @@ +import { describe, expect, it } from '@jest/globals'; + +import type { Schedule, ScheduleUpsertCommand } from '@/contracts'; + +import { upsertSchedule } from '@/dev/fakes/schedule/scheduleConflicts'; + +function makeSchedule(overrides: Partial = {}): Schedule { + return { + id: 'schedule_existing', + user_id: 'default_user', + source_mode: 'manual', + schedule_type: 'time', + status: 'scheduled', + title: '已有日程', + notes: null, + start_time: new Date(2026, 6, 29, 9, 0).toISOString(), + end_time: new Date(2026, 6, 29, 10, 0).toISOString(), + timezone: 'Asia/Shanghai', + location_name: null, + location_address: null, + latitude: null, + longitude: null, + geofence_radius_meters: 100, + geofence_armed: false, + time_remind_offset_minutes: 15, + time_triggered_at: null, + geo_triggered_at: null, + system_schedule_ref_id: null, + system_alarm_ref_id: null, + created_at: new Date(2026, 6, 20, 10, 0).toISOString(), + updated_at: new Date(2026, 6, 20, 10, 0).toISOString(), + ...overrides, + }; +} + +function makeCommand(startHour: number, endHour: number | null): ScheduleUpsertCommand { + return { + type: 'schedule.upsert.command', + request_id: 'req_test', + payload: { + source_mode: 'manual', + schedule_type: 'time', + title: '新日程', + start_time: new Date(2026, 6, 29, startHour, 0).toISOString(), + end_time: endHour === null ? null : new Date(2026, 6, 29, endHour, 0).toISOString(), + }, + }; +} + +describe('upsertSchedule conflict detection', () => { + const existing = [makeSchedule()]; + + it('flags a schedule that overlaps an existing one', () => { + const result = upsertSchedule(makeCommand(9, 10), existing, 'schedule_new'); + expect(result.payload.conflicts.map((conflict) => conflict.schedule_id)).toEqual([ + 'schedule_existing', + ]); + }); + + it('flags a partial overlap', () => { + const result = upsertSchedule(makeCommand(9, 11), existing, 'schedule_new'); + expect(result.payload.conflicts).toHaveLength(1); + }); + + it('treats touching boundaries as a conflict', () => { + const result = upsertSchedule(makeCommand(10, 11), existing, 'schedule_new'); + expect(result.payload.conflicts).toHaveLength(1); + }); + + it('reports nothing for a non-overlapping slot', () => { + const result = upsertSchedule(makeCommand(11, 12), existing, 'schedule_new'); + expect(result.payload.conflicts).toHaveLength(0); + }); + + it('does not conflict a schedule with itself while editing', () => { + const result = upsertSchedule(makeCommand(9, 10), existing, 'schedule_existing'); + expect(result.payload.conflicts).toHaveLength(0); + }); + + it('ignores deleted schedules', () => { + const deleted = [makeSchedule({ status: 'deleted' })]; + const result = upsertSchedule(makeCommand(9, 10), deleted, 'schedule_new'); + expect(result.payload.conflicts).toHaveLength(0); + }); + + it('reports nothing when the new schedule has no start time', () => { + const command: ScheduleUpsertCommand = { + type: 'schedule.upsert.command', + request_id: 'req_test', + payload: { + source_mode: 'manual', + schedule_type: 'location', + title: '地点日程', + start_time: null, + }, + }; + const result = upsertSchedule(command, existing, 'schedule_new'); + expect(result.payload.conflicts).toHaveLength(0); + }); + + it('echoes the request id and schedule id back', () => { + const result = upsertSchedule(makeCommand(14, 15), existing, 'schedule_new'); + expect(result.request_id).toBe('req_test'); + expect(result.payload.schedule_id).toBe('schedule_new'); + }); + + it('ignores unparseable start times on the new command', () => { + const command: ScheduleUpsertCommand = { + type: 'schedule.upsert.command', + request_id: 'req_test', + payload: { + source_mode: 'manual', + schedule_type: 'time', + title: '坏时间', + start_time: 'not-a-date', + end_time: null, + }, + }; + expect(upsertSchedule(command, existing, 'schedule_new').payload.conflicts).toHaveLength(0); + }); + + it('ignores existing items whose times do not parse', () => { + const broken = [makeSchedule({ id: 'broken', start_time: 'bad', end_time: 'also-bad' })]; + expect( + upsertSchedule(makeCommand(9, 10), broken, 'schedule_new').payload.conflicts, + ).toHaveLength(0); + }); + + it('inherits geofence_armed from the existing schedule when omitted', () => { + const armed = [makeSchedule({ id: 'schedule_existing', geofence_armed: true })]; + const command = makeCommand(14, 15); + delete (command.payload as { geofence_armed?: boolean }).geofence_armed; + expect(upsertSchedule(command, armed, 'schedule_existing').payload.geofence_armed).toBe(true); + }); +}); diff --git a/frontend/__tests__/features/schedule/application/ScheduleService.test.ts b/frontend/__tests__/features/schedule/application/ScheduleService.test.ts new file mode 100644 index 0000000..aea4b9c --- /dev/null +++ b/frontend/__tests__/features/schedule/application/ScheduleService.test.ts @@ -0,0 +1,211 @@ +import { afterEach, beforeEach, describe, expect, it, jest } from '@jest/globals'; + +import type { + Schedule, + ScheduleUpsertCommand, + ScheduleUpsertPayload as ScheduleDraft, + ScheduleUpsertResponse, +} from '@/contracts'; +import { ScheduleService } from '@/features/schedule/application/ScheduleService'; +import { ScheduleCache } from '@/features/schedule/data/ScheduleCache'; +import type { ScheduleRepositoryPort } from '@/features/schedule/data/ScheduleRepositoryPort'; +import { makeSchedule } from '@test/fixtures'; + +function makeDraft(overrides: Partial = {}): ScheduleDraft { + return { + source_mode: 'manual', + schedule_type: 'time', + title: '新会议', + start_time: new Date(Date.now() + 3_600_000).toISOString(), + end_time: null, + time_remind_offset_minutes: 5, + ...overrides, + }; +} + +function upsertOk(command: ScheduleUpsertCommand, id: string): ScheduleUpsertResponse { + return { + type: 'schedule.upsert.result', + request_id: command.request_id, + ok: true, + payload: { + schedule_id: id, + schedule_type: command.payload.schedule_type, + status: 'scheduled', + conflicts: [], + geofence_armed: true, + }, + }; +} + +describe('ScheduleService', () => { + let cache: ScheduleCache; + let repository: jest.Mocked; + let syncForSchedule: jest.MockedFunction< + NonNullable[0]['alarmAdapter']>['syncForSchedule'] + >; + let cancel: jest.MockedFunction< + NonNullable[0]['alarmAdapter']>['cancel'] + >; + let notifyConflicts: jest.MockedFunction< + NonNullable[0]['notifyConflicts']> + >; + let service: ScheduleService; + + beforeEach(() => { + jest.clearAllMocks(); + cache = new ScheduleCache(); + repository = { + list: jest.fn(async () => [] as Schedule[]), + upsert: jest.fn(async (command: ScheduleUpsertCommand) => + upsertOk(command, command.payload.schedule_id ?? 'schedule_auto'), + ), + updateStatus: jest.fn(async (id: string, status: 'scheduled' | 'done') => ({ + type: 'schedule.status.result' as const, + request_id: `req_status_${id}`, + ok: true as const, + payload: { schedule_id: id, status }, + })), + notifyDeleted: jest.fn(async (id: string) => ({ + type: 'schedule.deleted.ack' as const, + request_id: `req_deleted_${id}`, + schedule_id: id, + ok: true as const, + })), + subscribe: jest.fn(() => () => undefined), + }; + syncForSchedule = jest.fn(async () => null); + cancel = jest.fn(async () => null); + notifyConflicts = jest.fn(); + service = new ScheduleService({ + repository, + cache, + getUserId: () => 'default_user', + alarmAdapter: { syncForSchedule, cancel }, + notifyConflicts, + }); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + it('bootstraps from repository.list into cache', async () => { + const seed = [makeSchedule({ id: 'seed' })]; + repository.list.mockResolvedValueOnce(seed); + await service.bootstrap(); + expect(service.getItems()).toEqual(seed); + }); + + it('creates a schedule via upsert and caches it', async () => { + const saved = await service.saveDraft(makeDraft({ title: 'A' })); + expect(saved.title).toBe('A'); + expect(saved.id).toBe('schedule_auto'); + expect(service.getItems()).toHaveLength(1); + expect(repository.upsert).toHaveBeenCalled(); + expect(repository.upsert.mock.calls[0]![0].payload.schedule_id).toBeUndefined(); + }); + + it('alerts when upsert reports conflicts', async () => { + repository.upsert.mockImplementation(async (command) => ({ + type: 'schedule.upsert.result', + request_id: command.request_id, + ok: true, + payload: { + schedule_id: command.payload.schedule_id ?? 'x', + schedule_type: command.payload.schedule_type, + status: 'scheduled', + conflicts: [ + { + schedule_id: 'other', + title: '已有会议', + start_time: new Date().toISOString(), + end_time: null, + }, + ], + geofence_armed: true, + }, + })); + + await service.saveDraft(makeDraft()); + expect(notifyConflicts).toHaveBeenCalledWith([expect.objectContaining({ title: '已有会议' })]); + }); + + it('updates an existing schedule when schedule_id is set', async () => { + await service.saveDraft(makeDraft({ schedule_id: 'schedule_edit', title: '旧标题' })); + await service.saveDraft(makeDraft({ schedule_id: 'schedule_edit', title: '新标题' })); + expect(service.getItems()).toHaveLength(1); + expect(service.getItems()[0]?.title).toBe('新标题'); + }); + + it('syncs android alarms when adapter returns an id', async () => { + syncForSchedule.mockResolvedValue('alarm_99'); + const saved = await service.saveDraft(makeDraft({ title: '安卓会议' })); + expect(syncForSchedule).toHaveBeenCalled(); + expect(saved.system_schedule_ref_id).toBe('alarm_99'); + }); + + it('toggles done through updateStatus and re-arms on undo', async () => { + syncForSchedule.mockResolvedValue('alarm_old'); + await service.saveDraft( + makeDraft({ + schedule_id: 'toggle_1', + start_time: new Date(Date.now() + 3_600_000).toISOString(), + }), + ); + const item = service.getItems()[0]!; + await service.toggleDone(item); + expect(repository.updateStatus).toHaveBeenCalledWith('toggle_1', 'done'); + expect(repository.notifyDeleted).not.toHaveBeenCalled(); + expect(service.getItems()[0]?.status).toBe('done'); + + syncForSchedule.mockResolvedValue('alarm_rearm'); + await service.toggleDone(service.getItems()[0]!); + expect(repository.updateStatus).toHaveBeenCalledWith('toggle_1', 'scheduled'); + expect(service.getItems()[0]?.status).toBe('scheduled'); + expect(syncForSchedule).toHaveBeenCalled(); + }); + + it('marks a schedule deleted and cancels its alarm', async () => { + syncForSchedule.mockResolvedValue('alarm_del'); + await service.saveDraft(makeDraft({ schedule_id: 'del_1' })); + await service.deleteSchedule(service.getItems()[0]!); + expect(repository.notifyDeleted).toHaveBeenCalledWith('del_1'); + expect(cancel).toHaveBeenCalledWith('alarm_del'); + expect(service.getItems()[0]?.status).toBe('deleted'); + expect(service.getItems()[0]?.system_schedule_ref_id).toBeNull(); + }); + + it('keeps the alarm reference returned by the platform adapter', async () => { + cancel.mockResolvedValue('remote_alarm'); + cache.replaceAll([makeSchedule({ id: 'remote', system_schedule_ref_id: 'local_alarm' })]); + + await service.deleteSchedule(service.getItems()[0]!); + + expect(service.getItems()[0]?.system_schedule_ref_id).toBe('remote_alarm'); + }); + + it('does not mutate cache or alarms when delete is rejected', async () => { + repository.notifyDeleted.mockResolvedValueOnce({ + type: 'schedule.deleted.ack', + request_id: 'req_delete_failed', + schedule_id: 'reject', + ok: false, + error: { code: 'denied', message: '删除被拒绝', details: null }, + }); + const schedule = makeSchedule({ id: 'reject', system_schedule_ref_id: 'alarm_reject' }); + cache.replaceAll([schedule]); + + await expect(service.deleteSchedule(schedule)).rejects.toThrow('删除被拒绝'); + expect(cancel).not.toHaveBeenCalled(); + expect(service.getItems()[0]).toEqual(schedule); + }); + + it('ignores toggle and delete for already deleted items', async () => { + cache.replaceAll([makeSchedule({ id: 'gone', status: 'deleted' })]); + await service.toggleDone(service.getItems()[0]!); + await service.deleteSchedule(service.getItems()[0]!); + expect(repository.updateStatus).not.toHaveBeenCalled(); + expect(repository.notifyDeleted).not.toHaveBeenCalled(); + }); +}); diff --git a/frontend/__tests__/features/schedule/calendar/MonthView.test.tsx b/frontend/__tests__/features/schedule/calendar/MonthView.test.tsx new file mode 100644 index 0000000..23a2939 --- /dev/null +++ b/frontend/__tests__/features/schedule/calendar/MonthView.test.tsx @@ -0,0 +1,89 @@ +import { describe, expect, it, jest } from '@jest/globals'; +import { fireEvent, render, screen } from '@testing-library/react-native'; + +import { makeSchedule } from '@test/fixtures'; + +import { MonthView } from '@/features/schedule/calendar/MonthView'; +import { buildScheduleIndex } from '@/features/schedule/calendar/scheduleIndex'; + +describe('MonthView', () => { + const now = new Date(2026, 6, 31); + const month = new Date(2026, 6, 1); + + it('navigates months and selects a day with events', () => { + const onMonthChange = jest.fn(); + const onSelectDate = jest.fn(); + const onOpenSchedule = jest.fn(); + + render( + , + ); + + expect(screen.getByText('7月')).toBeTruthy(); + expect(screen.getByText('月底会议')).toBeTruthy(); + + fireEvent.press(screen.getByLabelText('上个月')); + expect(onMonthChange).toHaveBeenCalled(); + fireEvent.press(screen.getByLabelText('下个月')); + expect(onMonthChange).toHaveBeenCalledTimes(2); + + fireEvent.press(screen.getByText('月底会议')); + expect(onOpenSchedule).toHaveBeenCalledWith('m1'); + }); + + it('shows empty agenda copy when the selected day has no events', () => { + render( + , + ); + expect(screen.getByText('这一天暂无详细安排')).toBeTruthy(); + }); + + it('renders an undated location reminder and lets the user open it', () => { + const onOpenSchedule = jest.fn(); + render( + , + ); + + expect(screen.getByText('地点提醒')).toBeTruthy(); + fireEvent.press(screen.getByText('到公司提醒')); + expect(onOpenSchedule).toHaveBeenCalledWith('location-1'); + }); +}); diff --git a/frontend/__tests__/features/schedule/calendar/ScheduleRow.test.tsx b/frontend/__tests__/features/schedule/calendar/ScheduleRow.test.tsx new file mode 100644 index 0000000..5569c86 --- /dev/null +++ b/frontend/__tests__/features/schedule/calendar/ScheduleRow.test.tsx @@ -0,0 +1,40 @@ +import { describe, expect, it, jest } from '@jest/globals'; +import { fireEvent, render, screen } from '@testing-library/react-native'; + +import { makeSchedule } from '@test/fixtures'; + +import { ScheduleRow } from '@/features/schedule/calendar/ScheduleRow'; + +describe('ScheduleRow', () => { + it('renders title, time and optional meta', () => { + const onPress = jest.fn(); + const onToggle = jest.fn(); + render( + , + ); + expect(screen.getByText('晨会')).toBeTruthy(); + expect(screen.getByText('会议室')).toBeTruthy(); + fireEvent.press(screen.getByLabelText('09:05 晨会')); + expect(onPress).toHaveBeenCalled(); + fireEvent.press(screen.getByLabelText('完成 晨会')); + expect(onToggle).toHaveBeenCalled(); + }); + + it('shows restore affordance for done items in compact mode', () => { + const onToggle = jest.fn(); + render( + , + ); + fireEvent.press(screen.getByLabelText('恢复 已做完')); + expect(onToggle).toHaveBeenCalled(); + }); +}); diff --git a/frontend/__tests__/features/schedule/data/WsScheduleRepository.test.ts b/frontend/__tests__/features/schedule/data/WsScheduleRepository.test.ts new file mode 100644 index 0000000..6813609 --- /dev/null +++ b/frontend/__tests__/features/schedule/data/WsScheduleRepository.test.ts @@ -0,0 +1,33 @@ +import { describe, expect, it, jest } from '@jest/globals'; + +import type { WsJsonMessage } from '@/contracts'; +import type { ScheduleTransport } from '@/features/schedule/data/ScheduleTransport'; +import { WsScheduleRepository } from '@/features/schedule/data/WsScheduleRepository'; + +describe('WsScheduleRepository backend compatibility', () => { + it('accepts the MVP delete acknowledgement without request_id', async () => { + const request = jest.fn( + async ( + _message: WsJsonMessage & { request_id: string }, + isMatch?: (response: WsJsonMessage) => boolean, + ) => { + const response = { + type: 'schedule.deleted.ack', + schedule_id: 'schedule_1', + ok: true, + }; + expect(isMatch?.(response)).toBe(true); + return response; + }, + ); + const transport = { + onMessage: () => () => undefined, + request, + sendJson: () => undefined, + } as unknown as ScheduleTransport; + const repository = new WsScheduleRepository(transport); + + await expect(repository.notifyDeleted('schedule_1')).resolves.toMatchObject({ ok: true }); + repository.dispose(); + }); +}); diff --git a/frontend/__tests__/features/schedule/data/adapters.test.ts b/frontend/__tests__/features/schedule/data/adapters.test.ts new file mode 100644 index 0000000..e7fbcfe --- /dev/null +++ b/frontend/__tests__/features/schedule/data/adapters.test.ts @@ -0,0 +1,38 @@ +import { describe, expect, it } from '@jest/globals'; + +import { upsertDraftForSchedule } from '@/features/schedule/data/adapters'; +import { makeSchedule } from '@test/fixtures'; + +describe('upsertDraftForSchedule', () => { + it('maps schedule fields into a domain draft', () => { + const schedule = makeSchedule({ + id: 'schedule_42', + notes: '备注', + location_name: '办公室', + location_address: '南京东路1号', + latitude: 31.2, + longitude: 121.4, + geofence_radius_meters: 200, + geofence_armed: true, + time_remind_offset_minutes: 10, + }); + + expect(upsertDraftForSchedule(schedule)).toEqual({ + schedule_id: 'schedule_42', + source_mode: 'manual', + schedule_type: 'time', + title: '测试日程', + notes: '备注', + start_time: schedule.start_time, + end_time: null, + timezone: 'Asia/Shanghai', + location_name: '办公室', + location_address: '南京东路1号', + latitude: 31.2, + longitude: 121.4, + geofence_radius_meters: 200, + geofence_armed: true, + time_remind_offset_minutes: 10, + }); + }); +}); diff --git a/frontend/__tests__/features/schedule/detail/ScheduleDetailSheet.test.tsx b/frontend/__tests__/features/schedule/detail/ScheduleDetailSheet.test.tsx new file mode 100644 index 0000000..3fd7f92 --- /dev/null +++ b/frontend/__tests__/features/schedule/detail/ScheduleDetailSheet.test.tsx @@ -0,0 +1,84 @@ +import { describe, expect, it, jest } from '@jest/globals'; +import { fireEvent, render, screen, waitFor } from '@testing-library/react-native'; +import type { ReactElement } from 'react'; + +import { makeSchedule } from '@test/fixtures'; +import { ScheduleDetailSheet } from '@/features/schedule/detail/ScheduleDetailSheet'; +import { AppDialogProvider } from '@/shared/components/AppDialogProvider'; + +function renderWithDialog(element: ReactElement) { + return render({element}); +} + +describe('ScheduleDetailSheet', () => { + it('is hidden when schedule is null', () => { + renderWithDialog( + , + ); + expect(screen.queryByText('安排详情')).toBeNull(); + }); + + it('shows schedule content and opens the day view', () => { + const onClose = jest.fn(); + const onOpenDay = jest.fn(); + renderWithDialog( + , + ); + expect(screen.getByText('评审会')).toBeTruthy(); + fireEvent.press(screen.getByLabelText('查看当天日程')); + expect(onClose).toHaveBeenCalled(); + expect(onOpenDay).toHaveBeenCalled(); + }); + + it('offers edit when editable and confirms delete', async () => { + const onEdit = jest.fn(); + const onDelete = jest.fn(); + const onClose = jest.fn(); + renderWithDialog( + , + ); + fireEvent.press(screen.getByLabelText('编辑日程')); + expect(onEdit).toHaveBeenCalled(); + + fireEvent.press(screen.getByLabelText('删除日程')); + expect(screen.getByText('确定删除这个日程吗?相关提醒也会一并取消。')).toBeTruthy(); + fireEvent.press(screen.getByLabelText('删除')); + await waitFor(() => expect(onDelete).toHaveBeenCalled()); + expect(onClose).toHaveBeenCalled(); + }); + + it('renders completed status styling', () => { + renderWithDialog( + , + ); + expect(screen.getAllByText('已完成').length).toBeGreaterThan(0); + expect(screen.getByText('已完成 · 可回顾这次安排')).toBeTruthy(); + }); +}); diff --git a/frontend/__tests__/features/schedule/domain/scheduleOrdering.test.ts b/frontend/__tests__/features/schedule/domain/scheduleOrdering.test.ts new file mode 100644 index 0000000..a7c14d6 --- /dev/null +++ b/frontend/__tests__/features/schedule/domain/scheduleOrdering.test.ts @@ -0,0 +1,41 @@ +import { describe, expect, it } from '@jest/globals'; + +import { makeSchedule } from '@test/fixtures'; + +import { compareSchedules } from '@/features/schedule/domain/scheduleOrdering'; + +describe('compareSchedules', () => { + it('orders by start_time when both have one', () => { + const earlier = makeSchedule({ + id: 'a', + start_time: new Date(2026, 6, 29, 8, 0).toISOString(), + }); + const later = makeSchedule({ + id: 'b', + start_time: new Date(2026, 6, 29, 10, 0).toISOString(), + }); + expect(compareSchedules(earlier, later)).toBeLessThan(0); + expect(compareSchedules(later, earlier)).toBeGreaterThan(0); + }); + + it('puts timed schedules before location-only ones', () => { + const timed = makeSchedule({ id: 't', start_time: new Date(2026, 6, 29, 9, 0).toISOString() }); + const locationOnly = makeSchedule({ id: 'l', start_time: null }); + expect(compareSchedules(timed, locationOnly)).toBe(-1); + expect(compareSchedules(locationOnly, timed)).toBe(1); + }); + + it('falls back to created_at when neither has start_time', () => { + const older = makeSchedule({ + id: 'old', + start_time: null, + created_at: new Date(2026, 6, 1).toISOString(), + }); + const newer = makeSchedule({ + id: 'new', + start_time: null, + created_at: new Date(2026, 6, 20).toISOString(), + }); + expect(compareSchedules(older, newer)).toBeGreaterThan(0); + }); +}); diff --git a/frontend/__tests__/features/schedule/editor/DateTimeField.test.tsx b/frontend/__tests__/features/schedule/editor/DateTimeField.test.tsx new file mode 100644 index 0000000..b730369 --- /dev/null +++ b/frontend/__tests__/features/schedule/editor/DateTimeField.test.tsx @@ -0,0 +1,91 @@ +import { describe, expect, it, jest } from '@jest/globals'; +import { fireEvent, render, screen } from '@testing-library/react-native'; + +jest.mock('@/shared/components/DatePickerSheet', () => ({ + DatePickerSheet: ({ + visible, + onSelect, + onClose, + }: { + visible: boolean; + onSelect: (date: Date) => void; + onClose: () => void; + }) => { + const { Pressable, Text } = require('react-native'); + if (!visible) return null; + return ( + { + onSelect(new Date(2026, 7, 1)); + onClose(); + }} + > + mock-date + + ); + }, +})); + +jest.mock('@/shared/components/TimePickerSheet', () => ({ + TimePickerSheet: ({ + visible, + onSelect, + onClose, + }: { + visible: boolean; + onSelect: (value: string) => void; + onClose: () => void; + }) => { + const { Pressable, Text } = require('react-native'); + if (!visible) return null; + return ( + { + onSelect('15:30'); + onClose(); + }} + > + mock-time + + ); + }, +})); + +import { DateTimeField } from '@/features/schedule/editor/DateTimeField'; + +describe('DateTimeField', () => { + it('opens the date sheet and formats the selection', () => { + const onChange = jest.fn(); + render( + , + ); + fireEvent.press(screen.getByLabelText('日期')); + fireEvent.press(screen.getByLabelText('mock-date-select')); + expect(onChange).toHaveBeenCalledWith('2026 / 08 / 01'); + }); + + it('opens the time sheet and returns HH:mm', () => { + const onChange = jest.fn(); + render( + , + ); + expect(screen.getByText('09:00')).toBeTruthy(); + fireEvent.press(screen.getByLabelText('时间')); + fireEvent.press(screen.getByLabelText('mock-time-select')); + expect(onChange).toHaveBeenCalledWith('15:30'); + }); +}); diff --git a/frontend/__tests__/features/schedule/editor/StandardCreateSheet.test.tsx b/frontend/__tests__/features/schedule/editor/StandardCreateSheet.test.tsx new file mode 100644 index 0000000..d369d72 --- /dev/null +++ b/frontend/__tests__/features/schedule/editor/StandardCreateSheet.test.tsx @@ -0,0 +1,190 @@ +import { describe, expect, it, jest } from '@jest/globals'; +import { fireEvent, render, screen, waitFor } from '@testing-library/react-native'; + +import { StandardCreateSheet } from '@/features/schedule/editor/StandardCreateSheet'; +import type { ScheduleUpsertPayload as ScheduleDraft } from '@/contracts'; + +function pressPrimaryAction(label: string) { + const matches = screen.getAllByText(label); + fireEvent.press(matches[matches.length - 1]!); +} + +describe('StandardCreateSheet', () => { + const baseProps = { + onClose: jest.fn(), + onSave: jest.fn(async (_draft: ScheduleDraft) => undefined), + onUpsertLocation: jest.fn(), + savedLocations: [] as [], + }; + + it('rejects an empty title', async () => { + render(); + pressPrimaryAction('添加日程'); + expect(await screen.findByText('请填写日程标题。')).toBeTruthy(); + expect(baseProps.onSave).not.toHaveBeenCalled(); + }); + + it('creates a time schedule with a future start', async () => { + const onSave = jest.fn(async (_draft: ScheduleDraft) => undefined); + + render(); + fireEvent.changeText(screen.getByPlaceholderText('请输入日程标题'), '项目评审'); + pressPrimaryAction('添加日程'); + + await waitFor(() => expect(onSave).toHaveBeenCalled()); + const draft = onSave.mock.calls[0]![0]; + expect(draft.title).toBe('项目评审'); + expect(draft.schedule_type).toBe('time'); + expect(draft.start_time).toBeTruthy(); + }); + + it('rejects a start time that is not in the future', async () => { + const past = new Date(Date.now() - 120_000); + render( + , + ); + pressPrimaryAction('保存修改'); + expect(await screen.findByText('开始时间需晚于当前分钟,请选择下一分钟及以后。')).toBeTruthy(); + }); + + it('surfaces save errors from onSave', async () => { + const onSave = jest.fn(async () => { + throw new Error('网络异常'); + }); + render(); + fireEvent.changeText(screen.getByPlaceholderText('请输入日程标题'), '会失败'); + pressPrimaryAction('添加日程'); + expect(await screen.findByText('网络异常')).toBeTruthy(); + }); + + it('surfaces non-Error save failures', async () => { + const onSave = jest.fn(async () => { + throw 'boom'; + }); + render(); + fireEvent.changeText(screen.getByPlaceholderText('请输入日程标题'), '会失败'); + pressPrimaryAction('添加日程'); + expect(await screen.findByText('保存失败,请稍后重试。')).toBeTruthy(); + }); + + it('saves a location-only schedule', async () => { + const onSave = jest.fn(async (_draft: ScheduleDraft) => undefined); + const location = { + id: 'loc_1', + address: '南京东路1号', + latitude: 31.2, + longitude: 121.5, + name: '办公室', + }; + render( + , + ); + // Clearing the date also clears start/end, so the schedule becomes location-only. + fireEvent.press(screen.getByLabelText('清除日期')); + pressPrimaryAction('添加日程'); + await waitFor(() => expect(onSave).toHaveBeenCalled()); + expect(onSave.mock.calls[0]![0].schedule_type).toBe('location'); + }); + + it('rejects end time earlier than start', async () => { + const future = new Date(Date.now() + 3_600_000); + const later = new Date(Date.now() + 7_200_000); + render( + , + ); + pressPrimaryAction('保存修改'); + expect(await screen.findByText('结束时间不能早于开始时间。')).toBeTruthy(); + }); + + it('rejects a negative remind offset', async () => { + const future = new Date(Date.now() + 3_600_000); + render( + , + ); + fireEvent.changeText(screen.getByLabelText('提前提醒分钟数'), '-1'); + pressPrimaryAction('保存修改'); + expect(await screen.findByText('提前提醒分钟数必须是非负整数。')).toBeTruthy(); + }); + + it('rejects invalid geofence radius for location schedules', async () => { + render( + , + ); + fireEvent.press(screen.getByLabelText('清除日期')); + fireEvent.changeText(screen.getByLabelText('地理围栏半径'), '0'); + pressPrimaryAction('添加日程'); + expect(await screen.findByText('地理围栏半径必须是大于 0 的整数。')).toBeTruthy(); + }); +}); diff --git a/frontend/__tests__/features/schedule/editor/datetime.test.ts b/frontend/__tests__/features/schedule/editor/datetime.test.ts new file mode 100644 index 0000000..10bfce8 --- /dev/null +++ b/frontend/__tests__/features/schedule/editor/datetime.test.ts @@ -0,0 +1,126 @@ +import { describe, expect, it } from '@jest/globals'; + +import { + currentTimezone, + dateAndTimeFromIso, + defaultCreateDateAndTime, + formatDateValue, + isoFromDateAndTime, + optionalNumber, + parseDateValue, + parsePickerValue, + parseTimeValue, +} from '@/features/schedule/editor/datetime'; +import { formatTimeValue } from '@/shared/utils/date'; + +describe('parseDateValue', () => { + it('round-trips the format the field renders', () => { + expect(formatDateValue(parseDateValue('2026 / 07 / 29'))).toBe('2026 / 07 / 29'); + }); + + it('accepts any non-digit separator', () => { + expect(formatDateValue(parseDateValue('2026-7-29'))).toBe('2026 / 07 / 29'); + }); + + it('falls back to today when the input is incomplete', () => { + const today = new Date(); + today.setHours(0, 0, 0, 0); + expect(parseDateValue('2026 / 07').getTime()).toBe(today.getTime()); + }); +}); + +describe('parseTimeValue', () => { + it('reads hours and minutes', () => { + expect(formatTimeValue(parseTimeValue('09:05'))).toBe('09:05'); + }); + + it('accepts a single-digit hour', () => { + expect(formatTimeValue(parseTimeValue('9:05'))).toBe('09:05'); + }); +}); + +describe('isoFromDateAndTime', () => { + it('combines the two fields into one instant', () => { + expect(isoFromDateAndTime('2026 / 07 / 29', '09:05')).toBe( + new Date(2026, 6, 29, 9, 5).toISOString(), + ); + }); + + it('returns null when the time does not parse', () => { + expect(isoFromDateAndTime('2026 / 07 / 29', '9am')).toBeNull(); + }); + + it('returns null when the date is incomplete', () => { + expect(isoFromDateAndTime('2026 / 07', '09:05')).toBeNull(); + }); +}); + +describe('defaultCreateDateAndTime', () => { + it('defaults to the next whole minute', () => { + expect(defaultCreateDateAndTime(new Date(2026, 6, 30, 13, 58, 40, 123))).toEqual({ + date: '2026 / 07 / 30', + time: '13:59', + }); + }); + + it('rolls to the next day near midnight', () => { + expect(defaultCreateDateAndTime(new Date(2026, 6, 30, 23, 59, 10))).toEqual({ + date: '2026 / 07 / 31', + time: '00:00', + }); + }); +}); + +describe('dateAndTimeFromIso', () => { + it('returns empty strings for a missing value', () => { + expect(dateAndTimeFromIso(null)).toEqual({ date: '', time: '' }); + }); + + it('returns empty strings for an unparseable value', () => { + expect(dateAndTimeFromIso('not-a-date')).toEqual({ date: '', time: '' }); + }); + + it('splits an ISO string back into the two fields', () => { + expect(dateAndTimeFromIso(new Date(2026, 6, 29, 9, 5).toISOString())).toEqual({ + date: '2026 / 07 / 29', + time: '09:05', + }); + }); +}); + +describe('optionalNumber', () => { + it('treats blank input as absent rather than zero', () => { + expect(optionalNumber('')).toBeNull(); + expect(optionalNumber(' ')).toBeNull(); + }); + + it('rejects non-numeric input', () => { + expect(optionalNumber('abc')).toBeNull(); + }); + + it('keeps negative and decimal values', () => { + expect(optionalNumber('-31.2451')).toBe(-31.2451); + expect(optionalNumber('0')).toBe(0); + }); +}); + +describe('parsePickerValue', () => { + it('delegates to date or time parsers by mode', () => { + expect(formatDateValue(parsePickerValue('2026 / 07 / 29', 'date'))).toBe('2026 / 07 / 29'); + expect(formatTimeValue(parsePickerValue('09:05', 'time'))).toBe('09:05'); + }); +}); + +describe('parseTimeValue fallback', () => { + it('keeps the current clock when the string does not match', () => { + const parsed = parseTimeValue('bad'); + expect(parsed.getSeconds()).toBe(0); + expect(parsed.getMilliseconds()).toBe(0); + }); +}); + +describe('currentTimezone', () => { + it('returns the runtime timezone when Intl is available', () => { + expect(typeof currentTimezone()).toBe('string'); + }); +}); diff --git a/frontend/__tests__/features/schedule/location/AddressEditorSheet.test.tsx b/frontend/__tests__/features/schedule/location/AddressEditorSheet.test.tsx new file mode 100644 index 0000000..5a19bb7 --- /dev/null +++ b/frontend/__tests__/features/schedule/location/AddressEditorSheet.test.tsx @@ -0,0 +1,39 @@ +import { describe, expect, it, jest } from '@jest/globals'; +import { fireEvent, render, screen } from '@testing-library/react-native'; + +jest.mock('@/features/schedule/location/MapPicker', () => ({ + MapPicker: () => null, +})); + +import { AddressEditorSheet } from '@/features/schedule/location/AddressEditorSheet'; + +describe('AddressEditorSheet', () => { + it('requires a map location before save', () => { + const onSave = jest.fn(); + render(); + fireEvent.press(screen.getByLabelText('保存地点')); + expect(screen.getByText('请选择一个地图位置')).toBeTruthy(); + expect(onSave).not.toHaveBeenCalled(); + }); + + it('saves with an optional name', () => { + const onSave = jest.fn(); + render( + , + ); + fireEvent.changeText(screen.getByLabelText('地点名称'), '办公室'); + fireEvent.press(screen.getByLabelText('保存地点')); + expect(onSave).toHaveBeenCalledWith( + expect.objectContaining({ + address: '南京东路1号', + name: '办公室', + }), + ); + }); +}); diff --git a/frontend/__tests__/features/schedule/location/LocationPickerSheet.test.tsx b/frontend/__tests__/features/schedule/location/LocationPickerSheet.test.tsx new file mode 100644 index 0000000..71a22cf --- /dev/null +++ b/frontend/__tests__/features/schedule/location/LocationPickerSheet.test.tsx @@ -0,0 +1,100 @@ +import { describe, expect, it, jest } from '@jest/globals'; +import { fireEvent, render, screen } from '@testing-library/react-native'; + +jest.mock('@/features/schedule/location/AddressEditorSheet', () => ({ + AddressEditorSheet: ({ + visible, + onSave, + onClose, + }: { + visible: boolean; + onSave: (location: { + address: string; + latitude: number; + longitude: number; + name?: string; + }) => void; + onClose: () => void; + }) => { + const { Pressable, Text } = require('react-native'); + if (!visible) return null; + return ( + <> + + onSave({ address: '新地址', latitude: 31.1, longitude: 121.1, name: '新地点' }) + } + > + mock-save + + + mock-close + + + ); + }, +})); + +import { LocationPickerSheet } from '@/features/schedule/location/LocationPickerSheet'; + +const office = { + id: 'loc_1', + address: '南京东路1号', + latitude: 31.2, + longitude: 121.5, + name: '办公室', +}; + +describe('LocationPickerSheet', () => { + it('shows empty state', () => { + render( + , + ); + expect(screen.getByText('还没有常用地点')).toBeTruthy(); + }); + + it('selects a location and closes', () => { + const onSelect = jest.fn(); + const onClose = jest.fn(); + render( + , + ); + fireEvent.press(screen.getByLabelText('选择地点 办公室')); + expect(onSelect).toHaveBeenCalledWith(office); + expect(onClose).toHaveBeenCalled(); + }); + + it('opens the editor and upserts a new location', () => { + const onUpsert = jest.fn(); + const onSelect = jest.fn(); + const onClose = jest.fn(); + render( + , + ); + fireEvent.press(screen.getByLabelText('添加地点')); + fireEvent.press(screen.getByLabelText('mock-save-location')); + expect(onUpsert).toHaveBeenCalled(); + expect(onSelect).toHaveBeenCalled(); + expect(onClose).toHaveBeenCalled(); + }); +}); diff --git a/frontend/__tests__/features/schedule/location/MapPicker/MapPicker.native.test.tsx b/frontend/__tests__/features/schedule/location/MapPicker/MapPicker.native.test.tsx new file mode 100644 index 0000000..113de65 --- /dev/null +++ b/frontend/__tests__/features/schedule/location/MapPicker/MapPicker.native.test.tsx @@ -0,0 +1,149 @@ +import { describe, expect, it, jest } from '@jest/globals'; +import { act, fireEvent, render, screen } from '@testing-library/react-native'; + +jest.mock('@/features/schedule/location/MapPicker/baidu', () => ({ + BAIDU_MAP_AK: 'test-ak', + createCoordinateLocation: (latitude: number, longitude: number) => ({ + address: `坐标 ${latitude},${longitude}`, + latitude, + longitude, + }), + buildBaiduMapDocument: () => '', +})); + +jest.mock('@/features/schedule/location/MapPicker/Overlay', () => ({ + MapPickerOverlay: ({ + onCancel, + onConfirm, + onLocate, + onSearch, + mapReady, + selection, + }: { + onCancel: () => void; + onConfirm: () => void; + onLocate: () => void; + onSearch: (query: string) => Promise; + mapReady: boolean; + selection: { address: string } | null; + }) => { + const { Pressable, Text } = require('react-native'); + return ( + <> + {mapReady ? 'ready' : 'loading'} + {selection?.address ?? 'no-selection'} + + cancel + + + confirm + + + locate + + { + void onSearch('外滩'); + }} + > + search + + + ); + }, +})); + +import { MapPicker } from '@/features/schedule/location/MapPicker/MapPicker.native'; + +describe('MapPicker.native', () => { + it('handles bridge messages and confirms the selection', async () => { + const onConfirm = jest.fn(); + const onCancel = jest.fn(); + const { UNSAFE_getByType } = render( + , + ); + + const WebView = require('react-native-webview').WebView; + const webview = UNSAFE_getByType(WebView); + + await act(async () => { + webview.props.onMessage({ + nativeEvent: { data: JSON.stringify({ type: 'map-ready' }) }, + }); + }); + expect(screen.getByText('ready')).toBeTruthy(); + + await act(async () => { + webview.props.onMessage({ + nativeEvent: { + data: JSON.stringify({ type: 'selecting', latitude: 31.2, longitude: 121.5 }), + }, + }); + }); + expect(screen.getByText('坐标 31.2,121.5')).toBeTruthy(); + + await act(async () => { + webview.props.onMessage({ + nativeEvent: { + data: JSON.stringify({ + type: 'selected', + location: { address: '外滩', latitude: 31.2, longitude: 121.5 }, + }), + }, + }); + }); + expect(screen.getByText('外滩')).toBeTruthy(); + + fireEvent.press(screen.getByLabelText('mock-confirm')); + expect(onConfirm).toHaveBeenCalledWith(expect.objectContaining({ address: '外滩' })); + fireEvent.press(screen.getByLabelText('mock-cancel')); + expect(onCancel).toHaveBeenCalled(); + }); + + it('surfaces map errors from the bridge', async () => { + const { UNSAFE_getByType } = render( + , + ); + const WebView = require('react-native-webview').WebView; + const webview = UNSAFE_getByType(WebView); + await act(async () => { + webview.props.onMessage({ + nativeEvent: { data: JSON.stringify({ type: 'map-error', message: '坏了' }) }, + }); + }); + expect(screen.getByText('loading')).toBeTruthy(); + }); + + it('handles location errors from the bridge', async () => { + const { UNSAFE_getByType } = render( + , + ); + const WebView = require('react-native-webview').WebView; + const webview = UNSAFE_getByType(WebView); + + await act(async () => { + webview.props.onMessage({ + nativeEvent: { data: JSON.stringify({ type: 'map-ready' }) }, + }); + }); + + await act(async () => { + webview.props.onMessage({ + nativeEvent: { + data: JSON.stringify({ type: 'location-error', message: '无定位' }), + }, + }); + }); + + fireEvent.press(screen.getByLabelText('mock-locate')); + + await act(async () => { + webview.props.onMessage({ nativeEvent: { data: 'not-json' } }); + }); + }); +}); diff --git a/frontend/__tests__/features/schedule/location/MapPicker/Overlay.test.tsx b/frontend/__tests__/features/schedule/location/MapPicker/Overlay.test.tsx new file mode 100644 index 0000000..6ab1054 --- /dev/null +++ b/frontend/__tests__/features/schedule/location/MapPicker/Overlay.test.tsx @@ -0,0 +1,92 @@ +import { afterEach, beforeEach, describe, expect, it, jest } from '@jest/globals'; +import { act, fireEvent, render, screen } from '@testing-library/react-native'; + +import { MapPickerOverlay } from '@/features/schedule/location/MapPicker/Overlay'; + +async function flushDebouncedSearch() { + await act(async () => { + jest.advanceTimersByTime(320); + await Promise.resolve(); + await Promise.resolve(); + }); +} + +describe('MapPickerOverlay', () => { + beforeEach(() => { + jest.useFakeTimers(); + }); + + afterEach(() => { + jest.useRealTimers(); + }); + + const base = { + mapError: null as string | null, + mapReady: true, + locating: false, + locationError: null as string | null, + onCancel: jest.fn(), + onLocate: jest.fn(), + onConfirm: jest.fn(), + onSearch: jest.fn(async () => [ + { address: '外滩 · 中山东一路', latitude: 31.24, longitude: 121.49 }, + ]), + onSelectSearchResult: jest.fn(), + selection: { + address: '南京东路', + latitude: 31.23, + longitude: 121.48, + }, + }; + + it('debounces search and lists results', async () => { + render(); + fireEvent.changeText(screen.getByLabelText('搜索地点'), '外滩'); + await flushDebouncedSearch(); + expect(base.onSearch).toHaveBeenCalledWith('外滩'); + expect(screen.getByText('外滩 · 中山东一路')).toBeTruthy(); + fireEvent.press(screen.getByLabelText('选择 外滩 · 中山东一路')); + expect(base.onSelectSearchResult).toHaveBeenCalled(); + }); + + it('confirms and cancels', () => { + render(); + fireEvent.press(screen.getByLabelText('确认选中的地址')); + expect(base.onConfirm).toHaveBeenCalled(); + fireEvent.press(screen.getByLabelText('退出地图选点')); + expect(base.onCancel).toHaveBeenCalled(); + }); + + it('shows map errors and locating state', () => { + render( + , + ); + expect(screen.getByText('地图加载失败')).toBeTruthy(); + expect(screen.getByText('定位失败')).toBeTruthy(); + expect(screen.getByText('正在获取当前位置...')).toBeTruthy(); + }); + + it('surfaces search failures', async () => { + const onSearch = jest.fn(async () => { + throw new Error('qps'); + }); + render(); + fireEvent.changeText(screen.getByLabelText('搜索地点'), '失败'); + await flushDebouncedSearch(); + expect(screen.getByText('搜索暂时不可用,请直接在地图上选点')).toBeTruthy(); + }); + + it('shows empty search results', async () => { + const onSearch = jest.fn(async () => []); + render(); + fireEvent.changeText(screen.getByLabelText('搜索地点'), '空'); + await flushDebouncedSearch(); + expect(screen.getByText('没有找到相关地点')).toBeTruthy(); + }); +}); diff --git a/frontend/__tests__/features/schedule/location/MapPicker/baidu/baiduMapWebView.test.ts b/frontend/__tests__/features/schedule/location/MapPicker/baidu/baiduMapWebView.test.ts new file mode 100644 index 0000000..fdb0007 --- /dev/null +++ b/frontend/__tests__/features/schedule/location/MapPicker/baidu/baiduMapWebView.test.ts @@ -0,0 +1,37 @@ +import { describe, expect, it } from '@jest/globals'; + +import { buildBaiduMapDocument } from '@/features/schedule/location/MapPicker/baidu/baiduMapWebView'; + +describe('buildBaiduMapDocument', () => { + it('embeds the AK and default Shanghai center when no initial location', () => { + const html = buildBaiduMapDocument('test-ak', null); + expect(html).toContain(encodeURIComponent('test-ak')); + expect(html).toContain('31.236305'); + expect(html).toContain('121.480237'); + expect(html).toContain('null'); + }); + + it('embeds the provided initial location', () => { + const html = buildBaiduMapDocument('ak', { + address: '办公室', + latitude: 31.1, + longitude: 121.2, + name: '办公室', + }); + expect(html).toContain('31.1'); + expect(html).toContain('121.2'); + expect(html).toContain('办公室'); + }); + + it('escapes initial location data for the inline script context', () => { + const html = buildBaiduMapDocument('ak', { + address: '', + latitude: 31.1, + longitude: 121.2, + }); + + expect(html).not.toContain(' + + +
+ + +`; +} diff --git a/frontend/src/features/schedule/location/MapPicker/baidu/index.ts b/frontend/src/features/schedule/location/MapPicker/baidu/index.ts new file mode 100644 index 0000000..091cfbb --- /dev/null +++ b/frontend/src/features/schedule/location/MapPicker/baidu/index.ts @@ -0,0 +1,8 @@ +export { + BAIDU_MAP_AK, + SHANGHAI_CENTER, + createCoordinateLocation, + readablePoiAddress, +} from './services'; +export { createReverseGeocodeGate } from './reverseGeocodeGate'; +export { buildBaiduMapDocument, type BaiduMapBridgeMessage } from './baiduMapWebView'; diff --git a/frontend/src/features/schedule/location/MapPicker/baidu/reverseGeocodeGate.ts b/frontend/src/features/schedule/location/MapPicker/baidu/reverseGeocodeGate.ts new file mode 100644 index 0000000..7c0ef2c --- /dev/null +++ b/frontend/src/features/schedule/location/MapPicker/baidu/reverseGeocodeGate.ts @@ -0,0 +1,82 @@ +/** + * 逆地理编码调度:防抖 + 串行,避免连点/拖图触发超免费 QPS。 + * 同一时间只保留最新待解析坐标;上一请求结束后再发下一请求。 + */ +export type ReverseGeocodeJob = { + latitude: number; + longitude: number; + requestId: number; +}; + +export type ReverseGeocodeRunner = (job: ReverseGeocodeJob) => Promise | void; + +export type ReverseGeocodeGate = { + schedule: (job: ReverseGeocodeJob, run: ReverseGeocodeRunner) => void; + clear: () => void; +}; + +export function createReverseGeocodeGate(options?: { + debounceMs?: number; + minIntervalMs?: number; +}): ReverseGeocodeGate { + const debounceMs = options?.debounceMs ?? 450; + const minIntervalMs = options?.minIntervalMs ?? 350; + + let debounceTimer: ReturnType | null = null; + let intervalTimer: ReturnType | null = null; + let pending: { job: ReverseGeocodeJob; run: ReverseGeocodeRunner } | null = null; + let inFlight = false; + let lastStartedAt = 0; + + const clearTimers = () => { + if (debounceTimer != null) { + clearTimeout(debounceTimer); + debounceTimer = null; + } + if (intervalTimer != null) { + clearTimeout(intervalTimer); + intervalTimer = null; + } + }; + + const flush = async () => { + if (inFlight || !pending) return; + + const elapsed = Date.now() - lastStartedAt; + const wait = lastStartedAt === 0 ? 0 : Math.max(0, minIntervalMs - elapsed); + if (wait > 0) { + intervalTimer = setTimeout(() => { + intervalTimer = null; + void flush(); + }, wait); + return; + } + + const current = pending; + pending = null; + inFlight = true; + lastStartedAt = Date.now(); + + try { + await current.run(current.job); + } finally { + inFlight = false; + if (pending) void flush(); + } + }; + + return { + schedule(job, run) { + pending = { job, run }; + if (debounceTimer != null) clearTimeout(debounceTimer); + debounceTimer = setTimeout(() => { + debounceTimer = null; + void flush(); + }, debounceMs); + }, + clear() { + clearTimers(); + pending = null; + }, + }; +} diff --git a/frontend/src/features/schedule/location/MapPicker/baidu/services.ts b/frontend/src/features/schedule/location/MapPicker/baidu/services.ts new file mode 100644 index 0000000..060eadb --- /dev/null +++ b/frontend/src/features/schedule/location/MapPicker/baidu/services.ts @@ -0,0 +1,26 @@ +import type { MapLocation } from '@/shared/types/geo'; + +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/features/schedule/location/MapPicker/baidu/types.ts b/frontend/src/features/schedule/location/MapPicker/baidu/types.ts new file mode 100644 index 0000000..2b533c6 --- /dev/null +++ b/frontend/src/features/schedule/location/MapPicker/baidu/types.ts @@ -0,0 +1,3 @@ +import type { MapLocation } from '@/shared/types/geo'; + +export type { MapLocation }; diff --git a/frontend/src/features/schedule/location/MapPicker/index.ts b/frontend/src/features/schedule/location/MapPicker/index.ts new file mode 100644 index 0000000..bfcef56 --- /dev/null +++ b/frontend/src/features/schedule/location/MapPicker/index.ts @@ -0,0 +1,2 @@ +export { MapPicker } from './MapPicker'; +export type { MapLocation, MapPickerProps } from './types'; diff --git a/frontend/src/features/schedule/location/MapPicker/styles.ts b/frontend/src/features/schedule/location/MapPicker/styles.ts new file mode 100644 index 0000000..63a020f --- /dev/null +++ b/frontend/src/features/schedule/location/MapPicker/styles.ts @@ -0,0 +1,150 @@ +import { StyleSheet } from 'react-native'; + +import { colors } from '@/shared/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/features/schedule/location/MapPicker/types.ts b/frontend/src/features/schedule/location/MapPicker/types.ts new file mode 100644 index 0000000..c6e4128 --- /dev/null +++ b/frontend/src/features/schedule/location/MapPicker/types.ts @@ -0,0 +1,9 @@ +import type { MapLocation } from '../types'; + +export type { MapLocation }; + +export type MapPickerProps = { + initialLocation: MapLocation | null; + onCancel: () => void; + onConfirm: (location: MapLocation) => void; +}; diff --git a/frontend/src/features/schedule/location/index.ts b/frontend/src/features/schedule/location/index.ts new file mode 100644 index 0000000..421840d --- /dev/null +++ b/frontend/src/features/schedule/location/index.ts @@ -0,0 +1,4 @@ +export { LocationPickerSheet } from './LocationPickerSheet'; +export type { SavedLocation } from './types'; +export { createSavedLocation, matchSavedLocation, upsertSavedLocation } from './utils'; +export { useSessionSavedLocations } from './useSessionSavedLocations'; diff --git a/frontend/src/features/schedule/location/types.ts b/frontend/src/features/schedule/location/types.ts new file mode 100644 index 0000000..661083e --- /dev/null +++ b/frontend/src/features/schedule/location/types.ts @@ -0,0 +1,7 @@ +import type { MapLocation } from '@/shared/types/geo'; + +export type { MapLocation }; + +export type SavedLocation = MapLocation & { + id: string; +}; diff --git a/frontend/src/features/schedule/location/useSessionSavedLocations.ts b/frontend/src/features/schedule/location/useSessionSavedLocations.ts new file mode 100644 index 0000000..0ca552b --- /dev/null +++ b/frontend/src/features/schedule/location/useSessionSavedLocations.ts @@ -0,0 +1,21 @@ +import { useCallback, useMemo, useState } from 'react'; + +import type { SavedLocation } from './types'; +import { upsertSavedLocation } from './utils'; + +/** + * App-session scoped saved locations. + * + * Persistence is intentionally not implied: a host can replace this hook with + * a storage-backed provider once a cross-platform storage adapter is part of + * the composition root. + */ +export function useSessionSavedLocations() { + const [locations, setLocations] = useState([]); + + const upsert = useCallback((location: SavedLocation) => { + setLocations((current) => upsertSavedLocation(current, location)); + }, []); + + return useMemo(() => ({ locations, upsert }), [locations, upsert]); +} diff --git a/frontend/src/features/schedule/location/utils.ts b/frontend/src/features/schedule/location/utils.ts new file mode 100644 index 0000000..519c313 --- /dev/null +++ b/frontend/src/features/schedule/location/utils.ts @@ -0,0 +1,60 @@ +import type { MapLocation, SavedLocation } from './types'; + +export function createSavedLocation(location: MapLocation, id?: string): SavedLocation { + return { + ...location, + id: id ?? `loc_${Date.now()}`, + }; +} + +export function upsertSavedLocation( + locations: SavedLocation[], + location: SavedLocation, +): SavedLocation[] { + const index = locations.findIndex((item) => item.id === location.id); + if (index < 0) { + return [...locations, location]; + } + const next = [...locations]; + next[index] = location; + return next; +} + +export function matchSavedLocation( + locations: SavedLocation[], + candidate: { + latitude?: number | null; + longitude?: number | null; + location_name?: string | null; + location_address?: string | null; + }, +): SavedLocation | null { + if (candidate.latitude != null && candidate.longitude != null) { + const byCoords = locations.find( + (item) => item.latitude === candidate.latitude && item.longitude === candidate.longitude, + ); + if (byCoords) { + return byCoords; + } + } + + const name = candidate.location_name?.trim(); + const address = candidate.location_address?.trim(); + if (!name && !address) { + return null; + } + + return ( + locations.find((item) => { + const itemName = item.name?.trim() ?? ''; + const itemAddress = item.address.trim(); + if (name && address) { + return itemName === name && itemAddress === address; + } + if (name) { + return itemName === name; + } + return itemAddress === address; + }) ?? null + ); +} diff --git a/frontend/src/features/schedule/presentation/scheduleFormat.ts b/frontend/src/features/schedule/presentation/scheduleFormat.ts new file mode 100644 index 0000000..f8ed665 --- /dev/null +++ b/frontend/src/features/schedule/presentation/scheduleFormat.ts @@ -0,0 +1,53 @@ +import type { Schedule } from '@/contracts'; +import { formatTimeValue } from '@/shared/utils/date'; + +export function timeToMinutes(value: string) { + const [hours, minutes] = value.split(':').map(Number); + return hours * 60 + minutes; +} + +export function scheduleDate(item: Schedule) { + if (!item.start_time) return null; + const value = new Date(item.start_time); + return Number.isNaN(value.getTime()) ? null : value; +} + +export function scheduleTime(item: Schedule) { + const date = scheduleDate(item); + return date ? formatTimeValue(date) : '地点'; +} + +export function scheduleRange(item: Schedule) { + const start = scheduleDate(item); + if (!start) return item.location_name ?? item.location_address ?? '地点提醒'; + const startLabel = scheduleTime(item); + if (!item.end_time) return startLabel; + const end = new Date(item.end_time); + if (Number.isNaN(end.getTime())) return startLabel; + return `${startLabel}–${formatTimeValue(end)}`; +} + +export function scheduleDuration(item: Schedule) { + const start = scheduleDate(item); + const end = item.end_time ? new Date(item.end_time) : null; + if (!start || !end || Number.isNaN(end.getTime())) return '未设置时长'; + const minutes = Math.round((end.getTime() - start.getTime()) / 60_000); + return minutes > 0 ? `${minutes} 分钟` : '未设置时长'; +} + +export function scheduleColor(item: Schedule) { + if (item.status === 'done') return '#A8C7B5'; + if (item.schedule_type === 'location') return '#E79472'; + return item.source_mode === 'voice' ? '#AEC46B' : '#7DA6B8'; +} + +export function scheduleSourceLabel(item: Schedule) { + return item.source_mode === 'voice' ? '语音创建' : '手动创建'; +} + +/** 只映射契约里的三种 status,不做「已过期」等过程态。 */ +export function scheduleStatusLabel(item: Schedule) { + if (item.status === 'done') return '已完成'; + if (item.status === 'deleted') return '已删除'; + return '待完成'; +} diff --git a/frontend/src/features/schedule/screens/ScheduleScreen.tsx b/frontend/src/features/schedule/screens/ScheduleScreen.tsx new file mode 100644 index 0000000..37fb44a --- /dev/null +++ b/frontend/src/features/schedule/screens/ScheduleScreen.tsx @@ -0,0 +1,122 @@ +import { useMemo, useState } from 'react'; +import { ChevronDown, Plus } from 'lucide-react-native'; +import { Pressable, Text, View } from 'react-native'; + +import { DatePickerSheet } from '@/shared/components/DatePickerSheet'; +import type { Schedule } from '@/contracts'; +import { useCurrentDate } from '@/shared/hooks/useCurrentDate'; +import { colors } from '@/shared/theme'; +import { startOfMonth } from '@/shared/utils/date'; + +import { MonthView } from '../calendar/MonthView'; +import { buildScheduleIndex } from '../calendar/scheduleIndex'; +import { ScheduleDetailSheet } from '../detail/ScheduleDetailSheet'; +import { scheduleScreenStyles as styles } from './scheduleScreen.styles'; + +export function ScheduleScreen({ + canMutate = true, + onCreate, + onDeleteSchedule, + onEditSchedule, + onToggleSchedule, + scheduleItems, +}: { + canMutate?: boolean; + onCreate: () => void; + onDeleteSchedule: (item: Schedule) => void; + onEditSchedule: (item: Schedule) => void; + onToggleSchedule?: (item: Schedule) => void; + scheduleItems: Schedule[]; +}) { + const now = useCurrentDate(); + const [visibleMonth, setVisibleMonth] = useState(() => startOfMonth(new Date())); + const [selectedDate, setSelectedDate] = useState(() => new Date()); + const [selectedScheduleId, setSelectedScheduleId] = useState(null); + const [datePickerOpen, setDatePickerOpen] = useState(false); + + // Derive the selected entity from the cache. When a push removes it, the + // detail sheet naturally closes without mutating state during render. + const selectedSchedule = selectedScheduleId + ? scheduleItems.find((item) => item.id === selectedScheduleId) + : undefined; + const scheduleIndex = useMemo(() => buildScheduleIndex(scheduleItems), [scheduleItems]); + + const selectDate = (date: Date) => { + setSelectedDate(date); + setVisibleMonth(startOfMonth(date)); + }; + + return ( + + + setDatePickerOpen(true)} + style={styles.headerButton} + > + + {visibleMonth.getFullYear()}年{visibleMonth.getMonth() + 1}月 + + + {visibleMonth.getMonth() + 1}月 + + + + + + + + + + + setDatePickerOpen(false)} + onSelect={selectDate} + selectedDate={selectedDate} + visible={datePickerOpen} + /> + onDeleteSchedule(selectedSchedule) + : undefined + } + onEdit={ + canMutate && selectedSchedule && selectedSchedule.status !== 'deleted' + ? () => onEditSchedule(selectedSchedule) + : undefined + } + onToggle={ + canMutate && selectedSchedule && selectedSchedule.status !== 'deleted' && onToggleSchedule + ? () => onToggleSchedule(selectedSchedule) + : undefined + } + onClose={() => setSelectedScheduleId(null)} + onOpenDay={(date) => { + selectDate(date); + }} + /> + + ); +} diff --git a/frontend/src/features/schedule/screens/scheduleScreen.styles.ts b/frontend/src/features/schedule/screens/scheduleScreen.styles.ts new file mode 100644 index 0000000..696e772 --- /dev/null +++ b/frontend/src/features/schedule/screens/scheduleScreen.styles.ts @@ -0,0 +1,41 @@ +import { StyleSheet } from 'react-native'; + +import { colors } from '@/shared/theme'; + +export const scheduleScreenStyles = StyleSheet.create({ + screen: { flex: 1, paddingBottom: 76, paddingHorizontal: 20, paddingTop: 20 }, + header: { + alignItems: 'center', + flexDirection: 'row', + justifyContent: 'space-between', + marginBottom: 14, + minHeight: 50, + }, + headerButton: { flex: 1, marginRight: 12 }, + headerEyebrow: { + color: colors.muted, + fontSize: 10, + fontWeight: '700', + letterSpacing: 0, + marginBottom: 4, + }, + headerTitleRow: { alignItems: 'center', flexDirection: 'row', gap: 4 }, + headerTitle: { + color: colors.ink, + fontSize: 28, + fontWeight: '800', + letterSpacing: 0, + lineHeight: 29, + }, + addButton: { + alignItems: 'center', + backgroundColor: colors.limeSoft, + borderColor: '#D4E3B1', + borderRadius: 13, + borderWidth: 1, + height: 40, + justifyContent: 'center', + width: 40, + }, + addButtonDisabled: { opacity: 0.45 }, +}); diff --git a/frontend/src/infrastructure/storage/deviceIdStore.ts b/frontend/src/infrastructure/storage/deviceIdStore.ts new file mode 100644 index 0000000..21d998e --- /dev/null +++ b/frontend/src/infrastructure/storage/deviceIdStore.ts @@ -0,0 +1,150 @@ +import { Platform } from 'react-native'; +import { requireOptionalNativeModule } from 'expo'; + +const DEVICE_ID_KEY = 'timeflow.device_id'; + +export type DeviceIdStore = { + get(): Promise; + set(value: string): Promise; +}; + +type ExpoFileSystemLike = { + documentDirectory: string | null; + getInfoAsync: (path: string, options: Record) => Promise<{ exists: boolean }>; + readAsStringAsync: (path: string, options: Record) => Promise; + writeAsStringAsync: ( + path: string, + contents: string, + options: Record, + ) => Promise; +}; + +/** + * Native storage is deliberately a hard dependency at runtime. Falling back + * to an in-process map makes the device identity change on every cold start, + * which is worse than refusing to establish a session. + */ +export class DeviceIdPersistenceUnavailableError extends Error { + constructor(message = '原生设备存储不可用,无法持久化 device_id') { + super(message); + this.name = 'DeviceIdPersistenceUnavailableError'; + } +} + +function webStore(): DeviceIdStore { + return { + async get() { + if (typeof localStorage === 'undefined') { + throw new DeviceIdPersistenceUnavailableError('浏览器 localStorage 不可用'); + } + return localStorage.getItem(DEVICE_ID_KEY); + }, + async set(value) { + if (typeof localStorage === 'undefined') { + throw new DeviceIdPersistenceUnavailableError('浏览器 localStorage 不可用'); + } + localStorage.setItem(DEVICE_ID_KEY, value); + }, + }; +} + +/** Test/host adapter. Production code must inject a persistent implementation. */ +export function memoryStore(seed: Map = new Map()): DeviceIdStore { + return { + async get() { + return seed.get(DEVICE_ID_KEY) ?? null; + }, + async set(value) { + seed.set(DEVICE_ID_KEY, value); + }, + }; +} + +let nativeFileStore: DeviceIdStore | null = null; +let nativeFileStorePromise: Promise | null = null; + +function loadExpoFileSystem(): ExpoFileSystemLike | null { + // Expo Go and a custom Expo runtime expose the legacy module under this + // name. The lookup is static and Metro-visible; there is no hidden import + // or optional JS package that can silently disappear from a release bundle. + return requireOptionalNativeModule('ExponentFileSystem'); +} + +function createNativeFileStore(FileSystem = loadExpoFileSystem()): DeviceIdStore { + const base = FileSystem?.documentDirectory; + if ( + !FileSystem || + !base || + typeof FileSystem.getInfoAsync !== 'function' || + typeof FileSystem.readAsStringAsync !== 'function' || + typeof FileSystem.writeAsStringAsync !== 'function' + ) { + throw new DeviceIdPersistenceUnavailableError( + 'ExpoFileSystem 原生模块未链接;请在构建中声明 expo-file-system 或注入 DeviceIdStore', + ); + } + const path = `${base}.timeflow-device-id`; + return { + async get() { + let info: { exists: boolean }; + try { + info = await FileSystem.getInfoAsync(path, {}); + } catch (error) { + throw new DeviceIdPersistenceUnavailableError( + `读取 device_id 失败: ${error instanceof Error ? error.message : String(error)}`, + ); + } + if (!info.exists) return null; + try { + const value = await FileSystem.readAsStringAsync(path, {}); + return value.trim() || null; + } catch (error) { + throw new DeviceIdPersistenceUnavailableError( + `读取 device_id 失败: ${error instanceof Error ? error.message : String(error)}`, + ); + } + }, + async set(value) { + try { + await FileSystem.writeAsStringAsync(path, value, {}); + } catch (error) { + throw new DeviceIdPersistenceUnavailableError( + `写入 device_id 失败: ${error instanceof Error ? error.message : String(error)}`, + ); + } + }, + }; +} + +export async function createDeviceIdStore(): Promise { + if (Platform.OS === 'web') { + return webStore(); + } + if (!nativeFileStore) { + nativeFileStorePromise ??= Promise.resolve() + .then(() => createNativeFileStore()) + .catch((error) => { + // A transient host/module setup failure should not poison all later + // attempts during the same app lifetime. + nativeFileStorePromise = null; + throw error; + }); + nativeFileStore = await nativeFileStorePromise; + } + return nativeFileStore; +} + +export async function getOrCreateDeviceId(store?: DeviceIdStore): Promise { + const backend = store ?? (await createDeviceIdStore()); + const existing = await backend.get(); + if (existing) return existing; + const next = `device_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`; + await backend.set(next); + // Read back once so a best-effort native implementation cannot report a + // successful write while losing the value (for example, a denied file URI). + const persisted = await backend.get(); + if (persisted !== next) { + throw new DeviceIdPersistenceUnavailableError('device_id 写入后校验失败'); + } + return next; +} diff --git a/frontend/src/infrastructure/ws/WsClient.ts b/frontend/src/infrastructure/ws/WsClient.ts new file mode 100644 index 0000000..45c9b88 --- /dev/null +++ b/frontend/src/infrastructure/ws/WsClient.ts @@ -0,0 +1,250 @@ +import type { ConnectionStatus, WsJsonMessage } from '@/contracts'; + +export type { ConnectionStatus, WsJsonMessage } from '@/contracts'; + +export type WsClientOptions = { + /** 真实后端地址;为空则走 fakeHandler 进程内通道。 */ + url?: string | null; + /** 无 URL 时的进程内消息处理器(由 SessionProvider 在显式 Fake 模式下注入)。 */ + fakeHandler?: (message: WsJsonMessage | ArrayBuffer) => void | Promise; + requestTimeoutMs?: number; +}; + +type PendingRequest = { + resolve: (value: WsJsonMessage) => void; + reject: (error: Error) => void; + timer: ReturnType; + isMatch: (response: WsJsonMessage) => boolean; +}; + +/** + * 契约对齐的 WS 客户端:按 request_id 等待响应,支持订阅推送与二进制帧。 + * 无 URL 时走进程内 Fake 通道,便于本地与单测。 + */ +export class WsClient { + private socket: WebSocket | null = null; + private readonly pending = new Map(); + private readonly listeners = new Set<(message: WsJsonMessage | ArrayBuffer) => void>(); + private readonly statusListeners = new Set<(status: ConnectionStatus) => void>(); + private status: ConnectionStatus = 'idle'; + private readonly requestTimeoutMs: number; + private readonly url: string | null; + private readonly fakeHandler?: (message: WsJsonMessage | ArrayBuffer) => void | Promise; + private fakeReply: ((message: WsJsonMessage | ArrayBuffer) => void) | null = null; + private intentionallyClosed = false; + /** Invalidates callbacks belonging to a socket that has been replaced. */ + private socketGeneration = 0; + private connecting: { generation: number; reject: (error: Error) => void } | null = null; + + constructor(options: WsClientOptions = {}) { + this.url = options.url?.trim() || null; + this.fakeHandler = options.fakeHandler; + this.requestTimeoutMs = options.requestTimeoutMs ?? 15_000; + } + + getConnectionStatus(): ConnectionStatus { + return this.status; + } + + onStatus(listener: (status: ConnectionStatus) => void): () => void { + this.statusListeners.add(listener); + listener(this.status); + return () => this.statusListeners.delete(listener); + } + + onMessage(listener: (message: WsJsonMessage | ArrayBuffer) => void): () => void { + this.listeners.add(listener); + return () => this.listeners.delete(listener); + } + + async connect(): Promise { + this.intentionallyClosed = false; + if (!this.url) { + this.setStatus('connecting'); + this.fakeReply = (message) => this.dispatch(message); + this.setStatus('ready'); + return; + } + + this.setStatus(this.status === 'ready' ? 'reconnecting' : 'connecting'); + await new Promise((resolve, reject) => { + const generation = ++this.socketGeneration; + const socket = new WebSocket(this.url!); + this.socket = socket; + this.connecting = { generation, reject }; + socket.binaryType = 'arraybuffer'; + socket.onopen = () => { + if (this.socket !== socket || this.socketGeneration !== generation) return; + this.connecting = null; + this.setStatus('ready'); + resolve(); + }; + socket.onerror = () => { + if (this.socket !== socket || this.socketGeneration !== generation) return; + const error = new Error('WebSocket connection failed'); + this.setStatus('error'); + this.rejectPending(error); + this.rejectConnecting(generation, error); + }; + socket.onclose = () => { + const isCurrent = this.socket === socket && this.socketGeneration === generation; + if (!isCurrent) return; + this.socket = null; + if (this.intentionallyClosed) return; + const error = new Error('WebSocket closed unexpectedly'); + this.rejectPending(error); + this.setStatus('closed'); + this.rejectConnecting(generation, new Error('WebSocket closed before becoming ready')); + }; + socket.onmessage = (event) => { + if (this.socket !== socket || this.socketGeneration !== generation) return; + if (typeof event.data === 'string') { + try { + this.dispatch(JSON.parse(event.data) as WsJsonMessage); + } catch { + // ignore malformed JSON + } + return; + } + if (event.data instanceof ArrayBuffer) { + this.dispatch(event.data); + } + }; + }); + } + + close(): void { + this.intentionallyClosed = true; + this.rejectConnecting(this.socketGeneration, new Error('WebSocket closed')); + this.socketGeneration += 1; + const socket = this.socket; + this.socket = null; + socket?.close(); + this.fakeReply = null; + this.rejectPending(new Error('WebSocket closed')); + this.setStatus('closed'); + } + + sendJson(message: WsJsonMessage): void { + if (!this.url) { + void Promise.resolve(this.fakeHandler?.(message)).catch((error) => { + if (message.request_id) { + this.rejectRequest( + message.request_id, + error instanceof Error ? error : new Error(String(error)), + ); + } + }); + return; + } + if (!this.socket || this.socket.readyState !== WebSocket.OPEN) { + throw new Error('WebSocket is not open'); + } + this.socket.send(JSON.stringify(message)); + } + + sendBinary(data: ArrayBuffer): void { + if (!this.url) { + void Promise.resolve(this.fakeHandler?.(data)).catch(() => undefined); + return; + } + if (!this.socket || this.socket.readyState !== WebSocket.OPEN) { + throw new Error('WebSocket is not open'); + } + this.socket.send(data); + } + + /** Fake 服务端向客户端推送。 */ + emitFromServer(message: WsJsonMessage | ArrayBuffer): void { + this.fakeReply?.(message); + } + + request( + message: WsJsonMessage & { request_id: string }, + isMatch: (response: WsJsonMessage) => boolean = (response) => + response.request_id === message.request_id, + ): Promise { + return new Promise((resolve, reject) => { + if (!message.request_id) { + reject(new Error(`Request id is required: ${message.type}`)); + return; + } + if (this.pending.has(message.request_id)) { + reject(new Error(`Duplicate request id: ${message.request_id}`)); + return; + } + const timer = setTimeout(() => { + this.pending.delete(message.request_id); + reject(new Error(`Request timed out: ${message.type}`)); + }, this.requestTimeoutMs); + + this.pending.set(message.request_id, { + isMatch, + resolve: (value) => { + clearTimeout(timer); + this.pending.delete(message.request_id); + resolve(value as T); + }, + reject: (error) => { + clearTimeout(timer); + this.pending.delete(message.request_id); + reject(error); + }, + timer, + }); + + try { + this.sendJson(message); + } catch (error) { + clearTimeout(timer); + this.pending.delete(message.request_id); + reject(error instanceof Error ? error : new Error(String(error))); + } + }); + } + + private dispatch(message: WsJsonMessage | ArrayBuffer): void { + if (!(message instanceof ArrayBuffer)) { + for (const pending of this.pending.values()) { + if (pending.isMatch(message)) { + pending.resolve(message); + break; + } + } + } + for (const listener of this.listeners) { + listener(message); + } + } + + private rejectPending(error: Error): void { + const pending = [...this.pending.values()]; + this.pending.clear(); + for (const request of pending) { + clearTimeout(request.timer); + request.reject(error); + } + } + + private rejectRequest(requestId: string, error: Error): void { + const request = this.pending.get(requestId); + if (!request) return; + clearTimeout(request.timer); + this.pending.delete(requestId); + request.reject(error); + } + + private rejectConnecting(generation: number, error: Error): void { + const connecting = this.connecting; + if (!connecting || connecting.generation !== generation) return; + this.connecting = null; + connecting.reject(error); + } + + private setStatus(status: ConnectionStatus): void { + this.status = status; + for (const listener of this.statusListeners) { + listener(status); + } + } +} diff --git a/frontend/src/shared/components/AppDialogProvider.styles.ts b/frontend/src/shared/components/AppDialogProvider.styles.ts new file mode 100644 index 0000000..ff4aa31 --- /dev/null +++ b/frontend/src/shared/components/AppDialogProvider.styles.ts @@ -0,0 +1,86 @@ +import { StyleSheet } from 'react-native'; + +import { colors } from '@/shared/theme'; + +export const appDialogStyles = StyleSheet.create({ + backdrop: { + alignItems: 'center', + backgroundColor: 'rgba(14, 23, 19, 0.46)', + flex: 1, + justifyContent: 'center', + padding: 24, + }, + dismiss: { + bottom: 0, + left: 0, + position: 'absolute', + right: 0, + top: 0, + }, + dialog: { + backgroundColor: colors.surface, + borderColor: colors.line, + borderRadius: 8, + borderWidth: 1, + padding: 18, + width: '100%', + maxWidth: 360, + }, + icon: { + alignItems: 'center', + backgroundColor: colors.limeSoft, + borderRadius: 8, + height: 36, + justifyContent: 'center', + marginBottom: 13, + width: 36, + }, + iconDanger: { + backgroundColor: colors.peach, + }, + title: { + color: colors.ink, + fontSize: 20, + fontWeight: '800', + lineHeight: 26, + }, + message: { + color: colors.sub, + fontSize: 14, + lineHeight: 21, + marginTop: 9, + }, + actions: { + flexDirection: 'row', + gap: 10, + justifyContent: 'flex-end', + marginTop: 22, + }, + action: { + alignItems: 'center', + borderRadius: 8, + minHeight: 42, + minWidth: 84, + justifyContent: 'center', + paddingHorizontal: 15, + }, + cancelAction: { + backgroundColor: colors.surfaceTint, + }, + primaryAction: { + backgroundColor: colors.deep, + }, + dangerAction: { + backgroundColor: colors.coral, + }, + cancelText: { + color: colors.ink, + fontSize: 14, + fontWeight: '800', + }, + primaryText: { + color: colors.surface, + fontSize: 14, + fontWeight: '800', + }, +}); diff --git a/frontend/src/shared/components/AppDialogProvider.tsx b/frontend/src/shared/components/AppDialogProvider.tsx new file mode 100644 index 0000000..692b44a --- /dev/null +++ b/frontend/src/shared/components/AppDialogProvider.tsx @@ -0,0 +1,162 @@ +import { + createContext, + useCallback, + useContext, + useEffect, + useMemo, + useRef, + useState, + type ReactNode, +} from 'react'; +import { CircleAlert } from 'lucide-react-native'; +import { Modal, Pressable, Text, View } from 'react-native'; + +import { colors } from '@/shared/theme'; + +import { appDialogStyles as styles } from './AppDialogProvider.styles'; + +export type AppDialogTone = 'default' | 'danger'; + +export type AppDialogOptions = { + title: string; + message: string; + tone?: AppDialogTone; + confirmLabel?: string; + cancelLabel?: string; +}; + +type DialogRequest = Required> & + Pick & { + id: string; + mode: 'notice' | 'confirm'; + resolve: (confirmed: boolean) => void; + }; + +type AppDialogContextValue = { + showNotice: (options: AppDialogOptions) => Promise; + confirm: (options: AppDialogOptions) => Promise; +}; + +const AppDialogContext = createContext(null); + +let dialogSequence = 0; + +export function AppDialogProvider({ children }: { children: ReactNode }) { + const queueRef = useRef([]); + const [active, setActive] = useState(null); + + const enqueue = useCallback( + (mode: DialogRequest['mode'], options: AppDialogOptions): Promise => { + return new Promise((resolve) => { + const request: DialogRequest = { + id: `dialog_${++dialogSequence}`, + mode, + title: options.title, + message: options.message, + tone: options.tone ?? 'default', + confirmLabel: options.confirmLabel, + cancelLabel: options.cancelLabel, + resolve, + }; + queueRef.current = [...queueRef.current, request]; + if (queueRef.current.length === 1) setActive(request); + }); + }, + [], + ); + + const settle = useCallback((confirmed: boolean) => { + const [current, ...rest] = queueRef.current; + if (!current) return; + queueRef.current = rest; + setActive(rest[0] ?? null); + current.resolve(confirmed); + }, []); + + useEffect(() => { + return () => { + for (const request of queueRef.current) request.resolve(false); + queueRef.current = []; + }; + }, []); + + const value = useMemo( + () => ({ + showNotice: async (options) => { + await enqueue('notice', options); + }, + confirm: (options) => enqueue('confirm', options), + }), + [enqueue], + ); + + const isDanger = active?.tone === 'danger'; + const confirmLabel = active?.confirmLabel ?? (active?.mode === 'confirm' ? '确定' : '知道了'); + const cancelLabel = active?.cancelLabel ?? '取消'; + + return ( + + {children} + settle(false)} + transparent + visible={Boolean(active)} + > + + settle(false)} + style={styles.dismiss} + /> + {active ? ( + + + + + {active.title} + {active.message} + + {active.mode === 'confirm' ? ( + settle(false)} + style={[styles.action, styles.cancelAction]} + > + {cancelLabel} + + ) : null} + settle(true)} + style={[ + styles.action, + styles.primaryAction, + active.mode === 'confirm' && isDanger && styles.dangerAction, + ]} + > + {confirmLabel} + + + + ) : null} + + + + ); +} + +export function useAppDialog(): AppDialogContextValue { + const value = useContext(AppDialogContext); + if (!value) { + throw new Error('useAppDialog must be used within AppDialogProvider'); + } + return value; +} diff --git a/frontend/src/shared/components/BackButton.tsx b/frontend/src/shared/components/BackButton.tsx new file mode 100644 index 0000000..e228824 --- /dev/null +++ b/frontend/src/shared/components/BackButton.tsx @@ -0,0 +1,41 @@ +import { ChevronLeft } from 'lucide-react-native'; +import { Pressable, StyleSheet } from 'react-native'; + +import { colors } from '@/shared/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/shared/components/BottomSheetFrame.styles.ts b/frontend/src/shared/components/BottomSheetFrame.styles.ts new file mode 100644 index 0000000..49094af --- /dev/null +++ b/frontend/src/shared/components/BottomSheetFrame.styles.ts @@ -0,0 +1,34 @@ +import { StyleSheet } from 'react-native'; + +import { colors } from '@/shared/theme'; + +import { sheetChromeStyles } from './sheetChrome.styles'; + +const localStyles = StyleSheet.create({ + keyboardAvoider: { flex: 1 }, + sheet: { + backgroundColor: colors.surface, + borderTopLeftRadius: 26, + borderTopRightRadius: 26, + paddingBottom: 28, + paddingHorizontal: 16, + paddingTop: 10, + }, + header: { + alignItems: 'flex-start', + flexDirection: 'row', + justifyContent: 'space-between', + marginBottom: 8, + paddingHorizontal: 4, + }, + eyebrow: { color: colors.muted, fontSize: 10, fontWeight: '700' }, + title: { color: colors.ink, fontSize: 22, fontWeight: '800', marginTop: 5 }, +}); + +export const bottomSheetFrameStyles = { + backdrop: sheetChromeStyles.backdrop, + dismiss: sheetChromeStyles.dismiss, + handle: sheetChromeStyles.handle, + close: sheetChromeStyles.close, + ...localStyles, +}; diff --git a/frontend/src/shared/components/BottomSheetFrame.tsx b/frontend/src/shared/components/BottomSheetFrame.tsx new file mode 100644 index 0000000..2b848dc --- /dev/null +++ b/frontend/src/shared/components/BottomSheetFrame.tsx @@ -0,0 +1,106 @@ +import type { ReactNode } from 'react'; +import { X } from 'lucide-react-native'; +import { + KeyboardAvoidingView, + Modal, + Platform, + Pressable, + Text, + View, + type StyleProp, + type ViewStyle, +} from 'react-native'; + +import { colors } from '@/shared/theme'; + +import { bottomSheetFrameStyles as styles } from './BottomSheetFrame.styles'; + +type BottomSheetFrameProps = { + visible: boolean; + onClose: () => void; + closeAccessibilityLabel: string; + children: ReactNode; + /** 标准 eyebrow + title 头;与 header 二选一。 */ + eyebrow?: string; + title?: string; + /** 自定义头部左侧内容(覆盖 eyebrow/title)。 */ + header?: ReactNode; + showClose?: boolean; + showHandle?: boolean; + sheetStyle?: StyleProp; + headerStyle?: StyleProp; + keyboardAvoiding?: boolean; + animationType?: 'slide' | 'fade' | 'none'; +}; + +/** + * 底部 Sheet 共用外壳:Modal → backdrop → dismiss → sheet → handle → header → content。 + */ +export function BottomSheetFrame({ + visible, + onClose, + closeAccessibilityLabel, + children, + eyebrow, + title, + header, + showClose = true, + showHandle = true, + sheetStyle, + headerStyle, + keyboardAvoiding = false, + animationType = 'slide', +}: BottomSheetFrameProps) { + const hasHeader = Boolean(header || title || eyebrow); + const headerLeft = header ?? ( + + {eyebrow ? {eyebrow} : null} + {title ? {title} : null} + + ); + + const body = ( + + + + {showHandle ? : null} + {hasHeader ? ( + + {headerLeft} + {showClose ? ( + + + + ) : null} + + ) : null} + {children} + + + ); + + return ( + + {keyboardAvoiding ? ( + + {body} + + ) : ( + body + )} + + ); +} diff --git a/frontend/src/shared/components/DatePickerSheet.styles.ts b/frontend/src/shared/components/DatePickerSheet.styles.ts new file mode 100644 index 0000000..08bf810 --- /dev/null +++ b/frontend/src/shared/components/DatePickerSheet.styles.ts @@ -0,0 +1,22 @@ +import { StyleSheet } from 'react-native'; + +import { colors } from '@/shared/theme'; + +export const datePickerSheetStyles = StyleSheet.create({ + calendar: { + borderRadius: 14, + overflow: 'hidden', + }, + todayButton: { + alignItems: 'center', + backgroundColor: colors.limeSoft, + borderColor: '#D7E6B2', + borderRadius: 13, + borderWidth: 1, + height: 48, + justifyContent: 'center', + marginHorizontal: 4, + marginTop: 12, + }, + todayButtonText: { color: colors.deep, fontSize: 13, fontWeight: '800' }, +}); diff --git a/frontend/src/shared/components/DatePickerSheet.tsx b/frontend/src/shared/components/DatePickerSheet.tsx new file mode 100644 index 0000000..8435237 --- /dev/null +++ b/frontend/src/shared/components/DatePickerSheet.tsx @@ -0,0 +1,145 @@ +import { useMemo } from 'react'; +import { Pressable, Text } from 'react-native'; +import { Calendar, LocaleConfig, type DateData } from 'react-native-calendars'; + +import { BottomSheetFrame } from '@/shared/components/BottomSheetFrame'; +import { colors } from '@/shared/theme'; +import { dateKey } from '@/shared/utils/date'; + +import { datePickerSheetStyles as styles } from './DatePickerSheet.styles'; + +type DayMarking = { + dotColor?: string; + marked?: boolean; + selected?: boolean; + selectedColor?: string; +}; + +type MarkedDates = Record; + +LocaleConfig.locales.zh = { + monthNames: [ + '一月', + '二月', + '三月', + '四月', + '五月', + '六月', + '七月', + '八月', + '九月', + '十月', + '十一月', + '十二月', + ], + monthNamesShort: [ + '1月', + '2月', + '3月', + '4月', + '5月', + '6月', + '7月', + '8月', + '9月', + '10月', + '11月', + '12月', + ], + dayNames: ['星期日', '星期一', '星期二', '星期三', '星期四', '星期五', '星期六'], + dayNamesShort: ['日', '一', '二', '三', '四', '五', '六'], + today: '今天', +}; +LocaleConfig.defaultLocale = 'zh'; + +const calendarTheme = { + arrowColor: colors.deep, + backgroundColor: colors.surface, + calendarBackground: colors.surface, + dayTextColor: colors.ink, + dotColor: colors.coral, + monthTextColor: colors.ink, + selectedDayBackgroundColor: colors.deep, + selectedDayTextColor: colors.surface, + selectedDotColor: colors.surface, + textDayFontSize: 14, + textDayFontWeight: '600' as const, + textDayHeaderFontSize: 12, + textDayHeaderFontWeight: '700' as const, + textMonthFontSize: 16, + textMonthFontWeight: '800' as const, + textSectionTitleColor: colors.sub, + todayTextColor: colors.deep, +}; + +function parseDateKey(value: string) { + const [year, month, day] = value.split('-').map(Number); + return new Date(year, month - 1, day); +} + +type DatePickerSheetProps = { + markedDateKeys?: string[]; + onClose: () => void; + onSelect: (date: Date) => void; + selectedDate: Date; + visible: boolean; +}; + +export function DatePickerSheet({ + markedDateKeys = [], + onClose, + onSelect, + selectedDate, + visible, +}: DatePickerSheetProps) { + const selectedKey = dateKey(selectedDate); + const markedDates = useMemo(() => { + const next: MarkedDates = {}; + for (const key of markedDateKeys) { + next[key] = { marked: true, dotColor: colors.coral }; + } + next[selectedKey] = { + ...(next[selectedKey] ?? {}), + selected: true, + selectedColor: colors.deep, + }; + return next; + }, [markedDateKeys, selectedKey]); + + const handleDayPress = (day: DateData) => { + onSelect(parseDateKey(day.dateString)); + onClose(); + }; + + return ( + + + { + onSelect(new Date()); + onClose(); + }} + style={styles.todayButton} + > + 回到今天 + + + ); +} diff --git a/frontend/src/shared/components/TimePickerSheet.styles.ts b/frontend/src/shared/components/TimePickerSheet.styles.ts new file mode 100644 index 0000000..b38c1cd --- /dev/null +++ b/frontend/src/shared/components/TimePickerSheet.styles.ts @@ -0,0 +1,65 @@ +import { StyleSheet } from 'react-native'; + +import { colors } from '@/shared/theme'; + +export const timePickerSheetStyles = StyleSheet.create({ + preview: { + color: colors.ink, + fontSize: 28, + fontWeight: '800', + marginBottom: 12, + marginTop: 4, + textAlign: 'center', + }, + columns: { + flexDirection: 'row', + gap: 10, + paddingHorizontal: 4, + }, + column: { flex: 1 }, + columnLabel: { + color: colors.sub, + fontSize: 12, + fontWeight: '800', + marginBottom: 8, + textAlign: 'center', + }, + list: { + backgroundColor: '#F8FAF7', + borderColor: colors.line, + borderRadius: 14, + borderWidth: 1, + maxHeight: 220, + }, + item: { + alignItems: 'center', + height: 44, + justifyContent: 'center', + }, + itemSelected: { + backgroundColor: colors.limeSoft, + }, + itemText: { + color: colors.ink, + fontSize: 16, + fontWeight: '600', + }, + itemTextSelected: { + color: colors.deep, + fontWeight: '800', + }, + confirm: { + alignItems: 'center', + backgroundColor: colors.deep, + borderRadius: 13, + height: 48, + justifyContent: 'center', + marginHorizontal: 4, + marginTop: 14, + }, + confirmText: { + color: colors.surface, + fontSize: 13, + fontWeight: '800', + }, +}); diff --git a/frontend/src/shared/components/TimePickerSheet.tsx b/frontend/src/shared/components/TimePickerSheet.tsx new file mode 100644 index 0000000..4e12239 --- /dev/null +++ b/frontend/src/shared/components/TimePickerSheet.tsx @@ -0,0 +1,127 @@ +import { useEffect, useMemo, useRef, useState, type RefObject } from 'react'; +import { Pressable, ScrollView, Text, View } from 'react-native'; + +import { BottomSheetFrame } from '@/shared/components/BottomSheetFrame'; + +import { timePickerSheetStyles as styles } from './TimePickerSheet.styles'; + +const HOURS = Array.from({ length: 24 }, (_, index) => String(index).padStart(2, '0')); +const MINUTES = Array.from({ length: 60 }, (_, index) => String(index).padStart(2, '0')); +const ITEM_HEIGHT = 44; + +type TimePickerSheetProps = { + onClose: () => void; + onSelect: (time: string) => void; + selectedTime: Date; + visible: boolean; +}; + +function TimeWheelColumn({ + accessibilityUnit, + label, + listRef, + onSelect, + selected, + values, +}: { + accessibilityUnit: string; + label: string; + listRef: RefObject; + onSelect: (value: string) => void; + selected: string; + values: string[]; +}) { + return ( + + {label} + + {values.map((value) => { + const isSelected = value === selected; + return ( + onSelect(value)} + style={[styles.item, isSelected && styles.itemSelected]} + > + {value} + + ); + })} + + + ); +} + +export function TimePickerSheet({ + onClose, + onSelect, + selectedTime, + visible, +}: TimePickerSheetProps) { + const [hour, setHour] = useState(() => String(selectedTime.getHours()).padStart(2, '0')); + const [minute, setMinute] = useState(() => String(selectedTime.getMinutes()).padStart(2, '0')); + const hourListRef = useRef(null); + const minuteListRef = useRef(null); + const selectedTimeMs = selectedTime.getTime(); + + useEffect(() => { + if (!visible) return; + const nextHour = String(new Date(selectedTimeMs).getHours()).padStart(2, '0'); + const nextMinute = String(new Date(selectedTimeMs).getMinutes()).padStart(2, '0'); + // 打开或外部时间变化时同步滚轮;属受控 sheet 的合法同步。 + // eslint-disable-next-line react-hooks/set-state-in-effect -- sync picker when sheet opens + setHour(nextHour); + setMinute(nextMinute); + const frame = requestAnimationFrame(() => { + hourListRef.current?.scrollTo({ y: Number(nextHour) * ITEM_HEIGHT, animated: false }); + minuteListRef.current?.scrollTo({ y: Number(nextMinute) * ITEM_HEIGHT, animated: false }); + }); + return () => cancelAnimationFrame(frame); + }, [selectedTimeMs, visible]); + + const preview = useMemo(() => `${hour}:${minute}`, [hour, minute]); + + const confirm = () => { + onSelect(`${hour}:${minute}`); + onClose(); + }; + + return ( + + {preview} + + + + + + 确认 + + + ); +} diff --git a/frontend/src/shared/components/sheetChrome.styles.ts b/frontend/src/shared/components/sheetChrome.styles.ts new file mode 100644 index 0000000..9c8ac5b --- /dev/null +++ b/frontend/src/shared/components/sheetChrome.styles.ts @@ -0,0 +1,27 @@ +import { StyleSheet } from 'react-native'; + +/** 底部 Sheet 共用外壳:backdrop / dismiss / handle / close。 */ +export const sheetChromeStyles = StyleSheet.create({ + backdrop: { + backgroundColor: 'rgba(14, 23, 19, 0.46)', + flex: 1, + justifyContent: 'flex-end', + }, + dismiss: { bottom: 0, left: 0, position: 'absolute', right: 0, top: 0 }, + handle: { + alignSelf: 'center', + backgroundColor: '#D7D4CD', + borderRadius: 3, + height: 4, + marginBottom: 16, + width: 35, + }, + close: { + alignItems: 'center', + backgroundColor: '#ECE9E2', + borderRadius: 10, + height: 30, + justifyContent: 'center', + width: 30, + }, +}); diff --git a/frontend/src/shared/hooks/useCurrentDate.ts b/frontend/src/shared/hooks/useCurrentDate.ts new file mode 100644 index 0000000..f3e3c43 --- /dev/null +++ b/frontend/src/shared/hooks/useCurrentDate.ts @@ -0,0 +1,12 @@ +import { useEffect, useState } from 'react'; + +export function useCurrentDate() { + const [now, setNow] = useState(() => new Date()); + + useEffect(() => { + const timer = setInterval(() => setNow(new Date()), 60_000); + return () => clearInterval(timer); + }, []); + + return now; +} diff --git a/frontend/src/shared/theme/index.ts b/frontend/src/shared/theme/index.ts new file mode 100644 index 0000000..1c1e998 --- /dev/null +++ b/frontend/src/shared/theme/index.ts @@ -0,0 +1,25 @@ +export const colors = { + 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; diff --git a/frontend/src/shared/types/geo.ts b/frontend/src/shared/types/geo.ts new file mode 100644 index 0000000..9bb1e6e --- /dev/null +++ b/frontend/src/shared/types/geo.ts @@ -0,0 +1,7 @@ +/** 中立地理点类型:feature 与地图 adapter 共用,不归属具体供应商。 */ +export type MapLocation = { + address: string; + latitude: number; + longitude: number; + name?: string; +}; diff --git a/frontend/src/shared/utils/date.ts b/frontend/src/shared/utils/date.ts new file mode 100644 index 0000000..7fe6195 --- /dev/null +++ b/frontend/src/shared/utils/date.ts @@ -0,0 +1,40 @@ +export const WEEKDAY_LABELS = ['一', '二', '三', '四', '五', '六', '日']; + +export function dateKey(date: Date) { + return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}`; +} + +export function startOfWeek(date: Date) { + const day = date.getDay(); + const offset = day === 0 ? -6 : 1 - day; + return new Date(date.getFullYear(), date.getMonth(), date.getDate() + offset); +} + +export function addDays(date: Date, amount: number) { + return new Date(date.getFullYear(), date.getMonth(), date.getDate() + amount); +} + +export function startOfMonth(date: Date) { + return new Date(date.getFullYear(), date.getMonth(), 1); +} + +export function formatDate(date: Date) { + return `${date.getMonth() + 1}月${date.getDate()}日 · 星期${WEEKDAY_LABELS[date.getDay() === 0 ? 6 : date.getDay() - 1]}`; +} + +export function formatFullDate(date: Date) { + return `${date.getFullYear()}年${date.getMonth() + 1}月${date.getDate()}日 · 星期${WEEKDAY_LABELS[date.getDay() === 0 ? 6 : date.getDay() - 1]}`; +} + +export function formatMonthDay(date: Date) { + return `${date.getMonth() + 1}月${date.getDate()}日`; +} + +export function formatTimeValue(value: Date) { + return `${String(value.getHours()).padStart(2, '0')}:${String(value.getMinutes()).padStart(2, '0')}`; +} + +export function formatWeekRange(start: Date) { + const end = addDays(start, 6); + return `${formatMonthDay(start)}—${formatMonthDay(end)}`; +} diff --git a/frontend/src/shared/utils/requestId.ts b/frontend/src/shared/utils/requestId.ts new file mode 100644 index 0000000..f5aca22 --- /dev/null +++ b/frontend/src/shared/utils/requestId.ts @@ -0,0 +1,4 @@ +/** 生成 WS 请求 ID:`prefix_timestamp_random`。 */ +export function nextRequestId(prefix: string): string { + return `${prefix}_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`; +} diff --git a/frontend/tsconfig.json b/frontend/tsconfig.json index fe061fa..6a7437c 100644 --- a/frontend/tsconfig.json +++ b/frontend/tsconfig.json @@ -1,7 +1,11 @@ { "extends": "./node_modules/expo/tsconfig.base.json", "compilerOptions": { - "strict": true + "strict": true, + "paths": { + "@/*": ["./src/*"], + "@test/*": ["./__tests__/*"] + } }, "exclude": [ "${configDir}/node_modules",