Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
import type {
SystemNotificationPort,
SystemNotificationReceipt,
SystemNotificationRequest,
} from '../../features/reminder/application/interfaces';

type NotificationsModule = typeof import('expo-notifications');

let channelReady: Promise<void> | null = null;

async function loadNotifications(): Promise<NotificationsModule | null> {
try {
return await import('expo-notifications');
} catch {
return null;
}
}

async function ensureAndroidChannel(Notifications: NotificationsModule): Promise<void> {
if (channelReady != null) {
await channelReady;
return;
}
channelReady = (async () => {
await Notifications.setNotificationChannelAsync('timeflow-reminders', {
name: '日程提醒',
importance: Notifications.AndroidImportance.DEFAULT,
vibrationPattern: [0, 180],
lightColor: '#D7F36A',
});
})().catch((error) => {
// 失败别永久缓存住:清掉 channelReady,让下一条提醒重试,而不是这次失败
// 之后整个 App 生命周期里 show() 都跟着抛。
channelReady = null;
throw error;
});
await channelReady;
}

/** 基于 expo-notifications 的轻度提醒系统通知。 */
export class ExpoSystemNotification implements SystemNotificationPort {
/** 默认走真实的动态 import;测试注入一个假实现,绕开 expo-notifications 这个原生模块。 */
constructor(
private readonly loadNotificationsModule: () => Promise<NotificationsModule | null> = loadNotifications,
) {}

async show(request: SystemNotificationRequest): Promise<SystemNotificationReceipt> {
const Notifications = await this.loadNotificationsModule();
if (Notifications == null) {
return { notification_id: request.notification_id, shown: false };
}

await ensureAndroidChannel(Notifications);
Notifications.setNotificationHandler({
handleNotification: async () => ({
shouldShowBanner: true,
shouldShowList: true,
shouldPlaySound: false,
shouldSetBadge: false,
}),
});

await Notifications.scheduleNotificationAsync({
identifier: request.notification_id,
content: {
title: request.title,
body: request.body,
sound: false,
},
trigger: null,
});

return { notification_id: request.notification_id, shown: true };
}

async cancel(notificationId: string): Promise<void> {
const Notifications = await this.loadNotificationsModule();
if (Notifications == null) return;
await Notifications.dismissNotificationAsync(notificationId).catch(() => undefined);
await Notifications.cancelScheduledNotificationAsync(notificationId).catch(() => undefined);
}
}
26 changes: 0 additions & 26 deletions frontend/src/infrastructure/notifications/MockAlarmScheduler.ts

This file was deleted.

44 changes: 0 additions & 44 deletions frontend/src/infrastructure/notifications/MockDeviceCapability.ts

This file was deleted.

86 changes: 65 additions & 21 deletions frontend/src/infrastructure/notifications/NativeAlarmScheduler.ts
Original file line number Diff line number Diff line change
@@ -1,16 +1,23 @@
import type {
AlarmNativeDisposition,
AlarmNativeEvent,
AlarmScheduleReceipt,
AlarmScheduleRequest,
AlarmSchedulerPort,
} from '../../features/reminder/application/interfaces';
import {
isTimeflowAlarmAvailable,
nativeAckAlarmDispositions,
nativeAreAlarmPermissionsGranted,
nativeCancelAlarm,
nativeCancelAllAlarms,
nativePeekAlarmDispositions,
nativeScheduleAlarm,
nativeStopAlarmRinging,
subscribeNativeAlarmEvents,
} from './native/TimeflowAlarmBridge';

/** Android TimeflowAlarm 适配器;无法挂上时返回 scheduled: false。 */
/** Android TimeflowAlarm 适配器;无法挂上时返回 scheduled=false。 */
export class NativeAlarmScheduler implements AlarmSchedulerPort {
async schedule(request: AlarmScheduleRequest): Promise<AlarmScheduleReceipt> {
if (!isTimeflowAlarmAvailable()) {
Expand All @@ -22,29 +29,21 @@ export class NativeAlarmScheduler implements AlarmSchedulerPort {
return unscheduled(request.schedule_id);
}

try {
const ready = await nativeAreAlarmPermissionsGranted();
if (!ready) {
return unscheduled(request.schedule_id);
}

const alarmId = await nativeScheduleAlarm(
triggerAtMillis,
request.title,
request.schedule_id,
);
if (alarmId == null || alarmId.length === 0) {
return unscheduled(request.schedule_id);
}
const ready = await nativeAreAlarmPermissionsGranted();
if (!ready) {
return unscheduled(request.schedule_id);
}

return {
alarm_id: alarmId,
schedule_id: request.schedule_id,
scheduled: true,
};
} catch {
const alarmId = await nativeScheduleAlarm(triggerAtMillis, request.title, request.schedule_id);
if (alarmId == null || alarmId.length === 0) {
return unscheduled(request.schedule_id);
}

return {
alarm_id: alarmId,
schedule_id: request.schedule_id,
scheduled: true,
};
}

async cancel(alarmId: string | null): Promise<{ cancelled: boolean }> {
Expand All @@ -58,12 +57,57 @@ export class NativeAlarmScheduler implements AlarmSchedulerPort {
async rebuild(
requests: readonly AlarmScheduleRequest[],
): Promise<readonly AlarmScheduleReceipt[]> {
// 冷启动 registrations 为空时也要清掉 SharedPreferences 里的孤儿闹钟。
await nativeCancelAllAlarms();
const receipts: AlarmScheduleReceipt[] = [];
for (const request of requests) {
receipts.push(await this.schedule(request));
}
return receipts;
}

async stopRinging(): Promise<void> {
await nativeStopAlarmRinging();
}

subscribe(listener: (event: AlarmNativeEvent) => void): () => void {
return subscribeNativeAlarmEvents((payload) => {
listener({
type: payload.type,
schedule_id: payload.scheduleId,
alarm_id: payload.alarmId,
title: payload.title,
at: new Date(payload.atMillis || Date.now()).toISOString(),
});
});
}

async peekNativeDispositions(): Promise<readonly AlarmNativeDisposition[]> {
const rows = await nativePeekAlarmDispositions();
return rows
.map((row) => {
const state =
row.state === 'confirmed'
? 'confirmed'
: row.state === 'pending'
? 'pending'
: row.state === 'snoozed'
? 'snoozed'
: null;
if (state == null || !row.scheduleId) return null;
return {
schedule_id: row.scheduleId,
alarm_id: row.alarmId ?? '',
state,
updated_at: new Date(row.updatedAtMillis || Date.now()).toISOString(),
} satisfies AlarmNativeDisposition;
})
.filter((row): row is AlarmNativeDisposition => row != null);
}

async ackNativeDispositions(scheduleIds: readonly string[]): Promise<void> {
await nativeAckAlarmDispositions(scheduleIds);
}
}

function unscheduled(scheduleId: string): AlarmScheduleReceipt {
Expand Down
Loading
Loading