diff --git a/AGENTS.md b/AGENTS.md
index 9fc92f0..2b294e7 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -28,6 +28,7 @@ Do not place backend/frontend implementation details here.
- Default development branch is `dev`; do not develop directly on `main`.
- Never push unless explicitly requested by the user.
- Keep AGENTS layered and lean: shared rules at root, domain rules in sub-AGENTS.
+- **No Error Swallowing**: All exceptions must propagate or be converted to typed errors. Never catch an exception, log it, and silently continue. This destroys debuggability.
## Protocol Source of Truth
@@ -36,3 +37,7 @@ Do not place backend/frontend implementation details here.
- Update protocol docs before changing data/API/UI contracts.
- Document compatibility strategy (backward-compatible vs migration).
- Keep frontend/backend implementations aligned with documented protocol.
+
+## Database Access
+
+When viewing data in the database, use `supabase mcp` tools (`supabase_execute_sql`, `supabase_list_tables`, etc.) instead of direct queries or other methods.
diff --git a/apps/AGENTS.md b/apps/AGENTS.md
index f77a4a3..e42a317 100644
--- a/apps/AGENTS.md
+++ b/apps/AGENTS.md
@@ -58,6 +58,8 @@ This file governs `apps/**` (Flutter). Keep rules strict, short, and reusable.
- Loading indicators: `AppLoadingIndicator` only.
- Form pages should default to keyboard-overlay behavior to avoid full-page layout jumps.
+## Interaction & Feedback (Must)
+
## Agent Chat Protocol (Must)
- Agent chat must follow AG-UI over SSE.
diff --git a/apps/android/app/build.gradle.kts b/apps/android/app/build.gradle.kts
index ae08534..ba28171 100644
--- a/apps/android/app/build.gradle.kts
+++ b/apps/android/app/build.gradle.kts
@@ -1,3 +1,5 @@
+import java.util.Properties
+
plugins {
id("com.android.application")
id("kotlin-android")
@@ -6,7 +8,13 @@ plugins {
}
android {
- namespace = "com.social.social_app"
+ val keystoreProperties = Properties()
+ val keystorePropertiesFile = rootProject.file("key.properties")
+ if (keystorePropertiesFile.exists()) {
+ keystoreProperties.load(keystorePropertiesFile.inputStream())
+ }
+
+ namespace = "com.xunmee.xisocial"
compileSdk = flutter.compileSdkVersion
ndkVersion = flutter.ndkVersion
@@ -22,7 +30,7 @@ android {
defaultConfig {
// TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html).
- applicationId = "com.social.social_app"
+ applicationId = "com.xunmee.xisocial"
// You can update the following values to match your application needs.
// For more information, see: https://flutter.dev/to/review-gradle-config.
minSdk = flutter.minSdkVersion
@@ -31,11 +39,23 @@ android {
versionName = flutter.versionName
}
+ signingConfigs {
+ create("release") {
+ if (keystorePropertiesFile.exists()) {
+ storeFile = rootProject.file(keystoreProperties["storeFile"] as String)
+ storePassword = keystoreProperties["storePassword"] as String
+ keyAlias = keystoreProperties["keyAlias"] as String
+ keyPassword = keystoreProperties["keyPassword"] as String
+ }
+ }
+ }
+
buildTypes {
release {
- // TODO: Add your own signing config for the release build.
- // Signing with the debug keys for now, so `flutter run --release` works.
- signingConfig = signingConfigs.getByName("debug")
+ if (!keystorePropertiesFile.exists()) {
+ throw GradleException("Missing apps/android/key.properties for release signing")
+ }
+ signingConfig = signingConfigs.getByName("release")
}
}
}
diff --git a/apps/android/app/src/main/AndroidManifest.xml b/apps/android/app/src/main/AndroidManifest.xml
index 15f1bff..eda9a5a 100644
--- a/apps/android/app/src/main/AndroidManifest.xml
+++ b/apps/android/app/src/main/AndroidManifest.xml
@@ -3,7 +3,6 @@
-
diff --git a/apps/android/app/src/main/kotlin/com/social/social_app/MainActivity.kt b/apps/android/app/src/main/kotlin/com/xunmee/xisocial/MainActivity.kt
similarity index 75%
rename from apps/android/app/src/main/kotlin/com/social/social_app/MainActivity.kt
rename to apps/android/app/src/main/kotlin/com/xunmee/xisocial/MainActivity.kt
index 792930d..adbd005 100644
--- a/apps/android/app/src/main/kotlin/com/social/social_app/MainActivity.kt
+++ b/apps/android/app/src/main/kotlin/com/xunmee/xisocial/MainActivity.kt
@@ -1,4 +1,4 @@
-package com.social.social_app
+package com.xunmee.xisocial
import io.flutter.embedding.android.FlutterActivity
diff --git a/apps/android/key.properties.example b/apps/android/key.properties.example
new file mode 100644
index 0000000..4ee8f8b
--- /dev/null
+++ b/apps/android/key.properties.example
@@ -0,0 +1,4 @@
+storeFile=release.jks
+storePassword=replace_with_store_password
+keyAlias=replace_with_key_alias
+keyPassword=replace_with_key_password
diff --git a/apps/ios/Runner.xcodeproj/project.pbxproj b/apps/ios/Runner.xcodeproj/project.pbxproj
index e8a6c29..03f0535 100644
--- a/apps/ios/Runner.xcodeproj/project.pbxproj
+++ b/apps/ios/Runner.xcodeproj/project.pbxproj
@@ -477,7 +477,7 @@
"$(inherited)",
"@executable_path/Frameworks",
);
- PRODUCT_BUNDLE_IDENTIFIER = "com.zl-q.socialApp";
+ PRODUCT_BUNDLE_IDENTIFIER = "com.xunmee.xisocial";
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h";
SWIFT_VERSION = 5.0;
@@ -494,7 +494,7 @@
CURRENT_PROJECT_VERSION = 1;
GENERATE_INFOPLIST_FILE = YES;
MARKETING_VERSION = 1.0;
- PRODUCT_BUNDLE_IDENTIFIER = com.social.socialApp.RunnerTests;
+ PRODUCT_BUNDLE_IDENTIFIER = com.xunmee.xisocial.RunnerTests;
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG;
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
@@ -512,7 +512,7 @@
CURRENT_PROJECT_VERSION = 1;
GENERATE_INFOPLIST_FILE = YES;
MARKETING_VERSION = 1.0;
- PRODUCT_BUNDLE_IDENTIFIER = com.social.socialApp.RunnerTests;
+ PRODUCT_BUNDLE_IDENTIFIER = com.xunmee.xisocial.RunnerTests;
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_VERSION = 5.0;
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner";
@@ -528,7 +528,7 @@
CURRENT_PROJECT_VERSION = 1;
GENERATE_INFOPLIST_FILE = YES;
MARKETING_VERSION = 1.0;
- PRODUCT_BUNDLE_IDENTIFIER = com.social.socialApp.RunnerTests;
+ PRODUCT_BUNDLE_IDENTIFIER = com.xunmee.xisocial.RunnerTests;
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_VERSION = 5.0;
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner";
@@ -660,7 +660,7 @@
"$(inherited)",
"@executable_path/Frameworks",
);
- PRODUCT_BUNDLE_IDENTIFIER = "com.zl-q.socialApp";
+ PRODUCT_BUNDLE_IDENTIFIER = "com.xunmee.xisocial";
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h";
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
@@ -683,7 +683,7 @@
"$(inherited)",
"@executable_path/Frameworks",
);
- PRODUCT_BUNDLE_IDENTIFIER = "com.zl-q.socialApp";
+ PRODUCT_BUNDLE_IDENTIFIER = "com.xunmee.xisocial";
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h";
SWIFT_VERSION = 5.0;
diff --git a/apps/lib/app/app.dart b/apps/lib/app/app.dart
index 797fb83..1e5ce51 100644
--- a/apps/lib/app/app.dart
+++ b/apps/lib/app/app.dart
@@ -1,4 +1,5 @@
import 'dart:async';
+import 'package:flutter/scheduler.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
@@ -14,6 +15,11 @@ import '../data/cache/cache_scope.dart';
import 'services/app_prewarm_orchestrator.dart';
import 'router/app_router.dart';
import '../core/theme/app_theme.dart';
+import '../core/inbox/inbox_sync_store.dart';
+import '../core/notification/services/reminder_permission_service.dart';
+import '../core/notification/services/reminder_notification_router.dart';
+import '../core/notification/models/reminder_alarm.dart';
+import 'router/app_routes.dart';
class LinksyApp extends StatefulWidget {
const LinksyApp({super.key});
@@ -25,12 +31,15 @@ class LinksyApp extends StatefulWidget {
class _LinksyAppState extends State {
late final AuthBloc _authBloc;
late final GoRouter _router;
+ StreamSubscription? _reminderTapSubscription;
+ String? _pendingReminderRoute;
int _cacheScopeVersion = 0;
Future _onAuthenticated(String userId) async {
_cacheScopeVersion += 1;
final scopeKey = 'user:$userId:v$_cacheScopeVersion';
CacheScope.configureProvider(() => scopeKey);
+ await sl().resetForUser(userId);
await sl().switchUser(userId);
await sl().ensureStartedFor(userId);
}
@@ -39,6 +48,7 @@ class _LinksyAppState extends State {
_cacheScopeVersion += 1;
final scopeKey = 'anonymous:v$_cacheScopeVersion';
CacheScope.configureProvider(() => scopeKey);
+ await sl().resetForUser(null);
await sl().switchUser(null);
sl().reset();
}
@@ -51,10 +61,30 @@ class _LinksyAppState extends State {
CacheScope.configureProvider(() => initialScopeKey);
_authBloc.add(AuthStarted());
_router = createAppRouter(_authBloc);
+ SchedulerBinding.instance.addPostFrameCallback((_) {
+ unawaited(_bootstrapReminderNotification());
+ });
+ }
+
+ Future _bootstrapReminderNotification() async {
+ await sl().initializeAtBoot();
+ final router = sl();
+ await router.start();
+ _reminderTapSubscription ??= router.taps.listen(_onReminderTap);
+ }
+
+ void _onReminderTap(ReminderNotificationTap tap) {
+ final route = AppRoutes.calendarReminderAlarm(tap.eventId);
+ if (_authBloc.state is AuthAuthenticated) {
+ _router.go(route);
+ return;
+ }
+ _pendingReminderRoute = route;
}
@override
void dispose() {
+ _reminderTapSubscription?.cancel();
_router.dispose();
super.dispose();
}
@@ -67,6 +97,11 @@ class _LinksyAppState extends State {
listener: (context, state) {
if (state is AuthAuthenticated) {
unawaited(_onAuthenticated(state.user.id));
+ final pendingRoute = _pendingReminderRoute;
+ if (pendingRoute != null) {
+ _pendingReminderRoute = null;
+ _router.go(pendingRoute);
+ }
}
if (state is AuthUnauthenticated) {
unawaited(_onUnauthenticated());
diff --git a/apps/lib/app/di/injection.dart b/apps/lib/app/di/injection.dart
index b4b4fe5..3b1cb16 100644
--- a/apps/lib/app/di/injection.dart
+++ b/apps/lib/app/di/injection.dart
@@ -6,6 +6,7 @@ import '../../data/cache/cache_store.dart';
import '../../features/calendar/data/repositories/calendar_repository.dart';
import '../../features/contacts/data/repositories/friend_repository.dart';
import '../../features/messages/data/repositories/inbox_repository.dart';
+import '../../core/inbox/inbox_sync_store.dart';
import '../../features/contacts/data/repositories/user_repository.dart';
import '../../core/auth/session_controller.dart';
import '../../data/network/api_client.dart';
@@ -36,6 +37,10 @@ import '../../features/todo/data/apis/todo_api.dart';
import '../../features/todo/data/repositories/todo_repository.dart';
import '../services/app_prewarm_orchestrator.dart';
import '../services/auth_session_controller.dart';
+import '../../core/notification/services/reminder_scheduler_service.dart';
+import '../../core/notification/services/reminder_permission_service.dart';
+import '../../core/notification/services/reminder_reconcile_service.dart';
+import '../../core/notification/services/reminder_notification_router.dart';
final sl = GetIt.instance;
@@ -96,15 +101,31 @@ Future configureDependencies() async {
final calendarApi = CalendarApi(apiClient);
sl.registerSingleton(calendarApi);
+
+ final reminderScheduler = ReminderSchedulerService();
+ sl.registerSingleton(reminderScheduler);
+ sl.registerSingleton(
+ ReminderPermissionService(scheduler: reminderScheduler),
+ );
+ sl.registerSingleton(
+ ReminderReconcileService(scheduler: reminderScheduler),
+ );
+ sl.registerSingleton(
+ ReminderNotificationRouter(scheduler: reminderScheduler),
+ dispose: (service) => service.dispose(),
+ );
+
final calendarService = CalendarService(
apiClient: apiClient,
invalidator: sl(),
+ reminderReconcileService: sl(),
);
sl.registerSingleton(calendarService);
final calendarRepository = CalendarRepository(
store: hybridCacheStore,
apiClient: apiClient,
+ reminderReconcileService: sl(),
);
sl.registerSingleton(calendarRepository);
@@ -125,8 +146,14 @@ Future configureDependencies() async {
final inboxApi = InboxApi(apiClient);
sl.registerSingleton(inboxApi);
- sl.registerSingleton(
- InboxRepositoryImpl(apiClient: apiClient, store: hybridCacheStore),
+ final inboxRepository = InboxRepositoryImpl(
+ apiClient: apiClient,
+ store: hybridCacheStore,
+ );
+ sl.registerSingleton(inboxRepository);
+ sl.registerSingleton(
+ InboxSyncStore(repository: inboxRepository, inboxApi: inboxApi),
+ dispose: (store) => store.dispose(),
);
final chatApi = ChatApiImpl(apiClient);
@@ -172,7 +199,21 @@ Future configureDependencies() async {
sl.registerSingleton(authBloc);
sl.registerSingleton(AuthSessionController(authBloc));
sl.registerSingleton(
- ChatBloc(chatApi: chatApi, historyRepository: chatHistoryRepository),
+ ChatBloc(
+ chatApi: chatApi,
+ historyRepository: chatHistoryRepository,
+ onCalendarMutated: () async {
+ final calendarRepository = sl();
+ final selected = sl().selectedDate;
+ await Future.wait([
+ calendarRepository.getDayEvents(selected, forceRefresh: true),
+ calendarRepository.getMonthEvents(
+ DateTime(selected.year, selected.month, 1),
+ forceRefresh: true,
+ ),
+ ]);
+ },
+ ),
);
apiClient.setRefreshCallback((token) async {
diff --git a/apps/lib/app/router/app_router.dart b/apps/lib/app/router/app_router.dart
index 8d8dea4..b76f928 100644
--- a/apps/lib/app/router/app_router.dart
+++ b/apps/lib/app/router/app_router.dart
@@ -12,15 +12,16 @@ import '../../../features/auth/presentation/screens/auth_boot_screen.dart';
import '../../../features/auth/presentation/screens/login_screen.dart';
import '../../../features/home/presentation/screens/home_screen.dart';
import '../../../features/messages/presentation/screens/message_invite_list_screen.dart';
-import '../../../features/messages/presentation/screens/message_invite_detail_screen.dart';
import '../../../features/contacts/presentation/screens/contacts_screen.dart';
-import '../../../features/contacts/presentation/screens/add_contact_screen.dart';
+import '../../../features/contacts/presentation/screens/contact_detail_screen.dart';
+import '../../../features/contacts/data/apis/users_api.dart';
import '../../../features/calendar/presentation/screens/calendar_dayweek_screen.dart';
import '../../../features/calendar/presentation/screens/calendar_month_screen.dart';
import '../../../features/calendar/presentation/screens/calendar_event_detail_screen.dart';
import '../../../features/calendar/presentation/screens/calendar_event_create_screen.dart';
import '../../../features/calendar/presentation/screens/calendar_event_edit_screen.dart';
import '../../../features/calendar/presentation/screens/calendar_event_share_screen.dart';
+import '../../../features/calendar/presentation/screens/calendar_reminder_alarm_screen.dart';
import '../../../features/calendar/presentation/calendar_time_utils.dart';
import '../../../features/todo/presentation/screens/todo_quadrants_screen.dart';
import '../../../features/todo/presentation/screens/todo_detail_screen.dart';
@@ -46,8 +47,9 @@ final _homeSecondLevelRoutes = [
final _protectedRoutes = [
..._homeSecondLevelRoutes,
AppRoutes.contactsList,
- AppRoutes.contactsAdd,
+ '/contacts/',
'/calendar/events',
+ '/calendar/reminders',
AppRoutes.settingsFeatures,
AppRoutes.settingsMemory,
AppRoutes.settingsMemoryUser,
@@ -73,7 +75,6 @@ String? resolveAuthRedirect({
final isProtected =
isHomeRoute ||
_protectedRoutes.any((route) => matchedLocation.startsWith(route));
- final _ = prewarm;
if (isAuthChecking && !isBootRoute) {
return AppRoutes.authBoot;
@@ -137,6 +138,11 @@ GoRouter createAppRouter(AuthBloc authBloc) {
builder: (context, state) =>
CalendarEventShareScreen(eventId: state.pathParameters['id']!),
),
+ GoRoute(
+ path: '/calendar/reminders/:id/alarm',
+ builder: (context, state) =>
+ CalendarReminderAlarmScreen(eventId: state.pathParameters['id']!),
+ ),
GoRoute(
path: AppRoutes.authLogin,
builder: (context, state) => const LoginScreen(),
@@ -149,18 +155,16 @@ GoRouter createAppRouter(AuthBloc authBloc) {
path: AppRoutes.messageInviteList,
builder: (context, state) => const MessageInviteListScreen(),
),
- GoRoute(
- path: '/messages/invites/:id',
- builder: (context, state) =>
- MessageInviteDetailScreen(inviteId: state.pathParameters['id']!),
- ),
GoRoute(
path: AppRoutes.contactsList,
builder: (context, state) => const ContactsScreen(),
),
GoRoute(
- path: AppRoutes.contactsAdd,
- builder: (context, state) => const AddContactScreen(),
+ path: '/contacts/:id',
+ builder: (context, state) {
+ final user = state.extra as UserBasicInfo;
+ return ContactDetailScreen(user: user);
+ },
),
GoRoute(
path: AppRoutes.calendarDayWeek,
diff --git a/apps/lib/app/router/app_routes.dart b/apps/lib/app/router/app_routes.dart
index 3fe265c..05c821b 100644
--- a/apps/lib/app/router/app_routes.dart
+++ b/apps/lib/app/router/app_routes.dart
@@ -9,14 +9,15 @@ class AppRoutes {
static const shellTodoBranch = todoList;
static const messageInviteList = '/messages/invites';
- static String messageInviteDetail(String id) => '/messages/invites/$id';
static const contactsList = '/contacts';
- static const contactsAdd = '/contacts/add';
+ static String contactDetail(String id) => '/contacts/$id';
static const calendarDayWeek = '/calendar/dayweek';
static const calendarMonth = '/calendar/month';
static String calendarEventDetail(String id) => '/calendar/events/$id';
+ static String calendarReminderAlarm(String id) =>
+ '/calendar/reminders/$id/alarm';
static const calendarEventCreate = '/calendar/events/new';
static String calendarEventEdit(String id) => '/calendar/events/$id/edit';
static String calendarEventShare(String id) => '/calendar/events/$id/share';
diff --git a/apps/lib/app/services/app_prewarm_orchestrator.dart b/apps/lib/app/services/app_prewarm_orchestrator.dart
index 8f047b3..7d68147 100644
--- a/apps/lib/app/services/app_prewarm_orchestrator.dart
+++ b/apps/lib/app/services/app_prewarm_orchestrator.dart
@@ -16,12 +16,14 @@ class AppPrewarmOrchestrator extends ChangeNotifier {
this.bootBudget = const Duration(milliseconds: 1200),
Future Function()? prewarmChatHistory,
Future Function()? prewarmCalendarToday,
+ Future Function()? prewarmCalendarReminderWindow,
Future Function()? prewarmUnreadInbox,
}) : _calendarRepository = calendarRepository,
_inboxRepository = inboxRepository,
_chatHistoryRepository = chatHistoryRepository,
_prewarmChatHistory = prewarmChatHistory,
_prewarmCalendarToday = prewarmCalendarToday,
+ _prewarmCalendarReminderWindow = prewarmCalendarReminderWindow,
_prewarmUnreadInbox = prewarmUnreadInbox;
final CalendarRepository _calendarRepository;
@@ -30,6 +32,7 @@ class AppPrewarmOrchestrator extends ChangeNotifier {
final Duration bootBudget;
final Future Function()? _prewarmChatHistory;
final Future Function()? _prewarmCalendarToday;
+ final Future Function()? _prewarmCalendarReminderWindow;
final Future Function()? _prewarmUnreadInbox;
AppPrewarmStatus _status = AppPrewarmStatus.idle;
@@ -59,6 +62,7 @@ class AppPrewarmOrchestrator extends ChangeNotifier {
final tasks = Future.wait([
_runPrewarmChatHistory(),
_runPrewarmCalendarToday(),
+ _runPrewarmCalendarReminderWindow(),
_runPrewarmUnreadInbox(),
]);
@@ -95,6 +99,21 @@ class AppPrewarmOrchestrator extends ChangeNotifier {
return _inboxRepository.getMessages(isRead: false);
}
+ Future _runPrewarmCalendarReminderWindow() {
+ final override = _prewarmCalendarReminderWindow;
+ if (override != null) {
+ return override();
+ }
+ final now = DateTime.now();
+ final start = DateTime(
+ now.year,
+ now.month,
+ now.day,
+ ).subtract(const Duration(days: 1));
+ final end = start.add(const Duration(days: 90));
+ return _calendarRepository.listByRange(startAt: start, endAt: end);
+ }
+
Future _runWithBudget(
Future tasks, {
required String userId,
diff --git a/apps/lib/core/inbox/inbox_sync_store.dart b/apps/lib/core/inbox/inbox_sync_store.dart
new file mode 100644
index 0000000..dd4c30e
--- /dev/null
+++ b/apps/lib/core/inbox/inbox_sync_store.dart
@@ -0,0 +1,313 @@
+import 'dart:async';
+import 'dart:convert';
+
+import 'package:flutter/foundation.dart';
+
+import '../../features/messages/data/apis/inbox_api.dart' show InboxApi;
+import '../../features/messages/data/models/inbox_message.dart';
+import '../../features/messages/data/repositories/inbox_repository.dart';
+
+class InboxSyncStore extends ChangeNotifier {
+ InboxSyncStore({
+ required InboxRepository repository,
+ required InboxApi inboxApi,
+ }) : _repository = repository,
+ _inboxApi = inboxApi;
+
+ final InboxRepository _repository;
+ final InboxApi _inboxApi;
+
+ final Map _messagesById = {};
+ final Map _messageVersionById = {};
+
+ StreamSubscription? _sseSubscription;
+ bool _started = false;
+ bool _disposed = false;
+ String? _activeUserId;
+ String? _lastEventId;
+ Object? _lastStreamError;
+
+ Object? get lastStreamError => _lastStreamError;
+
+ int get unreadCount => _messagesById.values.where((m) => !m.isRead).length;
+
+ List get unreadMessages {
+ final list = _messagesById.values.where((m) => !m.isRead).toList();
+ list.sort((a, b) => b.createdAt.compareTo(a.createdAt));
+ return list;
+ }
+
+ List get readMessages {
+ final list = _messagesById.values.where((m) => m.isRead).toList();
+ list.sort((a, b) => b.createdAt.compareTo(a.createdAt));
+ return list;
+ }
+
+ Future ensureStarted() async {
+ if (_activeUserId == null) {
+ return;
+ }
+ if (_started) {
+ return;
+ }
+ _started = true;
+ await refreshSnapshot();
+ unawaited(_streamLoop());
+ }
+
+ Future refreshSnapshot() async {
+ final result = await Future.wait([
+ _repository.getMessages(isRead: false, forceRefresh: true),
+ _repository.getMessages(isRead: true, forceRefresh: true),
+ ]);
+ final merged = [...result[0], ...result[1]];
+ _messagesById
+ ..clear()
+ ..addEntries(merged.map((m) => MapEntry(m.id, m)));
+ _messageVersionById
+ ..clear()
+ ..addEntries(
+ merged.map((m) => MapEntry(m.id, m.createdAt.millisecondsSinceEpoch)),
+ );
+ _notifyIfActive();
+ }
+
+ Future stop() async {
+ _started = false;
+ final sub = _sseSubscription;
+ _sseSubscription = null;
+ await sub?.cancel();
+ }
+
+ Future resetForUser(String? userId) async {
+ await stop();
+ _activeUserId = userId;
+ _lastEventId = null;
+ _lastStreamError = null;
+ _messagesById.clear();
+ _messageVersionById.clear();
+ if (userId != null) {
+ await ensureStarted();
+ }
+ _notifyIfActive();
+ }
+
+ @override
+ void dispose() {
+ _disposed = true;
+ unawaited(stop());
+ super.dispose();
+ }
+
+ Future _streamLoop() async {
+ var retry = 0;
+ while (_started && !_disposed) {
+ try {
+ final lines = await _inboxApi.streamEvents(lastEventId: _lastEventId);
+ await _consumeLines(lines);
+ retry = 0;
+ if (_lastStreamError != null) {
+ _lastStreamError = null;
+ _notifyIfActive();
+ }
+ } catch (error) {
+ retry += 1;
+ _lastStreamError = error;
+ _notifyIfActive();
+ }
+ if (!_started || _disposed) {
+ break;
+ }
+ final waitMs = (retry * 300).clamp(300, 5000);
+ await Future.delayed(Duration(milliseconds: waitMs));
+ }
+ }
+
+ Future _consumeLines(Stream lines) async {
+ final completer = Completer();
+ String? eventId;
+ String? eventType;
+ final dataBuffer = StringBuffer();
+
+ void flushFrame() {
+ if (dataBuffer.isEmpty) {
+ eventId = null;
+ eventType = null;
+ return;
+ }
+ final raw = dataBuffer.toString();
+ dataBuffer.clear();
+ if (eventId != null && eventId!.isNotEmpty) {
+ _lastEventId = eventId;
+ }
+ if (eventType == null || eventType == 'INBOX_MESSAGE') {
+ eventId = null;
+ eventType = null;
+ return;
+ }
+ try {
+ final jsonValue = jsonDecode(raw);
+ if (jsonValue is Map) {
+ _applyEnvelope(jsonValue);
+ }
+ } catch (error) {
+ _lastStreamError = StateError(
+ 'Failed to parse inbox SSE frame: $error',
+ );
+ _notifyIfActive();
+ }
+ eventId = null;
+ eventType = null;
+ }
+
+ late final StreamSubscription subscription;
+ subscription = lines.listen(
+ (line) {
+ if (!_started || _disposed) {
+ if (!completer.isCompleted) {
+ completer.complete();
+ }
+ unawaited(subscription.cancel());
+ return;
+ }
+ if (line.isEmpty) {
+ flushFrame();
+ return;
+ }
+ if (line.startsWith(':')) {
+ return;
+ }
+ if (line.startsWith('id:')) {
+ eventId = line.substring(3).trim();
+ return;
+ }
+ if (line.startsWith('event:')) {
+ eventType = line.substring(6).trim();
+ return;
+ }
+ if (line.startsWith('data:')) {
+ final fragment = line.substring(5).trim();
+ if (dataBuffer.isNotEmpty) {
+ dataBuffer.write('\n');
+ }
+ dataBuffer.write(fragment);
+ }
+ },
+ onError: (Object error, StackTrace stackTrace) {
+ _lastStreamError = error;
+ _notifyIfActive();
+ if (!completer.isCompleted) {
+ completer.completeError(error, stackTrace);
+ }
+ },
+ onDone: () {
+ flushFrame();
+ if (!completer.isCompleted) {
+ completer.complete();
+ }
+ },
+ cancelOnError: false,
+ );
+
+ _sseSubscription = subscription;
+ await completer.future;
+ if (identical(_sseSubscription, subscription)) {
+ _sseSubscription = null;
+ }
+ }
+
+ void _applyEnvelope(Map envelope) {
+ final messageId = envelope['message_id'];
+ final op = envelope['op'];
+ final version = envelope['version'];
+ if (messageId is! String || op is! String || version is! int) {
+ return;
+ }
+ final currentVersion = _messageVersionById[messageId];
+ if (currentVersion != null && version <= currentVersion) {
+ return;
+ }
+
+ final data = envelope['data'];
+ if (op == 'snapshot_required') {
+ _messageVersionById[messageId] = version;
+ unawaited(refreshSnapshot());
+ return;
+ }
+ if (data is! Map) {
+ return;
+ }
+
+ switch (op) {
+ case 'created':
+ final messageRaw = data['message'];
+ if (messageRaw is! Map) {
+ return;
+ }
+ final message = InboxMessage.fromJson(messageRaw);
+ _messagesById[message.id] = message;
+ case 'read_changed':
+ final existing = _messagesById[messageId];
+ final isRead = data['is_read'];
+ if (existing == null || isRead is! bool) {
+ return;
+ }
+ _messagesById[messageId] = InboxMessage(
+ id: existing.id,
+ recipientId: existing.recipientId,
+ senderId: existing.senderId,
+ messageType: existing.messageType,
+ scheduleItemId: existing.scheduleItemId,
+ friendshipId: existing.friendshipId,
+ content: existing.content,
+ isRead: isRead,
+ status: existing.status,
+ createdAt: existing.createdAt,
+ );
+ case 'status_changed':
+ final existing = _messagesById[messageId];
+ final status = data['status'];
+ if (existing == null || status is! String) {
+ return;
+ }
+ _messagesById[messageId] = InboxMessage(
+ id: existing.id,
+ recipientId: existing.recipientId,
+ senderId: existing.senderId,
+ messageType: existing.messageType,
+ scheduleItemId: existing.scheduleItemId,
+ friendshipId: existing.friendshipId,
+ content: existing.content,
+ isRead: existing.isRead,
+ status: _statusFromApi(status),
+ createdAt: existing.createdAt,
+ );
+ default:
+ return;
+ }
+ _messageVersionById[messageId] = version;
+ _notifyIfActive();
+ }
+
+ InboxMessageStatus _statusFromApi(String raw) {
+ switch (raw) {
+ case 'pending':
+ return InboxMessageStatus.pending;
+ case 'accepted':
+ return InboxMessageStatus.accepted;
+ case 'rejected':
+ return InboxMessageStatus.rejected;
+ case 'dismissed':
+ return InboxMessageStatus.dismissed;
+ default:
+ throw StateError('Unsupported inbox message status: $raw');
+ }
+ }
+
+ void _notifyIfActive() {
+ if (_disposed) {
+ return;
+ }
+ notifyListeners();
+ }
+}
diff --git a/apps/lib/core/notification/models/reminder_action.dart b/apps/lib/core/notification/models/reminder_action.dart
new file mode 100644
index 0000000..319187c
--- /dev/null
+++ b/apps/lib/core/notification/models/reminder_action.dart
@@ -0,0 +1,12 @@
+enum ReminderAction { archive, snooze10m }
+
+extension ReminderActionValue on ReminderAction {
+ String get value {
+ switch (this) {
+ case ReminderAction.archive:
+ return 'archive';
+ case ReminderAction.snooze10m:
+ return 'snooze10m';
+ }
+ }
+}
diff --git a/apps/lib/core/notification/models/reminder_alarm.dart b/apps/lib/core/notification/models/reminder_alarm.dart
new file mode 100644
index 0000000..613218b
--- /dev/null
+++ b/apps/lib/core/notification/models/reminder_alarm.dart
@@ -0,0 +1,97 @@
+class ReminderEventSnapshot {
+ const ReminderEventSnapshot({
+ required this.eventId,
+ required this.title,
+ required this.startAt,
+ required this.timezone,
+ required this.reminderMinutes,
+ this.endAt,
+ this.location,
+ this.notes,
+ this.isArchived = false,
+ });
+
+ final String eventId;
+ final String title;
+ final DateTime startAt;
+ final DateTime? endAt;
+ final String timezone;
+ final int? reminderMinutes;
+ final String? location;
+ final String? notes;
+ final bool isArchived;
+}
+
+class ReminderAlarm {
+ const ReminderAlarm({
+ required this.eventId,
+ required this.title,
+ required this.startAt,
+ required this.timezone,
+ required this.reminderMinutes,
+ required this.fireAt,
+ required this.fireTimeBucket,
+ this.endAt,
+ this.location,
+ this.notes,
+ this.version = 1,
+ });
+
+ final String eventId;
+ final String title;
+ final DateTime startAt;
+ final DateTime? endAt;
+ final String timezone;
+ final int reminderMinutes;
+ final DateTime fireAt;
+ final int fireTimeBucket;
+ final String? location;
+ final String? notes;
+ final int version;
+
+ Map toJson() {
+ return {
+ 'eventId': eventId,
+ 'title': title,
+ 'startAt': startAt.toIso8601String(),
+ 'endAt': endAt?.toIso8601String(),
+ 'timezone': timezone,
+ 'reminderMinutes': reminderMinutes,
+ 'fireAt': fireAt.toIso8601String(),
+ 'fireTimeBucket': fireTimeBucket,
+ 'location': location,
+ 'notes': notes,
+ 'version': version,
+ };
+ }
+
+ factory ReminderAlarm.fromJson(Map json) {
+ return ReminderAlarm(
+ eventId: json['eventId'] as String,
+ title: json['title'] as String? ?? '',
+ startAt: DateTime.parse(json['startAt'] as String),
+ endAt: json['endAt'] == null
+ ? null
+ : DateTime.parse(json['endAt'] as String),
+ timezone: json['timezone'] as String? ?? 'UTC',
+ reminderMinutes: json['reminderMinutes'] as int? ?? 0,
+ fireAt: DateTime.parse(json['fireAt'] as String),
+ fireTimeBucket: json['fireTimeBucket'] as int,
+ location: json['location'] as String?,
+ notes: json['notes'] as String?,
+ version: json['version'] as int? ?? 1,
+ );
+ }
+}
+
+class ReminderNotificationTap {
+ const ReminderNotificationTap({
+ required this.eventId,
+ required this.fireTimeBucket,
+ required this.payload,
+ });
+
+ final String eventId;
+ final int fireTimeBucket;
+ final Map payload;
+}
diff --git a/apps/lib/core/notification/models/reminder_payload.dart b/apps/lib/core/notification/models/reminder_payload.dart
deleted file mode 100644
index af09c6e..0000000
--- a/apps/lib/core/notification/models/reminder_payload.dart
+++ /dev/null
@@ -1,182 +0,0 @@
-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 aggregateIds;
- final int? fireTimeBucket;
- 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.fireTimeBucket,
- this.version = 1,
- });
-
- ReminderPayload copyWith({
- String? eventId,
- String? title,
- DateTime? startAt,
- DateTime? endAt,
- String? timezone,
- String? location,
- String? notes,
- String? color,
- ReminderPayloadMode? mode,
- List? aggregateIds,
- int? fireTimeBucket,
- 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,
- fireTimeBucket: fireTimeBucket ?? this.fireTimeBucket,
- version: version ?? this.version,
- );
- }
-
- Map 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,
- 'fireTimeBucket': fireTimeBucket,
- 'version': version,
- };
- }
-
- factory ReminderPayload.fromJson(Map 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? ?? 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,
- fireTimeBucket: json['fireTimeBucket'] as int?,
- 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.fireTimeBucket == fireTimeBucket &&
- other.version == version;
- }
-
- @override
- int get hashCode {
- return Object.hash(
- eventId,
- title,
- startAt,
- endAt,
- timezone,
- location,
- notes,
- color,
- mode,
- Object.hashAll(aggregateIds),
- fireTimeBucket,
- 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 left, List 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;
-}
diff --git a/apps/lib/core/notification/services/reminder_notification_router.dart b/apps/lib/core/notification/services/reminder_notification_router.dart
new file mode 100644
index 0000000..fc731e1
--- /dev/null
+++ b/apps/lib/core/notification/services/reminder_notification_router.dart
@@ -0,0 +1,27 @@
+import 'dart:async';
+
+import '../models/reminder_alarm.dart';
+import 'reminder_scheduler_service.dart';
+
+class ReminderNotificationRouter {
+ ReminderNotificationRouter({required ReminderSchedulerService scheduler})
+ : _scheduler = scheduler;
+
+ final ReminderSchedulerService _scheduler;
+ final StreamController _controller =
+ StreamController.broadcast();
+
+ Stream get taps => _controller.stream;
+
+ Future start() async {
+ await _scheduler.initialize(onTap: _controller.add);
+ final launchTap = await _scheduler.consumeLaunchTap();
+ if (launchTap != null) {
+ _controller.add(launchTap);
+ }
+ }
+
+ void dispose() {
+ _controller.close();
+ }
+}
diff --git a/apps/lib/core/notification/services/reminder_permission_service.dart b/apps/lib/core/notification/services/reminder_permission_service.dart
new file mode 100644
index 0000000..a839ea3
--- /dev/null
+++ b/apps/lib/core/notification/services/reminder_permission_service.dart
@@ -0,0 +1,13 @@
+import 'reminder_scheduler_service.dart';
+
+class ReminderPermissionService {
+ const ReminderPermissionService({required ReminderSchedulerService scheduler})
+ : _scheduler = scheduler;
+
+ final ReminderSchedulerService _scheduler;
+
+ Future initializeAtBoot() async {
+ await _scheduler.initialize();
+ return _scheduler.requestNotificationPermission();
+ }
+}
diff --git a/apps/lib/core/notification/services/reminder_queue_manager.dart b/apps/lib/core/notification/services/reminder_queue_manager.dart
deleted file mode 100644
index f7df247..0000000
--- a/apps/lib/core/notification/services/reminder_queue_manager.dart
+++ /dev/null
@@ -1,31 +0,0 @@
-import '../models/reminder_payload.dart';
-
-class ReminderQueueManager {
- ReminderPayload? _currentPayload;
- final List _pending = [];
-
- void enqueueFromClick(ReminderPayload payload) {
- _currentPayload = payload;
- }
-
- void enqueuePending(List payloads) {
- payloads.sort((a, b) => a.startAt.compareTo(b.startAt));
- _pending.addAll(payloads);
- }
-
- ReminderPayload? get currentPayload => _currentPayload;
-
- bool get isEmpty => _currentPayload == null && _pending.isEmpty;
-
- void dequeueCurrent() {
- _currentPayload = null;
- if (_pending.isNotEmpty) {
- _currentPayload = _pending.removeAt(0);
- }
- }
-
- void clear() {
- _currentPayload = null;
- _pending.clear();
- }
-}
diff --git a/apps/lib/core/notification/services/reminder_reconcile_service.dart b/apps/lib/core/notification/services/reminder_reconcile_service.dart
new file mode 100644
index 0000000..3f647d8
--- /dev/null
+++ b/apps/lib/core/notification/services/reminder_reconcile_service.dart
@@ -0,0 +1,38 @@
+import '../models/reminder_alarm.dart';
+import 'reminder_scheduler_service.dart';
+
+class ReminderReconcileService {
+ const ReminderReconcileService({required ReminderSchedulerService scheduler})
+ : _scheduler = scheduler;
+
+ final ReminderSchedulerService _scheduler;
+
+ Future reconcileEvent(
+ ReminderEventSnapshot event, {
+ DateTime? now,
+ }) async {
+ if (event.isArchived || event.reminderMinutes == null) {
+ await _scheduler.cancelEventReminders(event.eventId);
+ return;
+ }
+ await _scheduler.upsertEventReminders(event, now: now);
+ }
+
+ Future reconcileEvents(
+ List events, {
+ DateTime? now,
+ }) async {
+ for (final event in events) {
+ await reconcileEvent(event, now: now);
+ }
+ }
+
+ Future archiveAndCancel(String eventId) {
+ return _scheduler.cancelEventReminders(eventId);
+ }
+
+ Future snooze10m(ReminderEventSnapshot event) async {
+ await _scheduler.cancelEventReminders(event.eventId);
+ await _scheduler.scheduleSingleSnooze(event);
+ }
+}
diff --git a/apps/lib/core/notification/services/reminder_scheduler_service.dart b/apps/lib/core/notification/services/reminder_scheduler_service.dart
new file mode 100644
index 0000000..966ffc8
--- /dev/null
+++ b/apps/lib/core/notification/services/reminder_scheduler_service.dart
@@ -0,0 +1,359 @@
+import 'dart:convert';
+
+import 'package:flutter/foundation.dart';
+import 'package:flutter/services.dart';
+import 'package:flutter_local_notifications/flutter_local_notifications.dart';
+import 'package:timezone/data/latest.dart' as tz;
+import 'package:timezone/timezone.dart' as tz;
+
+import '../models/reminder_alarm.dart';
+
+class ReminderSchedulerService {
+ ReminderSchedulerService({FlutterLocalNotificationsPlugin? plugin})
+ : _plugin = plugin ?? FlutterLocalNotificationsPlugin();
+
+ static const String _channelId = 'calendar_reminder_alarm_v2';
+ static const String _channelName = 'Schedule alarm';
+ static const String _channelDescription =
+ 'Alarm-style notifications for scheduled events';
+
+ final FlutterLocalNotificationsPlugin _plugin;
+ final List _tapCallbacks = [];
+ ReminderNotificationTap? _launchTap;
+ bool _initialized = false;
+ bool _tzInitialized = false;
+
+ Future initialize({
+ void Function(ReminderNotificationTap tap)? onTap,
+ }) async {
+ if (onTap != null && !_tapCallbacks.contains(onTap)) {
+ _tapCallbacks.add(onTap);
+ }
+
+ if (!_tzInitialized) {
+ tz.initializeTimeZones();
+ _tzInitialized = true;
+ }
+
+ final android = _plugin
+ .resolvePlatformSpecificImplementation<
+ AndroidFlutterLocalNotificationsPlugin
+ >();
+ await android?.createNotificationChannel(
+ const AndroidNotificationChannel(
+ _channelId,
+ _channelName,
+ description: _channelDescription,
+ importance: Importance.max,
+ ),
+ );
+
+ if (_initialized) {
+ return;
+ }
+
+ const androidSettings = AndroidInitializationSettings(
+ '@mipmap/ic_launcher',
+ );
+ const iosSettings = DarwinInitializationSettings();
+ const settings = InitializationSettings(
+ android: androidSettings,
+ iOS: iosSettings,
+ );
+
+ await _plugin.initialize(
+ settings,
+ onDidReceiveNotificationResponse: (response) {
+ final tap = _parseTap(response.payload);
+ if (tap != null) {
+ for (final callback in _tapCallbacks) {
+ callback(tap);
+ }
+ }
+ },
+ onDidReceiveBackgroundNotificationResponse: _onBackgroundTap,
+ );
+
+ final launchDetails = await _plugin.getNotificationAppLaunchDetails();
+ if (launchDetails?.didNotificationLaunchApp ?? false) {
+ _launchTap = _parseTap(launchDetails?.notificationResponse?.payload);
+ }
+
+ _initialized = true;
+ }
+
+ Future consumeLaunchTap() async {
+ final value = _launchTap;
+ _launchTap = null;
+ return value;
+ }
+
+ Future requestNotificationPermission() async {
+ await _ensureInitialized();
+ final android = _plugin
+ .resolvePlatformSpecificImplementation<
+ AndroidFlutterLocalNotificationsPlugin
+ >();
+ final ios = _plugin
+ .resolvePlatformSpecificImplementation<
+ IOSFlutterLocalNotificationsPlugin
+ >();
+ final macos = _plugin
+ .resolvePlatformSpecificImplementation<
+ MacOSFlutterLocalNotificationsPlugin
+ >();
+
+ final androidGranted = await android?.requestNotificationsPermission();
+ final iosGranted = await ios?.requestPermissions(alert: true, sound: true);
+ final macosGranted = await macos?.requestPermissions(
+ alert: true,
+ sound: true,
+ );
+
+ return (androidGranted ?? true) &&
+ (iosGranted ?? true) &&
+ (macosGranted ?? true);
+ }
+
+ Future upsertEventReminders(
+ ReminderEventSnapshot event, {
+ DateTime? now,
+ }) async {
+ await _ensureInitialized();
+ await cancelEventReminders(event.eventId);
+ final alarms = buildAlarmsForEvent(event, now: now);
+ for (final alarm in alarms) {
+ await _scheduleAlarm(alarm);
+ }
+ }
+
+ 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();
+ for (final request in pending) {
+ final payloadRaw = request.payload;
+ if (payloadRaw == null || payloadRaw.isEmpty) {
+ continue;
+ }
+ final decoded = _parsePayload(payloadRaw);
+ if (decoded['eventId'] == eventId) {
+ await _plugin.cancel(request.id);
+ }
+ }
+ }
+
+ Future cancelAllReminders() {
+ return _ensureInitialized().then((_) => _plugin.cancelAll());
+ }
+
+ Future _ensureInitialized() {
+ if (_initialized) {
+ return Future.value();
+ }
+ return initialize();
+ }
+
+ static List buildAlarmsForEvent(
+ ReminderEventSnapshot event, {
+ DateTime? now,
+ }) {
+ if (event.isArchived) {
+ return const [];
+ }
+ final reminderMinutes = event.reminderMinutes;
+ if (reminderMinutes == null) {
+ return const [];
+ }
+
+ final current = now ?? DateTime.now();
+ final remindAt = event.startAt.subtract(Duration(minutes: reminderMinutes));
+ final endAt = event.endAt;
+
+ if (endAt != null && current.isAfter(endAt)) {
+ return const [];
+ }
+
+ final List alarms = [];
+ DateTime fireAt;
+
+ if (current.isBefore(remindAt)) {
+ fireAt = remindAt;
+ } else {
+ fireAt = current.add(const Duration(seconds: 5));
+ }
+
+ var iterations = 0;
+ while (iterations < 144) {
+ if (endAt != null && fireAt.isAfter(endAt)) {
+ break;
+ }
+ alarms.add(
+ ReminderAlarm(
+ eventId: event.eventId,
+ title: event.title,
+ startAt: event.startAt,
+ endAt: endAt,
+ timezone: event.timezone,
+ reminderMinutes: reminderMinutes,
+ fireAt: fireAt,
+ fireTimeBucket: _toBucket(fireAt),
+ location: event.location,
+ notes: event.notes,
+ ),
+ );
+
+ if (endAt == null) {
+ break;
+ }
+ fireAt = fireAt.add(const Duration(minutes: 10));
+ iterations += 1;
+ }
+ return alarms;
+ }
+
+ Future _scheduleAlarm(ReminderAlarm alarm) async {
+ final location = _safeLocation(alarm.timezone);
+ final fireAt = tz.TZDateTime.from(alarm.fireAt, location);
+ final payload = jsonEncode(alarm.toJson());
+ final id = _notificationId(alarm.eventId, alarm.fireTimeBucket);
+
+ const androidDetails = AndroidNotificationDetails(
+ _channelId,
+ _channelName,
+ channelDescription: _channelDescription,
+ importance: Importance.max,
+ priority: Priority.max,
+ category: AndroidNotificationCategory.alarm,
+ timeoutAfter: 15000,
+ playSound: true,
+ enableVibration: true,
+ fullScreenIntent: false,
+ ticker: 'calendar-reminder',
+ );
+ const iosDetails = DarwinNotificationDetails(
+ presentAlert: true,
+ presentSound: true,
+ interruptionLevel: InterruptionLevel.timeSensitive,
+ );
+
+ try {
+ await _plugin.zonedSchedule(
+ id,
+ alarm.title,
+ _buildBody(alarm),
+ fireAt,
+ const NotificationDetails(android: androidDetails, iOS: iosDetails),
+ payload: payload,
+ androidScheduleMode: AndroidScheduleMode.exactAllowWhileIdle,
+ uiLocalNotificationDateInterpretation:
+ UILocalNotificationDateInterpretation.absoluteTime,
+ );
+ } on PlatformException {
+ await _plugin.zonedSchedule(
+ id,
+ alarm.title,
+ _buildBody(alarm),
+ fireAt,
+ const NotificationDetails(android: androidDetails, iOS: iosDetails),
+ payload: payload,
+ androidScheduleMode: AndroidScheduleMode.inexactAllowWhileIdle,
+ uiLocalNotificationDateInterpretation:
+ UILocalNotificationDateInterpretation.absoluteTime,
+ );
+ }
+ }
+
+ String _buildBody(ReminderAlarm alarm) {
+ final startLabel =
+ '${alarm.startAt.hour.toString().padLeft(2, '0')}:${alarm.startAt.minute.toString().padLeft(2, '0')}';
+ if (alarm.location == null || alarm.location!.isEmpty) {
+ return '开始时间 $startLabel';
+ }
+ return '开始时间 $startLabel · ${alarm.location}';
+ }
+
+ ReminderNotificationTap? _parseTap(String? rawPayload) {
+ if (rawPayload == null || rawPayload.isEmpty) {
+ return null;
+ }
+ final decoded = _parsePayload(rawPayload);
+ final eventId = decoded['eventId'] as String?;
+ final fireBucket = decoded['fireTimeBucket'] as int?;
+ if (eventId == null || fireBucket == null) {
+ return null;
+ }
+ return ReminderNotificationTap(
+ eventId: eventId,
+ fireTimeBucket: fireBucket,
+ payload: decoded,
+ );
+ }
+
+ static Map _parsePayload(String rawPayload) {
+ try {
+ final decoded = jsonDecode(rawPayload);
+ if (decoded is Map) {
+ return decoded;
+ }
+ if (decoded is Map) {
+ return Map.from(decoded);
+ }
+ } catch (_) {
+ return const {};
+ }
+ return const {};
+ }
+
+ static int _toBucket(DateTime value) {
+ return value.millisecondsSinceEpoch ~/ 60000;
+ }
+
+ static int _notificationId(String eventId, int fireTimeBucket) {
+ final input = '$eventId|$fireTimeBucket';
+ var hash = 0x811c9dc5;
+ for (final unit in input.codeUnits) {
+ hash ^= unit;
+ hash = (hash * 0x01000193) & 0x7fffffff;
+ }
+ return hash;
+ }
+
+ static tz.Location _safeLocation(String timezone) {
+ try {
+ return tz.getLocation(timezone);
+ } catch (_) {
+ return tz.UTC;
+ }
+ }
+}
+
+@pragma('vm:entry-point')
+void _onBackgroundTap(NotificationResponse response) {
+ debugPrint('Background reminder tap received: ${response.payload}');
+}
diff --git a/apps/lib/data/cache/cache_store.dart b/apps/lib/data/cache/cache_store.dart
index d92c823..557da54 100644
--- a/apps/lib/data/cache/cache_store.dart
+++ b/apps/lib/data/cache/cache_store.dart
@@ -39,6 +39,10 @@ class MemoryCacheStore implements CacheStore {
Future remove(String key) async {
_values.remove(key);
}
+
+ Future removeByPrefix(String prefix) async {
+ _values.removeWhere((key, _) => key.startsWith(prefix));
+ }
}
class SharedPrefsCacheStore implements CacheStore {
@@ -135,6 +139,18 @@ class PersistentCacheStore implements CacheStore {
final store = SharedPrefsCacheStore(prefs: prefs);
await store.remove(key);
}
+
+ Future removeByPrefix(String prefix) async {
+ final prefs = await _getPrefs();
+ if (prefs == null) {
+ _fallbackValues.removeWhere((key, _) => key.startsWith(prefix));
+ return;
+ }
+ final targets = prefs.getKeys().where((key) => key.startsWith(prefix));
+ for (final key in targets) {
+ await prefs.remove(key);
+ }
+ }
}
class HybridCacheStore {
@@ -166,6 +182,15 @@ class HybridCacheStore {
await persistent.remove(key);
}
+ Future clearByPrefix(String prefix) async {
+ if (memory is MemoryCacheStore) {
+ await (memory as MemoryCacheStore).removeByPrefix(prefix);
+ }
+ if (persistent is PersistentCacheStore) {
+ await (persistent as PersistentCacheStore).removeByPrefix(prefix);
+ }
+ }
+
Future getOrLoad(String key, {required Future Function() loader}) {
final running = _inflight[key];
if (running != null) {
diff --git a/apps/lib/features/calendar/data/repositories/calendar_repository.dart b/apps/lib/features/calendar/data/repositories/calendar_repository.dart
index c5285d3..de3ce67 100644
--- a/apps/lib/features/calendar/data/repositories/calendar_repository.dart
+++ b/apps/lib/features/calendar/data/repositories/calendar_repository.dart
@@ -1,18 +1,24 @@
import '../../../../data/cache/cache_policy.dart';
import '../../../../data/cache/cached_repository.dart';
+import '../../../../data/cache/cache_scope.dart';
import '../../../../data/network/i_api_client.dart';
+import '../../../../core/notification/models/reminder_alarm.dart';
+import '../../../../core/notification/services/reminder_reconcile_service.dart';
import '../models/schedule_item_model.dart';
class CalendarRepository extends CachedRepository> {
final IApiClient _apiClient;
+ final ReminderReconcileService? _reminderReconcileService;
static const _prefix = '/api/v1/schedule-items';
CalendarRepository({
required super.store,
required IApiClient apiClient,
+ ReminderReconcileService? reminderReconcileService,
CachePolicy? policy,
super.now,
}) : _apiClient = apiClient,
+ _reminderReconcileService = reminderReconcileService,
super(
policy:
policy ??
@@ -69,7 +75,9 @@ class CalendarRepository extends CachedRepository> {
if (data == null) {
throw StateError('Invalid getEventById response: empty payload');
}
- return ScheduleItemModel.fromJson(data);
+ final event = ScheduleItemModel.fromJson(data);
+ await _reminderReconcileService?.reconcileEvent(_toReminderSnapshot(event));
+ return event;
}
Future getById(String id) {
@@ -91,11 +99,20 @@ class CalendarRepository extends CachedRepository> {
}
Future acceptSubscription(String itemId) {
- return _apiClient.post('$_prefix/$itemId/accept');
+ return _applySubscriptionAction(itemId, accept: true);
}
Future rejectSubscription(String itemId) {
- return _apiClient.post('$_prefix/$itemId/reject');
+ return _applySubscriptionAction(itemId, accept: false);
+ }
+
+ Future _applySubscriptionAction(
+ String itemId, {
+ required bool accept,
+ }) async {
+ final action = accept ? 'accept' : 'reject';
+ await _apiClient.post('$_prefix/$itemId/$action');
+ await store.clearByPrefix('cache:${CacheScope.token()}:calendar:');
}
Future> _listByRange({
@@ -111,10 +128,28 @@ class CalendarRepository extends CachedRepository> {
if (data == null) {
throw StateError('Invalid listByRange response: empty payload');
}
- return data
+ final events = data
.whereType