diff --git a/.DS_Store b/.DS_Store index 43dabe1..28c6471 100644 Binary files a/.DS_Store and b/.DS_Store differ diff --git a/apps/ios/Runner/AppDelegate.swift b/apps/ios/Runner/AppDelegate.swift index 08435b4..1e8d67e 100644 --- a/apps/ios/Runner/AppDelegate.swift +++ b/apps/ios/Runner/AppDelegate.swift @@ -18,16 +18,4 @@ import UserNotifications } return super.application(application, didFinishLaunchingWithOptions: launchOptions) } - - override func userNotificationCenter( - _ center: UNUserNotificationCenter, - didReceive response: UNNotificationResponse, - withCompletionHandler completionHandler: @escaping () -> Void - ) { - let userInfo = response.notification.request.content.userInfo - if let payloadString = userInfo["payload"] as? String { - UserDefaults.standard.set(payloadString, forKey: "pending_notification_payload") - } - completionHandler() - } } diff --git a/apps/lib/app/app.dart b/apps/lib/app/app.dart index db5237c..aa7a052 100644 --- a/apps/lib/app/app.dart +++ b/apps/lib/app/app.dart @@ -64,8 +64,8 @@ class _LinksyAppState extends State { Future _bootstrapReminderNotification() async { await sl().initializeAtBoot(); final router = sl(); - await router.start(); _reminderTapSubscription ??= router.taps.listen(_onReminderTap); + await router.start(); } void _onReminderTap(ReminderNotificationTap tap) { diff --git a/apps/lib/core/notification/services/reminder_notification_router.dart b/apps/lib/core/notification/services/reminder_notification_router.dart index fc731e1..82e5818 100644 --- a/apps/lib/core/notification/services/reminder_notification_router.dart +++ b/apps/lib/core/notification/services/reminder_notification_router.dart @@ -14,9 +14,15 @@ class ReminderNotificationRouter { Stream get taps => _controller.stream; Future start() async { - await _scheduler.initialize(onTap: _controller.add); + await _scheduler.initialize( + onTap: (tap) { + _scheduler.cancelReminder(tap); + _controller.add(tap); + }, + ); final launchTap = await _scheduler.consumeLaunchTap(); if (launchTap != null) { + await _scheduler.cancelReminder(launchTap); _controller.add(launchTap); } } diff --git a/apps/lib/core/notification/services/reminder_reconcile_service.dart b/apps/lib/core/notification/services/reminder_reconcile_service.dart index 3f647d8..dd85fa5 100644 --- a/apps/lib/core/notification/services/reminder_reconcile_service.dart +++ b/apps/lib/core/notification/services/reminder_reconcile_service.dart @@ -2,20 +2,37 @@ import '../models/reminder_alarm.dart'; import 'reminder_scheduler_service.dart'; class ReminderReconcileService { - const ReminderReconcileService({required ReminderSchedulerService scheduler}) - : _scheduler = scheduler; + ReminderReconcileService({ + required ReminderSchedulerService scheduler, + DateTime Function()? nowProvider, + }) : _scheduler = scheduler, + _now = nowProvider ?? DateTime.now; final ReminderSchedulerService _scheduler; + final DateTime Function() _now; + final Map _snoozeSuppressUntilByEventId = + {}; Future reconcileEvent( ReminderEventSnapshot event, { DateTime? now, }) async { + final current = now ?? _now(); if (event.isArchived || event.reminderMinutes == null) { + _snoozeSuppressUntilByEventId.remove(event.eventId); await _scheduler.cancelEventReminders(event.eventId); return; } - await _scheduler.upsertEventReminders(event, now: now); + + final suppressUntil = _snoozeSuppressUntilByEventId[event.eventId]; + if (suppressUntil != null) { + if (current.isBefore(suppressUntil)) { + return; + } + _snoozeSuppressUntilByEventId.remove(event.eventId); + } + + await _scheduler.upsertEventReminders(event, now: current); } Future reconcileEvents( @@ -28,11 +45,19 @@ class ReminderReconcileService { } Future archiveAndCancel(String eventId) { + _snoozeSuppressUntilByEventId.remove(eventId); return _scheduler.cancelEventReminders(eventId); } Future snooze10m(ReminderEventSnapshot event) async { await _scheduler.cancelEventReminders(event.eventId); - await _scheduler.scheduleSingleSnooze(event); + final firstFireAt = _now().add(const Duration(minutes: 10)); + final alarms = ReminderSchedulerService.buildAlarmSeries( + event: event, + firstFireAt: firstFireAt, + interval: const Duration(minutes: 10), + ); + await _scheduler.scheduleAlarms(alarms); + _snoozeSuppressUntilByEventId[event.eventId] = firstFireAt; } } diff --git a/apps/lib/core/notification/services/reminder_scheduler_service.dart b/apps/lib/core/notification/services/reminder_scheduler_service.dart index a7523c5..be8a774 100644 --- a/apps/lib/core/notification/services/reminder_scheduler_service.dart +++ b/apps/lib/core/notification/services/reminder_scheduler_service.dart @@ -18,6 +18,8 @@ class ReminderSchedulerService { 'Alarm-style notifications for scheduled events'; static const String _androidSoundResource = 'reminder_1'; static const String _iosSoundFile = 'reminder_1.wav'; + static const int _maxAlarmIntervals = 144; // 24 hours at 10-minute intervals + static const Duration _scheduleLeadTime = Duration(seconds: 2); final FlutterLocalNotificationsPlugin _plugin; final List _tapCallbacks = []; @@ -140,32 +142,6 @@ class ReminderSchedulerService { } } - Future scheduleSingleSnooze( - ReminderEventSnapshot event, { - Duration delay = const Duration(minutes: 10), - DateTime? now, - }) async { - await _ensureInitialized(); - final current = now ?? DateTime.now(); - final fireAt = current.add(delay); - if (event.endAt != null && fireAt.isAfter(event.endAt!)) { - return; - } - final alarm = ReminderAlarm( - eventId: event.eventId, - title: event.title, - startAt: event.startAt, - endAt: event.endAt, - timezone: event.timezone, - reminderMinutes: event.reminderMinutes ?? 0, - fireAt: fireAt, - fireTimeBucket: _toBucket(fireAt), - location: event.location, - notes: event.notes, - ); - await _scheduleAlarm(alarm); - } - Future cancelEventReminders(String eventId) async { await _ensureInitialized(); final pending = await _plugin.pendingNotificationRequests(); @@ -185,6 +161,12 @@ class ReminderSchedulerService { return _ensureInitialized().then((_) => _plugin.cancelAll()); } + Future cancelReminder(ReminderNotificationTap tap) async { + await _ensureInitialized(); + final id = _notificationId(tap.eventId, tap.fireTimeBucket); + await _plugin.cancel(id); + } + Future _ensureInitialized() { if (_initialized) { return Future.value(); @@ -212,17 +194,53 @@ class ReminderSchedulerService { return const []; } - final List alarms = []; - DateTime fireAt; + final firstFireAt = _nextFireTimeAfter( + current: current, + remindAt: remindAt, + endAt: endAt, + ); - if (current.isBefore(remindAt)) { - fireAt = remindAt; - } else { - fireAt = current.add(const Duration(seconds: 5)); + if (firstFireAt == null) { + return const []; } + return buildAlarmSeries( + event: event, + firstFireAt: firstFireAt, + interval: const Duration(minutes: 10), + ); + } + + static DateTime? _nextFireTimeAfter({ + required DateTime current, + required DateTime remindAt, + required DateTime? endAt, + }) { + final earliest = current.add(_scheduleLeadTime); + final next = remindAt.isAfter(earliest) ? remindAt : earliest; + + if (endAt != null && next.isAfter(endAt)) { + return null; + } + return next; + } + + static List buildAlarmSeries({ + required ReminderEventSnapshot event, + required DateTime firstFireAt, + Duration interval = const Duration(minutes: 10), + }) { + final endAt = event.endAt; + + if (endAt != null && firstFireAt.isAfter(endAt)) { + return const []; + } + + final alarms = []; + var fireAt = firstFireAt; var iterations = 0; - while (iterations < 144) { + + while (iterations < _maxAlarmIntervals) { if (endAt != null && fireAt.isAfter(endAt)) { break; } @@ -233,7 +251,7 @@ class ReminderSchedulerService { startAt: event.startAt, endAt: endAt, timezone: event.timezone, - reminderMinutes: reminderMinutes, + reminderMinutes: event.reminderMinutes ?? 0, fireAt: fireAt, fireTimeBucket: _toBucket(fireAt), location: event.location, @@ -244,15 +262,30 @@ class ReminderSchedulerService { if (endAt == null) { break; } - fireAt = fireAt.add(const Duration(minutes: 10)); + fireAt = fireAt.add(interval); iterations += 1; } return alarms; } + Future scheduleAlarms(List alarms) async { + for (final alarm in alarms) { + await _scheduleAlarm(alarm); + } + } + Future _scheduleAlarm(ReminderAlarm alarm) async { final location = _safeLocation(alarm.timezone); - final fireAt = tz.TZDateTime.from(alarm.fireAt, location); + final nowInTz = tz.TZDateTime.now(location); + var fireAt = tz.TZDateTime.from(alarm.fireAt, location); + final minAllowed = nowInTz.add(_scheduleLeadTime); + if (!fireAt.isAfter(minAllowed)) { + fireAt = minAllowed; + } + if (alarm.endAt != null && + fireAt.isAfter(tz.TZDateTime.from(alarm.endAt!, location))) { + return; + } final payload = jsonEncode(alarm.toJson()); final id = _notificationId(alarm.eventId, alarm.fireTimeBucket); @@ -340,7 +373,7 @@ class ReminderSchedulerService { return Map.from(decoded); } } catch (_) { - return const {}; + // ignore malformed payload } return const {}; } diff --git a/apps/lib/features/calendar/data/services/calendar_service.dart b/apps/lib/features/calendar/data/services/calendar_service.dart index 126169b..ce00479 100644 --- a/apps/lib/features/calendar/data/services/calendar_service.dart +++ b/apps/lib/features/calendar/data/services/calendar_service.dart @@ -50,14 +50,21 @@ class CalendarService { return events; } - Future getEventById(String id) async { + Future getEventById( + String id, { + bool reconcileReminder = true, + }) async { final response = await _apiClient.get>('$_prefix/$id'); final data = response.data; if (data == null) { throw StateError('Invalid getEventById response: empty payload'); } final event = ScheduleItemModel.fromJson(data); - await _reminderReconcileService?.reconcileEvent(_toReminderSnapshot(event)); + if (reconcileReminder) { + await _reminderReconcileService?.reconcileEvent( + _toReminderSnapshot(event), + ); + } return event; } diff --git a/apps/lib/features/calendar/presentation/dayweek/day_event_layout_engine.dart b/apps/lib/features/calendar/presentation/dayweek/day_event_layout_engine.dart index 7210d45..a228217 100644 --- a/apps/lib/features/calendar/presentation/dayweek/day_event_layout_engine.dart +++ b/apps/lib/features/calendar/presentation/dayweek/day_event_layout_engine.dart @@ -106,12 +106,20 @@ class DayEventLayoutEngine { final clusterColumnCount = cluster.map((item) => item.column).reduce((a, b) => a > b ? a : b) + 1; - final totalGap = (clusterColumnCount - 1) * columnGap; - final columnWidth = clusterColumnCount > 0 - ? ((eventAreaWidth - totalGap) / clusterColumnCount).toDouble() + final maxVisibleColumns = + ((eventAreaWidth + columnGap) / + (DayTimelineMetrics.minEventCardWidth + columnGap)) + .floor() + .clamp(1, clusterColumnCount); + final totalGap = (maxVisibleColumns - 1) * columnGap; + final columnWidth = maxVisibleColumns > 0 + ? (eventAreaWidth - totalGap) / maxVisibleColumns : eventAreaWidth; for (final item in cluster) { + if (item.column >= maxVisibleColumns) { + continue; + } final top = scale.pixelsForMinutes(item.startMinutes); final geometryHeight = scale.pixelsForMinutes( item.endMinutes - item.startMinutes, @@ -125,7 +133,7 @@ class DayEventLayoutEngine { startMinutes: item.startMinutes, endMinutes: item.endMinutes, column: item.column, - columnCount: clusterColumnCount, + columnCount: maxVisibleColumns, top: top, geometryHeight: geometryHeight, visualHeight: visualHeight, diff --git a/apps/lib/features/calendar/presentation/dayweek/day_timeline_metrics.dart b/apps/lib/features/calendar/presentation/dayweek/day_timeline_metrics.dart index 4f1aeea..bc655ae 100644 --- a/apps/lib/features/calendar/presentation/dayweek/day_timeline_metrics.dart +++ b/apps/lib/features/calendar/presentation/dayweek/day_timeline_metrics.dart @@ -9,6 +9,7 @@ class DayTimelineMetrics { static const double timeLabelGap = 8; static const double eventRightInset = 4; static const double eventColumnGap = 4; + static const double minEventCardWidth = 30; static double timelineHeight(DayViewScale scale) { return scale.pixelsForMinutes(minutesInDay); @@ -20,10 +21,10 @@ class DayTimelineMetrics { static double eventAreaWidth(double boardWidth) { final width = boardWidth - eventAreaLeft() - eventRightInset; - return width > 0 ? width : 0; + return width < 0 ? 0 : width; } static int clampMinuteOfDay(int minute) { - return minute.clamp(0, minutesInDay).toInt(); + return minute.clamp(0, minutesInDay); } } diff --git a/apps/lib/features/calendar/presentation/screens/calendar_dayweek_screen.dart b/apps/lib/features/calendar/presentation/screens/calendar_dayweek_screen.dart index 8d9e8f7..a3175ae 100644 --- a/apps/lib/features/calendar/presentation/screens/calendar_dayweek_screen.dart +++ b/apps/lib/features/calendar/presentation/screens/calendar_dayweek_screen.dart @@ -718,32 +718,57 @@ class _CalendarDayWeekScreenState extends State borderRadius: BorderRadius.circular(4), border: Border.all(color: eventColor, width: 1), ), - child: Row( - crossAxisAlignment: CrossAxisAlignment.center, - children: [ - Container( - width: 6, - height: 6, - decoration: BoxDecoration( - color: eventColor, - shape: BoxShape.circle, - ), - ), - if (!isCompact) const SizedBox(width: 4), - if (!isCompact) - Expanded( - child: Text( - layout.event.title, - style: TextStyle( - fontSize: 11, - fontWeight: FontWeight.w500, - color: eventColor, + child: LayoutBuilder( + builder: (context, constraints) { + const markerSize = 6.0; + const markerTitleGap = 4.0; + final canShowMarker = constraints.maxWidth >= markerSize; + final canShowTitle = + !isCompact && + constraints.maxWidth >= markerSize + markerTitleGap + 8; + + if (!canShowMarker) { + return const SizedBox.shrink(); + } + + return Stack( + children: [ + Align( + alignment: Alignment.centerLeft, + child: SizedBox( + width: markerSize, + height: markerSize, + child: DecoratedBox( + decoration: BoxDecoration( + color: eventColor, + shape: BoxShape.circle, + ), + ), ), - maxLines: 1, - overflow: TextOverflow.ellipsis, ), - ), - ], + if (canShowTitle) + Positioned( + left: markerSize + markerTitleGap, + right: 0, + top: 0, + bottom: 0, + child: Align( + alignment: Alignment.centerLeft, + child: Text( + layout.event.title, + style: TextStyle( + fontSize: 11, + fontWeight: FontWeight.w500, + color: eventColor, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ), + ), + ], + ); + }, ), ), ), diff --git a/apps/lib/features/calendar/presentation/screens/calendar_month_screen.dart b/apps/lib/features/calendar/presentation/screens/calendar_month_screen.dart index 0c70d1c..2b727cf 100644 --- a/apps/lib/features/calendar/presentation/screens/calendar_month_screen.dart +++ b/apps/lib/features/calendar/presentation/screens/calendar_month_screen.dart @@ -58,7 +58,9 @@ class _CalendarMonthScreenState extends State return; } _eventsByDay.clear(); - for (final event in events) { + for (final event in events.where( + (e) => e.status != ScheduleStatus.archived, + )) { final key = formatYmd(event.startAt); _eventsByDay[key] = [...(_eventsByDay[key] ?? const []), event]; } diff --git a/apps/lib/features/calendar/presentation/screens/calendar_reminder_alarm_screen.dart b/apps/lib/features/calendar/presentation/screens/calendar_reminder_alarm_screen.dart index 3301e1c..3697e18 100644 --- a/apps/lib/features/calendar/presentation/screens/calendar_reminder_alarm_screen.dart +++ b/apps/lib/features/calendar/presentation/screens/calendar_reminder_alarm_screen.dart @@ -28,12 +28,18 @@ class CalendarReminderAlarmScreen extends StatefulWidget { class _CalendarReminderAlarmScreenState extends State { late final Future _eventFuture; - bool _isSubmitting = false; + bool _isArchiving = false; + bool _isSnoozing = false; + + bool get _isProcessing => _isArchiving || _isSnoozing; @override void initState() { super.initState(); - _eventFuture = sl().getEventById(widget.eventId); + _eventFuture = sl().getEventById( + widget.eventId, + reconcileReminder: false, + ); } @override @@ -78,8 +84,8 @@ class _CalendarReminderAlarmScreenState child: AppButton( text: context.l10n.notificationSnoozeLater, isOutlined: true, - isLoading: _isSubmitting, - onPressed: _isSubmitting + isLoading: _isSnoozing, + onPressed: _isProcessing ? null : () => _snoozeEvent(event), ), @@ -88,8 +94,8 @@ class _CalendarReminderAlarmScreenState Expanded( child: AppButton( text: context.l10n.calendarDetailArchiveConfirm, - isLoading: _isSubmitting, - onPressed: _isSubmitting + isLoading: _isArchiving, + onPressed: _isProcessing ? null : () => _archiveEvent(event), ), @@ -107,15 +113,16 @@ class _CalendarReminderAlarmScreenState Future _archiveEvent(ScheduleItemModel event) async { setState(() { - _isSubmitting = true; + _isArchiving = true; }); try { await sl().archiveEvent(event.id); if (!mounted) { return; } - context.go(AppRoutes.calendarEventDetail(event.id)); - } catch (_) { + context.go(AppRoutes.homeMain); + } catch (e, st) { + debugPrint('[_archiveEvent] error: $e\n$st'); if (mounted) { Toast.show( context, @@ -126,7 +133,7 @@ class _CalendarReminderAlarmScreenState } finally { if (mounted) { setState(() { - _isSubmitting = false; + _isArchiving = false; }); } } @@ -134,7 +141,7 @@ class _CalendarReminderAlarmScreenState Future _snoozeEvent(ScheduleItemModel event) async { setState(() { - _isSubmitting = true; + _isSnoozing = true; }); try { await sl().snooze10m(_snapshotFromEvent(event)); @@ -142,19 +149,20 @@ class _CalendarReminderAlarmScreenState return; } Toast.show(context, context.l10n.notificationSnoozeMinutes(10)); - context.go(AppRoutes.calendarEventDetail(event.id)); - } catch (_) { + context.go(AppRoutes.homeMain); + } catch (e, st) { + debugPrint('[_snoozeEvent] error: $e\n$st'); if (mounted) { Toast.show( context, - context.l10n.todoSaveFailed('snooze failed'), + context.l10n.todoSaveFailed(e.toString()), type: ToastType.error, ); } } finally { if (mounted) { setState(() { - _isSubmitting = false; + _isSnoozing = false; }); } } diff --git a/apps/lib/features/calendar/presentation/widgets/create_event_sheet.dart b/apps/lib/features/calendar/presentation/widgets/create_event_sheet.dart index c25157f..b9d5ee7 100644 --- a/apps/lib/features/calendar/presentation/widgets/create_event_sheet.dart +++ b/apps/lib/features/calendar/presentation/widgets/create_event_sheet.dart @@ -378,24 +378,11 @@ class _CreateEventSheetState extends State _startDate = date; _startTime = time; if (_endDate != null && _endTime != null) { - final endDateTime = DateTime( - _endDate!.year, - _endDate!.month, - _endDate!.day, - _endTime!.hour, - _endTime!.minute, - ); - final startDateTime = DateTime( - date.year, - date.month, - date.day, - time.hour, - time.minute, - ); + final endDateTime = _composeDateTime(_endDate!, _endTime!); + final startDateTime = _composeDateTime(date, time); if (endDateTime.isBefore(startDateTime) || endDateTime.isAtSameMomentAs(startDateTime)) { - _endDate = date; - _endTime = time.add(const Duration(hours: 1)); + _setEndDateTime(_defaultEndDateTime(startDateTime)); } } }); @@ -408,44 +395,47 @@ class _CreateEventSheetState extends State _endTime ?? _startTime, (date, time) { setState(() { - final startDateTime = DateTime( - _startDate.year, - _startDate.month, - _startDate.day, - _startTime.hour, - _startTime.minute, - ); - final endDateTime = DateTime( - date.year, - date.month, - date.day, - time.hour, - time.minute, - ); + final startDateTime = _composeDateTime(_startDate, _startTime); + final endDateTime = _composeDateTime(date, time); if (endDateTime.isBefore(startDateTime) || endDateTime.isAtSameMomentAs(startDateTime)) { - _endDate = _startDate; - _endTime = _startTime.add(const Duration(hours: 1)); + Toast.show( + context, + context.l10n.calendarCreateInvalidTimeRange, + type: ToastType.error, + ); + _setEndDateTime(_defaultEndDateTime(startDateTime)); } else { - _endDate = date; - _endTime = time; + _setEndDateTime(endDateTime); } }); }, isOptional: true, - minTime: DateTime( - _startDate.year, - _startDate.month, - _startDate.day, - _startTime.hour, - _startTime.minute, - ), ), ], ), ); } + DateTime _composeDateTime(DateTime date, DateTime time) { + return DateTime(date.year, date.month, date.day, time.hour, time.minute); + } + + DateTime _defaultEndDateTime(DateTime startDateTime) { + return startDateTime.add(const Duration(hours: 1)); + } + + void _setEndDateTime(DateTime value) { + _endDate = DateTime(value.year, value.month, value.day); + _endTime = DateTime( + value.year, + value.month, + value.day, + value.hour, + value.minute, + ); + } + Widget _buildAdvancedTab() { return SingleChildScrollView( keyboardDismissBehavior: ScrollViewKeyboardDismissBehavior.onDrag, diff --git a/apps/lib/l10n/app_en.arb b/apps/lib/l10n/app_en.arb index 979190a..8e6f31a 100644 --- a/apps/lib/l10n/app_en.arb +++ b/apps/lib/l10n/app_en.arb @@ -171,7 +171,7 @@ "uiSchemaUrlOpenFailed": "Unable to open URL", "uiSchemaNavigationInvalidParams": "Invalid navigation params", "uiSchemaNavigationInvalidPath": "Invalid navigation path", - "notificationSnoozeMinutes": "{minutes} min", + "notificationSnoozeMinutes": "Reminder snoozed for {minutes} min", "@notificationSnoozeMinutes": { "placeholders": { "minutes": { @@ -705,6 +705,7 @@ "calendarCreateReminderNone": "No reminder", "calendarCreateReminderTime": "Reminder Time", "calendarCreatePickReminderTime": "Select Reminder Time", + "calendarCreateInvalidTimeRange": "End time must be after start time", "calendarCreateReminderPermissionFailed": "Failed to create reminder, check notification permission", "settingsEditProfileLoadFailed": "Failed to load user profile", "settingsEditProfileAvatarUploadSuccess": "Avatar uploaded successfully", diff --git a/apps/lib/l10n/app_localizations.dart b/apps/lib/l10n/app_localizations.dart index 9878f53..b5e11ce 100644 --- a/apps/lib/l10n/app_localizations.dart +++ b/apps/lib/l10n/app_localizations.dart @@ -911,7 +911,7 @@ abstract class AppLocalizations { /// No description provided for @notificationSnoozeMinutes. /// /// In zh, this message translates to: - /// **'{minutes} 分钟'** + /// **'提醒已推迟 {minutes} 分钟'** String notificationSnoozeMinutes(int minutes); /// No description provided for @notificationSnoozeLater. @@ -3265,6 +3265,12 @@ abstract class AppLocalizations { /// **'选择提醒时间'** String get calendarCreatePickReminderTime; + /// No description provided for @calendarCreateInvalidTimeRange. + /// + /// In zh, this message translates to: + /// **'结束时间必须晚于开始时间'** + String get calendarCreateInvalidTimeRange; + /// No description provided for @calendarCreateReminderPermissionFailed. /// /// In zh, this message translates to: diff --git a/apps/lib/l10n/app_localizations_en.dart b/apps/lib/l10n/app_localizations_en.dart index 545d763..344dbb2 100644 --- a/apps/lib/l10n/app_localizations_en.dart +++ b/apps/lib/l10n/app_localizations_en.dart @@ -449,7 +449,7 @@ class AppLocalizationsEn extends AppLocalizations { @override String notificationSnoozeMinutes(int minutes) { - return '$minutes min'; + return 'Reminder snoozed for $minutes min'; } @override @@ -1750,6 +1750,10 @@ class AppLocalizationsEn extends AppLocalizations { @override String get calendarCreatePickReminderTime => 'Select Reminder Time'; + @override + String get calendarCreateInvalidTimeRange => + 'End time must be after start time'; + @override String get calendarCreateReminderPermissionFailed => 'Failed to create reminder, check notification permission'; diff --git a/apps/lib/l10n/app_localizations_zh.dart b/apps/lib/l10n/app_localizations_zh.dart index 38bc7a5..b61be45 100644 --- a/apps/lib/l10n/app_localizations_zh.dart +++ b/apps/lib/l10n/app_localizations_zh.dart @@ -435,7 +435,7 @@ class AppLocalizationsZh extends AppLocalizations { @override String notificationSnoozeMinutes(int minutes) { - return '$minutes 分钟'; + return '提醒已推迟 $minutes 分钟'; } @override @@ -1707,6 +1707,9 @@ class AppLocalizationsZh extends AppLocalizations { @override String get calendarCreatePickReminderTime => '选择提醒时间'; + @override + String get calendarCreateInvalidTimeRange => '结束时间必须晚于开始时间'; + @override String get calendarCreateReminderPermissionFailed => '提醒创建失败,请检查通知权限'; diff --git a/apps/lib/l10n/app_zh.arb b/apps/lib/l10n/app_zh.arb index c471f20..17af534 100644 --- a/apps/lib/l10n/app_zh.arb +++ b/apps/lib/l10n/app_zh.arb @@ -171,7 +171,7 @@ "uiSchemaUrlOpenFailed": "无法打开链接", "uiSchemaNavigationInvalidParams": "导航参数无效", "uiSchemaNavigationInvalidPath": "导航路径无效", - "notificationSnoozeMinutes": "{minutes} 分钟", + "notificationSnoozeMinutes": "提醒已推迟 {minutes} 分钟", "@notificationSnoozeMinutes": { "placeholders": { "minutes": { @@ -705,6 +705,7 @@ "calendarCreateReminderNone": "无提醒", "calendarCreateReminderTime": "提醒时间", "calendarCreatePickReminderTime": "选择提醒时间", + "calendarCreateInvalidTimeRange": "结束时间必须晚于开始时间", "calendarCreateReminderPermissionFailed": "提醒创建失败,请检查通知权限", "settingsEditProfileLoadFailed": "加载用户信息失败", "settingsEditProfileAvatarUploadSuccess": "头像上传成功", diff --git a/apps/test/core/notification/reminder_reconcile_service_behavior_test.dart b/apps/test/core/notification/reminder_reconcile_service_behavior_test.dart new file mode 100644 index 0000000..f2da86e --- /dev/null +++ b/apps/test/core/notification/reminder_reconcile_service_behavior_test.dart @@ -0,0 +1,80 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:social_app/core/notification/models/reminder_alarm.dart'; +import 'package:social_app/core/notification/services/reminder_reconcile_service.dart'; +import 'package:social_app/core/notification/services/reminder_scheduler_service.dart'; + +class _FakeReminderSchedulerService extends ReminderSchedulerService { + int upsertCount = 0; + int cancelCount = 0; + List lastScheduled = const []; + + @override + Future upsertEventReminders( + ReminderEventSnapshot event, { + DateTime? now, + }) async { + upsertCount += 1; + } + + @override + Future cancelEventReminders(String eventId) async { + cancelCount += 1; + } + + @override + Future scheduleAlarms(List alarms) async { + lastScheduled = alarms; + } +} + +ReminderEventSnapshot _event({bool isArchived = false}) { + return ReminderEventSnapshot( + eventId: 'evt_lock', + title: 'Review', + startAt: DateTime(2026, 3, 30, 10, 0), + endAt: DateTime(2026, 3, 30, 11, 0), + timezone: 'Asia/Shanghai', + reminderMinutes: 15, + isArchived: isArchived, + ); +} + +void main() { + test('snooze suppresses reconcile before first snooze fire time', () async { + final now = DateTime(2026, 3, 30, 9, 50); + final scheduler = _FakeReminderSchedulerService(); + final service = ReminderReconcileService( + scheduler: scheduler, + nowProvider: () => now, + ); + + await service.snooze10m(_event()); + expect(scheduler.cancelCount, 1); + expect(scheduler.lastScheduled, isNotEmpty); + expect(scheduler.lastScheduled.first.fireAt, DateTime(2026, 3, 30, 10, 0)); + + await service.reconcileEvent(_event(), now: DateTime(2026, 3, 30, 9, 59)); + expect(scheduler.upsertCount, 0); + + await service.reconcileEvent(_event(), now: DateTime(2026, 3, 30, 10, 0)); + expect(scheduler.upsertCount, 1); + }); + + test('archived event cancels reminder and clears suppress state', () async { + final now = DateTime(2026, 3, 30, 9, 50); + final scheduler = _FakeReminderSchedulerService(); + final service = ReminderReconcileService( + scheduler: scheduler, + nowProvider: () => now, + ); + + await service.snooze10m(_event()); + expect(scheduler.cancelCount, 1); + + await service.reconcileEvent(_event(isArchived: true), now: now); + expect(scheduler.cancelCount, 2); + + await service.reconcileEvent(_event(), now: DateTime(2026, 3, 30, 9, 55)); + expect(scheduler.upsertCount, 1); + }); +} diff --git a/apps/test/core/notification/reminder_reconcile_service_test.dart b/apps/test/core/notification/reminder_reconcile_service_test.dart index eb89561..1d17ce5 100644 --- a/apps/test/core/notification/reminder_reconcile_service_test.dart +++ b/apps/test/core/notification/reminder_reconcile_service_test.dart @@ -26,28 +26,44 @@ void main() { expect(alarms.last.fireAt, DateTime(2026, 3, 30, 10, 35)); }); - test( - 'buildAlarms compensates by scheduling near-now when remindAt passed', - () { - final event = ReminderEventSnapshot( - eventId: 'evt_3', - title: 'Review', - startAt: DateTime(2026, 3, 30, 10, 0), - endAt: DateTime(2026, 3, 30, 10, 20), - timezone: 'Asia/Shanghai', - reminderMinutes: 30, - ); - final now = DateTime(2026, 3, 30, 10, 5, 0); + test('buildAlarms starts from near-now when remindAt passed', () { + final event = ReminderEventSnapshot( + eventId: 'evt_3', + title: 'Review', + startAt: DateTime(2026, 3, 30, 10, 0), + endAt: DateTime(2026, 3, 30, 10, 20), + timezone: 'Asia/Shanghai', + reminderMinutes: 30, + ); + final now = DateTime(2026, 3, 30, 10, 5, 0); - final alarms = ReminderSchedulerService.buildAlarmsForEvent( - event, - now: now, - ); + final alarms = ReminderSchedulerService.buildAlarmsForEvent( + event, + now: now, + ); - expect(alarms, isNotEmpty); - expect(alarms.first.fireAt, now.add(const Duration(seconds: 5))); - }, - ); + expect(alarms, isNotEmpty); + expect(alarms.first.fireAt, DateTime(2026, 3, 30, 10, 5, 2)); + expect(alarms.last.fireAt, DateTime(2026, 3, 30, 10, 15, 2)); + }); + + test('buildAlarms returns empty when next cadence is after endAt', () { + final event = ReminderEventSnapshot( + eventId: 'evt_3b', + title: 'Review', + startAt: DateTime(2026, 3, 30, 10, 0), + endAt: DateTime(2026, 3, 30, 10, 5), + timezone: 'Asia/Shanghai', + reminderMinutes: 30, + ); + + final alarms = ReminderSchedulerService.buildAlarmsForEvent( + event, + now: DateTime(2026, 3, 30, 10, 5, 30), + ); + + expect(alarms, isEmpty); + }); test('buildAlarms returns empty when event already ended', () { final event = ReminderEventSnapshot( diff --git a/backend/src/core/agentscope/tools/custom/calendar.py b/backend/src/core/agentscope/tools/custom/calendar.py index af31f3f..09b29b8 100644 --- a/backend/src/core/agentscope/tools/custom/calendar.py +++ b/backend/src/core/agentscope/tools/custom/calendar.py @@ -83,10 +83,10 @@ class CalendarWriteOperation(BaseModel): location: str | None = Field(default=None, description="Event location.") color: str | None = Field(default=None, description="Event color.") reminder_minutes: int | None = Field( - default=None, + default=5, ge=0, le=10080, - description="Reminder minutes before event start.", + description="Reminder minutes before event start. Defaults to 5 minutes if not specified.", ) status: Literal["active", "archived"] | None = Field( default=None,