feat: 实现日历提醒完整功能(操作执行、通知服务重构、归档)
- 新增 ReminderActionExecutor 处理取消/稍后提醒操作 - 新增 ReminderOutboxStore 本地存储待处理操作 - 重构 LocalNotificationService 支持聚合提醒和交互操作 - 新增 event_color_resolver 工具类统一颜色解析 - 新增 CalendarService.archiveEvent 归档方法 - 增强 ModelTracking 支持缓存命中、推理token和成本追踪 - 添加 qwen3.5-35b-a3b 模型配置 - 更新 AndroidManifest 全屏intent权限 - 补充相关单元测试和文档
This commit is contained in:
@@ -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);
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user