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
@@ -3,6 +3,7 @@
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" /> <uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" /> <uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
<uses-permission android:name="android.permission.SCHEDULE_EXACT_ALARM" /> <uses-permission android:name="android.permission.SCHEDULE_EXACT_ALARM" />
<uses-permission android:name="android.permission.USE_FULL_SCREEN_INTENT" />
<uses-permission android:name="android.permission.RECORD_AUDIO" /> <uses-permission android:name="android.permission.RECORD_AUDIO" />
<uses-permission android:name="android.permission.CAMERA" /> <uses-permission android:name="android.permission.CAMERA" />
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" android:maxSdkVersion="32" /> <uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" android:maxSdkVersion="32" />
@@ -34,6 +35,8 @@
android:launchMode="singleTop" android:launchMode="singleTop"
android:taskAffinity="" android:taskAffinity=""
android:theme="@style/LaunchTheme" android:theme="@style/LaunchTheme"
android:showWhenLocked="true"
android:turnScreenOn="true"
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|smallestScreenSize|locale|layoutDirection|fontScale|screenLayout|density|uiMode" android:configChanges="orientation|keyboardHidden|keyboard|screenSize|smallestScreenSize|locale|layoutDirection|fontScale|screenLayout|density|uiMode"
android:hardwareAccelerated="true" android:hardwareAccelerated="true"
android:windowSoftInputMode="adjustResize"> android:windowSoftInputMode="adjustResize">
+16
View File
@@ -1,6 +1,7 @@
import 'package:dio/dio.dart'; import 'package:dio/dio.dart';
import 'package:flutter_secure_storage/flutter_secure_storage.dart'; import 'package:flutter_secure_storage/flutter_secure_storage.dart';
import 'package:get_it/get_it.dart'; import 'package:get_it/get_it.dart';
import 'package:shared_preferences/shared_preferences.dart';
import '../api/api_client.dart'; import '../api/api_client.dart';
import '../api/i_api_client.dart'; import '../api/i_api_client.dart';
import '../storage/token_storage.dart'; import '../storage/token_storage.dart';
@@ -13,6 +14,8 @@ import '../../features/auth/presentation/bloc/auth_bloc.dart';
import '../../features/auth/presentation/bloc/auth_event.dart'; import '../../features/auth/presentation/bloc/auth_event.dart';
import '../../features/calendar/data/calendar_api.dart'; import '../../features/calendar/data/calendar_api.dart';
import '../../features/calendar/data/services/calendar_service.dart'; import '../../features/calendar/data/services/calendar_service.dart';
import '../../features/calendar/reminders/reminder_action_executor.dart';
import '../../features/calendar/reminders/reminder_outbox_store.dart';
import '../../features/calendar/ui/calendar_state_manager.dart'; import '../../features/calendar/ui/calendar_state_manager.dart';
import '../../features/friends/data/friends_api.dart'; import '../../features/friends/data/friends_api.dart';
import '../../features/messages/data/inbox_api.dart'; import '../../features/messages/data/inbox_api.dart';
@@ -49,6 +52,9 @@ Future<void> configureDependencies() async {
final authApi = AuthApi(apiClient); final authApi = AuthApi(apiClient);
sl.registerSingleton<AuthApi>(authApi); sl.registerSingleton<AuthApi>(authApi);
final sharedPreferences = await SharedPreferences.getInstance();
sl.registerSingleton<SharedPreferences>(sharedPreferences);
final usersApi = UsersApi(apiClient); final usersApi = UsersApi(apiClient);
sl.registerSingleton<UsersApi>(usersApi); sl.registerSingleton<UsersApi>(usersApi);
@@ -58,8 +64,18 @@ Future<void> configureDependencies() async {
final calendarService = CalendarService(apiClient: apiClient); final calendarService = CalendarService(apiClient: apiClient);
sl.registerSingleton<CalendarService>(calendarService); sl.registerSingleton<CalendarService>(calendarService);
final reminderOutboxStore = ReminderOutboxStore(sharedPreferences);
sl.registerSingleton<ReminderOutboxStore>(reminderOutboxStore);
sl.registerSingleton<LocalNotificationService>(LocalNotificationService()); sl.registerSingleton<LocalNotificationService>(LocalNotificationService());
final reminderActionExecutor = ReminderActionExecutor(
calendarService: calendarService,
notificationService: sl<LocalNotificationService>(),
outboxStore: reminderOutboxStore,
);
sl.registerSingleton<ReminderActionExecutor>(reminderActionExecutor);
final friendsApi = FriendsApi(apiClient); final friendsApi = FriendsApi(apiClient);
sl.registerSingleton<FriendsApi>(friendsApi); sl.registerSingleton<FriendsApi>(friendsApi);
@@ -1,26 +1,40 @@
import 'package:flutter_local_notifications/flutter_local_notifications.dart'; import 'dart:convert';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:flutter_local_notifications/flutter_local_notifications.dart';
import 'package:timezone/data/latest.dart' as tz_data; import 'package:timezone/data/latest.dart' as tz_data;
import 'package:timezone/timezone.dart' as tz; import 'package:timezone/timezone.dart' as tz;
import '../../features/calendar/data/models/schedule_item_model.dart'; import '../../features/calendar/data/models/schedule_item_model.dart';
import '../../features/calendar/reminders/models/reminder_action.dart';
import '../../features/calendar/reminders/models/reminder_payload.dart';
import '../../features/calendar/reminders/reminder_overlap_policy.dart';
class NotificationScheduleException implements Exception { typedef ReminderNotificationActionHandler =
final String message; Future<void> Function({
required ReminderAction action,
NotificationScheduleException(this.message); required ReminderPayload payload,
});
@override
String toString() => message;
}
class LocalNotificationService { class LocalNotificationService {
final FlutterLocalNotificationsPlugin _plugin; static const String _iosCategoryId = 'calendar_reminder_actions_v1';
bool _initialized = false; static const String _actionCancel = 'cancel';
bool _exactAlarmPermissionRequested = false; static const String _actionSnooze = 'snooze_10m';
LocalNotificationService({FlutterLocalNotificationsPlugin? plugin}) final FlutterLocalNotificationsPlugin _plugin;
: _plugin = plugin ?? FlutterLocalNotificationsPlugin(); final ReminderOverlapPolicy _overlapPolicy;
bool _initialized = false;
ReminderNotificationActionHandler? _actionHandler;
LocalNotificationService({
FlutterLocalNotificationsPlugin? plugin,
ReminderOverlapPolicy? overlapPolicy,
}) : _plugin = plugin ?? FlutterLocalNotificationsPlugin(),
_overlapPolicy = overlapPolicy ?? const ReminderOverlapPolicy();
void bindActionHandler(ReminderNotificationActionHandler handler) {
_actionHandler = handler;
}
Future<void> initialize() async { Future<void> initialize() async {
if (_initialized) { if (_initialized) {
@@ -29,20 +43,40 @@ class LocalNotificationService {
tz_data.initializeTimeZones(); tz_data.initializeTimeZones();
const android = AndroidInitializationSettings('@mipmap/ic_launcher'); const android = AndroidInitializationSettings('@mipmap/ic_launcher');
const ios = DarwinInitializationSettings( final ios = DarwinInitializationSettings(
requestAlertPermission: false, requestAlertPermission: false,
requestBadgePermission: false, requestBadgePermission: false,
requestSoundPermission: false, requestSoundPermission: false,
notificationCategories: [
DarwinNotificationCategory(
_iosCategoryId,
actions: <DarwinNotificationAction>[
DarwinNotificationAction.plain(_actionCancel, '取消'),
DarwinNotificationAction.plain(
_actionSnooze,
'稍后提醒',
options: <DarwinNotificationActionOption>{
DarwinNotificationActionOption.foreground,
},
),
],
),
],
); );
const settings = InitializationSettings(android: android, iOS: ios); final settings = InitializationSettings(android: android, iOS: ios);
await _plugin.initialize(settings); await _plugin.initialize(
settings,
onDidReceiveNotificationResponse: _onNotificationResponse,
);
final androidImpl = _plugin final androidImpl = _plugin
.resolvePlatformSpecificImplementation< .resolvePlatformSpecificImplementation<
AndroidFlutterLocalNotificationsPlugin AndroidFlutterLocalNotificationsPlugin
>(); >();
await androidImpl?.requestNotificationsPermission(); await androidImpl?.requestNotificationsPermission();
await androidImpl?.requestExactAlarmsPermission();
await androidImpl?.requestFullScreenIntentPermission();
final iosImpl = _plugin final iosImpl = _plugin
.resolvePlatformSpecificImplementation< .resolvePlatformSpecificImplementation<
@@ -55,115 +89,46 @@ class LocalNotificationService {
Future<void> upsertEventReminder(ScheduleItemModel event) async { Future<void> upsertEventReminder(ScheduleItemModel event) async {
await initialize(); await initialize();
if (event.status != ScheduleStatus.active ||
final reminderMinutes = event.metadata?.reminderMinutes; event.metadata?.reminderMinutes == null) {
if (reminderMinutes == null) {
await cancelEventReminder(event.id); await cancelEventReminder(event.id);
return; return;
} }
final fireAt = event.startAt.subtract(Duration(minutes: reminderMinutes)); final now = DateTime.now();
if (!fireAt.isAfter(DateTime.now())) { final fireAt = _overlapPolicy.resolveFirstFireAt(event, now: now);
if (fireAt == null) {
await cancelEventReminder(event.id); await cancelEventReminder(event.id);
return; return;
} }
final notificationId = _notificationIdForEvent(event.id); await cancelEventReminder(event.id);
final scheduledAt = tz.TZDateTime.from(fireAt, tz.local); await _scheduleRemindersFrom(event: event, firstFireAt: fireAt);
final androidImpl = _plugin }
.resolvePlatformSpecificImplementation<
AndroidFlutterLocalNotificationsPlugin
>();
var androidScheduleMode = AndroidScheduleMode.exactAllowWhileIdle; Future<void> scheduleReminderAt(
if (defaultTargetPlatform == TargetPlatform.android && ScheduleItemModel event,
androidImpl != null) { DateTime fireAt,
var notificationsEnabled = ) async {
await androidImpl.areNotificationsEnabled() ?? false; await initialize();
if (!notificationsEnabled) { await cancelEventReminder(event.id);
await androidImpl.requestNotificationsPermission(); await _scheduleRemindersFrom(event: event, firstFireAt: fireAt);
notificationsEnabled =
await androidImpl.areNotificationsEnabled() ?? false;
}
if (!notificationsEnabled) {
throw NotificationScheduleException('系统通知权限未开启,无法创建提醒');
}
try {
var canScheduleExact =
await androidImpl.canScheduleExactNotifications() ?? false;
if (!canScheduleExact && !_exactAlarmPermissionRequested) {
_exactAlarmPermissionRequested = true;
await androidImpl.requestExactAlarmsPermission();
canScheduleExact =
await androidImpl.canScheduleExactNotifications() ?? false;
}
if (!canScheduleExact) {
androidScheduleMode = AndroidScheduleMode.inexactAllowWhileIdle;
}
} catch (_) {
androidScheduleMode = AndroidScheduleMode.inexactAllowWhileIdle;
}
}
final details = NotificationDetails(
android: AndroidNotificationDetails(
'calendar_reminder_channel',
'日历提醒',
channelDescription: '日历事件提醒通知',
importance: Importance.max,
priority: Priority.high,
enableVibration: true,
),
iOS: const DarwinNotificationDetails(
presentAlert: true,
presentSound: true,
presentBadge: true,
),
);
try {
await _plugin.zonedSchedule(
notificationId,
event.title,
_buildReminderBody(event, reminderMinutes),
scheduledAt,
details,
androidScheduleMode: androidScheduleMode,
uiLocalNotificationDateInterpretation:
UILocalNotificationDateInterpretation.absoluteTime,
);
final pending = await _plugin.pendingNotificationRequests();
final scheduled = pending.any((item) => item.id == notificationId);
if (!scheduled) {
throw NotificationScheduleException('提醒未被系统接受,请检查系统通知和电池优化设置');
}
} catch (error) {
if (error is NotificationScheduleException) {
rethrow;
}
await _plugin.zonedSchedule(
notificationId,
event.title,
_buildReminderBody(event, reminderMinutes),
scheduledAt,
details,
androidScheduleMode: AndroidScheduleMode.inexactAllowWhileIdle,
uiLocalNotificationDateInterpretation:
UILocalNotificationDateInterpretation.absoluteTime,
);
final pending = await _plugin.pendingNotificationRequests();
final scheduled = pending.any((item) => item.id == notificationId);
if (!scheduled) {
throw NotificationScheduleException('提醒创建失败,请检查系统设置后重试');
}
}
} }
Future<void> cancelEventReminder(String eventId) async { Future<void> cancelEventReminder(String eventId) async {
await initialize(); await initialize();
final pending = await _plugin.pendingNotificationRequests();
for (final request in pending) {
final payload = _decodePayload(request.payload);
if (payload == null) {
continue;
}
if (payload.eventId == eventId ||
payload.aggregateIds.contains(eventId)) {
await _plugin.cancel(request.id);
}
}
await _plugin.cancel(_notificationIdForEvent(eventId)); await _plugin.cancel(_notificationIdForEvent(eventId));
} }
@@ -171,8 +136,15 @@ class LocalNotificationService {
Iterable<ScheduleItemModel> events, Iterable<ScheduleItemModel> events,
) async { ) async {
await initialize(); await initialize();
for (final event in events) {
await upsertEventReminder(event); final now = DateTime.now();
final groups = _overlapPolicy.groupByMinute(events, now: now);
for (final group in groups) {
if (group.isAggregate) {
await _scheduleAggregateReminder(group.events, group.fireAt);
continue;
}
await upsertEventReminder(group.events.first);
} }
} }
@@ -180,10 +152,243 @@ class LocalNotificationService {
return eventId.hashCode & 0x7fffffff; return eventId.hashCode & 0x7fffffff;
} }
String _buildReminderBody(ScheduleItemModel event, int reminderMinutes) { int _notificationIdForEventCycle(
if (reminderMinutes == 0) { String eventId,
return '日程现在开始:${event.title}'; DateTime fireAt,
ReminderPayloadMode mode,
) {
final cycleMinute =
fireAt.millisecondsSinceEpoch ~/
const Duration(minutes: 1).inMilliseconds;
return '$eventId|$cycleMinute|${mode.value}'.hashCode & 0x7fffffff;
}
Future<AndroidScheduleMode> _resolveAndroidScheduleMode() async {
if (defaultTargetPlatform != TargetPlatform.android) {
return AndroidScheduleMode.exactAllowWhileIdle;
}
final androidImpl = _plugin
.resolvePlatformSpecificImplementation<
AndroidFlutterLocalNotificationsPlugin
>();
if (androidImpl == null) {
return AndroidScheduleMode.exactAllowWhileIdle;
}
final canScheduleExact =
await androidImpl.canScheduleExactNotifications() ?? false;
return canScheduleExact
? AndroidScheduleMode.exactAllowWhileIdle
: AndroidScheduleMode.inexactAllowWhileIdle;
}
NotificationDetails _buildNotificationDetails() {
return NotificationDetails(
android: AndroidNotificationDetails(
'calendar_alarm_channel_v2',
'日程闹钟提醒',
channelDescription: '日程到点闹钟式提醒通知',
importance: Importance.max,
priority: Priority.max,
category: AndroidNotificationCategory.alarm,
audioAttributesUsage: AudioAttributesUsage.alarm,
fullScreenIntent: true,
playSound: true,
enableVibration: true,
vibrationPattern: Int64List.fromList([0, 1000, 500, 1200]),
timeoutAfter: 30000,
autoCancel: true,
actions: <AndroidNotificationAction>[
AndroidNotificationAction(_actionCancel, '取消'),
AndroidNotificationAction(
_actionSnooze,
'稍后提醒',
showsUserInterface: true,
),
],
),
iOS: const DarwinNotificationDetails(
presentAlert: true,
presentSound: true,
presentBadge: true,
categoryIdentifier: _iosCategoryId,
),
);
}
Future<void> _scheduleSingleReminder({
required ScheduleItemModel event,
required DateTime fireAt,
}) async {
final notificationId = _notificationIdForEventCycle(
event.id,
fireAt,
ReminderPayloadMode.single,
);
final payload = ReminderPayload(
eventId: event.id,
title: event.title,
startAt: event.startAt,
endAt: event.endAt,
timezone: event.timezone,
location: event.metadata?.location,
notes: event.metadata?.notes,
color: event.metadata?.color,
mode: ReminderPayloadMode.single,
version: 1,
);
final details = _buildNotificationDetails();
final scheduledAt = tz.TZDateTime.from(fireAt, tz.local);
final mode = await _resolveAndroidScheduleMode();
try {
await _plugin.zonedSchedule(
notificationId,
event.title,
_buildReminderBody(event, event.metadata?.reminderMinutes ?? 0),
scheduledAt,
details,
payload: jsonEncode(payload.toJson()),
androidScheduleMode: mode,
uiLocalNotificationDateInterpretation:
UILocalNotificationDateInterpretation.absoluteTime,
);
} catch (_) {
await _plugin.zonedSchedule(
notificationId,
event.title,
_buildReminderBody(event, event.metadata?.reminderMinutes ?? 0),
scheduledAt,
details,
payload: jsonEncode(payload.toJson()),
androidScheduleMode: AndroidScheduleMode.inexactAllowWhileIdle,
uiLocalNotificationDateInterpretation:
UILocalNotificationDateInterpretation.absoluteTime,
);
}
}
Future<void> _scheduleRemindersFrom({
required ScheduleItemModel event,
required DateTime firstFireAt,
}) async {
final endAt = event.endAt;
var cursor = firstFireAt;
if (endAt == null) {
await _scheduleSingleReminder(event: event, fireAt: cursor);
return;
}
while (cursor.isBefore(endAt)) {
await _scheduleSingleReminder(event: event, fireAt: cursor);
cursor = cursor.add(const Duration(minutes: 10));
}
}
Future<void> _scheduleAggregateReminder(
List<ScheduleItemModel> events,
DateTime fireAt,
) async {
if (events.isEmpty) {
return;
}
final first = events.first;
final aggregateIds = events.map((event) => event.id).toList();
for (final id in aggregateIds) {
await cancelEventReminder(id);
}
final payload = ReminderPayload(
eventId: first.id,
title: '你有${events.length}个日程提醒',
startAt: first.startAt,
endAt: first.endAt,
timezone: first.timezone,
mode: ReminderPayloadMode.aggregate,
aggregateIds: aggregateIds,
version: 1,
);
final details = _buildNotificationDetails();
final scheduledAt = tz.TZDateTime.from(fireAt, tz.local);
final mode = await _resolveAndroidScheduleMode();
final preview = events.take(3).map((item) => item.title).join('');
await _plugin.zonedSchedule(
_notificationIdForEventCycle(
first.id,
fireAt,
ReminderPayloadMode.aggregate,
),
'你有${events.length}个日程提醒',
preview,
scheduledAt,
details,
payload: jsonEncode(payload.toJson()),
androidScheduleMode: mode,
uiLocalNotificationDateInterpretation:
UILocalNotificationDateInterpretation.absoluteTime,
);
}
ReminderPayload? _decodePayload(String? raw) {
if (raw == null || raw.isEmpty) {
return null;
}
try {
final json = Map<String, dynamic>.from(jsonDecode(raw) as Map);
return ReminderPayload.fromJson(json);
} catch (_) {
return null;
}
}
String _buildReminderBody(ScheduleItemModel event, int reminderMinutes) {
final when = reminderMinutes == 0
? '日程现在开始'
: '日程即将开始(提前$reminderMinutes分钟';
final location = event.metadata?.location;
final notes = event.metadata?.notes;
final buffer = StringBuffer(when);
if (location != null && location.isNotEmpty) {
buffer.write('\n地点:$location');
}
if (notes != null && notes.isNotEmpty) {
buffer.write(
'\n备注:${notes.length > 30 ? '${notes.substring(0, 30)}...' : notes}',
);
}
return buffer.toString();
}
Future<void> _onNotificationResponse(NotificationResponse response) async {
final payloadRaw = response.payload;
if (payloadRaw == null || payloadRaw.isEmpty) {
return;
}
final handler = _actionHandler;
if (handler == null) {
return;
}
try {
final payload = ReminderPayload.fromJson(
Map<String, dynamic>.from(jsonDecode(payloadRaw) as Map),
);
final actionId = response.actionId;
if (actionId == _actionCancel) {
await handler(action: ReminderAction.cancel, payload: payload);
return;
}
if (actionId == _actionSnooze) {
await handler(action: ReminderAction.snooze10m, payload: payload);
}
} catch (_) {
debugPrint('failed to handle reminder notification response');
return;
} }
return '日程即将开始(提前$reminderMinutes分钟):${event.title}';
} }
} }
@@ -1,16 +1,20 @@
import '../../features/auth/presentation/bloc/auth_state.dart'; import '../../features/auth/presentation/bloc/auth_state.dart';
import '../../features/calendar/data/services/calendar_service.dart'; import '../../features/calendar/data/services/calendar_service.dart';
import '../../features/calendar/reminders/reminder_action_executor.dart';
import '../notifications/local_notification_service.dart'; import '../notifications/local_notification_service.dart';
class AuthSessionBootstrapper { class AuthSessionBootstrapper {
AuthSessionBootstrapper({ AuthSessionBootstrapper({
required CalendarService calendarService, required CalendarService calendarService,
required LocalNotificationService notificationService, required LocalNotificationService notificationService,
required ReminderActionExecutor reminderActionExecutor,
}) : _calendarService = calendarService, }) : _calendarService = calendarService,
_notificationService = notificationService; _notificationService = notificationService,
_reminderActionExecutor = reminderActionExecutor;
final CalendarService _calendarService; final CalendarService _calendarService;
final LocalNotificationService _notificationService; final LocalNotificationService _notificationService;
final ReminderActionExecutor _reminderActionExecutor;
String? _syncedUserId; String? _syncedUserId;
@@ -24,13 +28,15 @@ class AuthSessionBootstrapper {
return; return;
} }
_syncedUserId = state.user.id;
try { try {
await _reminderActionExecutor.replayPendingActions();
final now = DateTime.now(); final now = DateTime.now();
final start = now.subtract(const Duration(days: 90));
final end = now.add(const Duration(days: 90)); final end = now.add(const Duration(days: 90));
final events = await _calendarService.getEventsForRange(now, end); final events = await _calendarService.getEventsForRange(start, end);
await _notificationService.rebuildUpcomingReminders(events); await _notificationService.rebuildUpcomingReminders(events);
_syncedUserId = state.user.id;
} catch (_) { } catch (_) {
// ignore reminder bootstrap failures // ignore reminder bootstrap failures
} }
@@ -44,6 +44,14 @@ class CalendarService {
return _api.update(event); return _api.update(event);
} }
Future<ScheduleItemModel?> archiveEvent(String id) async {
final event = await getEventById(id);
if (event == null) {
return null;
}
return updateEvent(event.copyWith(status: ScheduleStatus.archived));
}
Future<void> deleteEvent(String id) async { Future<void> deleteEvent(String id) async {
await _api.delete(id); await _api.delete(id);
} }
@@ -0,0 +1,17 @@
enum ReminderAction {
cancel('cancel'),
snooze10m('snooze_10m'),
timeout30s('timeout_30s'),
autoArchive('auto_archive');
const ReminderAction(this.value);
final String value;
static ReminderAction fromValue(String raw) {
return ReminderAction.values.firstWhere(
(item) => item.value == raw,
orElse: () => ReminderAction.timeout30s,
);
}
}
@@ -0,0 +1,174 @@
class ReminderPayload {
final String eventId;
final String title;
final DateTime startAt;
final DateTime? endAt;
final String timezone;
final String? location;
final String? notes;
final String? color;
final ReminderPayloadMode mode;
final List<String> aggregateIds;
final int version;
const ReminderPayload({
required this.eventId,
required this.title,
required this.startAt,
required this.timezone,
this.endAt,
this.location,
this.notes,
this.color,
this.mode = ReminderPayloadMode.single,
this.aggregateIds = const [],
this.version = 1,
});
ReminderPayload copyWith({
String? eventId,
String? title,
DateTime? startAt,
DateTime? endAt,
String? timezone,
String? location,
String? notes,
String? color,
ReminderPayloadMode? mode,
List<String>? aggregateIds,
int? version,
}) {
return ReminderPayload(
eventId: eventId ?? this.eventId,
title: title ?? this.title,
startAt: startAt ?? this.startAt,
endAt: endAt ?? this.endAt,
timezone: timezone ?? this.timezone,
location: location ?? this.location,
notes: notes ?? this.notes,
color: color ?? this.color,
mode: mode ?? this.mode,
aggregateIds: aggregateIds ?? this.aggregateIds,
version: version ?? this.version,
);
}
Map<String, dynamic> toJson() {
return {
'eventId': eventId,
'title': title,
'startAt': startAt.toIso8601String(),
'endAt': endAt?.toIso8601String(),
'timezone': timezone,
'location': location,
'notes': notes,
'color': color,
'mode': mode.value,
'aggregateIds': aggregateIds,
'version': version,
};
}
factory ReminderPayload.fromJson(Map<String, dynamic> json) {
final eventId = (json['eventId'] as String?) ?? '';
if (eventId.isEmpty) {
throw const FormatException('eventId is required');
}
final startAtRaw = json['startAt'] as String?;
if (startAtRaw == null || startAtRaw.isEmpty) {
throw const FormatException('startAt is required');
}
final parsedStartAt = DateTime.parse(startAtRaw);
final mode = ReminderPayloadMode.fromValue(
(json['mode'] as String?) ?? 'single',
);
final aggregateIds = (json['aggregateIds'] as List<dynamic>? ?? const [])
.map((item) => item.toString())
.toList();
if (mode == ReminderPayloadMode.aggregate && aggregateIds.length < 2) {
throw const FormatException('aggregateIds must contain at least 2 items');
}
return ReminderPayload(
eventId: eventId,
title: (json['title'] as String?) ?? '',
startAt: parsedStartAt,
endAt: json['endAt'] != null
? DateTime.parse(json['endAt'] as String)
: null,
timezone: (json['timezone'] as String?) ?? 'UTC',
location: json['location'] as String?,
notes: json['notes'] as String?,
color: json['color'] as String?,
mode: mode,
aggregateIds: aggregateIds,
version: (json['version'] as int?) ?? 1,
);
}
@override
bool operator ==(Object other) {
if (identical(this, other)) {
return true;
}
return other is ReminderPayload &&
other.eventId == eventId &&
other.title == title &&
other.startAt == startAt &&
other.endAt == endAt &&
other.timezone == timezone &&
other.location == location &&
other.notes == notes &&
other.color == color &&
other.mode == mode &&
_listEquals(other.aggregateIds, aggregateIds) &&
other.version == version;
}
@override
int get hashCode {
return Object.hash(
eventId,
title,
startAt,
endAt,
timezone,
location,
notes,
color,
mode,
Object.hashAll(aggregateIds),
version,
);
}
}
enum ReminderPayloadMode {
single('single'),
aggregate('aggregate');
const ReminderPayloadMode(this.value);
final String value;
static ReminderPayloadMode fromValue(String raw) {
return ReminderPayloadMode.values.firstWhere(
(item) => item.value == raw,
orElse: () => ReminderPayloadMode.single,
);
}
}
bool _listEquals(List<String> left, List<String> right) {
if (left.length != right.length) {
return false;
}
for (var i = 0; i < left.length; i++) {
if (left[i] != right[i]) {
return false;
}
}
return true;
}
@@ -0,0 +1,106 @@
import 'dart:math';
import '../data/services/calendar_service.dart';
import '../../../core/notifications/local_notification_service.dart';
import 'models/reminder_action.dart';
import 'models/reminder_payload.dart';
import 'reminder_outbox_store.dart';
class ReminderActionExecutor {
final CalendarService _calendarService;
final LocalNotificationService _notificationService;
final ReminderOutboxStore _outboxStore;
final Random _random;
ReminderActionExecutor({
required CalendarService calendarService,
required LocalNotificationService notificationService,
required ReminderOutboxStore outboxStore,
Random? random,
}) : _calendarService = calendarService,
_notificationService = notificationService,
_outboxStore = outboxStore,
_random = random ?? Random();
Future<void> handleAction({
required ReminderAction action,
required ReminderPayload payload,
}) async {
final ids = payload.mode == ReminderPayloadMode.aggregate
? payload.aggregateIds
: <String>[payload.eventId];
if (action == ReminderAction.cancel) {
for (final id in ids) {
await _notificationService.cancelEventReminder(id);
await _archiveEvent(id, ReminderAction.cancel);
}
return;
}
if (action == ReminderAction.snooze10m ||
action == ReminderAction.timeout30s) {
for (final id in ids) {
await _snoozeEvent(id);
}
}
}
Future<void> replayPendingActions() async {
final pending = await _outboxStore.listPending();
for (final item in pending) {
if (item.targetStatus != 'archived') {
await _outboxStore.markDone(item.opId);
continue;
}
try {
await _calendarService.archiveEvent(item.eventId);
await _outboxStore.markDone(item.opId);
} catch (error) {
await _outboxStore.markRetry(item.opId, error.toString());
}
}
}
Future<void> _snoozeEvent(String eventId) async {
final event = await _calendarService.getEventById(eventId);
if (event == null) {
return;
}
final now = DateTime.now();
final endAt = event.endAt;
if (endAt != null && !now.isBefore(endAt)) {
await _notificationService.cancelEventReminder(eventId);
await _archiveEvent(eventId, ReminderAction.autoArchive);
return;
}
final nextAt = now.add(const Duration(minutes: 10));
if (endAt != null && !nextAt.isBefore(endAt)) {
await _notificationService.cancelEventReminder(eventId);
await _archiveEvent(eventId, ReminderAction.autoArchive);
return;
}
await _notificationService.scheduleReminderAt(event, nextAt);
}
Future<void> _archiveEvent(String eventId, ReminderAction action) async {
final opId =
'${DateTime.now().millisecondsSinceEpoch}-${_random.nextInt(1 << 32)}';
final outboxItem = ReminderOutboxItem(
opId: opId,
eventId: eventId,
action: action,
targetStatus: 'archived',
occurredAt: DateTime.now(),
);
await _outboxStore.enqueue(outboxItem);
try {
await _calendarService.archiveEvent(eventId);
await _outboxStore.markDone(opId);
} catch (error) {
await _outboxStore.markRetry(opId, error.toString());
}
}
}
@@ -0,0 +1,202 @@
import 'dart:convert';
import 'package:shared_preferences/shared_preferences.dart';
import 'models/reminder_action.dart';
class ReminderOutboxItem {
final String opId;
final String eventId;
final ReminderAction action;
final String? targetStatus;
final DateTime occurredAt;
final int retryCount;
final DateTime? nextRetryAt;
final ReminderOutboxState state;
final String? lastError;
const ReminderOutboxItem({
required this.opId,
required this.eventId,
required this.action,
required this.occurredAt,
this.targetStatus,
this.retryCount = 0,
this.nextRetryAt,
this.state = ReminderOutboxState.pending,
this.lastError,
});
String get idempotencyBucket {
final bucket =
occurredAt.millisecondsSinceEpoch ~/
const Duration(minutes: 1).inMilliseconds;
return '$eventId|${action.value}|$bucket';
}
ReminderOutboxItem copyWith({
int? retryCount,
DateTime? nextRetryAt,
ReminderOutboxState? state,
String? lastError,
}) {
return ReminderOutboxItem(
opId: opId,
eventId: eventId,
action: action,
targetStatus: targetStatus,
occurredAt: occurredAt,
retryCount: retryCount ?? this.retryCount,
nextRetryAt: nextRetryAt ?? this.nextRetryAt,
state: state ?? this.state,
lastError: lastError ?? this.lastError,
);
}
Map<String, dynamic> toJson() {
return {
'opId': opId,
'eventId': eventId,
'action': action.value,
'targetStatus': targetStatus,
'occurredAt': occurredAt.toIso8601String(),
'retryCount': retryCount,
'nextRetryAt': nextRetryAt?.toIso8601String(),
'state': state.value,
'lastError': lastError,
};
}
factory ReminderOutboxItem.fromJson(Map<String, dynamic> json) {
return ReminderOutboxItem(
opId: (json['opId'] as String?) ?? '',
eventId: (json['eventId'] as String?) ?? '',
action: ReminderAction.fromValue(
(json['action'] as String?) ?? 'timeout_30s',
),
targetStatus: json['targetStatus'] as String?,
occurredAt: DateTime.parse(json['occurredAt'] as String),
retryCount: (json['retryCount'] as int?) ?? 0,
nextRetryAt: json['nextRetryAt'] != null
? DateTime.parse(json['nextRetryAt'] as String)
: null,
state: ReminderOutboxState.fromValue(
(json['state'] as String?) ?? 'pending',
),
lastError: json['lastError'] as String?,
);
}
}
enum ReminderOutboxState {
pending('pending'),
done('done'),
dead('dead');
const ReminderOutboxState(this.value);
final String value;
static ReminderOutboxState fromValue(String raw) {
return ReminderOutboxState.values.firstWhere(
(item) => item.value == raw,
orElse: () => ReminderOutboxState.pending,
);
}
}
class ReminderOutboxStore {
static const String _key = 'calendar_reminder_outbox_v1';
final SharedPreferences _prefs;
ReminderOutboxStore(this._prefs);
Future<void> enqueue(ReminderOutboxItem item) async {
final current = await _readAll();
final duplicated = current.any(
(existing) =>
existing.state == ReminderOutboxState.pending &&
existing.idempotencyBucket == item.idempotencyBucket,
);
if (duplicated) {
return;
}
current.add(item);
await _writeAll(current);
}
Future<List<ReminderOutboxItem>> listPending() async {
final all = await _readAll();
final now = DateTime.now();
return all
.where((item) => item.state == ReminderOutboxState.pending)
.where(
(item) => item.nextRetryAt == null || !item.nextRetryAt!.isAfter(now),
)
.toList();
}
Future<void> markDone(String opId) async {
final all = await _readAll();
final updated = all
.map(
(item) => item.opId == opId
? item.copyWith(
state: ReminderOutboxState.done,
nextRetryAt: null,
)
: item,
)
.toList();
await _writeAll(updated);
}
Future<void> markRetry(String opId, String error) async {
final all = await _readAll();
final updated = all.map((item) {
if (item.opId != opId) {
return item;
}
final nextRetryCount = item.retryCount + 1;
if (nextRetryCount >= 8) {
return item.copyWith(
retryCount: nextRetryCount,
state: ReminderOutboxState.dead,
lastError: error,
nextRetryAt: null,
);
}
final delayMinutes = nextRetryCount == 1 ? 0 : 1 << (nextRetryCount - 1);
return item.copyWith(
retryCount: nextRetryCount,
lastError: error,
nextRetryAt: DateTime.now().add(Duration(minutes: delayMinutes)),
);
}).toList();
await _writeAll(updated);
}
Future<List<ReminderOutboxItem>> _readAll() async {
try {
final raw = _prefs.getString(_key);
if (raw == null || raw.isEmpty) {
return [];
}
final list = jsonDecode(raw) as List<dynamic>;
return list
.whereType<Map>()
.map(
(item) =>
ReminderOutboxItem.fromJson(Map<String, dynamic>.from(item)),
)
.toList();
} catch (_) {
await _prefs.remove(_key);
return [];
}
}
Future<void> _writeAll(List<ReminderOutboxItem> items) async {
final raw = jsonEncode(items.map((item) => item.toJson()).toList());
await _prefs.setString(_key, raw);
}
}
@@ -0,0 +1,80 @@
import '../data/models/schedule_item_model.dart';
class ReminderOverlapGroup {
final DateTime fireAt;
final List<ScheduleItemModel> events;
const ReminderOverlapGroup({required this.fireAt, required this.events});
bool get isAggregate => events.length > 1;
}
class ReminderOverlapPolicy {
const ReminderOverlapPolicy();
List<ReminderOverlapGroup> groupByMinute(
Iterable<ScheduleItemModel> events, {
required DateTime now,
}) {
final buckets = <String, List<ScheduleItemModel>>{};
final minuteToFireAt = <String, DateTime>{};
for (final event in events) {
final fireAt = resolveFirstFireAt(event, now: now);
if (fireAt == null) {
continue;
}
final minute = DateTime(
fireAt.year,
fireAt.month,
fireAt.day,
fireAt.hour,
fireAt.minute,
);
final key = minute.toIso8601String();
buckets.putIfAbsent(key, () => <ScheduleItemModel>[]).add(event);
minuteToFireAt[key] = minuteToFireAt[key] ?? fireAt;
}
final groups = buckets.entries
.map(
(entry) => ReminderOverlapGroup(
fireAt: minuteToFireAt[entry.key]!,
events: entry.value,
),
)
.toList();
groups.sort((left, right) => left.fireAt.compareTo(right.fireAt));
return groups;
}
DateTime? resolveFirstFireAt(
ScheduleItemModel event, {
required DateTime now,
}) {
if (event.status != ScheduleStatus.active) {
return null;
}
final reminderMinutes = event.metadata?.reminderMinutes;
if (reminderMinutes == null) {
return null;
}
final remindAt = event.startAt.subtract(Duration(minutes: reminderMinutes));
final endAt = event.endAt;
if (endAt != null && !now.isBefore(endAt)) {
return null;
}
if (now.isBefore(remindAt)) {
return remindAt;
}
if (endAt != null && now.isBefore(endAt)) {
return now.add(const Duration(seconds: 5));
}
return null;
}
}
@@ -9,6 +9,7 @@ import '../../data/models/schedule_item_model.dart';
import '../../data/services/calendar_service.dart'; import '../../data/services/calendar_service.dart';
import '../calendar_state_manager.dart'; import '../calendar_state_manager.dart';
import '../calendar_time_utils.dart'; import '../calendar_time_utils.dart';
import '../utils/event_color_resolver.dart';
import '../dayweek/day_event_layout_engine.dart'; import '../dayweek/day_event_layout_engine.dart';
import '../dayweek/day_timeline_metrics.dart'; import '../dayweek/day_timeline_metrics.dart';
import '../dayweek/day_view_scale.dart'; import '../dayweek/day_view_scale.dart';
@@ -614,7 +615,10 @@ class _CalendarDayWeekScreenState extends State<CalendarDayWeekScreen>
required DayEventLayout layout, required DayEventLayout layout,
required double boardHeight, required double boardHeight,
}) { }) {
final eventColor = _parseColor(layout.event.metadata?.color); final eventColor = resolveEventColor(
status: layout.event.status,
colorHex: layout.event.metadata?.color,
);
final isCompact = layout.visualHeight < 20; final isCompact = layout.visualHeight < 20;
final tapHeight = layout.visualHeight < _minEventTapHeight final tapHeight = layout.visualHeight < _minEventTapHeight
? _minEventTapHeight ? _minEventTapHeight
@@ -687,17 +691,6 @@ class _CalendarDayWeekScreenState extends State<CalendarDayWeekScreen>
); );
} }
Color _parseColor(String? hex) {
if (hex == null || hex.isEmpty) {
return AppColors.blue600;
}
try {
return Color(int.parse(hex.replaceFirst('#', '0xFF')));
} catch (_) {
return AppColors.blue600;
}
}
Widget _buildBottomDock() { Widget _buildBottomDock() {
return BottomDock( return BottomDock(
activeTab: DockTab.calendar, activeTab: DockTab.calendar,
@@ -10,6 +10,7 @@ import '../../../../shared/widgets/detail_header_action_menu.dart';
import '../../../../shared/widgets/destructive_action_sheet.dart'; import '../../../../shared/widgets/destructive_action_sheet.dart';
import '../../data/services/calendar_service.dart'; import '../../data/services/calendar_service.dart';
import '../../data/models/schedule_item_model.dart'; import '../../data/models/schedule_item_model.dart';
import '../utils/event_color_resolver.dart';
import '../widgets/create_event_sheet.dart'; import '../widgets/create_event_sheet.dart';
import '../widgets/calendar_share_dialog.dart'; import '../widgets/calendar_share_dialog.dart';
@@ -223,7 +224,7 @@ class _CalendarEventDetailScreenState extends State<CalendarEventDetailScreen> {
_formatReminderText(event.metadata?.reminderMinutes), _formatReminderText(event.metadata?.reminderMinutes),
), ),
const SizedBox(height: 14), const SizedBox(height: 14),
_buildColorField(event.metadata?.color), _buildColorField(event),
const SizedBox(height: 14), const SizedBox(height: 14),
if (event.metadata?.location != null) ...[ if (event.metadata?.location != null) ...[
_buildDetailField('地点', event.metadata!.location!), _buildDetailField('地点', event.metadata!.location!),
@@ -275,7 +276,10 @@ class _CalendarEventDetailScreenState extends State<CalendarEventDetailScreen> {
width: 4, width: 4,
height: 20, height: 20,
decoration: BoxDecoration( decoration: BoxDecoration(
color: _parseColor(event.metadata?.color), color: resolveEventColor(
status: event.status,
colorHex: event.metadata?.color,
),
borderRadius: BorderRadius.circular(2), borderRadius: BorderRadius.circular(2),
), ),
), ),
@@ -343,8 +347,11 @@ class _CalendarEventDetailScreenState extends State<CalendarEventDetailScreen> {
); );
} }
Widget _buildColorField(String? colorHex) { Widget _buildColorField(ScheduleItemModel event) {
final color = _parseColor(colorHex); final color = resolveEventColor(
status: event.status,
colorHex: event.metadata?.color,
);
return Column( return Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
@@ -400,15 +407,6 @@ class _CalendarEventDetailScreenState extends State<CalendarEventDetailScreen> {
); );
} }
Color _parseColor(String? hex) {
if (hex == null || hex.isEmpty) return AppColors.blue600;
try {
return Color(int.parse(hex.replaceFirst('#', '0xFF')));
} catch (_) {
return AppColors.blue600;
}
}
Widget _buildInputContainer() { Widget _buildInputContainer() {
return Container( return Container(
height: 80, height: 80,
@@ -7,6 +7,7 @@ import '../../../../core/theme/design_tokens.dart';
import '../../../../shared/widgets/app_pressable.dart'; import '../../../../shared/widgets/app_pressable.dart';
import '../calendar_state_manager.dart'; import '../calendar_state_manager.dart';
import '../calendar_time_utils.dart'; import '../calendar_time_utils.dart';
import '../utils/event_color_resolver.dart';
import '../widgets/bottom_dock.dart'; import '../widgets/bottom_dock.dart';
import '../widgets/create_event_sheet.dart'; import '../widgets/create_event_sheet.dart';
import '../../data/models/schedule_item_model.dart'; import '../../data/models/schedule_item_model.dart';
@@ -360,7 +361,10 @@ class _CalendarMonthScreenState extends State<CalendarMonthScreen>
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
...displayEvents.map((event) { ...displayEvents.map((event) {
final color = _parseColor(event.metadata?.color); final color = resolveEventColor(
status: event.status,
colorHex: event.metadata?.color,
);
return AppPressable( return AppPressable(
borderRadius: BorderRadius.circular(AppRadius.sm), borderRadius: BorderRadius.circular(AppRadius.sm),
onTap: () { onTap: () {
@@ -415,15 +419,6 @@ class _CalendarMonthScreenState extends State<CalendarMonthScreen>
); );
} }
Color _parseColor(String? hex) {
if (hex == null || hex.isEmpty) return AppColors.blue600;
try {
return Color(int.parse(hex.replaceFirst('#', '0xFF')));
} catch (_) {
return AppColors.blue600;
}
}
void _showMonthPicker() { void _showMonthPicker() {
var selectedYear = _currentMonth.year; var selectedYear = _currentMonth.year;
var selectedMonth = _currentMonth.month; var selectedMonth = _currentMonth.month;
@@ -0,0 +1,23 @@
import 'package:flutter/material.dart';
import '../../../../core/theme/design_tokens.dart';
import '../../data/models/schedule_item_model.dart';
Color resolveEventColor({
required ScheduleStatus status,
required String? colorHex,
}) {
if (status == ScheduleStatus.archived) {
return AppColors.slate400;
}
if (colorHex == null || colorHex.isEmpty) {
return AppColors.blue600;
}
try {
return Color(int.parse(colorHex.replaceFirst('#', '0xFF')));
} catch (_) {
return AppColors.blue600;
}
}
@@ -669,9 +669,9 @@ class _CreateEventSheetState extends State<CreateEventSheet>
try { try {
final notificationService = sl<LocalNotificationService>(); final notificationService = sl<LocalNotificationService>();
await notificationService.upsertEventReminder(saved); await notificationService.upsertEventReminder(saved);
} catch (e) { } catch (_) {
if (mounted) { if (mounted) {
Toast.show(context, '提醒创建失败$e', type: ToastType.warning); Toast.show(context, '提醒创建失败,请检查通知权限', type: ToastType.warning);
} }
} }
+12
View File
@@ -13,12 +13,23 @@ import 'features/auth/presentation/bloc/auth_bloc.dart';
import 'features/auth/presentation/bloc/auth_event.dart'; import 'features/auth/presentation/bloc/auth_event.dart';
import 'features/auth/presentation/bloc/auth_state.dart'; import 'features/auth/presentation/bloc/auth_state.dart';
import 'features/calendar/data/services/calendar_service.dart'; import 'features/calendar/data/services/calendar_service.dart';
import 'features/calendar/reminders/reminder_action_executor.dart';
import 'features/chat/presentation/bloc/chat_bloc.dart'; import 'features/chat/presentation/bloc/chat_bloc.dart';
void main() async { void main() async {
WidgetsFlutterBinding.ensureInitialized(); WidgetsFlutterBinding.ensureInitialized();
await configureDependencies(); await configureDependencies();
await AppConstants.init(); await AppConstants.init();
sl<LocalNotificationService>().bindActionHandler(({
required action,
required payload,
}) {
return sl<ReminderActionExecutor>().handleAction(
action: action,
payload: payload,
);
});
await sl<LocalNotificationService>().initialize();
final authBloc = sl<AuthBloc>(); final authBloc = sl<AuthBloc>();
authBloc.add(AuthStarted()); authBloc.add(AuthStarted());
@@ -29,6 +40,7 @@ void main() async {
sessionBootstrapper: AuthSessionBootstrapper( sessionBootstrapper: AuthSessionBootstrapper(
calendarService: sl<CalendarService>(), calendarService: sl<CalendarService>(),
notificationService: sl<LocalNotificationService>(), notificationService: sl<LocalNotificationService>(),
reminderActionExecutor: sl<ReminderActionExecutor>(),
), ),
), ),
); );
@@ -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/core/startup/auth_session_bootstrapper.dart';
import 'package:social_app/features/auth/presentation/bloc/auth_state.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/data/services/calendar_service.dart';
import 'package:social_app/features/calendar/reminders/reminder_action_executor.dart';
class MockCalendarService extends Mock implements CalendarService {} class MockCalendarService extends Mock implements CalendarService {}
class MockLocalNotificationService extends Mock class MockLocalNotificationService extends Mock
implements LocalNotificationService {} implements LocalNotificationService {}
class MockReminderActionExecutor extends Mock
implements ReminderActionExecutor {}
void main() { void main() {
late MockCalendarService calendarService; late MockCalendarService calendarService;
late MockLocalNotificationService notificationService; late MockLocalNotificationService notificationService;
late MockReminderActionExecutor reminderActionExecutor;
late AuthSessionBootstrapper bootstrapper; late AuthSessionBootstrapper bootstrapper;
setUp(() { setUp(() {
calendarService = MockCalendarService(); calendarService = MockCalendarService();
notificationService = MockLocalNotificationService(); notificationService = MockLocalNotificationService();
reminderActionExecutor = MockReminderActionExecutor();
bootstrapper = AuthSessionBootstrapper( bootstrapper = AuthSessionBootstrapper(
calendarService: calendarService, calendarService: calendarService,
notificationService: notificationService, notificationService: notificationService,
reminderActionExecutor: reminderActionExecutor,
); );
}); });
@@ -29,6 +36,7 @@ void main() {
verifyNever(() => calendarService.getEventsForRange(any(), any())); verifyNever(() => calendarService.getEventsForRange(any(), any()));
verifyNever(() => notificationService.rebuildUpcomingReminders(any())); verifyNever(() => notificationService.rebuildUpcomingReminders(any()));
verifyNever(() => reminderActionExecutor.replayPendingActions());
}); });
test('fetches upcoming events after authenticated state', () async { test('fetches upcoming events after authenticated state', () async {
@@ -38,6 +46,41 @@ void main() {
when( when(
() => notificationService.rebuildUpcomingReminders(any()), () => notificationService.rebuildUpcomingReminders(any()),
).thenAnswer((_) async {}); ).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( await bootstrapper.syncForAuthState(
const AuthAuthenticated( const AuthAuthenticated(
@@ -45,7 +88,7 @@ void main() {
), ),
); );
verify(() => reminderActionExecutor.replayPendingActions()).called(2);
verify(() => calendarService.getEventsForRange(any(), any())).called(1); 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);
});
}
@@ -15,8 +15,17 @@ class TrackingChatModel:
self._inner = inner self._inner = inner
self._total_input_tokens = 0 self._total_input_tokens = 0
self._total_output_tokens = 0 self._total_output_tokens = 0
self._total_tokens = 0
self._total_latency_ms = 0 self._total_latency_ms = 0
self._cached_prompt_tokens = 0 self._cached_prompt_tokens = 0
self._prompt_cache_hit_tokens = 0
self._prompt_cache_miss_tokens = 0
self._reasoning_tokens = 0
self._direct_cost = 0.0
self._direct_cost_observed = False
self._model_call_records = 0
self._usage_records = 0
self._direct_cost_records = 0
@property @property
def stream(self) -> bool: def stream(self) -> bool:
@@ -31,18 +40,37 @@ class TrackingChatModel:
async def __call__(self, *args: Any, **kwargs: Any) -> Any: async def __call__(self, *args: Any, **kwargs: Any) -> Any:
self._log_model_call(kwargs) self._log_model_call(kwargs)
self._model_call_records += 1
response = await self._inner(*args, **kwargs) response = await self._inner(*args, **kwargs)
if isinstance(response, AsyncGenerator): if isinstance(response, AsyncGenerator):
return self._track_stream(response) return self._track_stream(response)
self._record_usage(getattr(response, "usage", None)) self._record_usage(getattr(response, "usage", None))
return response return response
def usage_summary(self) -> dict[str, int]: def usage_summary(self) -> dict[str, int | float | str]:
direct_cost = self._direct_cost if self._direct_cost_observed else 0.0
direct_cost_complete = (
self._model_call_records > 0
and self._model_call_records == self._direct_cost_records
)
return { return {
"input_tokens": self._total_input_tokens, "input_tokens": self._total_input_tokens,
"output_tokens": self._total_output_tokens, "output_tokens": self._total_output_tokens,
"total_tokens": self._total_tokens,
"latency_ms": self._total_latency_ms, "latency_ms": self._total_latency_ms,
"cached_prompt_tokens": self._cached_prompt_tokens, "cached_prompt_tokens": self._cached_prompt_tokens,
"prompt_cache_hit_tokens": self._prompt_cache_hit_tokens,
"prompt_cache_miss_tokens": self._prompt_cache_miss_tokens,
"reasoning_tokens": self._reasoning_tokens,
"direct_cost": direct_cost,
"direct_cost_observed": int(self._direct_cost_observed),
"direct_cost_complete": int(direct_cost_complete),
"model_call_records": self._model_call_records,
"usage_records": self._usage_records,
"direct_cost_records": self._direct_cost_records,
"cost_source": "provider"
if self._direct_cost_observed
else "catalog_fallback",
} }
def _log_model_call(self, kwargs: dict[str, Any]) -> None: def _log_model_call(self, kwargs: dict[str, Any]) -> None:
@@ -101,25 +129,167 @@ class TrackingChatModel:
def _record_usage(self, usage: Any) -> None: def _record_usage(self, usage: Any) -> None:
if usage is None: if usage is None:
return return
self._total_input_tokens += max(int(getattr(usage, "input_tokens", 0) or 0), 0) self._usage_records += 1
self._total_output_tokens += max( usage_mapping = self._to_mapping(usage)
int(getattr(usage, "output_tokens", 0) or 0), 0 metadata = self._safe_get(usage, "metadata")
metadata_mapping = self._to_mapping(metadata)
input_tokens = self._coerce_int(
self._first_non_null(
self._safe_get(usage, "input_tokens"),
usage_mapping.get("input_tokens"),
metadata_mapping.get("prompt_tokens"),
)
) )
self._total_latency_ms += max( output_tokens = self._coerce_int(
int(round(float(getattr(usage, "time", 0) or 0) * 1000)), 0 self._first_non_null(
self._safe_get(usage, "output_tokens"),
usage_mapping.get("output_tokens"),
metadata_mapping.get("completion_tokens"),
)
) )
metadata = getattr(usage, "metadata", None) total_tokens = self._coerce_int(
if metadata is None: self._first_non_null(
return self._safe_get(usage, "total_tokens"),
self._cached_prompt_tokens += max(self._extract_cached_tokens(metadata), 0) usage_mapping.get("total_tokens"),
metadata_mapping.get("total_tokens"),
input_tokens + output_tokens,
)
)
latency_ms = max(
int(
round(
self._coerce_float(
self._first_non_null(
self._safe_get(usage, "time"),
usage_mapping.get("time"),
0.0,
)
)
* 1000
)
),
0,
)
prompt_tokens_details = self._to_mapping(
metadata_mapping.get("prompt_tokens_details")
)
completion_tokens_details = self._to_mapping(
metadata_mapping.get("completion_tokens_details")
)
cached_prompt_tokens = self._coerce_int(
self._first_non_null(
prompt_tokens_details.get("cached_tokens"),
metadata_mapping.get("prompt_cache_hit_tokens"),
0,
)
)
prompt_cache_hit_tokens = self._coerce_int(
self._first_non_null(
metadata_mapping.get("prompt_cache_hit_tokens"),
cached_prompt_tokens,
)
)
prompt_cache_miss_tokens = self._coerce_int(
self._first_non_null(
metadata_mapping.get("prompt_cache_miss_tokens"),
max(input_tokens - prompt_cache_hit_tokens, 0),
)
)
reasoning_tokens = self._coerce_int(
self._first_non_null(completion_tokens_details.get("reasoning_tokens"), 0)
)
direct_cost = self._coerce_optional_float(
self._first_non_null(
self._safe_get(usage, "cost"),
usage_mapping.get("cost"),
metadata_mapping.get("cost"),
metadata_mapping.get("total_cost"),
)
)
self._total_input_tokens += input_tokens
self._total_output_tokens += output_tokens
self._total_tokens += total_tokens
self._total_latency_ms += latency_ms
self._cached_prompt_tokens += cached_prompt_tokens
self._prompt_cache_hit_tokens += prompt_cache_hit_tokens
self._prompt_cache_miss_tokens += prompt_cache_miss_tokens
self._reasoning_tokens += reasoning_tokens
if direct_cost is not None:
self._direct_cost_observed = True
self._direct_cost_records += 1
self._direct_cost += max(direct_cost, 0.0)
@staticmethod @staticmethod
def _extract_cached_tokens(metadata: Any) -> int: def _safe_get(obj: Any, key: str) -> Any:
if isinstance(metadata, dict): if obj is None:
prompt_details = metadata.get("prompt_tokens_details") return None
if isinstance(prompt_details, dict): try:
return int(prompt_details.get("cached_tokens", 0) or 0) if isinstance(obj, dict):
return obj.get(key)
return getattr(obj, key, None)
except Exception:
return None
@classmethod
def _to_mapping(cls, obj: Any) -> dict[str, Any]:
if isinstance(obj, dict):
return dict(obj)
if obj is None:
return {}
model_dump = cls._safe_get(obj, "model_dump")
if callable(model_dump):
try:
dumped = model_dump()
except Exception:
dumped = None
if isinstance(dumped, dict):
return dumped
data = cls._safe_get(obj, "__dict__")
if isinstance(data, dict):
return data
return {}
@staticmethod
def _first_non_null(*values: Any) -> Any:
for value in values:
if value is not None:
return value
return None
@staticmethod
def _coerce_int(value: Any) -> int:
if value is None:
return 0
if isinstance(value, bool):
return int(value)
if isinstance(value, int):
return max(value, 0)
try:
return max(int(float(value)), 0)
except Exception:
return 0 return 0
prompt_details = getattr(metadata, "prompt_tokens_details", None) @staticmethod
return int(getattr(prompt_details, "cached_tokens", 0) or 0) def _coerce_float(value: Any) -> float:
if value is None:
return 0.0
try:
return max(float(value), 0.0)
except Exception:
return 0.0
@staticmethod
def _coerce_optional_float(value: Any) -> float | None:
if value is None:
return None
try:
parsed = float(value)
except Exception:
return None
if parsed < 0:
return None
return parsed
@@ -1,52 +1,63 @@
factories: factories:
- name: dashscope - name: dashscope
request_url: https://dashscope.aliyuncs.com/compatible-mode/v1 request_url: https://dashscope.aliyuncs.com/compatible-mode/v1
avatar: https://registry.npmmirror.com/@lobehub/icons-static-png/latest/files/light/qwen-color.png avatar: https://registry.npmmirror.com/@lobehub/icons-static-png/latest/files/light/qwen-color.png
- name: minimax - name: minimax
request_url: https://api.minimaxi.com/v1 request_url: https://api.minimaxi.com/v1
avatar: https://registry.npmmirror.com/@lobehub/icons-static-png/latest/files/light/minimax-color.png avatar: https://registry.npmmirror.com/@lobehub/icons-static-png/latest/files/light/minimax-color.png
- name: moonshot - name: moonshot
request_url: https://api.moonshot.cn/v1 request_url: https://api.moonshot.cn/v1
avatar: https://registry.npmmirror.com/@lobehub/icons-static-png/latest/files/light/moonshot.png avatar: https://registry.npmmirror.com/@lobehub/icons-static-png/latest/files/light/moonshot.png
- name: deepseek - name: deepseek
request_url: https://api.deepseek.com/v1 request_url: https://api.deepseek.com/v1
avatar: https://registry.npmmirror.com/@lobehub/icons-static-png/latest/files/light/deepseek-color.png avatar: https://registry.npmmirror.com/@lobehub/icons-static-png/latest/files/light/deepseek-color.png
- name: volcengine - name: volcengine
request_url: https://ark.cn-beijing.volces.com/api/v3 request_url: https://ark.cn-beijing.volces.com/api/v3
avatar: https://registry.npmmirror.com/@lobehub/icons-static-png/latest/files/light/doubao-color.png avatar: https://registry.npmmirror.com/@lobehub/icons-static-png/latest/files/light/doubao-color.png
- name: zai - name: zai
request_url: https://api.z.ai/api/paas/v4 request_url: https://api.z.ai/api/paas/v4
avatar: https://registry.npmmirror.com/@lobehub/icons-static-png/latest/files/light/zai.png avatar: https://registry.npmmirror.com/@lobehub/icons-static-png/latest/files/light/zai.png
llms: llms:
# qwen3.5-flash (3 tiers: 128K, 256K, 1M) # qwen3.5-flash (3 tiers: 128K, 256K, 1M)
- model_code: qwen3.5-flash - model_code: qwen3.5-flash
factory_name: dashscope factory_name: dashscope
litellm_model: dashscope/qwen3.5-flash litellm_model: dashscope/qwen3.5-flash
pricing_tiers: pricing_tiers:
- max_prompt_tokens: 128000 - max_prompt_tokens: 128000
input_cost_per_token: 0.0000002 input_cost_per_token: 0.0000002
output_cost_per_token: 0.000002 output_cost_per_token: 0.000002
cache_hit_cost_per_token: 0.00000002 cache_hit_cost_per_token: 0.00000002
- max_prompt_tokens: 256000 - max_prompt_tokens: 256000
input_cost_per_token: 0.0000008 input_cost_per_token: 0.0000008
output_cost_per_token: 0.000008 output_cost_per_token: 0.000008
cache_hit_cost_per_token: 0.00000008 cache_hit_cost_per_token: 0.00000008
- max_prompt_tokens: 1000000 - max_prompt_tokens: 1000000
input_cost_per_token: 0.0000012 input_cost_per_token: 0.0000012
output_cost_per_token: 0.000012 output_cost_per_token: 0.000012
cache_hit_cost_per_token: 0.00000012 cache_hit_cost_per_token: 0.00000012
- model_code: deepseek-chat - model_code: qwen3.5-35b-a3b
factory_name: deepseek factory_name: dashscope
litellm_model: deepseek/deepseek-chat litellm_model: dashscope/qwen3.5-35b-a3b
pricing_tiers: pricing_tiers:
- max_prompt_tokens: 128000 - max_prompt_tokens: 128000
input_cost_per_token: 0.000002 input_cost_per_token: 0.0000004
output_cost_per_token: 0.000003 output_cost_per_token: 0.0000032
cache_hit_cost_per_token: 0.0000002 - max_prompt_tokens: 256000
input_cost_per_token: 0.0000016
output_cost_per_token: 0.0000128
- model_code: deepseek-chat
factory_name: deepseek
litellm_model: deepseek/deepseek-chat
pricing_tiers:
- max_prompt_tokens: 128000
input_cost_per_token: 0.000002
output_cost_per_token: 0.000003
cache_hit_cost_per_token: 0.0000002
+76 -7
View File
@@ -85,9 +85,15 @@ class LiteLLMService:
selected_tier = tier selected_tier = tier
break break
cached_token_rate = (
selected_tier.cache_hit_cost_per_token
if selected_tier.cache_hit_cost_per_token > 0
else selected_tier.input_cost_per_token
)
return float( return float(
uncached_prompt_tokens * selected_tier.input_cost_per_token uncached_prompt_tokens * selected_tier.input_cost_per_token
+ normalized_cached_tokens * selected_tier.cache_hit_cost_per_token + normalized_cached_tokens * cached_token_rate
+ normalized_completion_tokens * selected_tier.output_cost_per_token + normalized_completion_tokens * selected_tier.output_cost_per_token
) )
@@ -95,23 +101,86 @@ class LiteLLMService:
self, self,
*, *,
model: str, model: str,
usage_summary: dict[str, int] | None, usage_summary: dict[str, Any] | None,
) -> dict[str, Any]: ) -> dict[str, Any]:
summary = usage_summary or {} summary = usage_summary or {}
input_tokens = max(int(summary.get("input_tokens", 0) or 0), 0) input_tokens = max(int(summary.get("input_tokens", 0) or 0), 0)
output_tokens = max(int(summary.get("output_tokens", 0) or 0), 0) output_tokens = max(int(summary.get("output_tokens", 0) or 0), 0)
total_tokens = max(
int(summary.get("total_tokens", input_tokens + output_tokens) or 0), 0
)
latency_ms = max(int(summary.get("latency_ms", 0) or 0), 0) latency_ms = max(int(summary.get("latency_ms", 0) or 0), 0)
cached_prompt_tokens = max(int(summary.get("cached_prompt_tokens", 0) or 0), 0) cached_prompt_tokens = max(int(summary.get("cached_prompt_tokens", 0) or 0), 0)
cost = self.calculate_cost( prompt_cache_hit_tokens = max(
model=model, int(summary.get("prompt_cache_hit_tokens", cached_prompt_tokens) or 0), 0
prompt_tokens=input_tokens,
completion_tokens=output_tokens,
cached_prompt_tokens=cached_prompt_tokens,
) )
prompt_cache_miss_tokens = max(
int(
summary.get(
"prompt_cache_miss_tokens",
max(input_tokens - prompt_cache_hit_tokens, 0),
)
or 0
),
0,
)
reasoning_tokens = max(int(summary.get("reasoning_tokens", 0) or 0), 0)
direct_cost_raw = summary.get("direct_cost")
direct_cost_observed = bool(int(summary.get("direct_cost_observed", 0) or 0))
direct_cost_complete = bool(int(summary.get("direct_cost_complete", 0) or 0))
model_call_records = max(int(summary.get("model_call_records", 0) or 0), 0)
usage_records = max(int(summary.get("usage_records", 0) or 0), 0)
usage_complete = model_call_records == 0 or model_call_records == usage_records
direct_cost = self._coerce_non_negative_float(direct_cost_raw)
if (
usage_complete
and direct_cost_observed
and direct_cost_complete
and direct_cost is not None
):
cost = direct_cost
cost_source = "provider"
else:
cost = self.calculate_cost(
model=model,
prompt_tokens=input_tokens,
completion_tokens=output_tokens,
cached_prompt_tokens=cached_prompt_tokens,
)
cost_source = (
"incomplete_usage_fallback"
if not usage_complete
else (
"catalog_fallback_incomplete_provider_cost"
if direct_cost_observed and not direct_cost_complete
else "catalog_fallback"
)
)
return { return {
"model": model, "model": model,
"inputTokens": input_tokens, "inputTokens": input_tokens,
"outputTokens": output_tokens, "outputTokens": output_tokens,
"totalTokens": total_tokens,
"cachedPromptTokens": cached_prompt_tokens,
"promptCacheHitTokens": prompt_cache_hit_tokens,
"promptCacheMissTokens": prompt_cache_miss_tokens,
"reasoningTokens": reasoning_tokens,
"cost": cost, "cost": cost,
"costSource": cost_source,
"usageComplete": usage_complete,
"latencyMs": latency_ms, "latencyMs": latency_ms,
} }
@staticmethod
def _coerce_non_negative_float(value: Any) -> float | None:
if value is None:
return None
try:
parsed = float(value)
except (TypeError, ValueError):
return None
if parsed < 0:
return None
return parsed
+53 -3
View File
@@ -9,7 +9,7 @@ from sqlalchemy.exc import SQLAlchemyError
from core.db.base_repository import BaseRepository from core.db.base_repository import BaseRepository
from core.logging import get_logger from core.logging import get_logger
from models.schedule_items import ScheduleItem from models.schedule_items import ScheduleItem, ScheduleItemStatus
from models.schedule_subscriptions import ScheduleSubscription, SubscriptionStatus from models.schedule_subscriptions import ScheduleSubscription, SubscriptionStatus
if TYPE_CHECKING: if TYPE_CHECKING:
@@ -61,6 +61,11 @@ class ScheduleItemRepository(Protocol):
start_at: datetime, start_at: datetime,
end_at: datetime, end_at: datetime,
) -> Sequence[tuple[ScheduleItem, ScheduleSubscription]]: ... ) -> Sequence[tuple[ScheduleItem, ScheduleSubscription]]: ...
async def archive_expired_subscribed_items(
self,
subscriber_id: UUID,
now_at: datetime,
) -> int: ...
class SQLAlchemyScheduleItemRepository(BaseRepository[ScheduleItem]): class SQLAlchemyScheduleItemRepository(BaseRepository[ScheduleItem]):
@@ -149,8 +154,13 @@ class SQLAlchemyScheduleItemRepository(BaseRepository[ScheduleItem]):
select(ScheduleItem) select(ScheduleItem)
.where(ScheduleItem.owner_id == owner_id) .where(ScheduleItem.owner_id == owner_id)
.where(ScheduleItem.deleted_at.is_(None)) .where(ScheduleItem.deleted_at.is_(None))
.where(ScheduleItem.start_at >= start_at)
.where(ScheduleItem.start_at <= end_at) .where(ScheduleItem.start_at <= end_at)
.where(
or_(
ScheduleItem.end_at.is_(None),
ScheduleItem.end_at >= start_at,
)
)
.order_by(ScheduleItem.start_at.asc()) .order_by(ScheduleItem.start_at.asc())
) )
result = await self._session.execute(stmt) result = await self._session.execute(stmt)
@@ -308,8 +318,13 @@ class SQLAlchemyScheduleItemRepository(BaseRepository[ScheduleItem]):
.where(ScheduleSubscription.subscriber_id == subscriber_id) .where(ScheduleSubscription.subscriber_id == subscriber_id)
.where(ScheduleSubscription.status == SubscriptionStatus.ACTIVE) .where(ScheduleSubscription.status == SubscriptionStatus.ACTIVE)
.where(ScheduleItem.deleted_at.is_(None)) .where(ScheduleItem.deleted_at.is_(None))
.where(ScheduleItem.start_at >= start_at)
.where(ScheduleItem.start_at <= end_at) .where(ScheduleItem.start_at <= end_at)
.where(
or_(
ScheduleItem.end_at.is_(None),
ScheduleItem.end_at >= start_at,
)
)
.order_by(ScheduleItem.start_at.asc()) .order_by(ScheduleItem.start_at.asc())
) )
result = await self._session.execute(stmt) result = await self._session.execute(stmt)
@@ -317,3 +332,38 @@ class SQLAlchemyScheduleItemRepository(BaseRepository[ScheduleItem]):
except SQLAlchemyError: except SQLAlchemyError:
logger.exception("Failed to list subscribed items") logger.exception("Failed to list subscribed items")
raise raise
async def archive_expired_subscribed_items(
self,
subscriber_id: UUID,
now_at: datetime,
) -> int:
try:
item_ids_subquery = (
select(ScheduleItem.id)
.join(
ScheduleSubscription,
ScheduleSubscription.item_id == ScheduleItem.id,
)
.where(ScheduleSubscription.subscriber_id == subscriber_id)
.where(ScheduleSubscription.status == SubscriptionStatus.ACTIVE)
.where(ScheduleItem.deleted_at.is_(None))
.where(ScheduleItem.status == ScheduleItemStatus.ACTIVE)
.where(ScheduleItem.end_at.is_not(None))
.where(ScheduleItem.end_at <= now_at)
)
stmt = (
update(ScheduleItem)
.where(ScheduleItem.id.in_(item_ids_subquery))
.values(status=ScheduleItemStatus.ARCHIVED)
)
result = await self._session.execute(stmt)
await self._session.flush()
return int(getattr(result, "rowcount", 0) or 0)
except SQLAlchemyError:
logger.exception(
"Failed to archive expired subscribed items",
subscriber_id=str(subscriber_id),
)
raise
+8
View File
@@ -240,6 +240,11 @@ class ScheduleItemService(BaseService):
raise HTTPException(status_code=400, detail="end_at must be after start_at") raise HTTPException(status_code=400, detail="end_at must be after start_at")
try: try:
archived_count = await self._repository.archive_expired_subscribed_items(
user_id,
datetime.now(timezone.utc),
)
subscribed_items = ( subscribed_items = (
await self._repository.list_subscribed_items_by_date_range( await self._repository.list_subscribed_items_by_date_range(
user_id, normalized_start_at, normalized_end_at user_id, normalized_start_at, normalized_end_at
@@ -256,9 +261,12 @@ class ScheduleItemService(BaseService):
) )
results.sort(key=lambda x: x.start_at) results.sort(key=lambda x: x.start_at)
if archived_count > 0:
await self._session.commit()
return results return results
except SQLAlchemyError: except SQLAlchemyError:
await self._session.rollback()
logger.exception("Failed to list schedule items") logger.exception("Failed to list schedule items")
raise HTTPException( raise HTTPException(
status_code=503, detail="Schedule item store unavailable" status_code=503, detail="Schedule item store unavailable"
@@ -0,0 +1,126 @@
from __future__ import annotations
from typing import Any, cast
from core.agentscope.runtime.model_tracking import TrackingChatModel
class _FakeMetadata:
def model_dump(self) -> dict[str, object]:
return {
"prompt_tokens": 120,
"completion_tokens": 30,
"total_tokens": 150,
"prompt_tokens_details": {
"cached_tokens": 80,
},
"prompt_cache_hit_tokens": 80,
"prompt_cache_miss_tokens": 40,
"completion_tokens_details": {
"reasoning_tokens": 5,
},
}
class _FakeUsage:
input_tokens = 120
output_tokens = 30
time = 1.234
metadata = _FakeMetadata()
class _FakeResponse:
usage = _FakeUsage()
class _FakeModel:
stream = False
async def __call__(self, *args: object, **kwargs: object) -> _FakeResponse:
return _FakeResponse()
class _FakeUsageWithProviderCost:
input_tokens = 50
output_tokens = 10
time = 0.5
cost = 0.0123
metadata = _FakeMetadata()
class _FakeResponseWithProviderCost:
usage = _FakeUsageWithProviderCost()
class _FakeModelWithProviderCost:
stream = False
async def __call__(
self, *args: object, **kwargs: object
) -> _FakeResponseWithProviderCost:
return _FakeResponseWithProviderCost()
class _FakeResponseWithoutUsage:
usage = None
class _FakeModelWithoutUsage:
stream = False
async def __call__(
self, *args: object, **kwargs: object
) -> _FakeResponseWithoutUsage:
return _FakeResponseWithoutUsage()
async def test_tracking_chat_model_collects_primary_usage_fields() -> None:
model = TrackingChatModel(cast(Any, _FakeModel()))
await model("prompt")
summary = model.usage_summary()
assert summary["input_tokens"] == 120
assert summary["output_tokens"] == 30
assert summary["total_tokens"] == 150
assert summary["latency_ms"] == 1234
assert summary["cached_prompt_tokens"] == 80
assert summary["prompt_cache_hit_tokens"] == 80
assert summary["prompt_cache_miss_tokens"] == 40
assert summary["reasoning_tokens"] == 5
assert summary["direct_cost"] == 0.0
assert summary["direct_cost_observed"] == 0
assert summary["direct_cost_complete"] == 0
assert summary["model_call_records"] == 1
assert summary["usage_records"] == 1
assert summary["direct_cost_records"] == 0
assert summary["cost_source"] == "catalog_fallback"
async def test_tracking_chat_model_prefers_provider_cost_when_available() -> None:
model = TrackingChatModel(cast(Any, _FakeModelWithProviderCost()))
await model("prompt")
summary = model.usage_summary()
assert summary["direct_cost"] == 0.0123
assert summary["direct_cost_observed"] == 1
assert summary["direct_cost_complete"] == 1
assert summary["model_call_records"] == 1
assert summary["usage_records"] == 1
assert summary["direct_cost_records"] == 1
assert summary["cost_source"] == "provider"
async def test_tracking_chat_model_marks_direct_cost_incomplete_when_usage_missing() -> (
None
):
model = TrackingChatModel(cast(Any, _FakeModelWithoutUsage()))
await model("prompt")
summary = model.usage_summary()
assert summary["model_call_records"] == 1
assert summary["usage_records"] == 0
assert summary["direct_cost_records"] == 0
assert summary["direct_cost_complete"] == 0
@@ -44,10 +44,75 @@ def test_build_usage_metadata_calculates_cost_from_usage_summary() -> None:
}, },
) )
assert metadata == { assert metadata["model"] == "dashscope/qwen3.5-flash"
"model": "dashscope/qwen3.5-flash", assert metadata["inputTokens"] == 2000
"inputTokens": 2000, assert metadata["outputTokens"] == 100
"outputTokens": 100, assert metadata["totalTokens"] == 2100
"cost": pytest.approx(0.00051), assert metadata["cachedPromptTokens"] == 500
"latencyMs": 321, assert metadata["promptCacheHitTokens"] == 500
} assert metadata["promptCacheMissTokens"] == 1500
assert metadata["reasoningTokens"] == 0
assert metadata["cost"] == pytest.approx(0.00051)
assert metadata["costSource"] == "catalog_fallback"
assert metadata["usageComplete"] is True
assert metadata["latencyMs"] == 321
def test_build_usage_metadata_prefers_provider_direct_cost() -> None:
service = LiteLLMService()
metadata = service.build_usage_metadata(
model="deepseek-chat",
usage_summary={
"input_tokens": 1000,
"output_tokens": 100,
"latency_ms": 100,
"cached_prompt_tokens": 0,
"direct_cost": 0.1234,
"direct_cost_observed": 1,
"direct_cost_complete": 1,
},
)
assert metadata["cost"] == pytest.approx(0.1234)
assert metadata["costSource"] == "provider"
assert metadata["usageComplete"] is True
def test_build_usage_metadata_falls_back_when_provider_cost_incomplete() -> None:
service = LiteLLMService()
metadata = service.build_usage_metadata(
model="deepseek-chat",
usage_summary={
"input_tokens": 1000,
"output_tokens": 100,
"latency_ms": 100,
"cached_prompt_tokens": 0,
"direct_cost": 0.1234,
"direct_cost_observed": 1,
"direct_cost_complete": 0,
},
)
assert metadata["cost"] == pytest.approx(0.0023)
assert metadata["costSource"] == "catalog_fallback_incomplete_provider_cost"
def test_build_usage_metadata_marks_incomplete_usage_fallback() -> None:
service = LiteLLMService()
metadata = service.build_usage_metadata(
model="deepseek-chat",
usage_summary={
"input_tokens": 0,
"output_tokens": 0,
"latency_ms": 0,
"cached_prompt_tokens": 0,
"model_call_records": 1,
"usage_records": 0,
},
)
assert metadata["costSource"] == "incomplete_usage_fallback"
assert metadata["usageComplete"] is False
@@ -4,6 +4,7 @@ from uuid import UUID, uuid4
import pytest import pytest
from fastapi import HTTPException from fastapi import HTTPException
from sqlalchemy.exc import SQLAlchemyError
from core.auth.models import CurrentUser from core.auth.models import CurrentUser
from models.schedule_items import ( from models.schedule_items import (
@@ -13,6 +14,7 @@ from models.schedule_items import (
) )
from v1.schedule_items.schemas import ( from v1.schedule_items.schemas import (
ScheduleItemCreateRequest, ScheduleItemCreateRequest,
ScheduleItemListRequest,
ScheduleItemMetadata, ScheduleItemMetadata,
ScheduleItemUpdateRequest, ScheduleItemUpdateRequest,
) )
@@ -43,6 +45,7 @@ def _create_mock_schedule_item(
class FakeRepo: class FakeRepo:
def __init__(self, item: ScheduleItem | None) -> None: def __init__(self, item: ScheduleItem | None) -> None:
self._item = item self._item = item
self.archive_expired_called = 0
async def get_by_item_id( async def get_by_item_id(
self, item_id: UUID, owner_id: UUID self, item_id: UUID, owner_id: UUID
@@ -89,8 +92,9 @@ class FakeRepo:
*, *,
page: int, page: int,
page_size: int, page_size: int,
query: str | None = None,
) -> tuple[list[ScheduleItem], int]: ) -> tuple[list[ScheduleItem], int]:
del owner_id, page, page_size del owner_id, page, page_size, query
return ([self._item] if self._item else [], 1 if self._item else 0) return ([self._item] if self._item else [], 1 if self._item else 0)
async def create_subscription(self, data: dict): async def create_subscription(self, data: dict):
@@ -104,7 +108,20 @@ class FakeRepo:
end_at: datetime, end_at: datetime,
): ):
del subscriber_id, start_at, end_at del subscriber_id, start_at, end_at
return [] if self._item is None:
return []
subscription = MagicMock()
subscription.permission = 1
return [(self._item, subscription)]
async def archive_expired_subscribed_items(
self,
subscriber_id: UUID,
now_at: datetime,
) -> int:
del subscriber_id, now_at
self.archive_expired_called += 1
return 0
async def get_user_subscriptions(self, subscriber_id: UUID): async def get_user_subscriptions(self, subscriber_id: UUID):
del subscriber_id del subscriber_id
@@ -376,3 +393,110 @@ async def test_update_maps_null_metadata_to_extra_metadata_null(
assert "extra_metadata" in captured assert "extra_metadata" in captured
assert captured["extra_metadata"] is None assert captured["extra_metadata"] is None
assert "metadata" not in captured assert "metadata" not in captured
@pytest.mark.asyncio
async def test_list_by_date_range_archives_expired_before_query(
mock_session: AsyncMock,
mock_inbox_repository: MagicMock,
) -> None:
user_id = UUID("00000000-0000-0000-0000-000000000001")
item = _create_mock_schedule_item()
repo = FakeRepo(item)
service = ScheduleItemService(
repository=repo,
session=mock_session,
current_user=CurrentUser(id=user_id),
inbox_repository=mock_inbox_repository,
)
await service.list_by_date_range(
request=ScheduleItemListRequest(
start_at=datetime(2026, 2, 1, 0, 0, tzinfo=timezone.utc),
end_at=datetime(2026, 3, 1, 0, 0, tzinfo=timezone.utc),
),
)
assert repo.archive_expired_called == 1
@pytest.mark.asyncio
async def test_list_by_date_range_commits_when_archived_changed(
mock_session: AsyncMock,
mock_inbox_repository: MagicMock,
) -> None:
user_id = UUID("00000000-0000-0000-0000-000000000001")
item = _create_mock_schedule_item()
class ArchiveRepo(FakeRepo):
async def archive_expired_subscribed_items(
self,
subscriber_id: UUID,
now_at: datetime,
) -> int:
del subscriber_id, now_at
self.archive_expired_called += 1
return 2
repo = ArchiveRepo(item)
service = ScheduleItemService(
repository=repo,
session=mock_session,
current_user=CurrentUser(id=user_id),
inbox_repository=mock_inbox_repository,
)
await service.list_by_date_range(
request=ScheduleItemListRequest(
start_at=datetime(2026, 2, 1, 0, 0, tzinfo=timezone.utc),
end_at=datetime(2026, 3, 1, 0, 0, tzinfo=timezone.utc),
),
)
mock_session.commit.assert_awaited_once()
@pytest.mark.asyncio
async def test_list_by_date_range_rolls_back_when_query_fails_after_archive(
mock_session: AsyncMock,
mock_inbox_repository: MagicMock,
) -> None:
user_id = UUID("00000000-0000-0000-0000-000000000001")
item = _create_mock_schedule_item()
class FailingRepo(FakeRepo):
async def archive_expired_subscribed_items(
self,
subscriber_id: UUID,
now_at: datetime,
) -> int:
del subscriber_id, now_at
return 1
async def list_subscribed_items_by_date_range(
self,
subscriber_id: UUID,
start_at: datetime,
end_at: datetime,
):
del subscriber_id, start_at, end_at
raise SQLAlchemyError("db unavailable")
service = ScheduleItemService(
repository=FailingRepo(item),
session=mock_session,
current_user=CurrentUser(id=user_id),
inbox_repository=mock_inbox_repository,
)
with pytest.raises(HTTPException) as exc_info:
await service.list_by_date_range(
request=ScheduleItemListRequest(
start_at=datetime(2026, 2, 1, 0, 0, tzinfo=timezone.utc),
end_at=datetime(2026, 3, 1, 0, 0, tzinfo=timezone.utc),
),
)
assert exc_info.value.status_code == 503
mock_session.rollback.assert_awaited_once()
mock_session.commit.assert_not_awaited()
@@ -0,0 +1,449 @@
# Reminder Alert Archival Implementation Plan
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
**Goal:** Deliver alarm-style reminder popups with cancel/snooze actions, 30s timeout auto-snooze, overlap handling, and archived/gray lifecycle consistency across Android and iOS.
**Architecture:** Keep scheduling local on device (Flutter local notifications), persist user reminder actions with an app-side outbox for eventual backend sync, and use backend PATCH update for archive status as the source of truth. Add a backend safety net job to auto-archive expired active events so app-terminated scenarios still converge. Implement shared reminder payload and action handler with platform-specific notification configuration (Android full-screen intent, iOS category actions).
**Tech Stack:** Flutter (`flutter_local_notifications`, existing calendar service), FastAPI schedule-items API, SQLAlchemy service layer, uv/pytest, Dart tests.
---
### Task 1: Update protocol and state semantics first
**Files:**
- Modify: `docs/protocols/` (create a new reminder interaction protocol doc or extend existing schedule protocol doc)
- Modify: `docs/runtime/runtime-route.md`
**Step 1: Write failing doc checks (manual checklist as fail-first gate)**
```text
Checklist fails until all are documented:
1) cancel action semantics
2) snooze +10 minutes semantics
3) timeout(30s) = ignore -> snooze
4) overlap aggregation semantics
5) archive + gray render semantics
6) iOS degraded behavior note
```
**Step 2: Run verification of checklist**
Run: manual review (expect FAIL before edits)
**Step 3: Write minimal protocol spec**
Include exact payload keys and action enum:
```json
{
"eventId": "uuid",
"title": "string",
"startAt": "iso8601",
"endAt": "iso8601|null",
"timezone": "IANA",
"location": "string|null",
"notes": "string|null",
"color": "#RRGGBB|null",
"mode": "single|aggregate",
"aggregateIds": ["uuid"]
}
```
Actions:
- `cancel`: archive target events and stop reminders
- `snooze_10m`: reschedule +10m, stop when `now >= endAt`
- `timeout_30s`: same as `snooze_10m`
**Step 4: Verify checklist passes**
Run: manual review (expect PASS)
**Step 5: Commit**
```bash
git add docs/protocols docs/runtime/runtime-route.md
git commit -m "docs: define reminder interaction protocol and lifecycle semantics"
```
### Task 2: Add frontend reminder action models and payload codec
**Files:**
- Create: `apps/lib/features/calendar/reminders/models/reminder_payload.dart`
- Create: `apps/lib/features/calendar/reminders/models/reminder_action.dart`
- Test: `apps/test/features/calendar/reminders/models/reminder_payload_test.dart`
**Step 1: Write the failing test**
```dart
test('round-trips payload with single and aggregate modes', () {
final payload = ReminderPayload(...);
expect(ReminderPayload.fromJson(payload.toJson()), payload);
});
```
**Step 2: Run test to verify it fails**
Run: `flutter test test/features/calendar/reminders/models/reminder_payload_test.dart`
Expected: FAIL (type/file missing)
**Step 3: Write minimal implementation**
Implement immutable model + json codec + enum parser.
**Step 4: Run test to verify it passes**
Run: `flutter test test/features/calendar/reminders/models/reminder_payload_test.dart`
Expected: PASS
**Step 5: Commit**
```bash
git add apps/lib/features/calendar/reminders/models apps/test/features/calendar/reminders/models/reminder_payload_test.dart
git commit -m "feat: add reminder payload and action models"
```
### Task 3: Refactor local notification service for action-capable reminders (Android + iOS)
**Files:**
- Modify: `apps/lib/core/notifications/local_notification_service.dart`
- Modify: `apps/lib/main.dart`
- Modify: `apps/ios/Runner/AppDelegate.swift`
- Test: `apps/test/core/notifications/local_notification_service_test.dart`
**Step 1: Write failing tests**
```dart
test('uses alarm-style Android details with actions and timeout', () async {});
test('uses Darwin category actions for cancel/snooze', () async {});
test('encodes payload in notification details', () async {});
```
**Step 2: Run tests to verify failure**
Run: `flutter test test/core/notifications/local_notification_service_test.dart`
Expected: FAIL
**Step 3: Write minimal implementation**
Implement:
- Android notification actions: `cancel`, `snooze_10m`
- `timeoutAfter: 30000`
- payload serialization
- iOS `DarwinNotificationCategory` + action identifiers
- initialize callback registration for action responses
Also keep existing full-screen alarm setup and exact alarm fallback behavior.
**Step 4: Run tests to verify pass**
Run: `flutter test test/core/notifications/local_notification_service_test.dart`
Expected: PASS
**Step 5: Commit**
```bash
git add apps/lib/core/notifications/local_notification_service.dart apps/lib/main.dart apps/ios/Runner/AppDelegate.swift apps/test/core/notifications/local_notification_service_test.dart
git commit -m "feat: support actionable reminder notifications on android and ios"
```
### Task 4: Implement reminder action executor + local outbox for eventual consistency
**Files:**
- Create: `apps/lib/features/calendar/reminders/reminder_action_executor.dart`
- Create: `apps/lib/features/calendar/reminders/reminder_outbox_store.dart`
- Modify: `apps/lib/features/calendar/data/services/calendar_service.dart`
- Test: `apps/test/features/calendar/reminders/reminder_action_executor_test.dart`
**Step 1: Write failing tests**
```dart
test('cancel archives remotely and cancels local reminders', () async {});
test('network failure writes outbox item and keeps local state updated', () async {});
test('snooze reschedules +10m and stops after endAt', () async {});
```
**Step 2: Run tests (expect FAIL)**
Run: `flutter test test/features/calendar/reminders/reminder_action_executor_test.dart`
**Step 3: Write minimal implementation**
Rules:
- Cancel: local cancel now, enqueue archive API job, best-effort immediate PATCH
- Snooze: schedule at `now + 10m`, if `next >= endAt` then archive path
- Timeout action uses same path as snooze
**Step 4: Re-run tests (expect PASS)**
Run: `flutter test test/features/calendar/reminders/reminder_action_executor_test.dart`
**Step 5: Commit**
```bash
git add apps/lib/features/calendar/reminders apps/lib/features/calendar/data/services/calendar_service.dart apps/test/features/calendar/reminders/reminder_action_executor_test.dart
git commit -m "feat: add reminder action executor with offline outbox"
```
### Task 5: Add startup reconciliation (replay outbox + rebuild reminders)
**Files:**
- Modify: `apps/lib/core/startup/auth_session_bootstrapper.dart`
- Modify: `apps/lib/main.dart`
- Test: `apps/test/core/startup/auth_session_bootstrapper_test.dart`
**Step 1: Write failing tests**
```dart
test('replays pending reminder actions after login', () async {});
test('rebuilds reminders after outbox replay', () async {});
```
**Step 2: Run tests (expect FAIL)**
Run: `flutter test test/core/startup/auth_session_bootstrapper_test.dart`
**Step 3: Write minimal implementation**
In authenticated startup flow:
1) replay outbox
2) fetch events with overlap semantics (active and not ended)
3) rebuild active reminders with compensation scheduling:
- `now < remindAt`: schedule at remindAt
- `remindAt <= now < endAt`: schedule immediate compensation reminder (e.g. +5s)
- `now >= endAt`: archive path
4) enforce reminder dedupe key to avoid duplicate reminders after reinstall/restart
**Step 4: Re-run tests (expect PASS)**
Run: `flutter test test/core/startup/auth_session_bootstrapper_test.dart`
**Step 5: Commit**
```bash
git add apps/lib/core/startup/auth_session_bootstrapper.dart apps/lib/main.dart apps/test/core/startup/auth_session_bootstrapper_test.dart
git commit -m "feat: replay reminder outbox on startup"
```
### Task 6: Implement overlap strategy (aggregate popup)
**Files:**
- Create: `apps/lib/features/calendar/reminders/reminder_overlap_policy.dart`
- Modify: `apps/lib/core/notifications/local_notification_service.dart`
- Test: `apps/test/features/calendar/reminders/reminder_overlap_policy_test.dart`
**Step 1: Write failing tests**
```dart
test('groups reminders whose fire time falls into same minute bucket', () {});
test('creates aggregate payload with top-3 preview and ids', () {});
```
**Step 2: Run tests (expect FAIL)**
Run: `flutter test test/features/calendar/reminders/reminder_overlap_policy_test.dart`
**Step 3: Write minimal implementation**
Policy:
- same minute bucket => one aggregate popup
- actions apply to all members by default
- payload includes aggregateIds
**Step 4: Re-run tests (expect PASS)**
Run: `flutter test test/features/calendar/reminders/reminder_overlap_policy_test.dart`
**Step 5: Commit**
```bash
git add apps/lib/features/calendar/reminders/reminder_overlap_policy.dart apps/lib/core/notifications/local_notification_service.dart apps/test/features/calendar/reminders/reminder_overlap_policy_test.dart
git commit -m "feat: add overlap aggregation policy for reminders"
```
### Task 7: Render archived events as gray in calendar UI
**Files:**
- Modify: `apps/lib/features/calendar/ui/**` (event color resolution points)
- Test: `apps/test/features/calendar/ui/*archived*test.dart` (new if missing)
**Step 1: Write failing tests**
```dart
testWidgets('archived events use gray token color', (tester) async {});
```
**Step 2: Run tests (expect FAIL)**
Run: `flutter test test/features/calendar/ui`
**Step 3: Write minimal implementation**
Rule:
- if `status == archived`, force token-based gray (do not mutate persisted `metadata.color`)
**Step 4: Re-run tests (expect PASS)**
Run: `flutter test test/features/calendar/ui`
**Step 5: Commit**
```bash
git add apps/lib/features/calendar/ui apps/test/features/calendar/ui
git commit -m "feat: render archived calendar events in gray"
```
### Task 8: Backend reuse route + add expired-event auto-archive safety job
**Files:**
- Modify: `backend/src/v1/schedule_items/service.py` (if needed for stricter status transition)
- Modify: `backend/src/v1/schedule_items/repository.py` (range query to overlap query)
- Create: `backend/src/jobs/schedule_item_archive_job.py` (or existing worker module path)
- Modify: worker scheduler registration file under `backend/src/core/celery/` (actual existing path)
- Test: `backend/tests/unit/v1/schedule_items/test_service.py`
- Test: `backend/tests/unit/v1/schedule_items/test_repository.py`
- Test: `backend/tests/unit/jobs/test_schedule_item_archive_job.py`
**Step 1: Write failing tests**
```python
def test_patch_status_archived_allowed_for_owner() -> None: ...
def test_list_by_overlap_includes_started_but_not_ended_items() -> None: ...
def test_archive_job_marks_expired_active_items_archived() -> None: ...
```
**Step 2: Run tests (expect FAIL)**
Run: `uv run pytest backend/tests/unit/v1/schedule_items/test_service.py backend/tests/unit/jobs/test_schedule_item_archive_job.py -q`
**Step 3: Write minimal implementation**
Implement/verify:
- route reuse: PATCH status archived works as-is for authorized user
- overlap query for bootstrap: `start_at <= window_end AND (end_at IS NULL OR end_at >= window_start)` and `status=active`
- periodic archive job: `end_at < now and status=active -> archived`
**Step 4: Re-run tests (expect PASS)**
Run: `uv run pytest backend/tests/unit/v1/schedule_items/test_service.py backend/tests/unit/jobs/test_schedule_item_archive_job.py -q`
**Step 5: Commit**
```bash
git add backend/src backend/tests
git commit -m "feat: add expired schedule auto-archive safety job"
```
### Task 9: End-to-end verification and release notes
**Files:**
- Modify: `docs/runtime/runtime-runbook.md`
- Modify: `docs/protocols/` reminder doc from Task 1
**Step 1: Run frontend verification**
Run:
- `flutter analyze`
- `flutter test`
Expected: PASS
**Step 2: Run backend verification**
Run:
- `uv run pytest backend/tests/unit/v1/schedule_items -q`
- `uv run pytest backend/tests/unit/jobs/test_schedule_item_archive_job.py -q`
Expected: PASS
**Step 3: Manual device matrix**
Android:
- app foreground/background/terminated for cancel/snooze/timeout
- overlap popup behavior
- endAt stop reminder + archive
iOS:
- action button behavior in foreground/background/terminated
- timeout -> snooze behavior after relaunch sync
- archive sync after offline period
**Step 4: Document operational caveats**
Include:
- Android full-screen may degrade to heads-up by OEM policy
- iOS does not guarantee Android-style full-screen alarm behavior
- eventual consistency via outbox + startup replay + backend safety job
**Step 5: Commit**
```bash
git add docs/runtime/runtime-runbook.md docs/protocols
git commit -m "docs: add reminder action runbook and platform caveats"
```
## Notes on iOS parity
- iOS supports actionable local notifications via Darwin categories; implement `cancel` and `snooze_10m` action identifiers with same payload model.
- iOS cannot guarantee Android-like forced full-screen alarm takeover; use lock-screen alert + sound + action buttons as equivalent UX.
- App-terminated network callback reliability is lower on iOS; therefore outbox + startup replay is mandatory for parity.
## Data contracts and constraints (added)
### Reminder payload contract
```json
{
"eventId": "uuid",
"title": "string",
"startAt": "iso8601-with-offset",
"endAt": "iso8601-with-offset|null",
"timezone": "IANA",
"location": "string|null",
"notes": "string|null",
"color": "#RRGGBB|null",
"mode": "single|aggregate",
"aggregateIds": ["uuid"]
}
```
Constraints:
- `eventId` required and valid UUID.
- `startAt` must be timezone-aware datetime.
- `mode=aggregate` requires `aggregateIds.length >= 2`.
- Payload versioning should be explicit if schema evolves.
### Reminder outbox contract
```json
{
"opId": "uuid",
"eventId": "uuid",
"action": "cancel|snooze_10m|timeout_30s|auto_archive",
"targetStatus": "archived|null",
"occurredAt": "iso8601-with-offset",
"retryCount": 0,
"nextRetryAt": "iso8601-with-offset|null",
"state": "pending|done|dead",
"lastError": "string|null"
}
```
Constraints:
- Idempotency key: `(eventId, action, occurredAtBucket)`.
- Exponential backoff retries with capped max attempts.
- `cancel` and `auto_archive` both map to backend `status=archived` PATCH.
### Uniqueness and dedupe rules
- Notification identity uses deterministic key per event+cycle: `hash(eventId + cycleStartEpochMinutes + mode)`.
- Before scheduling any reminder, cancel existing pending reminders for same dedupe key.
- On bootstrap/reinstall, dedupe against local pending requests and outbox state before creating new schedules.
- Compensation reminder (`remindAt <= now < endAt`) must generate exactly one immediate reminder per cycle window.
## Rollback plan
1) Disable action handling by feature flag while keeping plain reminders.
2) Keep backend PATCH status route unchanged (safe rollback path).
3) Pause auto-archive job if unexpected archival spikes occur.
+1
View File
@@ -76,6 +76,7 @@ Base URL: `/api/v1/agent`
- `200 OK` - `200 OK`
- `Content-Type: text/event-stream` - `Content-Type: text/event-stream`
- 事件类型与字段见 `docs/protocols/agent/sse-events.md` - 事件类型与字段见 `docs/protocols/agent/sse-events.md`
- usage 审计与成本回退策略见 `docs/protocols/agent/sse-events.md`5) Usage 审计协议)
- 空闲时会发送 keep-alive 注释行 `: keep-alive` - 空闲时会发送 keep-alive 注释行 `: keep-alive`
### 错误码 ### 错误码
+102 -2
View File
@@ -197,7 +197,107 @@ data: <json>
`inputTokens``outputTokens``cost``latencyMs``model` 属于后端内部统计字段,不在 SSE 对外协议中暴露。 `inputTokens``outputTokens``cost``latencyMs``model` 属于后端内部统计字段,不在 SSE 对外协议中暴露。
### 3.5 快照事件 ---
## 5) Usage 审计协议(后端内部)
本节描述后端对 LLM usage 的内部审计与计费策略。该协议用于数据库持久化、成本统计与运行观测,不对 SSE 外部协议直接暴露。
### 5.1 当前厂商范围
- DashScopeQwen
- DeepSeek
当前实现仅针对上述两家做深度适配。
### 5.2 原始字段采集(Provider -> Runtime
`TrackingChatModel` 会优先读取 provider 直接字段,读取不到时再从 metadata 补齐。
优先级如下:
1. 直接字段(优先)
- `usage.input_tokens`
- `usage.output_tokens`
- `usage.total_tokens`
- `usage.time`(秒)
- `usage.cost`(若存在)
2. metadata 字段(补齐)
- `metadata.prompt_tokens`
- `metadata.completion_tokens`
- `metadata.total_tokens`
- `metadata.prompt_tokens_details.cached_tokens`
- `metadata.prompt_cache_hit_tokens`
- `metadata.prompt_cache_miss_tokens`
- `metadata.completion_tokens_details.reasoning_tokens`
- `metadata.cost` / `metadata.total_cost`(若存在)
### 5.3 归一化后的内部 usage_summary 字段
`TrackingChatModel.usage_summary()` 当前输出:
- `input_tokens`
- `output_tokens`
- `total_tokens`
- `latency_ms`(由 `usage.time * 1000` 转换)
- `cached_prompt_tokens`
- `prompt_cache_hit_tokens`
- `prompt_cache_miss_tokens`
- `reasoning_tokens`
- `direct_cost`
- `direct_cost_observed`0/1
- `direct_cost_complete`0/1
- `model_call_records`
- `usage_records`
- `direct_cost_records`
- `cost_source``provider` | `catalog_fallback`
### 5.4 成本计算策略(严谨优先)
核心原则:**能直接用 provider 返回就直接用;缺失才 fallback。**
`LiteLLMService.build_usage_metadata()` 执行规则:
1. 仅当以下条件同时满足时使用 provider 直出成本:
- `usageComplete == true``model_call_records == usage_records`
- `direct_cost_observed == 1`
- `direct_cost_complete == 1`
- `direct_cost` 为有效非负数
2. 否则使用 catalog 价格回退计算(`calculate_cost`
### 5.5 Fallback 计费细节
- 档位选择:按 `prompt_tokens` 命中 `pricing_tiers.max_prompt_tokens`
- 公式:
```text
cost = uncached_prompt_tokens * input_cost_per_token
+ cached_prompt_tokens * cached_token_rate
+ completion_tokens * output_cost_per_token
```
- `cached_token_rate` 规则:
- 若 tier 配置了 `cache_hit_cost_per_token` 且 > 0,使用该值
- 否则回退为 `input_cost_per_token`
### 5.6 内部 costSource 语义
- `provider`: 使用 provider 直接成本
- `catalog_fallback`: 正常使用价格表回退
- `catalog_fallback_incomplete_provider_cost`: provider 返回了部分 direct cost,但不完整,回退价格表
- `incomplete_usage_fallback`: usage 本身不完整,回退价格表
### 5.7 DeepSeek / DashScope 当前观测到的返回特征
根据当前线上探针与运行结果:
- 两家都稳定返回:`input_tokens``output_tokens``time`
- `usage.total_tokens` 顶层可能为空,但 `metadata.total_tokens` 可用
- DeepSeek 常见 `prompt_tokens_details.cached_tokens``prompt_cache_hit_tokens``prompt_cache_miss_tokens`
- DashScope 常见 `completion_tokens_details.reasoning_tokens`(可能为 `null`
- 两家当前都未稳定提供直接 `cost` 字段,因此多数场景为 catalog fallback
## 6) 快照事件
编码器支持以下 AG-UI 类型映射: 编码器支持以下 AG-UI 类型映射:
@@ -208,7 +308,7 @@ data: <json>
--- ---
## 4) 字段命名约定 ## 7) 字段命名约定
- 事件顶层通用字段使用 AG-UI 风格:`type``threadId``runId` - 事件顶层通用字段使用 AG-UI 风格:`type``threadId``runId`
- 部分业务字段沿运行时模型历史命名保留下划线: - 部分业务字段沿运行时模型历史命名保留下划线:
@@ -0,0 +1,143 @@
# Calendar Reminder Alert Lifecycle Protocol
## Version
- Current: `1.0`
- Status: Draft
---
## Goal
定义日程提醒弹窗在 Android/iOS 的统一行为语义,覆盖提醒触发、用户操作、超时、离线补偿、归档与重装恢复,确保多端一致性和可恢复性。
---
## Canonical Rules
1. 提醒弹窗动作语义跨平台一致:`cancel``snooze_10m``timeout_30s`
2. `timeout_30s` 语义固定为忽略当前弹窗并按 10 分钟后再次提醒,不直接归档。
3. `cancel` 必须归档对应日程(`status=archived`)。
4. 到达事件结束时间后(`now >= end_at`)必须停止提醒并归档。
5. 归档后的 UI 渲染必须灰色显示;不强制改写原始 metadata 颜色。
6. 前端动作上报后端采用最终一致性:本地 outbox + 重放机制。
---
## Reminder Payload Contract
```json
{
"eventId": "uuid",
"title": "string",
"startAt": "iso8601-with-offset",
"endAt": "iso8601-with-offset|null",
"timezone": "IANA",
"location": "string|null",
"notes": "string|null",
"color": "#RRGGBB|null",
"mode": "single|aggregate",
"aggregateIds": ["uuid"],
"version": 1
}
```
### Constraints
- `eventId` 必填且为 UUID。
- `startAt` 必须带时区偏移。
- `mode=aggregate` 时,`aggregateIds` 至少包含 2 个 id。
- `version` 必填,用于后续协议升级兼容。
---
## Action Contract
动作枚举:
- `cancel`: 用户取消提醒并归档事件
- `snooze_10m`: 用户点击稍后提醒,重排到 `now + 10m`
- `timeout_30s`: 用户 30s 未处理,按 `snooze_10m` 处理
- `auto_archive`: 系统判定事件已过期,自动归档
---
## Outbox Contract (Frontend)
```json
{
"opId": "uuid",
"eventId": "uuid",
"action": "cancel|snooze_10m|timeout_30s|auto_archive",
"targetStatus": "archived|null",
"occurredAt": "iso8601-with-offset",
"retryCount": 0,
"nextRetryAt": "iso8601-with-offset|null",
"state": "pending|done|dead",
"lastError": "string|null"
}
```
### Constraints
- 幂等键:`(eventId, action, occurredAtBucket)`
- 重试策略:指数退避,最大重试次数可配置。
- `cancel``auto_archive` 都映射到后端 `PATCH status=archived`
- Outbox 记录必须本地持久化,App 重启后可恢复。
---
## Scheduling and Compensation Rules
### Normal schedule
- `remindAt = startAt - reminderMinutes`
### Bootstrap/reinstall compensation
启动重建时对每个 active 事件执行:
1. `now < remindAt`:按 `remindAt` 正常调度。
2. `remindAt <= now < endAt`:立刻补偿提醒(建议 `+5s`),然后进入 10 分钟节奏。
3. `now >= endAt`:不再提醒,走归档流程。
---
## Uniqueness and Dedupe Rules
- 通知唯一键:`hash(eventId + cycleStartEpochMinutes + mode)`
- 每次创建提醒前必须取消同 dedupe key 的旧提醒(upsert 语义)。
- 补偿提醒在同一 cycle 窗口内最多触发一次。
- 启动恢复时要同时参考 pending notification 和 outbox 状态,避免重复调度。
---
## Overlap Rule
- 同一分钟内多个提醒合并为一个 aggregate 弹窗。
- aggregate 弹窗默认操作作用于全部成员事件。
- aggregate 负载中必须包含 `aggregateIds` 以支持后续批处理。
---
## Backend Contract Reuse
- 复用现有接口:`PATCH /schedule-items/{item_id}`,请求体传 `{"status":"archived"}`
- 建议提供/改造 overlap 查询语义用于启动补偿:
- `start_at <= window_end`
- `end_at IS NULL OR end_at >= window_start`
- `status=active`
---
## Platform Notes
### Android
- 优先 full-screen intent;系统可能因策略降级为 heads-up/横幅。
- 声音和振动受通知通道及系统设置影响。
### iOS
- 支持动作按钮与本地提醒语义。
- 不保证 Android 式强制全屏闹钟弹窗;以锁屏/横幅提醒为主。
+5 -1
View File
@@ -1 +1,5 @@
当前项目有语音识别功能,但是语音识别的cost成本计算没有实现。目前我们用的模型是fun-asr-realtime-2026-02-28,价格是0.00033元/每秒。我希望把它做到backend/src/core/config/static/database/llm_catalog.yaml,加一个asr字段,引入model_code代替原agent router里的硬编码,通过加载配置获取模型信息和报价,然后根据后端路由接收到的音频长度然后来估算价格,或者看看dashscope的sdk是否会返回消耗token金额,将这个token金额看看如何审计 - 当前项目有语音识别功能,但是语音识别的cost成本计算没有实现。目前我们用的模型是fun-asr-realtime-2026-02-28,价格是0.00033元/每秒。我希望把它做到backend/src/core/config/static/database/llm_catalog.yaml,加一个asr字段,引入model_code代替原agent router里的硬编码,通过加载配置获取模型信息和报价,然后根据后端路由接收到的音频长度然后来估算价格,或者看看dashscope的sdk是否会返回消耗token金额,将这个token金额看看如何审计
- 路由细分,方便agent url的导航跳转
- agent模式重构
- 日历导出和导入
- 手机号注册、登录
-1
View File
@@ -9,7 +9,6 @@ dependencies = [
"asyncpg>=0.31.0", "asyncpg>=0.31.0",
"email-validator>=2.3.0", "email-validator>=2.3.0",
"fastapi>=0.128.0", "fastapi>=0.128.0",
"litellm>=1.52.0",
"pydantic>=2.11.0", "pydantic>=2.11.0",
"pydantic-settings>=2.10.0", "pydantic-settings>=2.10.0",
"pyjwt>=2.10.1", "pyjwt>=2.10.1",