diff --git a/frontend/src/features/reminder/application/LocalReminderApplication.ts b/frontend/src/features/reminder/application/LocalReminderApplication.ts index b16039c5..294a3cba 100644 --- a/frontend/src/features/reminder/application/LocalReminderApplication.ts +++ b/frontend/src/features/reminder/application/LocalReminderApplication.ts @@ -68,6 +68,7 @@ export class LocalReminderApplication implements ReminderApplicationPort { private readonly permissionBlockedListeners = new Set< (event: ReminderPermissionBlockedEvent) => void >(); + private readonly scheduleConfirmedListeners = new Set<() => void>(); private opChain: Promise = Promise.resolve(); /** 每次 stop / 失败回滚自增;停机前开始的工作持有旧世代,重启后仍视为已取消。 */ private generation = 0; @@ -101,6 +102,13 @@ export class LocalReminderApplication implements ReminderApplicationPort { }; } + onScheduleConfirmed(listener: () => void): () => void { + this.scheduleConfirmedListeners.add(listener); + return () => { + this.scheduleConfirmedListeners.delete(listener); + }; + } + async handleTime(tick: { observed_at: string }): Promise { const generation = this.generation; return this.track(this.runHandleTime(tick, generation)); @@ -450,6 +458,9 @@ export class LocalReminderApplication implements ReminderApplicationPort { }); await this.dropRegistration(scheduleId); + for (const listener of this.scheduleConfirmedListeners) { + listener(); + } if (!this.isLive(generation)) { return { accepted: false, schedule_id: scheduleId, disposition: null }; diff --git a/frontend/src/features/reminder/application/interfaces/ReminderApplicationPort.ts b/frontend/src/features/reminder/application/interfaces/ReminderApplicationPort.ts index a61dc45d..0f86f8a7 100644 --- a/frontend/src/features/reminder/application/interfaces/ReminderApplicationPort.ts +++ b/frontend/src/features/reminder/application/interfaces/ReminderApplicationPort.ts @@ -59,4 +59,9 @@ export interface ReminderApplicationPort { * 重弹一遍,见调用方 registerInternal/rebuildInternal 的路径区分)。 */ onPermissionBlocked(listener: (event: ReminderPermissionBlockedEvent) => void): () => void; + /** + * 订阅"某条日程被确认"事件——确认只写本地 SQLite,不经过日历页读取的 + * ScheduleClientService,日历页不知道要重取,地点提醒会一直挂着直到重启。 + */ + onScheduleConfirmed(listener: () => void): () => void; } diff --git a/frontend/src/screens/HomeScreen.tsx b/frontend/src/screens/HomeScreen.tsx index c04474b4..ccfd85c1 100644 --- a/frontend/src/screens/HomeScreen.tsx +++ b/frontend/src/screens/HomeScreen.tsx @@ -1,4 +1,4 @@ -import { useState } from 'react'; +import { useEffect, useState } from 'react'; import { StyleSheet, View } from 'react-native'; import type { AssistantApplicationPort } from '../features/assistant/application/AssistantApplication'; @@ -57,9 +57,18 @@ export function HomeScreen({ const [trackedPttCommand, setTrackedPttCommand] = useState(pttCommand); const [trackedCallCommand, setTrackedCallCommand] = useState(callCommand); const [focusTarget, setFocusTarget] = useState(null); + const [confirmRevision, setConfirmRevision] = useState(0); useReminderPermissionNudge(reminder, alertDialog, onRequestPermission); + // 提醒确认(闹钟响铃/App 内弹窗)只写本地库,不经过语音写日程那条 + // scheduleDataRevision 路径——地点提醒确认后要从下方地点列表里消失, + // 靠这里单独订阅触发重取,否则要等重启才会刷新。 + useEffect( + () => reminder.onScheduleConfirmed(() => setConfirmRevision((value) => value + 1)), + [reminder], + ); + // command.result 写完本地库之后 lastAppliedCommand 才会更新(见 // AssistantConversationService.applyCommandResultLocally),所以这里发现它 // 变化时数据已经落地了,可以安全更新聚焦目标。日历重取由下方 revision 驱动, @@ -84,7 +93,7 @@ export function HomeScreen({ isSigningOut={isSigningOut} onOpenPermissions={() => onRequestPermission()} onSignOut={onSignOut} - refreshSignal={pttScheduleRevision + callScheduleRevision} + refreshSignal={pttScheduleRevision + callScheduleRevision + confirmRevision} focusTarget={focusTarget} service={scheduleService} timezone={timezone} diff --git a/frontend/tests/unit/features/reminder/application/LocalReminderApplication.test.ts b/frontend/tests/unit/features/reminder/application/LocalReminderApplication.test.ts index 28b9e67c..149e467d 100644 --- a/frontend/tests/unit/features/reminder/application/LocalReminderApplication.test.ts +++ b/frontend/tests/unit/features/reminder/application/LocalReminderApplication.test.ts @@ -1079,6 +1079,34 @@ describe('LocalReminderApplication', () => { accepted: true, }); }); + + it('notifies onScheduleConfirmed after a confirm commits, but not on register()', async () => { + const schedule = fixtureLocationSchedule({ id: 's1' }); + const deps = createDeps({ schedules: new FakeScheduleReader([schedule]) }); + const app = new LocalReminderApplication(deps); + await app.start(); + const listener = jest.fn(); + app.onScheduleConfirmed(listener); + + await app.register(schedule); + expect(listener).not.toHaveBeenCalled(); + + await app.confirm('s1', '2026-08-18T10:00:00.000Z'); + expect(listener).toHaveBeenCalledTimes(1); + }); + + it('stops notifying onScheduleConfirmed after unsubscribing', async () => { + const schedule = fixtureLocationSchedule({ id: 's1' }); + const deps = createDeps({ schedules: new FakeScheduleReader([schedule]) }); + const app = new LocalReminderApplication(deps); + await app.start(); + const listener = jest.fn(); + const unsubscribe = app.onScheduleConfirmed(listener); + unsubscribe(); + + await app.confirm('s1', '2026-08-18T10:00:00.000Z'); + expect(listener).not.toHaveBeenCalled(); + }); }); describe('runDeliver edge receipts', () => { diff --git a/frontend/tests/unit/screens/HomeScreen.test.tsx b/frontend/tests/unit/screens/HomeScreen.test.tsx index 8eac1a3b..efdf83ec 100644 --- a/frontend/tests/unit/screens/HomeScreen.test.tsx +++ b/frontend/tests/unit/screens/HomeScreen.test.tsx @@ -73,7 +73,9 @@ describe('HomeScreen calendar refresh', () => { onRequestPermission={() => {}} onSignOut={async () => {}} pushToTalkApplication={pushToTalkApplication} - reminder={{ onPermissionBlocked: () => () => {} } as never} + reminder={ + { onPermissionBlocked: () => () => {}, onScheduleConfirmed: () => () => {} } as never + } scheduleService={scheduleService} timezone="Asia/Shanghai" username="Sarah" @@ -110,4 +112,52 @@ describe('HomeScreen calendar refresh', () => { accountId: 'account-a', }); }); + + it('reloads the calendar when a reminder is confirmed outside the voice-command path', async () => { + const pushToTalkApplication = new FakeAssistantApplication(); + const continuousApplication = new FakeAssistantApplication(); + const scheduleService: ScheduleCalendarReadService = { + getLocationSchedules: jest + .fn() + .mockResolvedValue([]), + getSchedulesByDay: jest + .fn() + .mockResolvedValue([]), + getSchedulesByRange: jest + .fn() + .mockResolvedValue([]), + }; + const confirmedListeners = new Set<() => void>(); + render( + {} }} + isSigningOut={false} + onRequestPermission={() => {}} + onSignOut={async () => {}} + pushToTalkApplication={pushToTalkApplication} + reminder={ + { + onPermissionBlocked: () => () => {}, + onScheduleConfirmed: (listener: () => void) => { + confirmedListeners.add(listener); + return () => confirmedListeners.delete(listener); + }, + } as never + } + scheduleService={scheduleService} + timezone="Asia/Shanghai" + username="Sarah" + />, + ); + + await waitFor(() => expect(scheduleService.getLocationSchedules).toHaveBeenCalledTimes(1)); + + act(() => { + for (const listener of confirmedListeners) listener(); + }); + + await waitFor(() => expect(scheduleService.getLocationSchedules).toHaveBeenCalledTimes(2)); + }); });