feat: 实现日历提醒完整功能(操作执行、通知服务重构、归档)

- 新增 ReminderActionExecutor 处理取消/稍后提醒操作
- 新增 ReminderOutboxStore 本地存储待处理操作
- 重构 LocalNotificationService 支持聚合提醒和交互操作
- 新增 event_color_resolver 工具类统一颜色解析
- 新增 CalendarService.archiveEvent 归档方法
- 增强 ModelTracking 支持缓存命中、推理token和成本追踪
- 添加 qwen3.5-35b-a3b 模型配置
- 更新 AndroidManifest 全屏intent权限
- 补充相关单元测试和文档
This commit is contained in:
qzl
2026-03-18 19:12:47 +08:00
parent 257cb0f5d5
commit 00f37d7e19
35 changed files with 2676 additions and 244 deletions
@@ -4,23 +4,30 @@ import 'package:social_app/core/notifications/local_notification_service.dart';
import 'package:social_app/core/startup/auth_session_bootstrapper.dart';
import 'package:social_app/features/auth/presentation/bloc/auth_state.dart';
import 'package:social_app/features/calendar/data/services/calendar_service.dart';
import 'package:social_app/features/calendar/reminders/reminder_action_executor.dart';
class MockCalendarService extends Mock implements CalendarService {}
class MockLocalNotificationService extends Mock
implements LocalNotificationService {}
class MockReminderActionExecutor extends Mock
implements ReminderActionExecutor {}
void main() {
late MockCalendarService calendarService;
late MockLocalNotificationService notificationService;
late MockReminderActionExecutor reminderActionExecutor;
late AuthSessionBootstrapper bootstrapper;
setUp(() {
calendarService = MockCalendarService();
notificationService = MockLocalNotificationService();
reminderActionExecutor = MockReminderActionExecutor();
bootstrapper = AuthSessionBootstrapper(
calendarService: calendarService,
notificationService: notificationService,
reminderActionExecutor: reminderActionExecutor,
);
});
@@ -29,6 +36,7 @@ void main() {
verifyNever(() => calendarService.getEventsForRange(any(), any()));
verifyNever(() => notificationService.rebuildUpcomingReminders(any()));
verifyNever(() => reminderActionExecutor.replayPendingActions());
});
test('fetches upcoming events after authenticated state', () async {
@@ -38,6 +46,41 @@ void main() {
when(
() => notificationService.rebuildUpcomingReminders(any()),
).thenAnswer((_) async {});
when(
() => reminderActionExecutor.replayPendingActions(),
).thenAnswer((_) async {});
await bootstrapper.syncForAuthState(
const AuthAuthenticated(
user: AuthUser(id: 'u1', email: 'a@test.com'),
),
);
verify(() => calendarService.getEventsForRange(any(), any())).called(1);
verify(() => notificationService.rebuildUpcomingReminders(any())).called(1);
verify(() => reminderActionExecutor.replayPendingActions()).called(1);
});
test('retries sync when previous bootstrap failed', () async {
when(
() => reminderActionExecutor.replayPendingActions(),
).thenThrow(Exception('offline'));
await bootstrapper.syncForAuthState(
const AuthAuthenticated(
user: AuthUser(id: 'u1', email: 'a@test.com'),
),
);
when(
() => reminderActionExecutor.replayPendingActions(),
).thenAnswer((_) async {});
when(
() => calendarService.getEventsForRange(any(), any()),
).thenAnswer((_) async => []);
when(
() => notificationService.rebuildUpcomingReminders(any()),
).thenAnswer((_) async {});
await bootstrapper.syncForAuthState(
const AuthAuthenticated(
@@ -45,7 +88,7 @@ void main() {
),
);
verify(() => reminderActionExecutor.replayPendingActions()).called(2);
verify(() => calendarService.getEventsForRange(any(), any())).called(1);
verify(() => notificationService.rebuildUpcomingReminders(any())).called(1);
});
}
@@ -0,0 +1,42 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:social_app/features/calendar/reminders/models/reminder_payload.dart';
void main() {
group('ReminderPayload', () {
test('round-trips single payload', () {
final payload = ReminderPayload(
eventId: 'evt_1',
title: 'Daily Sync',
startAt: DateTime.parse('2026-03-18T16:00:00+08:00'),
endAt: DateTime.parse('2026-03-18T17:00:00+08:00'),
timezone: 'Asia/Shanghai',
location: 'A101',
notes: 'Bring docs',
color: '#3B82F6',
mode: ReminderPayloadMode.single,
aggregateIds: const [],
version: 1,
);
final decoded = ReminderPayload.fromJson(payload.toJson());
expect(decoded, payload);
});
test('round-trips aggregate payload', () {
final payload = ReminderPayload(
eventId: 'evt_group',
title: 'Overlap Reminder',
startAt: DateTime.parse('2026-03-18T16:00:00+08:00'),
timezone: 'Asia/Shanghai',
mode: ReminderPayloadMode.aggregate,
aggregateIds: const ['evt_1', 'evt_2'],
version: 1,
);
final decoded = ReminderPayload.fromJson(payload.toJson());
expect(decoded.mode, ReminderPayloadMode.aggregate);
expect(decoded.aggregateIds, const ['evt_1', 'evt_2']);
expect(decoded, payload);
});
});
}
@@ -0,0 +1,117 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:mocktail/mocktail.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:social_app/core/notifications/local_notification_service.dart';
import 'package:social_app/features/calendar/data/models/schedule_item_model.dart';
import 'package:social_app/features/calendar/data/services/calendar_service.dart';
import 'package:social_app/features/calendar/reminders/models/reminder_action.dart';
import 'package:social_app/features/calendar/reminders/models/reminder_payload.dart';
import 'package:social_app/features/calendar/reminders/reminder_action_executor.dart';
import 'package:social_app/features/calendar/reminders/reminder_outbox_store.dart';
class MockCalendarService extends Mock implements CalendarService {}
class MockLocalNotificationService extends Mock
implements LocalNotificationService {}
void main() {
late MockCalendarService calendarService;
late MockLocalNotificationService notificationService;
late ReminderOutboxStore outboxStore;
late ReminderActionExecutor executor;
setUp(() async {
SharedPreferences.setMockInitialValues({});
final prefs = await SharedPreferences.getInstance();
calendarService = MockCalendarService();
notificationService = MockLocalNotificationService();
outboxStore = ReminderOutboxStore(prefs);
executor = ReminderActionExecutor(
calendarService: calendarService,
notificationService: notificationService,
outboxStore: outboxStore,
);
});
test('cancel archives remotely and cancels local reminder', () async {
when(
() => notificationService.cancelEventReminder('evt_1'),
).thenAnswer((_) async {});
when(
() => calendarService.archiveEvent('evt_1'),
).thenAnswer((_) async => null);
await executor.handleAction(
action: ReminderAction.cancel,
payload: ReminderPayload(
eventId: 'evt_1',
title: 'sync',
startAt: DateTime.parse('2026-03-18T16:00:00+08:00'),
timezone: 'Asia/Shanghai',
),
);
verify(() => notificationService.cancelEventReminder('evt_1')).called(1);
verify(() => calendarService.archiveEvent('evt_1')).called(1);
final pending = await outboxStore.listPending();
expect(pending, isEmpty);
});
test('archive failure writes pending outbox item', () async {
when(
() => notificationService.cancelEventReminder('evt_1'),
).thenAnswer((_) async {});
when(
() => calendarService.archiveEvent('evt_1'),
).thenThrow(Exception('offline'));
await executor.handleAction(
action: ReminderAction.cancel,
payload: ReminderPayload(
eventId: 'evt_1',
title: 'sync',
startAt: DateTime.parse('2026-03-18T16:00:00+08:00'),
timezone: 'Asia/Shanghai',
),
);
final pending = await outboxStore.listPending();
expect(pending.length, 1);
expect(pending.first.eventId, 'evt_1');
expect(pending.first.state, ReminderOutboxState.pending);
});
test('snooze reschedules +10m when event not expired', () async {
final now = DateTime.now();
final event = ScheduleItemModel(
id: 'evt_1',
ownerId: 'u1',
title: 'sync',
startAt: now.add(const Duration(minutes: 1)),
endAt: now.add(const Duration(hours: 1)),
metadata: ScheduleMetadata(reminderMinutes: 15),
);
when(
() => calendarService.getEventById('evt_1'),
).thenAnswer((_) async => event);
when(
() => notificationService.scheduleReminderAt(event, any()),
).thenAnswer((_) async {});
await executor.handleAction(
action: ReminderAction.snooze10m,
payload: ReminderPayload(
eventId: 'evt_1',
title: 'sync',
startAt: event.startAt,
endAt: event.endAt,
timezone: 'Asia/Shanghai',
),
);
verify(
() => notificationService.scheduleReminderAt(event, any()),
).called(1);
verifyNever(() => calendarService.archiveEvent(any()));
});
}
@@ -0,0 +1,48 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:social_app/features/calendar/data/models/schedule_item_model.dart';
import 'package:social_app/features/calendar/reminders/reminder_overlap_policy.dart';
void main() {
final policy = ReminderOverlapPolicy();
test('groups reminders in same minute bucket', () {
final now = DateTime(2026, 3, 18, 15, 40, 0);
final eventA = ScheduleItemModel(
id: 'a',
ownerId: 'u1',
title: 'A',
startAt: DateTime(2026, 3, 18, 16, 0, 0),
endAt: DateTime(2026, 3, 18, 17, 0, 0),
metadata: ScheduleMetadata(reminderMinutes: 15),
);
final eventB = ScheduleItemModel(
id: 'b',
ownerId: 'u1',
title: 'B',
startAt: DateTime(2026, 3, 18, 16, 0, 20),
endAt: DateTime(2026, 3, 18, 17, 0, 0),
metadata: ScheduleMetadata(reminderMinutes: 15),
);
final groups = policy.groupByMinute([eventA, eventB], now: now);
expect(groups.length, 1);
expect(groups.first.events.length, 2);
expect(groups.first.isAggregate, isTrue);
});
test('returns compensation fire time when remindAt already passed', () {
final now = DateTime(2026, 3, 18, 15, 50, 0);
final event = ScheduleItemModel(
id: 'a',
ownerId: 'u1',
title: 'A',
startAt: DateTime(2026, 3, 18, 16, 0, 0),
endAt: DateTime(2026, 3, 18, 16, 30, 0),
metadata: ScheduleMetadata(reminderMinutes: 15),
);
final fireAt = policy.resolveFirstFireAt(event, now: now);
expect(fireAt, isNotNull);
expect(fireAt!.isAfter(now), isTrue);
});
}
@@ -0,0 +1,25 @@
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:social_app/core/theme/design_tokens.dart';
import 'package:social_app/features/calendar/data/models/schedule_item_model.dart';
import 'package:social_app/features/calendar/ui/utils/event_color_resolver.dart';
void main() {
test('returns gray for archived status regardless of custom color', () {
final color = resolveEventColor(
status: ScheduleStatus.archived,
colorHex: '#EF4444',
);
expect(color, AppColors.slate400);
});
test('returns parsed color for active status', () {
final color = resolveEventColor(
status: ScheduleStatus.active,
colorHex: '#3B82F6',
);
expect(color.value, const Color(0xFF3B82F6).value);
});
}