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>() .map(ScheduleItemModel.fromJson) .toList(growable: false); + await _reminderReconcileService?.reconcileEvents( + events.map(_toReminderSnapshot).toList(growable: false), + ); + return events; + } + + ReminderEventSnapshot _toReminderSnapshot(ScheduleItemModel event) { + return ReminderEventSnapshot( + eventId: event.id, + title: event.title, + startAt: event.startAt, + endAt: event.endAt, + timezone: event.timezone, + reminderMinutes: event.metadata?.reminderMinutes, + location: event.metadata?.location, + notes: event.metadata?.notes, + isArchived: event.status == ScheduleStatus.archived, + ); } static Object? _encodeEventList(List events) { diff --git a/apps/lib/features/calendar/data/services/calendar_service.dart b/apps/lib/features/calendar/data/services/calendar_service.dart index 215c85e..126169b 100644 --- a/apps/lib/features/calendar/data/services/calendar_service.dart +++ b/apps/lib/features/calendar/data/services/calendar_service.dart @@ -1,5 +1,7 @@ import '../../../../data/network/i_api_client.dart'; import '../../../../data/cache/cache_store.dart'; +import '../../../../core/notification/models/reminder_alarm.dart'; +import '../../../../core/notification/services/reminder_reconcile_service.dart'; import '../models/schedule_item_model.dart'; class CalendarService { @@ -7,12 +9,15 @@ class CalendarService { final IApiClient _apiClient; final CacheInvalidator _invalidator; + final ReminderReconcileService? _reminderReconcileService; CalendarService({ required IApiClient apiClient, required CacheInvalidator invalidator, + ReminderReconcileService? reminderReconcileService, }) : _apiClient = apiClient, - _invalidator = invalidator; + _invalidator = invalidator, + _reminderReconcileService = reminderReconcileService; Future> getEventsForDay(DateTime date) async { final start = DateTime(date.year, date.month, date.day); @@ -35,10 +40,14 @@ class CalendarService { if (data == null) { throw StateError('Invalid getEventsForRange response: empty payload'); } - return data + final events = data .map((item) => item as Map) .map(ScheduleItemModel.fromJson) .toList(growable: false); + await _reminderReconcileService?.reconcileEvents( + events.map(_toReminderSnapshot).toList(growable: false), + ); + return events; } Future getEventById(String id) async { @@ -47,7 +56,9 @@ class CalendarService { 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 addEvent(ScheduleItemModel event) async { @@ -61,6 +72,9 @@ class CalendarService { } final created = ScheduleItemModel.fromJson(data); _invalidateEventCache(created); + await _reminderReconcileService?.reconcileEvent( + _toReminderSnapshot(created), + ); return created; } @@ -75,6 +89,9 @@ class CalendarService { } final updated = ScheduleItemModel.fromJson(data); _invalidateEventCache(updated); + await _reminderReconcileService?.reconcileEvent( + _toReminderSnapshot(updated), + ); return updated; } @@ -84,6 +101,7 @@ class CalendarService { event.copyWith(status: ScheduleStatus.archived), ); _invalidateEventCache(updatedEvent); + await _reminderReconcileService?.archiveAndCancel(id); return updatedEvent; } @@ -91,6 +109,7 @@ class CalendarService { final event = await getEventById(id); _invalidateEventCache(event); await _apiClient.delete('$_prefix/$id'); + await _reminderReconcileService?.archiveAndCancel(id); } void _invalidateEventCache(ScheduleItemModel event) { @@ -109,4 +128,18 @@ class CalendarService { current = current.add(const Duration(days: 1)); } } + + ReminderEventSnapshot _toReminderSnapshot(ScheduleItemModel event) { + return ReminderEventSnapshot( + eventId: event.id, + title: event.title, + startAt: event.startAt, + endAt: event.endAt, + timezone: event.timezone, + reminderMinutes: event.metadata?.reminderMinutes, + location: event.metadata?.location, + notes: event.metadata?.notes, + isArchived: event.status == ScheduleStatus.archived, + ); + } } 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 db09d31..8d9e8f7 100644 --- a/apps/lib/features/calendar/presentation/screens/calendar_dayweek_screen.dart +++ b/apps/lib/features/calendar/presentation/screens/calendar_dayweek_screen.dart @@ -453,7 +453,7 @@ class _CalendarDayWeekScreenState extends State mainAxisSize: MainAxisSize.min, children: [ Text( - _weekdayLabel(date), + _weekdayLabel(context, date), style: TextStyle( fontSize: 11, color: isWeekend @@ -492,8 +492,16 @@ class _CalendarDayWeekScreenState extends State ); } - String _weekdayLabel(DateTime date) { - const labels = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat']; + String _weekdayLabel(BuildContext context, DateTime date) { + final labels = [ + context.l10n.calendarMonthWeekdaySunShort, + context.l10n.calendarMonthWeekdayMonShort, + context.l10n.calendarMonthWeekdayTueShort, + context.l10n.calendarMonthWeekdayWedShort, + context.l10n.calendarMonthWeekdayThuShort, + context.l10n.calendarMonthWeekdayFriShort, + context.l10n.calendarMonthWeekdaySatShort, + ]; return labels[date.weekday % 7]; } 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 new file mode 100644 index 0000000..3301e1c --- /dev/null +++ b/apps/lib/features/calendar/presentation/screens/calendar_reminder_alarm_screen.dart @@ -0,0 +1,237 @@ +import 'package:flutter/material.dart'; +import 'package:go_router/go_router.dart'; +import 'package:intl/intl.dart'; + +import '../../../../app/di/injection.dart'; +import '../../../../app/router/app_routes.dart'; +import '../../../../core/l10n/l10n.dart'; +import '../../../../core/notification/models/reminder_alarm.dart'; +import '../../../../core/notification/services/reminder_reconcile_service.dart'; +import '../../../../core/theme/design_tokens.dart'; +import '../../../../shared/widgets/app_button.dart'; +import '../../../../shared/widgets/app_loading_indicator.dart'; +import '../../../../shared/widgets/toast/toast.dart'; +import '../../../../shared/widgets/toast/toast_type.dart'; +import '../../data/models/schedule_item_model.dart'; +import '../../data/services/calendar_service.dart'; + +class CalendarReminderAlarmScreen extends StatefulWidget { + const CalendarReminderAlarmScreen({super.key, required this.eventId}); + + final String eventId; + + @override + State createState() => + _CalendarReminderAlarmScreenState(); +} + +class _CalendarReminderAlarmScreenState + extends State { + late final Future _eventFuture; + bool _isSubmitting = false; + + @override + void initState() { + super.initState(); + _eventFuture = sl().getEventById(widget.eventId); + } + + @override + Widget build(BuildContext context) { + final colorScheme = Theme.of(context).colorScheme; + return Scaffold( + backgroundColor: colorScheme.surface, + appBar: AppBar( + backgroundColor: colorScheme.surface, + title: Text(context.l10n.notificationChannelName), + ), + body: FutureBuilder( + future: _eventFuture, + builder: (context, snapshot) { + if (snapshot.connectionState != ConnectionState.done) { + return const Center(child: AppLoadingIndicator()); + } + if (snapshot.hasError || snapshot.data == null) { + return Center( + child: Padding( + padding: const EdgeInsets.all(AppSpacing.lg), + child: Text( + context.l10n.errorRequestFailed, + style: TextStyle(color: colorScheme.error), + ), + ), + ); + } + + final event = snapshot.data!; + return SafeArea( + child: Padding( + padding: const EdgeInsets.all(AppSpacing.lg), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + _EventCard(event: event), + const Spacer(), + Row( + children: [ + Expanded( + child: AppButton( + text: context.l10n.notificationSnoozeLater, + isOutlined: true, + isLoading: _isSubmitting, + onPressed: _isSubmitting + ? null + : () => _snoozeEvent(event), + ), + ), + const SizedBox(width: AppSpacing.md), + Expanded( + child: AppButton( + text: context.l10n.calendarDetailArchiveConfirm, + isLoading: _isSubmitting, + onPressed: _isSubmitting + ? null + : () => _archiveEvent(event), + ), + ), + ], + ), + ], + ), + ), + ); + }, + ), + ); + } + + Future _archiveEvent(ScheduleItemModel event) async { + setState(() { + _isSubmitting = true; + }); + try { + await sl().archiveEvent(event.id); + if (!mounted) { + return; + } + context.go(AppRoutes.calendarEventDetail(event.id)); + } catch (_) { + if (mounted) { + Toast.show( + context, + context.l10n.calendarDetailArchiveFailed, + type: ToastType.error, + ); + } + } finally { + if (mounted) { + setState(() { + _isSubmitting = false; + }); + } + } + } + + Future _snoozeEvent(ScheduleItemModel event) async { + setState(() { + _isSubmitting = true; + }); + try { + await sl().snooze10m(_snapshotFromEvent(event)); + if (!mounted) { + return; + } + Toast.show(context, context.l10n.notificationSnoozeMinutes(10)); + context.go(AppRoutes.calendarEventDetail(event.id)); + } catch (_) { + if (mounted) { + Toast.show( + context, + context.l10n.todoSaveFailed('snooze failed'), + type: ToastType.error, + ); + } + } finally { + if (mounted) { + setState(() { + _isSubmitting = false; + }); + } + } + } + + ReminderEventSnapshot _snapshotFromEvent(ScheduleItemModel event) { + return ReminderEventSnapshot( + eventId: event.id, + title: event.title, + startAt: event.startAt, + endAt: event.endAt, + timezone: event.timezone, + reminderMinutes: event.metadata?.reminderMinutes, + location: event.metadata?.location, + notes: event.metadata?.notes, + isArchived: event.status == ScheduleStatus.archived, + ); + } +} + +class _EventCard extends StatelessWidget { + const _EventCard({required this.event}); + + final ScheduleItemModel event; + + @override + Widget build(BuildContext context) { + final colorScheme = Theme.of(context).colorScheme; + final textTheme = Theme.of(context).textTheme; + final formatter = DateFormat('MM-dd HH:mm'); + final endAt = event.endAt; + return Container( + decoration: BoxDecoration( + color: colorScheme.surfaceContainerLow, + borderRadius: BorderRadius.circular(AppRadius.lg), + border: Border.all(color: colorScheme.outlineVariant), + ), + padding: const EdgeInsets.all(AppSpacing.lg), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + event.title, + style: textTheme.titleLarge?.copyWith( + color: colorScheme.onSurface, + fontWeight: FontWeight.w700, + ), + ), + const SizedBox(height: AppSpacing.md), + Text( + endAt == null + ? formatter.format(event.startAt) + : '${formatter.format(event.startAt)} - ${formatter.format(endAt)}', + style: textTheme.bodyMedium?.copyWith( + color: colorScheme.onSurfaceVariant, + ), + ), + if ((event.metadata?.location?.isNotEmpty ?? false)) ...[ + const SizedBox(height: AppSpacing.sm), + Text( + context.l10n.notificationLocation(event.metadata!.location!), + style: textTheme.bodyMedium?.copyWith( + color: colorScheme.onSurfaceVariant, + ), + ), + ], + if ((event.metadata?.notes?.isNotEmpty ?? false)) ...[ + const SizedBox(height: AppSpacing.sm), + Text( + context.l10n.notificationNotes(event.metadata!.notes!), + style: textTheme.bodyMedium?.copyWith( + color: colorScheme.onSurfaceVariant, + ), + ), + ], + ], + ), + ); + } +} diff --git a/apps/lib/features/chat/presentation/bloc/chat_bloc.dart b/apps/lib/features/chat/presentation/bloc/chat_bloc.dart index dd79dce..4e2184d 100644 --- a/apps/lib/features/chat/presentation/bloc/chat_bloc.dart +++ b/apps/lib/features/chat/presentation/bloc/chat_bloc.dart @@ -1,3 +1,4 @@ +import 'dart:async'; import 'dart:typed_data'; import 'package:flutter_bloc/flutter_bloc.dart'; @@ -110,11 +111,13 @@ class ChatBloc extends Cubit implements ChatOrchestrator { AgUiService? service, required ChatApi chatApi, ChatHistoryRepository? historyRepository, + Future Function()? onCalendarMutated, Duration recoveryPollInterval = const Duration(milliseconds: 700), Duration recoveryTimeout = const Duration(seconds: 20), }) : _service = service ?? AgUiService(chatApi: chatApi, historyRepository: historyRepository), + _onCalendarMutated = onCalendarMutated, _recoveryPollInterval = recoveryPollInterval, _recoveryTimeout = recoveryTimeout, super(const ChatState()) { @@ -122,6 +125,7 @@ class ChatBloc extends Cubit implements ChatOrchestrator { } final AgUiService _service; + final Future Function()? _onCalendarMutated; final Duration _recoveryPollInterval; final Duration _recoveryTimeout; String? _activeUserId; @@ -214,5 +218,33 @@ class ChatBloc extends Cubit implements ChatOrchestrator { _attachmentPreviewCache.clear(); _attachmentPreviewInflight.clear(); emit(const ChatState()); + if (normalizedUserId != null && epoch == _sessionEpoch) { + try { + await _loadHistory(); + } catch (error) { + emit(state.copyWith(error: error.toString())); + } + } + } + + bool _shouldRefreshCalendarForTool(ToolCallResultEvent event) { + final name = event.toolName.trim().toLowerCase(); + final status = event.status.trim().toLowerCase(); + if (name != 'calendar_write') { + return false; + } + return status == 'success' || status == 'partial'; + } + + Future _refreshCalendarAfterToolMutation() async { + final callback = _onCalendarMutated; + if (callback == null) { + return; + } + try { + await callback(); + } catch (error) { + emit(state.copyWith(error: error.toString())); + } } } diff --git a/apps/lib/features/chat/presentation/bloc/chat_bloc_events.dart b/apps/lib/features/chat/presentation/bloc/chat_bloc_events.dart index bb6c8da..a5ec915 100644 --- a/apps/lib/features/chat/presentation/bloc/chat_bloc_events.dart +++ b/apps/lib/features/chat/presentation/bloc/chat_bloc_events.dart @@ -198,6 +198,9 @@ extension _ChatBlocEvents on ChatBloc { } void _handleToolCallResult(ToolCallResultEvent event) { + if (_shouldRefreshCalendarForTool(event)) { + unawaited(_refreshCalendarAfterToolMutation()); + } emit( state.copyWith( items: state.items.map((item) { diff --git a/apps/lib/features/contacts/data/apis/friends_api.dart b/apps/lib/features/contacts/data/apis/friends_api.dart index 0c8d7e7..80da421 100644 --- a/apps/lib/features/contacts/data/apis/friends_api.dart +++ b/apps/lib/features/contacts/data/apis/friends_api.dart @@ -83,14 +83,24 @@ class UserBasicInfo { final String id; final String username; final String? avatarUrl; + final String? phone; + final String? bio; - UserBasicInfo({required this.id, required this.username, this.avatarUrl}); + UserBasicInfo({ + required this.id, + required this.username, + this.avatarUrl, + this.phone, + this.bio, + }); factory UserBasicInfo.fromJson(Map json) { return UserBasicInfo( id: json['id'] as String, username: json['username'] as String, avatarUrl: json['avatar_url'] as String?, + phone: json['phone'] as String?, + bio: json['bio'] as String?, ); } } diff --git a/apps/lib/features/contacts/data/apis/users_api.dart b/apps/lib/features/contacts/data/apis/users_api.dart index 24eddb0..f8ade8d 100644 --- a/apps/lib/features/contacts/data/apis/users_api.dart +++ b/apps/lib/features/contacts/data/apis/users_api.dart @@ -2,22 +2,9 @@ import 'dart:io'; import 'package:dio/dio.dart'; import 'package:social_app/data/network/i_api_client.dart'; import '../models/user_profile.dart'; +import 'friends_api.dart'; -class UserBasicInfo { - final String id; - final String username; - final String? avatarUrl; - - UserBasicInfo({required this.id, required this.username, this.avatarUrl}); - - factory UserBasicInfo.fromJson(Map json) { - return UserBasicInfo( - id: json['id'] as String, - username: json['username'] as String, - avatarUrl: json['avatar_url'] as String?, - ); - } -} +export 'friends_api.dart' show UserBasicInfo; class UsersApi { final IApiClient _client; diff --git a/apps/lib/features/contacts/presentation/screens/add_contact_screen.dart b/apps/lib/features/contacts/presentation/screens/add_contact_screen.dart deleted file mode 100644 index 60b03aa..0000000 --- a/apps/lib/features/contacts/presentation/screens/add_contact_screen.dart +++ /dev/null @@ -1,207 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:go_router/go_router.dart'; -import '../../../../core/l10n/l10n.dart'; -import '../../../../core/theme/design_tokens.dart'; -import '../../../../shared/widgets/back_title_page_header.dart'; -import '../../../../shared/widgets/app_input.dart'; -import '../../../../shared/widgets/link_button.dart'; -import '../../../../shared/widgets/toast/toast.dart'; -import '../../../../shared/widgets/toast/toast_type.dart'; - -class AddContactScreen extends StatefulWidget { - final String? contactId; - - const AddContactScreen({super.key, this.contactId}); - - @override - State createState() => _AddContactScreenState(); -} - -class _AddContactScreenState extends State { - final _nameController = TextEditingController(); - final _phoneController = TextEditingController(); - final _remarkController = TextEditingController(); - - bool get isEditing => widget.contactId != null; - - @override - void dispose() { - _nameController.dispose(); - _phoneController.dispose(); - _remarkController.dispose(); - super.dispose(); - } - - @override - Widget build(BuildContext context) { - final colorScheme = Theme.of(context).colorScheme; - return Scaffold( - backgroundColor: colorScheme.surfaceContainerLow, - resizeToAvoidBottomInset: false, - body: SafeArea( - maintainBottomViewPadding: true, - child: Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - BackTitlePageHeader( - title: isEditing - ? context.l10n.contactEditTitle - : context.l10n.contactAddTitle, - onBack: () => context.pop(), - trailing: _buildConfirmButton(), - ), - Expanded( - child: SingleChildScrollView( - padding: const EdgeInsets.fromLTRB(20, 8, 20, 20), - child: Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - _buildAvatarSection(), - const SizedBox(height: 14), - _buildFormCard(), - if (isEditing) ...[ - const SizedBox(height: 14), - _buildDeleteRow(), - ], - ], - ), - ), - ), - ], - ), - ), - ); - } - - Widget _buildConfirmButton() { - final colorScheme = Theme.of(context).colorScheme; - return SizedBox( - width: AppSpacing.xxl * 2, - height: AppSpacing.xxl * 2, - child: TextButton( - onPressed: _handleConfirm, - style: TextButton.styleFrom( - padding: const EdgeInsets.all(AppSpacing.none), - backgroundColor: colorScheme.primaryContainer, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(AppRadius.full), - side: BorderSide(color: colorScheme.outlineVariant), - ), - ), - child: Icon( - Icons.check, - size: AppSpacing.lg, - color: colorScheme.primary, - ), - ), - ); - } - - Widget _buildAvatarSection() { - final colorScheme = Theme.of(context).colorScheme; - return Center( - child: Container( - width: 72, - height: 72, - decoration: BoxDecoration( - color: colorScheme.primaryContainer.withValues(alpha: 0.45), - borderRadius: BorderRadius.circular(36), - border: Border.all(color: colorScheme.surface.withValues(alpha: 0)), - ), - child: Icon(Icons.person_outline, size: 24, color: colorScheme.outline), - ), - ); - } - - Widget _buildFormCard() { - final colorScheme = Theme.of(context).colorScheme; - return Container( - padding: const EdgeInsets.all(14), - decoration: BoxDecoration( - color: colorScheme.surface, - borderRadius: BorderRadius.circular(16), - border: Border.all(color: colorScheme.outlineVariant), - ), - child: Column( - children: [ - AppInput( - label: context.l10n.contactNickname, - hint: context.l10n.contactNicknameHint, - controller: _nameController, - ), - const SizedBox(height: 14), - AppInput( - label: context.l10n.contactPhone, - hint: context.l10n.contactPhoneHint, - controller: _phoneController, - keyboardType: TextInputType.phone, - ), - const SizedBox(height: 14), - AppInput( - label: context.l10n.contactRemark, - hint: context.l10n.contactRemarkHint, - controller: _remarkController, - maxLines: 3, - ), - ], - ), - ); - } - - Widget _buildDeleteRow() { - final colorScheme = Theme.of(context).colorScheme; - return Padding( - padding: const EdgeInsets.only(bottom: AppSpacing.sm), - child: LinkButton( - text: context.l10n.contactDelete, - onTap: _handleDelete, - foregroundColor: colorScheme.error, - ), - ); - } - - void _handleConfirm() { - final name = _nameController.text.trim(); - final phone = _phoneController.text.trim(); - - if (name.isEmpty || phone.isEmpty) { - Toast.show( - context, - context.l10n.contactFillRequired, - type: ToastType.warning, - ); - return; - } - - // TODO: Implement save logic - context.pop(); - } - - void _handleDelete() { - final colorScheme = Theme.of(context).colorScheme; - showDialog( - context: context, - builder: (context) => AlertDialog( - title: Text(context.l10n.contactDeleteConfirmTitle), - content: Text(context.l10n.contactDeleteConfirmMessage), - actions: [ - TextButton( - onPressed: () => Navigator.pop(context), - child: Text(context.l10n.commonCancel), - ), - TextButton( - onPressed: () { - Navigator.pop(context); - // TODO: Implement delete logic - context.pop(); - }, - child: Text( - context.l10n.commonDelete, - style: TextStyle(color: colorScheme.error), - ), - ), - ], - ), - ); - } -} diff --git a/apps/lib/features/contacts/presentation/screens/contact_detail_screen.dart b/apps/lib/features/contacts/presentation/screens/contact_detail_screen.dart new file mode 100644 index 0000000..3c7e6b8 --- /dev/null +++ b/apps/lib/features/contacts/presentation/screens/contact_detail_screen.dart @@ -0,0 +1,158 @@ +import 'package:flutter/material.dart'; +import 'package:go_router/go_router.dart'; +import 'package:social_app/core/l10n/l10n.dart'; +import 'package:social_app/core/theme/design_tokens.dart'; +import 'package:social_app/shared/widgets/back_title_page_header.dart'; +import 'package:social_app/features/contacts/data/apis/users_api.dart'; + +class ContactDetailScreen extends StatelessWidget { + final UserBasicInfo user; + + const ContactDetailScreen({super.key, required this.user}); + + @override + Widget build(BuildContext context) { + final colorScheme = Theme.of(context).colorScheme; + return Scaffold( + backgroundColor: colorScheme.surfaceContainerLow, + resizeToAvoidBottomInset: false, + body: SafeArea( + maintainBottomViewPadding: true, + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + BackTitlePageHeader( + title: context.l10n.contactDetailTitle, + onBack: () => context.pop(), + ), + Expanded( + child: SingleChildScrollView( + padding: const EdgeInsets.fromLTRB(20, 8, 20, 20), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + _buildAvatarSection(context, colorScheme), + const SizedBox(height: 14), + _buildInfoCard(context, colorScheme), + ], + ), + ), + ), + ], + ), + ), + ); + } + + Widget _buildAvatarSection(BuildContext context, ColorScheme colorScheme) { + final palette = Theme.of(context).extension()!; + final avatarColor = palette + .avatarColors[user.id.hashCode.abs() % palette.avatarColors.length]; + + return Center( + child: Container( + width: 80, + height: 80, + decoration: BoxDecoration( + color: colorScheme.primaryContainer.withValues(alpha: 0.45), + borderRadius: BorderRadius.circular(40), + border: Border.all(color: colorScheme.surface.withValues(alpha: 0)), + boxShadow: [ + BoxShadow( + color: colorScheme.primary.withValues(alpha: 0.2), + blurRadius: 12, + offset: const Offset(0, 4), + ), + ], + ), + child: user.avatarUrl != null + ? ClipRRect( + borderRadius: BorderRadius.circular(40), + child: Image.network( + user.avatarUrl!, + width: 80, + height: 80, + fit: BoxFit.cover, + errorBuilder: (context, error, stackTrace) => + Icon(Icons.person, size: 32, color: avatarColor), + ), + ) + : Icon(Icons.person, size: 32, color: avatarColor), + ), + ); + } + + Widget _buildInfoCard(BuildContext context, ColorScheme colorScheme) { + return Container( + padding: const EdgeInsets.all(14), + decoration: BoxDecoration( + color: colorScheme.surface, + borderRadius: BorderRadius.circular(16), + border: Border.all(color: colorScheme.outlineVariant), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _buildInfoRow( + context.l10n.contactDetailUsername, + user.username, + Icons.person_outline, + colorScheme, + ), + const SizedBox(height: 14), + _buildInfoRow( + context.l10n.contactDetailPhone, + user.phone ?? context.l10n.commonNone, + Icons.phone_outlined, + colorScheme, + ), + const SizedBox(height: 14), + _buildInfoRow( + context.l10n.contactDetailBio, + user.bio ?? context.l10n.commonNone, + Icons.info_outline, + colorScheme, + ), + ], + ), + ); + } + + Widget _buildInfoRow( + String label, + String value, + IconData icon, + ColorScheme colorScheme, + ) { + return Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Icon(icon, size: 18, color: colorScheme.onSurfaceVariant), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + label, + style: TextStyle( + fontSize: 12, + color: colorScheme.onSurfaceVariant, + ), + ), + const SizedBox(height: 2), + Text( + value, + style: TextStyle( + fontSize: 15, + fontWeight: FontWeight.w500, + color: colorScheme.onSurface, + ), + ), + ], + ), + ), + ], + ); + } +} diff --git a/apps/lib/features/contacts/presentation/screens/contacts_screen.dart b/apps/lib/features/contacts/presentation/screens/contacts_screen.dart index 1e091ce..338bea9 100644 --- a/apps/lib/features/contacts/presentation/screens/contacts_screen.dart +++ b/apps/lib/features/contacts/presentation/screens/contacts_screen.dart @@ -8,6 +8,7 @@ import '../../../../shared/widgets/app_loading_indicator.dart'; import '../../../../shared/widgets/toast/index.dart'; import '../../../../shared/widgets/app_button.dart'; import '../../../../shared/widgets/back_title_page_header.dart'; +import '../../../../shared/widgets/shared_divider.dart'; import '../../../contacts/data/apis/friends_api.dart'; import '../../../contacts/data/apis/users_api.dart'; @@ -448,7 +449,7 @@ class _ContactsScreenState extends State { children: [ for (int i = 0; i < _searchResults.length; i++) ...[ _buildSearchResultItem(_searchResults[i]), - if (i < _searchResults.length - 1) _buildDivider(), + if (i < _searchResults.length - 1) SharedDivider(), ], ], ), @@ -620,7 +621,7 @@ class _ContactsScreenState extends State { children: [ for (int i = 0; i < requests.length; i++) ...[ _buildPendingRequestItem(requests[i]), - if (i < requests.length - 1) _buildDivider(), + if (i < requests.length - 1) SharedDivider(), ], ], ), @@ -679,7 +680,7 @@ class _ContactsScreenState extends State { children: [ for (int i = 0; i < friends.length; i++) ...[ _buildContactItem(friends[i]), - if (i < friends.length - 1) _buildDivider(), + if (i < friends.length - 1) SharedDivider(), ], ], ), @@ -691,7 +692,8 @@ class _ContactsScreenState extends State { final friendInfo = friend.friend; return GestureDetector( - onTap: () => context.push('/contacts/add?id=${friendInfo.id}'), + onTap: () => + context.push('/contacts/${friendInfo.id}', extra: friendInfo), child: Container( height: 70, padding: const EdgeInsets.symmetric(horizontal: 14), @@ -753,15 +755,6 @@ class _ContactsScreenState extends State { : colorScheme.primary.withValues(alpha: opacity); } - Widget _buildDivider() { - final colorScheme = Theme.of(context).colorScheme; - return Container( - height: 1, - margin: const EdgeInsets.symmetric(horizontal: 14), - color: colorScheme.outlineVariant, - ); - } - Widget _buildAvatar( String? avatarUrl, String userId, diff --git a/apps/lib/features/home/presentation/screens/home_screen.dart b/apps/lib/features/home/presentation/screens/home_screen.dart index 4f473a1..43ca6cb 100644 --- a/apps/lib/features/home/presentation/screens/home_screen.dart +++ b/apps/lib/features/home/presentation/screens/home_screen.dart @@ -1,3 +1,4 @@ +import 'dart:async'; import 'dart:io'; import 'package:flutter/material.dart'; @@ -12,7 +13,7 @@ import '../../../../app/router/app_route_observer.dart'; import '../../../../app/router/app_routes.dart'; import '../../../../core/l10n/l10n.dart'; import '../../../../core/theme/design_tokens.dart'; -import '../../../../features/messages/data/repositories/inbox_repository.dart'; +import '../../../../core/inbox/inbox_sync_store.dart'; import '../../../chat/presentation/bloc/chat_bloc.dart'; import '../../data/voice_recorder.dart'; import '../controllers/home_keyboard_inset_calculator.dart'; @@ -73,7 +74,7 @@ class _HomeScreenState extends State final ScrollController _scrollController = ScrollController(); late final ChatBloc _chatBloc; late final VoiceRecorder _voiceRecorder; - late final InboxRepository _inboxRepository; + late final InboxSyncStore _inboxSyncStore; late final Future Function(String filePath) _transcribeAudio; late final AnimationController _listeningAnimationController; bool _isRecording = false; @@ -110,7 +111,7 @@ class _HomeScreenState extends State _chatBloc = context.read(); } _voiceRecorder = widget.voiceRecorder ?? RecordVoiceRecorder(); - _inboxRepository = sl(); + _inboxSyncStore = sl(); _transcribeAudio = widget.onTranscribeAudio ?? _chatBloc.transcribeAudioFile; _listeningAnimationController = AnimationController( @@ -118,24 +119,26 @@ class _HomeScreenState extends State duration: const Duration(milliseconds: _rippleDurationMs), ); _selectedImages.addAll(widget.initialSelectedImages); - if (widget.autoLoadHistory && _chatBloc.state.items.isEmpty) { + if (widget.autoLoadHistory && + _chatBloc.state.items.isEmpty && + !_chatBloc.state.isLoadingHistory) { _chatBloc.loadHistory(); } _scrollController.addListener(_handleScrollChanged); _previousItemCount = _chatBloc.state.items.length; _previousIsLoadingHistory = _chatBloc.state.isLoadingHistory; - _loadUnreadCount(); + _inboxSyncStore.addListener(_handleInboxStateChanged); + unawaited(_inboxSyncStore.ensureStarted()); + _handleInboxStateChanged(); } - Future _loadUnreadCount() async { - try { - final messages = await _inboxRepository.getMessages(isRead: false); - if (mounted) { - setState(() => _unreadCount = messages.length); - } - } catch (_) { - // Ignore errors + void _handleInboxStateChanged() { + if (!mounted) { + return; } + setState(() { + _unreadCount = _inboxSyncStore.unreadCount; + }); } @override @@ -145,6 +148,7 @@ class _HomeScreenState extends State _scrollController.dispose(); _listeningAnimationController.dispose(); _voiceRecorder.dispose(); + _inboxSyncStore.removeListener(_handleInboxStateChanged); if (_routeAwareSubscribed) { appRouteObserver.unsubscribe(this); _routeAwareSubscribed = false; @@ -164,6 +168,7 @@ class _HomeScreenState extends State @override void didPopNext() { + unawaited(_inboxSyncStore.refreshSnapshot()); _applyViewportDecision( _dispatchViewportEvent( type: ViewportEventType.screenResumedFromSubRoute, diff --git a/apps/lib/features/home/presentation/widgets/home_conversation_chrome.dart b/apps/lib/features/home/presentation/widgets/home_conversation_chrome.dart index 45c541e..034db58 100644 --- a/apps/lib/features/home/presentation/widgets/home_conversation_chrome.dart +++ b/apps/lib/features/home/presentation/widgets/home_conversation_chrome.dart @@ -49,8 +49,7 @@ class HomeDateDivider extends StatelessWidget { Widget build(BuildContext context) { final colorScheme = Theme.of(context).colorScheme; final now = DateTime.now(); - const weekdays = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun']; - final weekday = weekdays[date.weekday - 1]; + final weekday = _weekdayLabel(context, date.weekday); final label = date.year == now.year ? context.l10n.homeDateLabelNoYear(date.month, date.day, weekday) : context.l10n.homeDateLabelWithYear( @@ -69,6 +68,19 @@ class HomeDateDivider extends StatelessWidget { ), ); } + + String _weekdayLabel(BuildContext context, int weekday) { + final labels = [ + context.l10n.calendarWeekdaySun, + context.l10n.calendarWeekdayMon, + context.l10n.calendarWeekdayTue, + context.l10n.calendarWeekdayWed, + context.l10n.calendarWeekdayThu, + context.l10n.calendarWeekdayFri, + context.l10n.calendarWeekdaySat, + ]; + return labels[weekday % 7]; + } } class HomeLoadMoreButton extends StatelessWidget { diff --git a/apps/lib/features/messages/data/apis/inbox_api.dart b/apps/lib/features/messages/data/apis/inbox_api.dart index ce5c5c6..bc743ce 100644 --- a/apps/lib/features/messages/data/apis/inbox_api.dart +++ b/apps/lib/features/messages/data/apis/inbox_api.dart @@ -17,6 +17,14 @@ class InboxApi { final response = await _client.patch('$_prefix/$messageId/read'); return InboxMessageResponse.fromJson(response.data); } + + Future> streamEvents({String? lastEventId}) { + final headers = {'Accept': 'text/event-stream'}; + if (lastEventId != null && lastEventId.isNotEmpty) { + headers['Last-Event-ID'] = lastEventId; + } + return _client.getSseLines('$_prefix/stream', headers: headers); + } } class InboxMessageResponse { diff --git a/apps/lib/features/messages/data/repositories/inbox_repository.dart b/apps/lib/features/messages/data/repositories/inbox_repository.dart index 5975ae7..d92d03d 100644 --- a/apps/lib/features/messages/data/repositories/inbox_repository.dart +++ b/apps/lib/features/messages/data/repositories/inbox_repository.dart @@ -4,7 +4,10 @@ import '../../../../data/cache/cached_repository.dart'; import '../models/inbox_message.dart'; abstract class InboxRepository { - Future> getMessages({bool? isRead}); + Future> getMessages({ + bool? isRead, + bool forceRefresh = false, + }); Future markAsRead(String messageId); } @@ -26,9 +29,13 @@ class InboxRepositoryImpl extends CachedRepository> ); @override - Future> getMessages({bool? isRead}) async { + Future> getMessages({ + bool? isRead, + bool forceRefresh = false, + }) async { return getOrLoad( key: _messagesKey(isRead), + forceRefresh: forceRefresh, loadFromRemote: () => _loadMessagesFromRemote(isRead: isRead), ); } diff --git a/apps/lib/features/messages/presentation/screens/message_invite_detail_screen.dart b/apps/lib/features/messages/presentation/screens/message_invite_detail_screen.dart deleted file mode 100644 index 02ecb25..0000000 --- a/apps/lib/features/messages/presentation/screens/message_invite_detail_screen.dart +++ /dev/null @@ -1,443 +0,0 @@ -import 'package:flutter/material.dart' hide BackButton; -import 'package:go_router/go_router.dart'; -import 'package:intl/intl.dart'; - -import '../../../../app/di/injection.dart'; -import '../../../../features/calendar/data/repositories/calendar_repository.dart'; -import '../../../../features/messages/data/models/inbox_message.dart'; -import '../../../../features/messages/data/repositories/inbox_repository.dart'; -import '../../../../features/contacts/data/repositories/user_repository.dart'; -import '../../../../core/l10n/l10n.dart'; -import '../../../../shared/widgets/app_loading_indicator.dart'; -import '../../../../shared/widgets/page_header.dart'; -import '../../../../shared/widgets/toast/toast.dart'; -import '../../../../shared/widgets/toast/toast_type.dart'; - -class MessageInviteDetailScreen extends StatefulWidget { - final String inviteId; - - const MessageInviteDetailScreen({super.key, required this.inviteId}); - - @override - State createState() => - _MessageInviteDetailScreenState(); -} - -class _MessageInviteDetailScreenState extends State { - late final InboxRepository _inboxRepository; - late final CalendarRepository _calendarRepository; - late final UserRepository _userRepository; - - InboxMessage? _message; - String? _calendarTitle; - String? _senderName; - bool _loading = true; - bool _submitting = false; - String? _error; - - ColorScheme get _colorScheme => Theme.of(context).colorScheme; - - bool get _isPending => _message?.status == InboxMessageStatus.pending; - - @override - void initState() { - super.initState(); - _inboxRepository = sl(); - _calendarRepository = sl(); - _userRepository = sl(); - _loadDetail(); - } - - Future _loadDetail() async { - setState(() { - _loading = true; - _error = null; - }); - - try { - final results = await Future.wait([ - _inboxRepository.getMessages(isRead: false), - _inboxRepository.getMessages(isRead: true), - ]); - final messages = [...results[0], ...results[1]]; - InboxMessage? message; - for (final item in messages) { - if (item.id == widget.inviteId) { - message = item; - break; - } - } - if (message == null) { - throw StateError(L10n.current.messagesInviteDetailNotFound); - } - - String? calendarTitle; - if (message.scheduleItemId != null) { - try { - final event = await _calendarRepository.getById( - message.scheduleItemId!, - ); - calendarTitle = event.title; - } catch (_) { - calendarTitle = null; - } - } - - String? senderName; - if (message.senderId != null) { - try { - final sender = await _userRepository.getById(message.senderId!); - senderName = sender.username; - } catch (_) { - senderName = null; - } - } - - if (!mounted) { - return; - } - setState(() { - _message = message; - _calendarTitle = calendarTitle; - _senderName = senderName; - _loading = false; - }); - } catch (e) { - if (!mounted) { - return; - } - setState(() { - _error = e.toString().replaceFirst('Bad state: ', ''); - _loading = false; - }); - } - } - - Future _acceptInvite() async { - final message = _message; - final itemId = message?.scheduleItemId; - if (message == null || itemId == null || _submitting) { - return; - } - - setState(() => _submitting = true); - try { - await _calendarRepository.acceptSubscription(itemId); - await _inboxRepository.markAsRead(message.id); - if (!mounted) { - return; - } - Toast.show( - context, - context.l10n.messagesInviteAcceptedToast, - type: ToastType.success, - ); - await _loadDetail(); - } catch (_) { - if (!mounted) { - return; - } - Toast.show( - context, - context.l10n.messagesInviteOperationFailed, - type: ToastType.error, - ); - } finally { - if (mounted) { - setState(() => _submitting = false); - } - } - } - - Future _rejectInvite() async { - final message = _message; - final itemId = message?.scheduleItemId; - if (message == null || itemId == null || _submitting) { - return; - } - - setState(() => _submitting = true); - try { - await _calendarRepository.rejectSubscription(itemId); - await _inboxRepository.markAsRead(message.id); - if (!mounted) { - return; - } - Toast.show( - context, - context.l10n.messagesInviteRejectedToast, - type: ToastType.success, - ); - await _loadDetail(); - } catch (_) { - if (!mounted) { - return; - } - Toast.show( - context, - context.l10n.messagesInviteOperationFailed, - type: ToastType.error, - ); - } finally { - if (mounted) { - setState(() => _submitting = false); - } - } - } - - @override - Widget build(BuildContext context) { - if (_loading) { - return const Scaffold( - body: SafeArea(child: Center(child: AppLoadingIndicator(size: 22))), - ); - } - - return Scaffold( - backgroundColor: _colorScheme.surface, - body: SafeArea( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - PageHeader(leading: BackButton(onPressed: () => context.pop())), - Expanded( - child: SingleChildScrollView( - padding: const EdgeInsets.only(left: 20, right: 20, bottom: 20), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - _buildSummaryCard(), - const SizedBox(height: 14), - _buildCalendarTip(), - const SizedBox(height: 14), - _buildActionRow(), - if (_error != null) ...[ - const SizedBox(height: 14), - Text( - _error!, - style: TextStyle( - fontSize: 12, - color: _colorScheme.error, - ), - ), - ], - ], - ), - ), - ), - ], - ), - ), - ); - } - - Widget _buildSummaryCard() { - final message = _message; - final statusText = message == null - ? context.l10n.commonUnknown - : switch (message.status) { - InboxMessageStatus.pending => context.l10n.messagesStatusPending, - InboxMessageStatus.accepted => - context.l10n.messagesInviteStatusAccepted, - InboxMessageStatus.rejected => - context.l10n.messagesInviteStatusRejected, - InboxMessageStatus.dismissed => - context.l10n.messagesInviteStatusHandled, - }; - - final createdAt = message?.createdAt; - final createdAtText = createdAt == null - ? context.l10n.commonUnknown - : DateFormat.yMd(context.l10n.localeName).add_Hm().format(createdAt); - - return Container( - width: double.infinity, - padding: const EdgeInsets.all(14), - decoration: BoxDecoration( - color: _colorScheme.surface, - borderRadius: BorderRadius.circular(16), - border: Border.all(color: _colorScheme.outlineVariant), - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - context.l10n.messagesInviteDetailTitle, - style: TextStyle( - fontSize: 16, - fontWeight: FontWeight.w600, - color: _colorScheme.onSurface, - ), - ), - const SizedBox(height: 10), - Text( - context.l10n.messagesInviteEvent( - _calendarTitle ?? context.l10n.messagesInviteUnnamedEvent, - ), - style: TextStyle( - fontSize: 14, - fontWeight: FontWeight.w500, - color: _colorScheme.onSurface, - ), - ), - const SizedBox(height: 10), - Text( - context.l10n.messagesInviteSender( - _senderName ?? context.l10n.messagesInviteUnknownUser, - ), - style: TextStyle( - fontSize: 13, - fontWeight: FontWeight.normal, - color: _colorScheme.onSurfaceVariant, - ), - ), - const SizedBox(height: 10), - Text( - context.l10n.messagesInviteTime(createdAtText), - style: TextStyle( - fontSize: 13, - fontWeight: FontWeight.normal, - color: _colorScheme.onSurfaceVariant, - ), - ), - const SizedBox(height: 10), - Text( - context.l10n.messagesInviteStatus(statusText), - style: TextStyle( - fontSize: 13, - fontWeight: FontWeight.normal, - color: _colorScheme.onSurfaceVariant, - ), - ), - const SizedBox(height: 10), - Text( - context.l10n.messagesInviteId(widget.inviteId), - style: TextStyle( - fontSize: 13, - fontWeight: FontWeight.normal, - color: _colorScheme.onSurfaceVariant, - ), - ), - ], - ), - ); - } - - Widget _buildCalendarTip() { - return Container( - width: double.infinity, - padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10), - decoration: BoxDecoration( - color: _colorScheme.surfaceContainerLow, - borderRadius: BorderRadius.circular(12), - border: Border.all(color: _colorScheme.outlineVariant), - ), - child: Row( - children: [ - Icon( - Icons.info_outline, - size: 14, - color: _colorScheme.onSurfaceVariant, - ), - const SizedBox(width: 8), - Expanded( - child: Text( - context.l10n.messagesInviteTip, - style: TextStyle( - fontSize: 12, - fontWeight: FontWeight.normal, - color: _colorScheme.onSurfaceVariant, - ), - ), - ), - ], - ), - ); - } - - Widget _buildActionRow() { - if (!_isPending) { - return Container( - width: double.infinity, - padding: const EdgeInsets.symmetric(vertical: 10, horizontal: 12), - decoration: BoxDecoration( - color: _colorScheme.surface, - borderRadius: BorderRadius.circular(12), - border: Border.all(color: _colorScheme.outlineVariant), - ), - child: Text( - context.l10n.messagesInviteAlreadyHandled, - style: TextStyle( - fontSize: 13, - fontWeight: FontWeight.w500, - color: _colorScheme.onSurfaceVariant, - ), - textAlign: TextAlign.center, - ), - ); - } - - return SizedBox( - height: 46, - child: Row( - children: [ - Expanded( - child: GestureDetector( - onTap: _submitting ? null : _rejectInvite, - child: Container( - decoration: BoxDecoration( - color: _colorScheme.surface, - borderRadius: BorderRadius.circular(12), - border: Border.all(color: _colorScheme.error), - ), - child: Row( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Icon(Icons.close, size: 15, color: _colorScheme.error), - const SizedBox(width: 6), - Text( - context.l10n.messagesReject, - style: TextStyle( - fontSize: 14, - fontWeight: FontWeight.w600, - color: _colorScheme.error, - ), - ), - ], - ), - ), - ), - ), - const SizedBox(width: 10), - Expanded( - child: GestureDetector( - onTap: _submitting ? null : _acceptInvite, - child: Container( - decoration: BoxDecoration( - color: _colorScheme.primaryContainer, - borderRadius: BorderRadius.circular(12), - border: Border.all(color: _colorScheme.primary), - ), - child: Row( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Icon(Icons.check, size: 15, color: _colorScheme.primary), - const SizedBox(width: 6), - Text( - context.l10n.messagesAccept, - style: TextStyle( - fontSize: 14, - fontWeight: FontWeight.w600, - color: _colorScheme.primary, - ), - ), - ], - ), - ), - ), - ), - ], - ), - ); - } -} diff --git a/apps/lib/features/messages/presentation/screens/message_invite_list_screen.dart b/apps/lib/features/messages/presentation/screens/message_invite_list_screen.dart index 7896bb2..2b8d5f3 100644 --- a/apps/lib/features/messages/presentation/screens/message_invite_list_screen.dart +++ b/apps/lib/features/messages/presentation/screens/message_invite_list_screen.dart @@ -1,8 +1,11 @@ +import 'dart:async'; + import 'package:flutter/material.dart' hide BackButton; import 'package:go_router/go_router.dart'; import '../../../../app/di/injection.dart'; -import '../../../../app/router/app_routes.dart'; +import '../../../../core/inbox/inbox_sync_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 '../../../../features/contacts/data/models/friend_request.dart'; @@ -33,11 +36,15 @@ class MessageInviteListScreen extends StatefulWidget { class _MessageInviteListScreenState extends State { late final InboxRepository _inboxRepository; late final FriendRepository _friendRepository; + late final CalendarRepository _calendarRepository; + late final InboxSyncStore _inboxSyncStore; List _unreadMessages = []; List _readMessages = []; - bool _isLoading = false; + bool _isInitialLoading = true; bool _isPullRefreshing = false; + bool _isHydrating = false; + bool _pendingStoreSync = false; int _activeTabIndex = 0; ColorScheme get _colorScheme => Theme.of(context).colorScheme; @@ -47,26 +54,65 @@ class _MessageInviteListScreenState extends State { super.initState(); _inboxRepository = sl(); _friendRepository = sl(); - _loadMessages(); + _calendarRepository = sl(); + _inboxSyncStore = sl(); + _inboxSyncStore.addListener(_handleInboxStoreChanged); + unawaited(_bootstrapInbox()); } - Future _loadMessages({bool showPageLoader = true}) async { - if (_isLoading || _isPullRefreshing) { + @override + void dispose() { + _inboxSyncStore.removeListener(_handleInboxStoreChanged); + super.dispose(); + } + + void _handleInboxStoreChanged() { + if (_isInitialLoading || _isPullRefreshing || _isHydrating) { + _pendingStoreSync = true; return; } - if (mounted) { + unawaited( + _syncMessagesFromStore(forceSnapshot: false, fromPullRefresh: false), + ); + } + + Future _bootstrapInbox() async { + if (!mounted) { + return; + } + setState(() { + _isInitialLoading = true; + }); + await _inboxSyncStore.ensureStarted(); + await _syncMessagesFromStore(forceSnapshot: false, fromPullRefresh: false); + if (!mounted) { + return; + } + setState(() { + _isInitialLoading = false; + }); + } + + Future _syncMessagesFromStore({ + required bool forceSnapshot, + required bool fromPullRefresh, + }) async { + if (_isHydrating) { + _pendingStoreSync = true; + return; + } + _isHydrating = true; + if (mounted && fromPullRefresh) { setState(() { - _isLoading = showPageLoader; - _isPullRefreshing = !showPageLoader; + _isPullRefreshing = true; }); } try { - final results = await Future.wait([ - _inboxRepository.getMessages(isRead: false), - _inboxRepository.getMessages(isRead: true), - ]); - final unreadRaw = results[0]; - final readRaw = results[1]; + if (forceSnapshot) { + await _inboxSyncStore.refreshSnapshot(); + } + final unreadRaw = _inboxSyncStore.unreadMessages; + final readRaw = _inboxSyncStore.readMessages; final allMessages = [...unreadRaw, ...readRaw]; final friendshipIds = allMessages @@ -90,15 +136,23 @@ class _MessageInviteListScreenState extends State { setState(() { _unreadMessages = unread; _readMessages = read; - _isLoading = false; _isPullRefreshing = false; }); + _isHydrating = false; + if (_pendingStoreSync) { + _pendingStoreSync = false; + await _syncMessagesFromStore( + forceSnapshot: false, + fromPullRefresh: false, + ); + } } catch (e) { if (!mounted) return; setState(() { - _isLoading = false; _isPullRefreshing = false; }); + _isHydrating = false; + _pendingStoreSync = false; Toast.show( context, context.l10n.messagesLoadFailed, @@ -108,7 +162,7 @@ class _MessageInviteListScreenState extends State { } Future _onRefreshMessages() async { - await _loadMessages(showPageLoader: false); + await _syncMessagesFromStore(forceSnapshot: true, fromPullRefresh: true); } List _mapMessagesWithFriend( @@ -128,17 +182,19 @@ class _MessageInviteListScreenState extends State { switch (message.messageType) { case InboxMessageType.calendar: final content = _parseCalendarContent(message.content); - if (content == null) return; + if (content == null) { + _showProtocolErrorToast(); + return; + } final type = content['type'] as String?; if (type == 'invite') { - context.push(AppRoutes.messageInviteDetail(message.id)); - } else if (type == 'update') { - if (message.scheduleItemId != null) { - context.push( - AppRoutes.calendarEventDetail(message.scheduleItemId!), - ); - } + final isHandled = message.status != InboxMessageStatus.pending; + _showCalendarInviteSheet(item, isReadOnly: isHandled); + } else if (type == 'updated' || type == 'deleted') { + _showCalendarChangeSheet(item); + } else { + _showProtocolErrorToast(); } return; case InboxMessageType.friendRequest: @@ -159,9 +215,127 @@ class _MessageInviteListScreenState extends State { } Map? _parseCalendarContent(Map? content) { + if (content == null) { + return null; + } + final type = content['type']; + final schemaVersion = content['schema_version']; + final item = content['item']; + final actor = content['actor']; + final summary = content['summary']; + if (type is! String || schemaVersion is! int || schemaVersion != 2) { + return null; + } + if (item is! Map || actor is! Map) { + return null; + } + final itemId = item['id']; + final itemTitle = item['title']; + final actorId = actor['user_id']; + final actorName = actor['username']; + if (itemId is! String || itemTitle is! String || itemTitle.trim().isEmpty) { + return null; + } + if (actorId is! String || + actorName is! String || + actorName.trim().isEmpty) { + return null; + } + if (summary is! String || summary.trim().isEmpty) { + return null; + } + if (type == 'updated') { + final changes = content['changes']; + if (changes is! List) { + return null; + } + } return content; } + void _showProtocolErrorToast() { + Toast.show( + context, + context.l10n.messagesProtocolInvalid, + type: ToastType.error, + ); + } + + void _showCalendarChangeSheet(MessageWithFriend item) { + final message = item.message; + final content = _parseCalendarContent(message.content); + if (content == null) { + _showProtocolErrorToast(); + return; + } + final actor = content['actor'] as Map; + final actorName = + actor['username'] as String? ?? context.l10n.messagesUnknownActor; + final summary = content['summary'] as String; + final type = content['type'] as String; + final changes = + (content['changes'] as List?) + ?.whereType>() + .toList() ?? + const []; + + final details = []; + if (changes.isNotEmpty) { + for (final entry in changes) { + final label = + entry['label'] as String? ?? (entry['field'] as String? ?? '-'); + final before = entry['display_before'] as String? ?? '-'; + final after = entry['display_after'] as String? ?? '-'; + details.add('$label: $before -> $after'); + } + } + final description = details.isEmpty + ? summary + : ([summary, ...details].join('\n')); + + showModalBottomSheet( + context: context, + backgroundColor: _colorScheme.surface.withValues(alpha: 0), + isScrollControlled: true, + builder: (ctx) => MessageActionSheet( + title: type == 'deleted' + ? context.l10n.messagesCalendarDeletedBy(actorName) + : context.l10n.messagesCalendarUpdatedBy(actorName), + description: description, + isReadOnly: true, + icon: type == 'deleted' + ? Icons.delete_outline + : Icons.edit_calendar_outlined, + iconColor: type == 'deleted' + ? _colorScheme.error + : _colorScheme.primary, + primaryActionText: context.l10n.messagesAcknowledge, + onPrimaryAction: () async { + await _markMessageAsRead(message); + }, + ), + ); + } + + Future _markMessageAsRead(InboxMessage message) async { + if (message.isRead) { + return; + } + try { + await _inboxRepository.markAsRead(message.id); + await _syncMessagesFromStore(forceSnapshot: true, fromPullRefresh: false); + } catch (_) { + if (!mounted) { + return; + } + Toast.show( + context, + context.l10n.messagesActionFailed, + type: ToastType.error, + ); + } + } + void _showFriendRequestSheet( MessageWithFriend item, { bool isReadOnly = false, @@ -207,6 +381,98 @@ class _MessageInviteListScreenState extends State { ); } + void _showCalendarInviteSheet( + MessageWithFriend item, { + bool isReadOnly = false, + }) { + final message = item.message; + final parsed = _parseCalendarContent(message.content); + if (parsed == null) { + _showProtocolErrorToast(); + return; + } + final itemMap = parsed['item'] as Map; + final actorMap = parsed['actor'] as Map; + final title = (itemMap['title'] as String).trim(); + final resolvedTitle = context.l10n.messagesInviteEvent(title); + final statusText = isReadOnly ? _calendarStatusLabel(message.status) : null; + final actorName = + actorMap['username'] as String? ?? context.l10n.messagesUnknownActor; + final actorPhone = actorMap['phone'] as String?; + final summary = parsed['summary'] as String; + final detailLines = [ + summary, + '${context.l10n.messagesCalendarInviteActorLabel}: $actorName${(actorPhone != null && actorPhone.isNotEmpty) ? ' / $actorPhone' : ''}', + ]; + final startAt = _formatInviteDateTime(itemMap['start_at']); + final endAt = _formatInviteDateTime(itemMap['end_at']); + final timezone = itemMap['timezone'] as String?; + if (startAt != null) { + detailLines.add( + '${context.l10n.messagesCalendarInviteTimeLabel}: ${endAt != null ? '$startAt - $endAt' : startAt}${timezone != null && timezone.isNotEmpty ? ' ($timezone)' : ''}', + ); + } + final descriptionText = itemMap['description'] as String?; + if (descriptionText != null && descriptionText.trim().isNotEmpty) { + detailLines.add( + '${context.l10n.messagesCalendarInviteDescriptionLabel}: ${descriptionText.trim()}', + ); + } + if (isReadOnly) { + detailLines.add(context.l10n.messagesInviteAlreadyHandled); + } + final description = detailLines.join('\n'); + + showModalBottomSheet( + context: context, + backgroundColor: _colorScheme.surface.withValues(alpha: 0), + isScrollControlled: true, + builder: (ctx) => MessageActionSheet( + title: resolvedTitle, + description: description, + statusText: statusText, + isReadOnly: isReadOnly, + icon: Icons.event_outlined, + iconColor: _colorScheme.primary, + onAccept: isReadOnly + ? null + : () async { + await _processCalendarInvite(item, accept: true); + }, + onDecline: isReadOnly + ? null + : () async { + await _processCalendarInvite(item, accept: false); + }, + ), + ); + } + + String? _formatInviteDateTime(Object? raw) { + if (raw is! String || raw.isEmpty) { + return null; + } + final dt = DateTime.tryParse(raw); + if (dt == null) { + return null; + } + final local = dt.toLocal(); + final month = local.month.toString().padLeft(2, '0'); + final day = local.day.toString().padLeft(2, '0'); + final hour = local.hour.toString().padLeft(2, '0'); + final minute = local.minute.toString().padLeft(2, '0'); + return '${local.year}-$month-$day $hour:$minute'; + } + + String _calendarStatusLabel(InboxMessageStatus status) { + return switch (status) { + InboxMessageStatus.pending => context.l10n.messagesStatusPending, + InboxMessageStatus.accepted => context.l10n.messagesInviteStatusAccepted, + InboxMessageStatus.rejected => context.l10n.messagesInviteStatusRejected, + InboxMessageStatus.dismissed => context.l10n.messagesInviteStatusHandled, + }; + } + Future _processFriendRequest( MessageWithFriend item, { required bool accept, @@ -243,7 +509,7 @@ class _MessageInviteListScreenState extends State { } } await _inboxRepository.markAsRead(message.id); - await _loadMessages(); + await _syncMessagesFromStore(forceSnapshot: true, fromPullRefresh: false); } catch (e) { if (mounted) { Toast.show( @@ -255,6 +521,54 @@ class _MessageInviteListScreenState extends State { } } + Future _processCalendarInvite( + MessageWithFriend item, { + required bool accept, + }) async { + final message = item.message; + final scheduleItemId = message.scheduleItemId; + if (scheduleItemId == null) { + Toast.show( + context, + context.l10n.messagesInviteOperationFailed, + type: ToastType.error, + ); + return; + } + + try { + if (accept) { + await _calendarRepository.acceptSubscription(scheduleItemId); + if (mounted) { + Toast.show( + context, + context.l10n.messagesInviteAcceptedToast, + type: ToastType.success, + ); + } + } else { + await _calendarRepository.rejectSubscription(scheduleItemId); + if (mounted) { + Toast.show( + context, + context.l10n.messagesInviteRejectedToast, + type: ToastType.success, + ); + } + } + await _inboxRepository.markAsRead(message.id); + await _syncMessagesFromStore(forceSnapshot: true, fromPullRefresh: false); + } catch (_) { + if (mounted) { + Toast.show( + context, + context.l10n.messagesInviteOperationFailed, + type: ToastType.error, + ); + } + } + } + @override Widget build(BuildContext context) { return Scaffold( @@ -264,7 +578,7 @@ class _MessageInviteListScreenState extends State { children: [ _buildHeader(context), Expanded( - child: _isLoading + child: _isInitialLoading ? const Center( child: AppLoadingIndicator( size: 22, @@ -479,6 +793,42 @@ class _MessageCard extends StatelessWidget { InboxMessage get message => item.message; FriendRequest? get friendRequest => item.friendRequest; + Map? _parseCalendarContent(Map? content) { + if (content == null) { + return null; + } + final type = content['type']; + final schemaVersion = content['schema_version']; + final item = content['item']; + final actor = content['actor']; + final summary = content['summary']; + if (type is! String || schemaVersion is! int || schemaVersion != 2) { + return null; + } + if (item is! Map || actor is! Map) { + return null; + } + final itemId = item['id']; + final itemTitle = item['title']; + final actorId = actor['user_id']; + final actorName = actor['username']; + if (itemId is! String || itemTitle is! String || itemTitle.trim().isEmpty) { + return null; + } + if (actorId is! String || + actorName is! String || + actorName.trim().isEmpty) { + return null; + } + if (summary is! String || summary.trim().isEmpty) { + return null; + } + if (type == 'updated' && content['changes'] is! List) { + return null; + } + return content; + } + @override Widget build(BuildContext context) { final colorScheme = Theme.of(context).colorScheme; @@ -555,19 +905,39 @@ class _MessageCard extends StatelessWidget { ); } if (message.messageType == InboxMessageType.calendar) { - final data = message.content; - return data?['title'] as String? ?? L10n.current.messagesCalendarInvite; + final data = _parseCalendarContent(message.content); + if (data == null) { + return L10n.current.messagesProtocolInvalidCardTitle; + } + final type = data['type']; + final item = data['item']; + final itemTitle = item is Map + ? item['title'] as String? + : null; + if (type == 'invite' && + itemTitle != null && + itemTitle.trim().isNotEmpty) { + return L10n.current.messagesInviteEvent(itemTitle); + } + if (type == 'updated' && + itemTitle != null && + itemTitle.trim().isNotEmpty) { + return L10n.current.messagesCalendarCardUpdatedWithTitle(itemTitle); + } + if (type == 'deleted' && + itemTitle != null && + itemTitle.trim().isNotEmpty) { + return L10n.current.messagesCalendarCardDeletedWithTitle(itemTitle); + } + return L10n.current.messagesProtocolInvalidCardTitle; } return L10n.current.messagesSystemMessage; } String _content() { if (message.messageType == InboxMessageType.calendar) { - Map? data; - if (message.content != null) { - data = message.content; - } - if (data == null) return L10n.current.messagesTapToView; + final data = _parseCalendarContent(message.content); + if (data == null) return L10n.current.messagesProtocolInvalidCardDesc; final type = data['type'] as String?; if (type == 'invite') { @@ -579,10 +949,12 @@ class _MessageCard extends StatelessWidget { } else if (status == InboxMessageStatus.rejected) { return L10n.current.messagesInviteRejected; } - } else if (type == 'update') { + } else if (type == 'updated') { return L10n.current.messagesCalendarUpdated; + } else if (type == 'deleted') { + return L10n.current.messagesCalendarDeleted; } - return L10n.current.messagesTapToView; + return L10n.current.messagesProtocolInvalidCardDesc; } return message.content?['message'] as String? ?? L10n.current.messagesTapToView; diff --git a/apps/lib/features/messages/presentation/widgets/message_action_sheet.dart b/apps/lib/features/messages/presentation/widgets/message_action_sheet.dart index 4bfbe71..0787d62 100644 --- a/apps/lib/features/messages/presentation/widgets/message_action_sheet.dart +++ b/apps/lib/features/messages/presentation/widgets/message_action_sheet.dart @@ -12,6 +12,8 @@ class MessageActionSheet extends StatelessWidget { final VoidCallback? onDecline; final IconData? icon; final Color? iconColor; + final String? primaryActionText; + final VoidCallback? onPrimaryAction; const MessageActionSheet({ super.key, @@ -23,6 +25,8 @@ class MessageActionSheet extends StatelessWidget { this.onDecline, this.icon, this.iconColor, + this.primaryActionText, + this.onPrimaryAction, }); @override @@ -124,6 +128,17 @@ class MessageActionSheet extends StatelessWidget { ), ], ), + ] else if (primaryActionText != null && onPrimaryAction != null) ...[ + SizedBox( + width: double.infinity, + child: AppButton( + text: primaryActionText!, + onPressed: () { + Navigator.pop(context); + onPrimaryAction?.call(); + }, + ), + ), ], SizedBox(height: MediaQuery.of(context).padding.bottom + 12), ], diff --git a/apps/lib/features/settings/presentation/screens/edit_profile_screen.dart b/apps/lib/features/settings/presentation/screens/edit_profile_screen.dart index a066981..60f715a 100644 --- a/apps/lib/features/settings/presentation/screens/edit_profile_screen.dart +++ b/apps/lib/features/settings/presentation/screens/edit_profile_screen.dart @@ -160,7 +160,7 @@ class _EditProfileScreenState extends State { ); return; } - if (newUsername.length < 3 || newUsername.length > 30) { + if (newUsername.length > 30) { Toast.show( context, context.l10n.settingsEditProfileUsernameLengthInvalid, diff --git a/apps/lib/features/settings/presentation/screens/settings_screen.dart b/apps/lib/features/settings/presentation/screens/settings_screen.dart index 3071954..e451840 100644 --- a/apps/lib/features/settings/presentation/screens/settings_screen.dart +++ b/apps/lib/features/settings/presentation/screens/settings_screen.dart @@ -1,11 +1,15 @@ import 'package:flutter/material.dart'; import 'package:go_router/go_router.dart'; +import 'package:url_launcher/url_launcher.dart'; import 'package:social_app/core/config/env.dart'; import 'package:social_app/app/di/injection.dart'; import 'package:social_app/app/router/app_routes.dart'; +import 'package:social_app/app/services/app_prewarm_orchestrator.dart'; import 'package:social_app/core/l10n/l10n.dart'; import 'package:social_app/core/theme/design_tokens.dart'; import 'package:social_app/core/auth/session_controller.dart'; +import 'package:social_app/data/cache/cache_store.dart'; +import 'package:social_app/core/inbox/inbox_sync_store.dart'; import 'package:social_app/features/contacts/data/models/user_profile.dart'; import 'package:social_app/features/contacts/data/repositories/friend_repository.dart'; import 'package:social_app/shared/widgets/app_button.dart'; @@ -14,6 +18,7 @@ import 'package:social_app/shared/widgets/app_pressable.dart'; import 'package:social_app/shared/widgets/destructive_action_sheet.dart'; import 'package:social_app/shared/widgets/toast/toast.dart'; import 'package:social_app/shared/widgets/toast/toast_type.dart'; +import 'package:social_app/shared/widgets/shared_divider.dart'; import 'package:social_app/core/utils/phone_display_formatter.dart'; import 'package:social_app/features/settings/data/apis/settings_api.dart'; import 'package:social_app/features/settings/data/apis/automation_jobs_api.dart'; @@ -171,21 +176,24 @@ class _SettingsScreenState extends State { Row( crossAxisAlignment: CrossAxisAlignment.center, children: [ - Container( - width: 64, - height: 64, - decoration: BoxDecoration( - borderRadius: BorderRadius.circular(32), - boxShadow: [ - BoxShadow( - color: colorScheme.primary.withValues(alpha: 0.2), - blurRadius: 12, - offset: const Offset(0, 4), - ), - ], + GestureDetector( + onTap: _onTapEditProfile, + child: Container( + width: 64, + height: 64, + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(32), + boxShadow: [ + BoxShadow( + color: colorScheme.primary.withValues(alpha: 0.2), + blurRadius: 12, + offset: const Offset(0, 4), + ), + ], + ), + clipBehavior: Clip.antiAlias, + child: _buildAvatarImage(_user?.avatarUrl), ), - clipBehavior: Clip.antiAlias, - child: _buildAvatarImage(_user?.avatarUrl), ), const SizedBox(width: AppSpacing.lg), Expanded( @@ -599,19 +607,25 @@ class _SettingsScreenState extends State { title: context.l10n.settingsMenuNotifications, onTap: () {}, ), - _buildDivider(), + SharedDivider(), _buildMenuItem( icon: Icons.bookmark, title: context.l10n.memoryTitle, onTap: () => context.push(AppRoutes.settingsMemory), ), - _buildDivider(), + SharedDivider(), _buildMenuItem( icon: Icons.system_update, title: context.l10n.settingsMenuCheckUpdates, trailing: 'v${Env.version}', onTap: _checkForUpdates, ), + SharedDivider(), + _buildMenuItem( + icon: Icons.cleaning_services_outlined, + title: context.l10n.settingsMenuClearCache, + onTap: _clearLocalCache, + ), ], ), ); @@ -673,16 +687,6 @@ class _SettingsScreenState extends State { ); } - Widget _buildDivider() { - final colorScheme = Theme.of(context).colorScheme; - - return Container( - height: 1, - margin: const EdgeInsets.symmetric(horizontal: 14), - color: colorScheme.outlineVariant, - ); - } - Future _onTapEditProfile() async { final changed = await context.push(AppRoutes.settingsEditProfile); if (changed == true && mounted) { @@ -769,11 +773,17 @@ class _SettingsScreenState extends State { ); if (shouldUpdate == true && result.downloadUrl != null && mounted) { - Toast.show( - context, - context.l10n.settingsDownloadLink(result.downloadUrl!), - type: ToastType.info, - ); + final uri = Uri.tryParse(result.downloadUrl!); + final launched = + uri != null && + await launchUrl(uri, mode: LaunchMode.externalApplication); + if (!launched && mounted) { + Toast.show( + context, + context.l10n.settingsDownloadLink(result.downloadUrl!), + type: ToastType.info, + ); + } } } catch (e) { if (!mounted) return; @@ -785,6 +795,46 @@ class _SettingsScreenState extends State { } } + Future _clearLocalCache() async { + final confirmed = await showDestructiveActionSheet( + context, + title: context.l10n.settingsClearCacheTitle, + message: context.l10n.settingsClearCacheMessage, + confirmText: context.l10n.settingsClearCacheAction, + ); + if (!confirmed || !mounted) { + return; + } + + try { + await sl().clearByPrefix('cache:'); + + final userId = _user?.id; + if (userId != null && userId.isNotEmpty) { + await sl().ensureStartedFor(userId); + } + await sl().refreshSnapshot(); + + if (!mounted) { + return; + } + Toast.show( + context, + context.l10n.settingsClearCacheSuccess, + type: ToastType.success, + ); + } catch (_) { + if (!mounted) { + return; + } + Toast.show( + context, + context.l10n.settingsClearCacheFailed, + type: ToastType.error, + ); + } + } + Widget _buildLogoutAction() { return SizedBox( width: double.infinity, diff --git a/apps/lib/features/todo/presentation/screens/todo_quadrants_screen.dart b/apps/lib/features/todo/presentation/screens/todo_quadrants_screen.dart index dfa4070..6986248 100644 --- a/apps/lib/features/todo/presentation/screens/todo_quadrants_screen.dart +++ b/apps/lib/features/todo/presentation/screens/todo_quadrants_screen.dart @@ -1,5 +1,4 @@ import 'package:flutter/material.dart'; -import 'package:drag_and_drop_lists/drag_and_drop_lists.dart'; import 'package:go_router/go_router.dart'; import 'package:lucide_icons/lucide_icons.dart'; import '../../../../app/di/injection.dart'; @@ -398,65 +397,6 @@ class _TodoQuadrantsScreenState extends State { Widget _buildDragBoard() { final colorScheme = Theme.of(context).colorScheme; final palette = Theme.of(context).extension()!; - final quadrants = [ - _QuadrantMeta( - value: 1, - title: context.l10n.todoQuadrantImportantUrgent, - textColor: palette.g1Text, - dividerColor: palette.g1Divider, - borderColor: palette.g1Border, - items: _importantUrgent, - ), - _QuadrantMeta( - value: 3, - title: context.l10n.todoQuadrantUrgentNotImportant, - textColor: palette.g3Text, - dividerColor: palette.g3Divider, - borderColor: palette.g3Border, - items: _urgentNotImportant, - ), - _QuadrantMeta( - value: 2, - title: context.l10n.todoQuadrantImportantNotUrgent, - textColor: palette.g2Text, - dividerColor: palette.g2Divider, - borderColor: palette.g2Border, - items: _importantNotUrgent, - ), - ]; - - final lists = quadrants - .map( - (meta) => DragAndDropList( - canDrag: false, - header: _buildQuadrantHeader(meta), - contentsWhenEmpty: _buildEmptyQuadrant(), - lastTarget: const SizedBox(height: AppSpacing.lg), - decoration: BoxDecoration( - color: colorScheme.surface, - borderRadius: BorderRadius.circular(14), - border: Border.all(color: meta.borderColor), - ), - children: meta.items - .map( - (item) => DragAndDropItem( - child: Padding( - padding: const EdgeInsets.symmetric( - horizontal: AppSpacing.sm, - ), - child: _TodoItemWidget( - key: ValueKey(item.id), - item: item, - onComplete: () => _completeTodo(item), - onTap: () => _navigateToDetail(item), - ), - ), - ), - ) - .toList(growable: false), - ), - ) - .toList(growable: false); return SingleChildScrollView( padding: const EdgeInsets.fromLTRB( @@ -465,36 +405,199 @@ class _TodoQuadrantsScreenState extends State { AppSpacing.lg, 96, ), - child: DragAndDropLists( - children: lists, - onItemReorder: _onItemReorder, - onListReorder: (oldListIndex, newListIndex) {}, - listDivider: const SizedBox(height: AppSpacing.md), - itemDivider: Padding( - padding: const EdgeInsets.symmetric(horizontal: AppSpacing.md), - child: Container(height: 1, color: colorScheme.surfaceContainerHigh), - ), - listPadding: EdgeInsets.zero, - itemDecorationWhileDragging: BoxDecoration( - color: colorScheme.surface, - borderRadius: BorderRadius.circular(AppRadius.md), - border: Border.all(color: colorScheme.outlineVariant), - boxShadow: [ - BoxShadow( - color: colorScheme.shadow.withValues(alpha: 0.16), - blurRadius: AppRadius.md, - offset: const Offset(0, AppSpacing.xs), - ), - ], - ), - itemGhost: const SizedBox(height: 42), - itemDragOnLongPress: true, - lastItemTargetHeight: AppSpacing.xl, - disableScrolling: true, + child: Column( + children: [ + _buildQuadrant( + value: 1, + title: context.l10n.todoQuadrantImportantUrgent, + textColor: palette.g1Text, + dividerColor: palette.g1Divider, + borderColor: palette.g1Border, + items: _importantUrgent, + colorScheme: colorScheme, + ), + const SizedBox(height: AppSpacing.md), + _buildQuadrant( + value: 3, + title: context.l10n.todoQuadrantUrgentNotImportant, + textColor: palette.g3Text, + dividerColor: palette.g3Divider, + borderColor: palette.g3Border, + items: _urgentNotImportant, + colorScheme: colorScheme, + ), + const SizedBox(height: AppSpacing.md), + _buildQuadrant( + value: 2, + title: context.l10n.todoQuadrantImportantNotUrgent, + textColor: palette.g2Text, + dividerColor: palette.g2Divider, + borderColor: palette.g2Border, + items: _importantNotUrgent, + colorScheme: colorScheme, + ), + ], ), ); } + Widget _buildQuadrant({ + required int value, + required String title, + required Color textColor, + required Color dividerColor, + required Color borderColor, + required List items, + required ColorScheme colorScheme, + }) { + return DragTarget<_TodoDragInfo>( + onWillAcceptWithDetails: (details) => true, + onAcceptWithDetails: (details) { + final info = details.data; + if (info.sourceQuadrant != value) { + _onItemReorder( + info.sourceIndex, + _listIndexByQuadrant(info.sourceQuadrant), + 0, + _listIndexByQuadrant(value), + ); + } + }, + builder: (context, candidateData, rejectedData) { + final isHovering = candidateData.isNotEmpty; + return AnimatedContainer( + duration: const Duration(milliseconds: 150), + decoration: BoxDecoration( + color: colorScheme.surface, + borderRadius: BorderRadius.circular(14), + border: Border.all( + color: isHovering ? colorScheme.primary : borderColor, + width: isHovering ? 2 : 1, + ), + ), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + _buildQuadrantHeader( + _QuadrantMeta( + value: value, + title: title, + textColor: textColor, + dividerColor: dividerColor, + borderColor: borderColor, + items: items, + ), + ), + if (items.isEmpty) + _buildEmptyContent(colorScheme) + else + ..._buildItemList(items, value, colorScheme), + ], + ), + ); + }, + ); + } + + Widget _buildDraggableItem( + TodoResponse item, + int sourceQuadrant, + ColorScheme colorScheme, + ) { + final sourceIndex = _sortedQuadrantTodos( + sourceQuadrant, + ).indexWhere((t) => t.id == item.id); + final dragInfo = _TodoDragInfo( + todoId: item.id, + sourceQuadrant: sourceQuadrant, + sourceIndex: sourceIndex, + ); + + return LongPressDraggable<_TodoDragInfo>( + data: dragInfo, + delay: const Duration(milliseconds: 150), + feedback: Material( + elevation: 4, + borderRadius: BorderRadius.circular(AppRadius.md), + child: Container( + width: 280, + padding: const EdgeInsets.symmetric( + horizontal: AppSpacing.md, + vertical: AppSpacing.sm, + ), + decoration: BoxDecoration( + color: colorScheme.surface, + borderRadius: BorderRadius.circular(AppRadius.md), + ), + child: Text( + item.title, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle( + fontSize: 13, + fontWeight: FontWeight.w600, + color: colorScheme.onSurfaceVariant, + ), + ), + ), + ), + childWhenDragging: Opacity( + opacity: 0.3, + child: _TodoItemWidget( + key: ValueKey(item.id), + item: item, + onComplete: () => _completeTodo(item), + onTap: () => _navigateToDetail(item), + ), + ), + child: _TodoItemWidget( + key: ValueKey(item.id), + item: item, + onComplete: () => _completeTodo(item), + onTap: () => _navigateToDetail(item), + ), + ); + } + + Widget _buildEmptyContent(ColorScheme colorScheme) { + return SizedBox( + height: 60, + child: Center( + child: Text( + context.l10n.todoNoItems, + style: TextStyle( + fontFamily: 'Inter', + fontSize: 13, + color: colorScheme.outline, + ), + ), + ), + ); + } + + List _buildItemList( + List items, + int quadrant, + ColorScheme colorScheme, + ) { + final result = []; + for (var i = 0; i < items.length; i++) { + result.add(_buildDraggableItem(items[i], quadrant, colorScheme)); + if (i < items.length - 1) { + result.add( + Padding( + padding: const EdgeInsets.symmetric(horizontal: AppSpacing.md), + child: Container( + height: 1, + color: colorScheme.surfaceContainerHigh, + ), + ), + ); + } + } + return result; + } + Widget _buildQuadrantHeader(_QuadrantMeta meta) { return Column( mainAxisSize: MainAxisSize.min, @@ -533,21 +636,17 @@ class _TodoQuadrantsScreenState extends State { ); } - Widget _buildEmptyQuadrant() { - final colorScheme = Theme.of(context).colorScheme; - return SizedBox( - height: 60, - child: Center( - child: Text( - context.l10n.todoNoItems, - style: TextStyle( - fontFamily: 'Inter', - fontSize: 13, - color: colorScheme.outline, - ), - ), - ), - ); + int _listIndexByQuadrant(int quadrant) { + switch (quadrant) { + case 1: + return 0; + case 3: + return 1; + case 2: + return 2; + default: + return 0; + } } Widget _buildBottomDock() { @@ -571,6 +670,18 @@ class _TodoQuadrantsScreenState extends State { } } +class _TodoDragInfo { + final String todoId; + final int sourceQuadrant; + final int sourceIndex; + + const _TodoDragInfo({ + required this.todoId, + required this.sourceQuadrant, + required this.sourceIndex, + }); +} + class _ReorderResult { final List todos; final List changedTodos; diff --git a/apps/lib/l10n/app_en.arb b/apps/lib/l10n/app_en.arb index bd50c0f..882a9fe 100644 --- a/apps/lib/l10n/app_en.arb +++ b/apps/lib/l10n/app_en.arb @@ -20,6 +20,7 @@ } }, "commonUnknown": "Unknown", + "commonNone": "None", "toastLabelSuccess": "Success", "toastLabelWarning": "Warning", "toastLabelError": "Error", @@ -312,6 +313,12 @@ "contactFillRequired": "Please fill nickname and phone", "contactDeleteConfirmTitle": "Delete Contact", "contactDeleteConfirmMessage": "Are you sure to delete this contact?", + "contactDetailTitle": "Contact Detail", + "contactDetailLoadFailed": "Failed to load contact info", + "contactDetailNotFound": "Contact not found", + "contactDetailUsername": "Username", + "contactDetailPhone": "Phone", + "contactDetailBio": "Bio", "messagesLoadFailed": "Failed to load messages, please try again", "messagesSenderLoadFailed": "Failed to load sender info, pull to retry", "messagesFriendRequestMissing": "Missing friend request data", @@ -347,6 +354,9 @@ "messagesInviteUnnamedEvent": "Unnamed schedule", "messagesInviteSender": "Sender: {name}", "@messagesInviteSender": {"placeholders": {"name": {}}}, + "messagesCalendarInviteActorLabel": "Inviter", + "messagesCalendarInviteTimeLabel": "Time", + "messagesCalendarInviteDescriptionLabel": "Description", "messagesInviteUnknownUser": "Unknown user", "messagesInviteTime": "Time: {time}", "@messagesInviteTime": {"placeholders": {"time": {}}}, @@ -358,6 +368,18 @@ "messagesInviteAlreadyHandled": "This invite has been handled", "messagesReject": "Reject", "messagesAccept": "Accept", + "messagesAcknowledge": "Acknowledge", + "messagesProtocolInvalid": "Message data is invalid, please try again later", + "messagesProtocolInvalidCardTitle": "Invalid message data", + "messagesProtocolInvalidCardDesc": "This message is missing required fields and cannot be rendered", + "messagesUnknownActor": "Unknown user", + "messagesCalendarUpdatedBy": "{name} updated a calendar", + "@messagesCalendarUpdatedBy": {"placeholders": {"name": {}}}, + "messagesCalendarDeletedBy": "{name} deleted a calendar", + "@messagesCalendarDeletedBy": {"placeholders": {"name": {}}}, + "messagesCalendarDeleted": "Deleted calendar event", + "messagesCalendarCardDeletedWithTitle": "{title} deleted", + "@messagesCalendarCardDeletedWithTitle": {"placeholders": {"title": {}}}, "messagesStatusPending": "Pending", "settingsFeaturesTitle": "Recurring Plans", "settingsSectionDaily": "Daily", @@ -424,6 +446,7 @@ "settingsUpgradeButton": "Upgrade", "settingsMenuNotifications": "Reminder Settings", "settingsMenuCheckUpdates": "Check for Updates", + "settingsMenuClearCache": "Clear Cache", "settingsLogoutTitle": "Log Out", "settingsLogoutConfirmMessage": "Are you sure you want to log out of this account?", "settingsLogoutConfirm": "Confirm Logout", @@ -438,6 +461,11 @@ "settingsDownloadLink": "Download link: {url}", "@settingsDownloadLink": {"placeholders": {"url": {}}}, "settingsUpdateCheckFailed": "Failed to check updates", + "settingsClearCacheTitle": "Clear Local Cache", + "settingsClearCacheMessage": "This will clear local cache and fetch fresh data. Continue?", + "settingsClearCacheAction": "Clear", + "settingsClearCacheSuccess": "Cache cleared. Refreshing data...", + "settingsClearCacheFailed": "Failed to clear cache. Please try again later", "settingsJobDetailTitle": "Job Detail", "settingsJobCreatePageTitle": "Create Recurring Plan", "settingsJobLoadFailed": "Load failed", @@ -618,13 +646,13 @@ "minutes": {"type": "int"} } }, - "calendarWeekdayMon": "Mon", - "calendarWeekdayTue": "Tue", - "calendarWeekdayWed": "Wed", - "calendarWeekdayThu": "Thu", - "calendarWeekdayFri": "Fri", - "calendarWeekdaySat": "Sat", - "calendarWeekdaySun": "Sun", + "calendarWeekdayMon": "Monday", + "calendarWeekdayTue": "Tuesday", + "calendarWeekdayWed": "Wednesday", + "calendarWeekdayThu": "Thursday", + "calendarWeekdayFri": "Friday", + "calendarWeekdaySat": "Saturday", + "calendarWeekdaySun": "Sunday", "calendarDetailDeleteTitle": "Delete Event", "calendarDetailDeleteMessage": "Are you sure you want to delete this event?", "calendarDetailDeleteConfirm": "Delete", @@ -714,13 +742,13 @@ } }, "calendarMonthToday": "Today", - "calendarMonthWeekdaySunShort": "S", - "calendarMonthWeekdayMonShort": "M", - "calendarMonthWeekdayTueShort": "T", - "calendarMonthWeekdayWedShort": "W", - "calendarMonthWeekdayThuShort": "T", - "calendarMonthWeekdayFriShort": "F", - "calendarMonthWeekdaySatShort": "S", + "calendarMonthWeekdaySunShort": "Sun", + "calendarMonthWeekdayMonShort": "Mon", + "calendarMonthWeekdayTueShort": "Tue", + "calendarMonthWeekdayWedShort": "Wed", + "calendarMonthWeekdayThuShort": "Thu", + "calendarMonthWeekdayFriShort": "Fri", + "calendarMonthWeekdaySatShort": "Sat", "calendarMonthYearLabel": "{year}", "@calendarMonthYearLabel": { "placeholders": { diff --git a/apps/lib/l10n/app_localizations.dart b/apps/lib/l10n/app_localizations.dart index 2de1739..dec7f6b 100644 --- a/apps/lib/l10n/app_localizations.dart +++ b/apps/lib/l10n/app_localizations.dart @@ -188,6 +188,12 @@ abstract class AppLocalizations { /// **'未知'** String get commonUnknown; + /// No description provided for @commonNone. + /// + /// In zh, this message translates to: + /// **'暂无'** + String get commonNone; + /// No description provided for @toastLabelSuccess. /// /// In zh, this message translates to: @@ -1388,6 +1394,42 @@ abstract class AppLocalizations { /// **'确定要删除此联系人吗?'** String get contactDeleteConfirmMessage; + /// No description provided for @contactDetailTitle. + /// + /// In zh, this message translates to: + /// **'联系人详情'** + String get contactDetailTitle; + + /// No description provided for @contactDetailLoadFailed. + /// + /// In zh, this message translates to: + /// **'加载联系人信息失败'** + String get contactDetailLoadFailed; + + /// No description provided for @contactDetailNotFound. + /// + /// In zh, this message translates to: + /// **'联系人不存在'** + String get contactDetailNotFound; + + /// No description provided for @contactDetailUsername. + /// + /// In zh, this message translates to: + /// **'用户名'** + String get contactDetailUsername; + + /// No description provided for @contactDetailPhone. + /// + /// In zh, this message translates to: + /// **'手机号'** + String get contactDetailPhone; + + /// No description provided for @contactDetailBio. + /// + /// In zh, this message translates to: + /// **'个人简介'** + String get contactDetailBio; + /// No description provided for @messagesLoadFailed. /// /// In zh, this message translates to: @@ -1580,6 +1622,24 @@ abstract class AppLocalizations { /// **'邀请人:{name}'** String messagesInviteSender(Object name); + /// No description provided for @messagesCalendarInviteActorLabel. + /// + /// In zh, this message translates to: + /// **'邀请人'** + String get messagesCalendarInviteActorLabel; + + /// No description provided for @messagesCalendarInviteTimeLabel. + /// + /// In zh, this message translates to: + /// **'时间'** + String get messagesCalendarInviteTimeLabel; + + /// No description provided for @messagesCalendarInviteDescriptionLabel. + /// + /// In zh, this message translates to: + /// **'说明'** + String get messagesCalendarInviteDescriptionLabel; + /// No description provided for @messagesInviteUnknownUser. /// /// In zh, this message translates to: @@ -1628,6 +1688,60 @@ abstract class AppLocalizations { /// **'同意'** String get messagesAccept; + /// No description provided for @messagesAcknowledge. + /// + /// In zh, this message translates to: + /// **'已知晓'** + String get messagesAcknowledge; + + /// No description provided for @messagesProtocolInvalid. + /// + /// In zh, this message translates to: + /// **'消息数据异常,请稍后重试'** + String get messagesProtocolInvalid; + + /// No description provided for @messagesProtocolInvalidCardTitle. + /// + /// In zh, this message translates to: + /// **'消息数据异常'** + String get messagesProtocolInvalidCardTitle; + + /// No description provided for @messagesProtocolInvalidCardDesc. + /// + /// In zh, this message translates to: + /// **'该消息缺少必要字段,无法按业务类型渲染'** + String get messagesProtocolInvalidCardDesc; + + /// No description provided for @messagesUnknownActor. + /// + /// In zh, this message translates to: + /// **'未知用户'** + String get messagesUnknownActor; + + /// No description provided for @messagesCalendarUpdatedBy. + /// + /// In zh, this message translates to: + /// **'{name} 更新了日历'** + String messagesCalendarUpdatedBy(Object name); + + /// No description provided for @messagesCalendarDeletedBy. + /// + /// In zh, this message translates to: + /// **'{name} 删除了日历'** + String messagesCalendarDeletedBy(Object name); + + /// No description provided for @messagesCalendarDeleted. + /// + /// In zh, this message translates to: + /// **'删除了日历事件'** + String get messagesCalendarDeleted; + + /// No description provided for @messagesCalendarCardDeletedWithTitle. + /// + /// In zh, this message translates to: + /// **'{title} 已删除'** + String messagesCalendarCardDeletedWithTitle(Object title); + /// No description provided for @messagesStatusPending. /// /// In zh, this message translates to: @@ -1964,6 +2078,12 @@ abstract class AppLocalizations { /// **'检查更新'** String get settingsMenuCheckUpdates; + /// No description provided for @settingsMenuClearCache. + /// + /// In zh, this message translates to: + /// **'清理缓存'** + String get settingsMenuClearCache; + /// No description provided for @settingsLogoutTitle. /// /// In zh, this message translates to: @@ -2030,6 +2150,36 @@ abstract class AppLocalizations { /// **'检查更新失败'** String get settingsUpdateCheckFailed; + /// No description provided for @settingsClearCacheTitle. + /// + /// In zh, this message translates to: + /// **'清理本地缓存'** + String get settingsClearCacheTitle; + + /// No description provided for @settingsClearCacheMessage. + /// + /// In zh, this message translates to: + /// **'将清理本地缓存并重新拉取最新数据,是否继续?'** + String get settingsClearCacheMessage; + + /// No description provided for @settingsClearCacheAction. + /// + /// In zh, this message translates to: + /// **'确认清理'** + String get settingsClearCacheAction; + + /// No description provided for @settingsClearCacheSuccess. + /// + /// In zh, this message translates to: + /// **'缓存已清理,正在重新拉取数据'** + String get settingsClearCacheSuccess; + + /// No description provided for @settingsClearCacheFailed. + /// + /// In zh, this message translates to: + /// **'清理缓存失败,请稍后重试'** + String get settingsClearCacheFailed; + /// No description provided for @settingsJobDetailTitle. /// /// In zh, this message translates to: @@ -3445,12 +3595,6 @@ abstract class AppLocalizations { /// **'{month}月{day}日'** String messagesCalendarCardTimeDate(int month, int day); - /// No description provided for @messagesCalendarCardDeletedWithTitle. - /// - /// In zh, this message translates to: - /// **'{title} 已删除'** - String messagesCalendarCardDeletedWithTitle(Object title); - /// No description provided for @messagesCalendarCardDeletedWithoutTitle. /// /// 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 6ea0fc5..3ac3c67 100644 --- a/apps/lib/l10n/app_localizations_en.dart +++ b/apps/lib/l10n/app_localizations_en.dart @@ -55,6 +55,9 @@ class AppLocalizationsEn extends AppLocalizations { @override String get commonUnknown => 'Unknown'; + @override + String get commonNone => 'None'; + @override String get toastLabelSuccess => 'Success'; @@ -718,6 +721,24 @@ class AppLocalizationsEn extends AppLocalizations { String get contactDeleteConfirmMessage => 'Are you sure to delete this contact?'; + @override + String get contactDetailTitle => 'Contact Detail'; + + @override + String get contactDetailLoadFailed => 'Failed to load contact info'; + + @override + String get contactDetailNotFound => 'Contact not found'; + + @override + String get contactDetailUsername => 'Username'; + + @override + String get contactDetailPhone => 'Phone'; + + @override + String get contactDetailBio => 'Bio'; + @override String get messagesLoadFailed => 'Failed to load messages, please try again'; @@ -823,6 +844,15 @@ class AppLocalizationsEn extends AppLocalizations { return 'Sender: $name'; } + @override + String get messagesCalendarInviteActorLabel => 'Inviter'; + + @override + String get messagesCalendarInviteTimeLabel => 'Time'; + + @override + String get messagesCalendarInviteDescriptionLabel => 'Description'; + @override String get messagesInviteUnknownUser => 'Unknown user'; @@ -854,6 +884,41 @@ class AppLocalizationsEn extends AppLocalizations { @override String get messagesAccept => 'Accept'; + @override + String get messagesAcknowledge => 'Acknowledge'; + + @override + String get messagesProtocolInvalid => + 'Message data is invalid, please try again later'; + + @override + String get messagesProtocolInvalidCardTitle => 'Invalid message data'; + + @override + String get messagesProtocolInvalidCardDesc => + 'This message is missing required fields and cannot be rendered'; + + @override + String get messagesUnknownActor => 'Unknown user'; + + @override + String messagesCalendarUpdatedBy(Object name) { + return '$name updated a calendar'; + } + + @override + String messagesCalendarDeletedBy(Object name) { + return '$name deleted a calendar'; + } + + @override + String get messagesCalendarDeleted => 'Deleted calendar event'; + + @override + String messagesCalendarCardDeletedWithTitle(Object title) { + return '$title deleted'; + } + @override String get messagesStatusPending => 'Pending'; @@ -1044,6 +1109,9 @@ class AppLocalizationsEn extends AppLocalizations { @override String get settingsMenuCheckUpdates => 'Check for Updates'; + @override + String get settingsMenuClearCache => 'Clear Cache'; + @override String get settingsLogoutTitle => 'Log Out'; @@ -1084,6 +1152,23 @@ class AppLocalizationsEn extends AppLocalizations { @override String get settingsUpdateCheckFailed => 'Failed to check updates'; + @override + String get settingsClearCacheTitle => 'Clear Local Cache'; + + @override + String get settingsClearCacheMessage => + 'This will clear local cache and fetch fresh data. Continue?'; + + @override + String get settingsClearCacheAction => 'Clear'; + + @override + String get settingsClearCacheSuccess => 'Cache cleared. Refreshing data...'; + + @override + String get settingsClearCacheFailed => + 'Failed to clear cache. Please try again later'; + @override String get settingsJobDetailTitle => 'Job Detail'; @@ -1540,25 +1625,25 @@ class AppLocalizationsEn extends AppLocalizations { } @override - String get calendarWeekdayMon => 'Mon'; + String get calendarWeekdayMon => 'Monday'; @override - String get calendarWeekdayTue => 'Tue'; + String get calendarWeekdayTue => 'Tuesday'; @override - String get calendarWeekdayWed => 'Wed'; + String get calendarWeekdayWed => 'Wednesday'; @override - String get calendarWeekdayThu => 'Thu'; + String get calendarWeekdayThu => 'Thursday'; @override - String get calendarWeekdayFri => 'Fri'; + String get calendarWeekdayFri => 'Friday'; @override - String get calendarWeekdaySat => 'Sat'; + String get calendarWeekdaySat => 'Saturday'; @override - String get calendarWeekdaySun => 'Sun'; + String get calendarWeekdaySun => 'Sunday'; @override String get calendarDetailDeleteTitle => 'Delete Event'; @@ -1769,25 +1854,25 @@ class AppLocalizationsEn extends AppLocalizations { String get calendarMonthToday => 'Today'; @override - String get calendarMonthWeekdaySunShort => 'S'; + String get calendarMonthWeekdaySunShort => 'Sun'; @override - String get calendarMonthWeekdayMonShort => 'M'; + String get calendarMonthWeekdayMonShort => 'Mon'; @override - String get calendarMonthWeekdayTueShort => 'T'; + String get calendarMonthWeekdayTueShort => 'Tue'; @override - String get calendarMonthWeekdayWedShort => 'W'; + String get calendarMonthWeekdayWedShort => 'Wed'; @override - String get calendarMonthWeekdayThuShort => 'T'; + String get calendarMonthWeekdayThuShort => 'Thu'; @override - String get calendarMonthWeekdayFriShort => 'F'; + String get calendarMonthWeekdayFriShort => 'Fri'; @override - String get calendarMonthWeekdaySatShort => 'S'; + String get calendarMonthWeekdaySatShort => 'Sat'; @override String calendarMonthYearLabel(int year) { @@ -1853,11 +1938,6 @@ class AppLocalizationsEn extends AppLocalizations { return '$month/$day'; } - @override - String messagesCalendarCardDeletedWithTitle(Object title) { - return '$title deleted'; - } - @override String get messagesCalendarCardDeletedWithoutTitle => 'Calendar event deleted'; diff --git a/apps/lib/l10n/app_localizations_zh.dart b/apps/lib/l10n/app_localizations_zh.dart index be8ed1a..85991c9 100644 --- a/apps/lib/l10n/app_localizations_zh.dart +++ b/apps/lib/l10n/app_localizations_zh.dart @@ -55,6 +55,9 @@ class AppLocalizationsZh extends AppLocalizations { @override String get commonUnknown => '未知'; + @override + String get commonNone => '暂无'; + @override String get toastLabelSuccess => '成功'; @@ -695,6 +698,24 @@ class AppLocalizationsZh extends AppLocalizations { @override String get contactDeleteConfirmMessage => '确定要删除此联系人吗?'; + @override + String get contactDetailTitle => '联系人详情'; + + @override + String get contactDetailLoadFailed => '加载联系人信息失败'; + + @override + String get contactDetailNotFound => '联系人不存在'; + + @override + String get contactDetailUsername => '用户名'; + + @override + String get contactDetailPhone => '手机号'; + + @override + String get contactDetailBio => '个人简介'; + @override String get messagesLoadFailed => '消息加载失败,请稍后重试'; @@ -797,6 +818,15 @@ class AppLocalizationsZh extends AppLocalizations { return '邀请人:$name'; } + @override + String get messagesCalendarInviteActorLabel => '邀请人'; + + @override + String get messagesCalendarInviteTimeLabel => '时间'; + + @override + String get messagesCalendarInviteDescriptionLabel => '说明'; + @override String get messagesInviteUnknownUser => '未知用户'; @@ -827,6 +857,39 @@ class AppLocalizationsZh extends AppLocalizations { @override String get messagesAccept => '同意'; + @override + String get messagesAcknowledge => '已知晓'; + + @override + String get messagesProtocolInvalid => '消息数据异常,请稍后重试'; + + @override + String get messagesProtocolInvalidCardTitle => '消息数据异常'; + + @override + String get messagesProtocolInvalidCardDesc => '该消息缺少必要字段,无法按业务类型渲染'; + + @override + String get messagesUnknownActor => '未知用户'; + + @override + String messagesCalendarUpdatedBy(Object name) { + return '$name 更新了日历'; + } + + @override + String messagesCalendarDeletedBy(Object name) { + return '$name 删除了日历'; + } + + @override + String get messagesCalendarDeleted => '删除了日历事件'; + + @override + String messagesCalendarCardDeletedWithTitle(Object title) { + return '$title 已删除'; + } + @override String get messagesStatusPending => '待处理'; @@ -1015,6 +1078,9 @@ class AppLocalizationsZh extends AppLocalizations { @override String get settingsMenuCheckUpdates => '检查更新'; + @override + String get settingsMenuClearCache => '清理缓存'; + @override String get settingsLogoutTitle => '退出登录'; @@ -1054,6 +1120,21 @@ class AppLocalizationsZh extends AppLocalizations { @override String get settingsUpdateCheckFailed => '检查更新失败'; + @override + String get settingsClearCacheTitle => '清理本地缓存'; + + @override + String get settingsClearCacheMessage => '将清理本地缓存并重新拉取最新数据,是否继续?'; + + @override + String get settingsClearCacheAction => '确认清理'; + + @override + String get settingsClearCacheSuccess => '缓存已清理,正在重新拉取数据'; + + @override + String get settingsClearCacheFailed => '清理缓存失败,请稍后重试'; + @override String get settingsJobDetailTitle => '任务详情'; @@ -1807,11 +1888,6 @@ class AppLocalizationsZh extends AppLocalizations { return '$month月$day日'; } - @override - String messagesCalendarCardDeletedWithTitle(Object title) { - return '$title 已删除'; - } - @override String get messagesCalendarCardDeletedWithoutTitle => '日历事件已删除'; diff --git a/apps/lib/l10n/app_zh.arb b/apps/lib/l10n/app_zh.arb index 22c760c..dbc2e47 100644 --- a/apps/lib/l10n/app_zh.arb +++ b/apps/lib/l10n/app_zh.arb @@ -20,6 +20,7 @@ } }, "commonUnknown": "未知", + "commonNone": "暂无", "toastLabelSuccess": "成功", "toastLabelWarning": "提醒", "toastLabelError": "错误", @@ -312,6 +313,12 @@ "contactFillRequired": "请填写昵称和手机号", "contactDeleteConfirmTitle": "删除联系人", "contactDeleteConfirmMessage": "确定要删除此联系人吗?", + "contactDetailTitle": "联系人详情", + "contactDetailLoadFailed": "加载联系人信息失败", + "contactDetailNotFound": "联系人不存在", + "contactDetailUsername": "用户名", + "contactDetailPhone": "手机号", + "contactDetailBio": "个人简介", "messagesLoadFailed": "消息加载失败,请稍后重试", "messagesSenderLoadFailed": "发送者信息加载失败,请下拉重试", "messagesFriendRequestMissing": "好友请求数据缺失", @@ -347,6 +354,9 @@ "messagesInviteUnnamedEvent": "未命名日程", "messagesInviteSender": "邀请人:{name}", "@messagesInviteSender": {"placeholders": {"name": {}}}, + "messagesCalendarInviteActorLabel": "邀请人", + "messagesCalendarInviteTimeLabel": "时间", + "messagesCalendarInviteDescriptionLabel": "说明", "messagesInviteUnknownUser": "未知用户", "messagesInviteTime": "消息时间:{time}", "@messagesInviteTime": {"placeholders": {"time": {}}}, @@ -358,6 +368,18 @@ "messagesInviteAlreadyHandled": "该邀请已处理,无需重复操作", "messagesReject": "拒绝", "messagesAccept": "同意", + "messagesAcknowledge": "已知晓", + "messagesProtocolInvalid": "消息数据异常,请稍后重试", + "messagesProtocolInvalidCardTitle": "消息数据异常", + "messagesProtocolInvalidCardDesc": "该消息缺少必要字段,无法按业务类型渲染", + "messagesUnknownActor": "未知用户", + "messagesCalendarUpdatedBy": "{name} 更新了日历", + "@messagesCalendarUpdatedBy": {"placeholders": {"name": {}}}, + "messagesCalendarDeletedBy": "{name} 删除了日历", + "@messagesCalendarDeletedBy": {"placeholders": {"name": {}}}, + "messagesCalendarDeleted": "删除了日历事件", + "messagesCalendarCardDeletedWithTitle": "{title} 已删除", + "@messagesCalendarCardDeletedWithTitle": {"placeholders": {"title": {}}}, "messagesStatusPending": "待处理", "settingsFeaturesTitle": "周期计划", "settingsSectionDaily": "每日", @@ -424,6 +446,7 @@ "settingsUpgradeButton": "升级", "settingsMenuNotifications": "提醒设置", "settingsMenuCheckUpdates": "检查更新", + "settingsMenuClearCache": "清理缓存", "settingsLogoutTitle": "退出登录", "settingsLogoutConfirmMessage": "确定退出当前账户吗?", "settingsLogoutConfirm": "确认退出", @@ -438,6 +461,11 @@ "settingsDownloadLink": "下载链接: {url}", "@settingsDownloadLink": {"placeholders": {"url": {}}}, "settingsUpdateCheckFailed": "检查更新失败", + "settingsClearCacheTitle": "清理本地缓存", + "settingsClearCacheMessage": "将清理本地缓存并重新拉取最新数据,是否继续?", + "settingsClearCacheAction": "确认清理", + "settingsClearCacheSuccess": "缓存已清理,正在重新拉取数据", + "settingsClearCacheFailed": "清理缓存失败,请稍后重试", "settingsJobDetailTitle": "任务详情", "settingsJobCreatePageTitle": "新建周期计划", "settingsJobLoadFailed": "加载失败", diff --git a/apps/lib/shared/widgets/notification/reminder_overlay.dart b/apps/lib/shared/widgets/notification/reminder_overlay.dart deleted file mode 100644 index d31363d..0000000 --- a/apps/lib/shared/widgets/notification/reminder_overlay.dart +++ /dev/null @@ -1,195 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:intl/intl.dart'; -import '../../../core/l10n/l10n.dart'; -import '../../../core/theme/design_tokens.dart'; -import '../../../core/notification/models/reminder_payload.dart'; -import '../app_button.dart'; -import '../../../core/notification/services/reminder_queue_manager.dart'; - -class ReminderOverlay extends StatefulWidget { - const ReminderOverlay({ - super.key, - required this.queueManager, - required this.onComplete, - required this.onSnooze, - required this.onArchive, - }); - - final ReminderQueueManager queueManager; - final VoidCallback onComplete; - final void Function(int minutes) onSnooze; - final VoidCallback onArchive; - - @override - State createState() => _ReminderOverlayState(); -} - -class _ReminderOverlayState extends State { - OverlayEntry? _overlayEntry; - - ReminderPayload? get _currentPayload => widget.queueManager.currentPayload; - - @override - void dispose() { - _hideSnoozeOptions(); - super.dispose(); - } - - void _hideSnoozeOptions() { - _overlayEntry?.remove(); - _overlayEntry = null; - } - - void _showSnoozeDropdown() { - _hideSnoozeOptions(); - - final box = context.findRenderObject() as RenderBox?; - if (box == null) return; - - final button = box.localToGlobal(Offset.zero); - - _overlayEntry = OverlayEntry( - builder: (context) => Positioned( - left: button.dx, - top: button.dy + box.size.height + 4, - width: 120, - child: Builder( - builder: (context) { - final colorScheme = Theme.of(context).colorScheme; - return Material( - elevation: 4, - borderRadius: BorderRadius.circular(8), - child: Container( - decoration: BoxDecoration( - color: colorScheme.surface, - borderRadius: BorderRadius.circular(8), - border: Border.all(color: colorScheme.outlineVariant), - ), - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - _SnoozeOption( - label: context.l10n.notificationSnoozeMinutes(5), - onTap: () { - _hideSnoozeOptions(); - _handleSnooze(5); - }, - ), - Divider(height: 1, color: colorScheme.outlineVariant), - _SnoozeOption( - label: context.l10n.notificationSnoozeMinutes(15), - onTap: () { - _hideSnoozeOptions(); - _handleSnooze(15); - }, - ), - ], - ), - ), - ); - }, - ), - ), - ); - - Overlay.of(context).insert(_overlayEntry!); - } - - void _handleComplete() { - widget.onArchive(); - widget.queueManager.dequeueCurrent(); - widget.onComplete(); - } - - void _handleSnooze(int minutes) { - widget.onSnooze(minutes); - widget.queueManager.dequeueCurrent(); - widget.onComplete(); - } - - @override - Widget build(BuildContext context) { - final colorScheme = Theme.of(context).colorScheme; - final payload = _currentPayload; - if (payload == null) { - return const SizedBox.shrink(); - } - - return Container( - color: colorScheme.surface, - child: SafeArea( - child: Padding( - padding: const EdgeInsets.all(AppSpacing.lg), - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - Text( - payload.title, - style: Theme.of(context).textTheme.headlineSmall?.copyWith( - color: colorScheme.onSurface, - fontWeight: FontWeight.w600, - ), - textAlign: TextAlign.center, - ), - const SizedBox(height: AppSpacing.sm), - Text( - DateFormat('HH:mm').format(DateTime.now()), - style: Theme.of(context).textTheme.titleLarge?.copyWith( - color: colorScheme.onSurfaceVariant, - ), - textAlign: TextAlign.center, - ), - const SizedBox(height: AppSpacing.xl), - Row( - children: [ - Expanded( - child: AppButton( - text: context.l10n.notificationSnoozeLater, - isOutlined: true, - onPressed: _showSnoozeDropdown, - ), - ), - const SizedBox(width: AppSpacing.md), - Expanded( - child: AppButton( - text: context.l10n.commonDone, - onPressed: _handleComplete, - ), - ), - ], - ), - ], - ), - ), - ), - ); - } -} - -class _SnoozeOption extends StatelessWidget { - const _SnoozeOption({required this.label, required this.onTap}); - - final String label; - final VoidCallback onTap; - - @override - Widget build(BuildContext context) { - final colorScheme = Theme.of(context).colorScheme; - return InkWell( - onTap: onTap, - child: Padding( - padding: const EdgeInsets.symmetric( - horizontal: AppSpacing.md, - vertical: AppSpacing.sm, - ), - child: Text( - label, - style: Theme.of( - context, - ).textTheme.bodyMedium?.copyWith(color: colorScheme.onSurface), - ), - ), - ); - } -} diff --git a/apps/lib/shared/widgets/shared_divider.dart b/apps/lib/shared/widgets/shared_divider.dart new file mode 100644 index 0000000..f6c0cf6 --- /dev/null +++ b/apps/lib/shared/widgets/shared_divider.dart @@ -0,0 +1,15 @@ +import 'package:flutter/material.dart'; + +class SharedDivider extends StatelessWidget { + const SharedDivider({super.key}); + + @override + Widget build(BuildContext context) { + final colorScheme = Theme.of(context).colorScheme; + return Container( + height: 1, + margin: const EdgeInsets.symmetric(horizontal: 14), + color: colorScheme.outlineVariant, + ); + } +} diff --git a/apps/pubspec.yaml b/apps/pubspec.yaml index bf84cb9..610060b 100644 --- a/apps/pubspec.yaml +++ b/apps/pubspec.yaml @@ -1,7 +1,7 @@ name: social_app description: "Social App - A Flutter mobile application" publish_to: 'none' -version: 0.1.1+5 +version: 0.1.2+4 environment: sdk: ^3.10.7 @@ -27,7 +27,6 @@ dependencies: timezone: ^0.9.4 image_picker: ^1.0.7 package_info_plus: ^8.0.3 - drag_and_drop_lists: ^0.4.2 url_launcher: ^6.3.1 dev_dependencies: diff --git a/apps/test/core/inbox/inbox_sync_store_test.dart b/apps/test/core/inbox/inbox_sync_store_test.dart new file mode 100644 index 0000000..d5457ab --- /dev/null +++ b/apps/test/core/inbox/inbox_sync_store_test.dart @@ -0,0 +1,254 @@ +import 'dart:async'; +import 'dart:convert'; + +import 'package:dio/dio.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:social_app/core/inbox/inbox_sync_store.dart'; +import 'package:social_app/data/network/i_api_client.dart'; +import 'package:social_app/features/messages/data/apis/inbox_api.dart' + show InboxApi; +import 'package:social_app/features/messages/data/models/inbox_message.dart'; +import 'package:social_app/features/messages/data/repositories/inbox_repository.dart'; + +class _FakeInboxRepository implements InboxRepository { + List unread = []; + List read = []; + + @override + Future> getMessages({ + bool? isRead, + bool forceRefresh = false, + }) async { + if (isRead == true) { + return read; + } + if (isRead == false) { + return unread; + } + return [...unread, ...read]; + } + + @override + Future markAsRead(String messageId) async { + throw UnimplementedError(); + } +} + +class _FakeApiClient implements IApiClient { + final StreamController streamController = + StreamController.broadcast(); + + @override + Future> getSseLines( + String path, { + Map? headers, + }) async { + return streamController.stream; + } + + @override + Future> delete(String path, {data, Options? options}) { + throw UnimplementedError(); + } + + @override + Future> get( + String path, { + Map? queryParameters, + Options? options, + }) { + throw UnimplementedError(); + } + + @override + Future> patch(String path, {data, Options? options}) { + throw UnimplementedError(); + } + + @override + Future> post(String path, {data, Options? options}) { + throw UnimplementedError(); + } + + @override + Future> put(String path, {data, Options? options}) { + throw UnimplementedError(); + } +} + +InboxMessage _message({ + required String id, + required bool isRead, + InboxMessageStatus status = InboxMessageStatus.pending, +}) { + return InboxMessage( + id: id, + recipientId: 'u1', + senderId: 'u2', + messageType: InboxMessageType.calendar, + scheduleItemId: 's1', + friendshipId: null, + content: const {'type': 'invite'}, + isRead: isRead, + status: status, + createdAt: DateTime.parse('2026-03-30T07:00:00Z'), + ); +} + +Future _emitEnvelope( + _FakeApiClient api, + Map envelope, { + String eventType = 'INBOX_MESSAGE_CREATED', + String streamId = '1743313300000-0', +}) async { + api.streamController.add('id: $streamId'); + api.streamController.add('event: $eventType'); + api.streamController.add('data: ${jsonEncode(envelope)}'); + api.streamController.add(''); + await Future.delayed(const Duration(milliseconds: 30)); +} + +void main() { + test('InboxSyncStore increments unread count on created event', () async { + final repo = _FakeInboxRepository(); + final apiClient = _FakeApiClient(); + final store = InboxSyncStore( + repository: repo, + inboxApi: InboxApi(apiClient), + ); + addTearDown(() async { + await store.stop(); + await apiClient.streamController.close(); + store.dispose(); + }); + + await store.resetForUser('u1'); + await Future.delayed(const Duration(milliseconds: 60)); + + await _emitEnvelope(apiClient, { + 'event_id': 'e1', + 'occurred_at': '2026-03-30T07:00:00Z', + 'user_id': 'u1', + 'message_id': 'm1', + 'op': 'created', + 'version': 1774854000000, + 'data': { + 'message': { + 'id': 'm1', + 'recipient_id': 'u1', + 'sender_id': 'u2', + 'message_type': 'calendar', + 'schedule_item_id': 's1', + 'friendship_id': null, + 'content': {'type': 'invite'}, + 'is_read': false, + 'status': 'pending', + 'created_at': '2026-03-30T07:00:00Z', + }, + }, + }); + + expect(store.unreadCount, 1); + expect(store.unreadMessages.single.id, 'm1'); + }); + + test( + 'InboxSyncStore decrements unread count on read_changed event', + () async { + final repo = _FakeInboxRepository() + ..unread = [_message(id: 'm1', isRead: false)]; + final apiClient = _FakeApiClient(); + final store = InboxSyncStore( + repository: repo, + inboxApi: InboxApi(apiClient), + ); + addTearDown(() async { + await store.stop(); + await apiClient.streamController.close(); + store.dispose(); + }); + + await store.resetForUser('u1'); + await Future.delayed(const Duration(milliseconds: 60)); + expect(store.unreadCount, 1); + + await _emitEnvelope( + apiClient, + { + 'event_id': 'e2', + 'occurred_at': '2026-03-30T07:00:01Z', + 'user_id': 'u1', + 'message_id': 'm1', + 'op': 'read_changed', + 'version': 1774854001000, + 'data': {'is_read': true}, + }, + eventType: 'INBOX_MESSAGE_READ_CHANGED', + streamId: '1743313301000-0', + ); + + expect(store.unreadCount, 0); + expect(store.readMessages.single.id, 'm1'); + }, + ); + + test('InboxSyncStore ignores stale version events', () async { + final repo = _FakeInboxRepository() + ..unread = [_message(id: 'm1', isRead: false)]; + final apiClient = _FakeApiClient(); + final store = InboxSyncStore( + repository: repo, + inboxApi: InboxApi(apiClient), + ); + addTearDown(() async { + await store.stop(); + await apiClient.streamController.close(); + store.dispose(); + }); + + await store.resetForUser('u1'); + await Future.delayed(const Duration(milliseconds: 60)); + + await _emitEnvelope( + apiClient, + { + 'event_id': 'e3', + 'occurred_at': '2026-03-30T07:00:00Z', + 'user_id': 'u1', + 'message_id': 'm1', + 'op': 'read_changed', + 'version': 1774853900000, + 'data': {'is_read': true}, + }, + eventType: 'INBOX_MESSAGE_READ_CHANGED', + streamId: '1743313200000-0', + ); + + expect(store.unreadCount, 1); + }); + + test('InboxSyncStore clears stale state on user switch', () async { + final repo = _FakeInboxRepository() + ..unread = [_message(id: 'm1', isRead: false)]; + final apiClient = _FakeApiClient(); + final store = InboxSyncStore( + repository: repo, + inboxApi: InboxApi(apiClient), + ); + addTearDown(() async { + await store.stop(); + await apiClient.streamController.close(); + store.dispose(); + }); + + await store.resetForUser('u1'); + await Future.delayed(const Duration(milliseconds: 60)); + expect(store.unreadCount, 1); + + repo.unread = []; + await store.resetForUser('u2'); + await Future.delayed(const Duration(milliseconds: 60)); + + expect(store.unreadCount, 0); + }); +} diff --git a/apps/test/core/notification/reminder_alarm_test.dart b/apps/test/core/notification/reminder_alarm_test.dart new file mode 100644 index 0000000..4deaf2d --- /dev/null +++ b/apps/test/core/notification/reminder_alarm_test.dart @@ -0,0 +1,32 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:social_app/core/notification/models/reminder_alarm.dart'; + +void main() { + test('ReminderAlarm JSON roundtrip keeps key fields', () { + final alarm = ReminderAlarm( + eventId: 'evt_1', + title: 'Standup', + startAt: DateTime(2026, 3, 30, 10, 0), + endAt: DateTime(2026, 3, 30, 10, 30), + timezone: 'Asia/Shanghai', + reminderMinutes: 15, + fireAt: DateTime(2026, 3, 30, 9, 45), + fireTimeBucket: 29112645, + version: 2, + location: 'Meeting Room A', + notes: 'Daily sync', + ); + + final decoded = ReminderAlarm.fromJson(alarm.toJson()); + + expect(decoded.eventId, alarm.eventId); + expect(decoded.title, alarm.title); + expect(decoded.startAt, alarm.startAt); + expect(decoded.endAt, alarm.endAt); + expect(decoded.timezone, alarm.timezone); + expect(decoded.reminderMinutes, alarm.reminderMinutes); + expect(decoded.fireAt, alarm.fireAt); + expect(decoded.fireTimeBucket, alarm.fireTimeBucket); + expect(decoded.version, alarm.version); + }); +} diff --git a/apps/test/core/notification/reminder_reconcile_service_test.dart b/apps/test/core/notification/reminder_reconcile_service_test.dart new file mode 100644 index 0000000..5ff341f --- /dev/null +++ b/apps/test/core/notification/reminder_reconcile_service_test.dart @@ -0,0 +1,66 @@ +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_scheduler_service.dart'; + +void main() { + test('buildAlarms uses remindAt cadence until endAt', () { + final event = ReminderEventSnapshot( + eventId: 'evt_2', + title: 'Planning', + startAt: DateTime(2026, 3, 30, 10, 0), + endAt: DateTime(2026, 3, 30, 10, 40), + timezone: 'Asia/Shanghai', + reminderMinutes: 15, + ); + + final alarms = ReminderSchedulerService.buildAlarmsForEvent( + event, + now: DateTime(2026, 3, 30, 9, 0), + ); + + expect(alarms.length, 6); + expect(alarms.first.fireAt, DateTime(2026, 3, 30, 9, 45)); + 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); + + final alarms = ReminderSchedulerService.buildAlarmsForEvent( + event, + now: now, + ); + + expect(alarms, isNotEmpty); + expect(alarms.first.fireAt, now.add(const Duration(seconds: 5))); + }, + ); + + test('buildAlarms returns empty when event already ended', () { + final event = ReminderEventSnapshot( + eventId: 'evt_4', + title: 'Expired', + startAt: DateTime(2026, 3, 30, 10, 0), + endAt: DateTime(2026, 3, 30, 10, 10), + timezone: 'Asia/Shanghai', + reminderMinutes: 5, + ); + + final alarms = ReminderSchedulerService.buildAlarmsForEvent( + event, + now: DateTime(2026, 3, 30, 10, 11), + ); + + expect(alarms, isEmpty); + }); +} diff --git a/apps/test/features/chat/presentation/bloc/chat_bloc_test.dart b/apps/test/features/chat/presentation/bloc/chat_bloc_test.dart index 2a29328..6111590 100644 --- a/apps/test/features/chat/presentation/bloc/chat_bloc_test.dart +++ b/apps/test/features/chat/presentation/bloc/chat_bloc_test.dart @@ -73,6 +73,7 @@ class _FakeAgUiService extends AgUiService { loadHistoryHandler; int loadHistoryCalls = 0; + final List setUserContextCalls = []; void emitEventForTest(AgUiEvent event) { onEvent(event); @@ -104,7 +105,9 @@ class _FakeAgUiService extends AgUiService { } @override - Future setUserContext(String? userId) async {} + Future setUserContext(String? userId) async { + setUserContextCalls.add(userId); + } } HistorySnapshot _snapshot( @@ -146,9 +149,14 @@ void main() { () async { final service = _FakeAgUiService(); final completer = Completer(); + var loadCall = 0; service.loadHistoryHandler = ({DateTime? beforeDate, bool forceRefresh = false}) { - return completer.future; + loadCall += 1; + if (loadCall == 1) { + return completer.future; + } + return Future.value(_snapshot(const [])); }; final bloc = ChatBloc( @@ -178,6 +186,82 @@ void main() { }, ); + test('switchUser loads history after resetting state', () async { + final service = _FakeAgUiService(); + service.loadHistoryHandler = + ({DateTime? beforeDate, bool forceRefresh = false}) async { + final now = DateTime.now(); + return _snapshot([ + _historyMessage( + id: 'history-1', + seq: 1, + role: 'assistant', + content: 'welcome back', + timestamp: now, + ), + ]); + }; + + final bloc = ChatBloc(service: service, chatApi: _NoopChatApi()); + await bloc.switchUser('user-a'); + + expect(service.setUserContextCalls, ['user-a']); + expect(service.loadHistoryCalls, 1); + expect(bloc.state.items, hasLength(1)); + expect( + bloc.state.items.single, + isA().having( + (item) => item.content, + 'content', + 'welcome back', + ), + ); + }); + + test('switchUser keeps flow when history load fails', () async { + final service = _FakeAgUiService(); + service.loadHistoryHandler = + ({DateTime? beforeDate, bool forceRefresh = false}) { + throw StateError('history unavailable'); + }; + + final bloc = ChatBloc(service: service, chatApi: _NoopChatApi()); + await bloc.switchUser('user-a'); + + expect(service.setUserContextCalls, ['user-a']); + expect(service.loadHistoryCalls, 1); + expect(bloc.state.error, contains('history unavailable')); + }); + + test( + 'tool calendar_write success triggers calendar refresh callback', + () async { + final service = _FakeAgUiService(); + var refreshCalls = 0; + final bloc = ChatBloc( + service: service, + chatApi: _NoopChatApi(), + onCalendarMutated: () async { + refreshCalls += 1; + }, + ); + + service.emitEventForTest( + ToolCallResultEvent( + messageId: 'msg-1', + toolCallId: 'call-1', + toolName: 'calendar_write', + resultSummary: 'ok', + status: 'success', + ), + ); + await Future.delayed(Duration.zero); + + expect(refreshCalls, 1); + expect(bloc.state.isLoading, isFalse); + }, + ); + test( 'sendMessage recovers from premature SSE close with polled history', () async { diff --git a/backend/src/core/agentscope/runtime/runner.py b/backend/src/core/agentscope/runtime/runner.py index 4bddc9a..71e0eb1 100644 --- a/backend/src/core/agentscope/runtime/runner.py +++ b/backend/src/core/agentscope/runtime/runner.py @@ -346,7 +346,7 @@ class AgentScopeRunner: *messages_for_router, ], output_model=RouterAgentOutput, - retries=0, + retries=3, ) response_msg = Msg( name="router", diff --git a/backend/src/core/agentscope/tools/custom/calendar.py b/backend/src/core/agentscope/tools/custom/calendar.py index cf4102a..af31f3f 100644 --- a/backend/src/core/agentscope/tools/custom/calendar.py +++ b/backend/src/core/agentscope/tools/custom/calendar.py @@ -1,3 +1,4 @@ +import json from typing import Annotated, Any, Literal, cast from uuid import UUID @@ -21,6 +22,7 @@ from core.agentscope.tools.tool_call_context import get_current_tool_call_id from schemas.agent.runtime_models import ErrorInfo, ToolAgentOutput, ToolStatus from v1.schedule_items.schemas import ( ScheduleItemCreateRequest, + ScheduleItemListRequest, ScheduleItemShareRequest, ScheduleItemStatus, ScheduleItemUpdateRequest, @@ -98,6 +100,13 @@ class CalendarWriteBatchArgs(BaseModel): operations: list[CalendarWriteOperation] = Field(min_length=1, max_length=20) +class CalendarShareArgs(BaseModel): + model_config = ConfigDict(extra="forbid") + + event_id: str + invitees: list[CalendarShareInvitee] = Field(min_length=1) + + def _validate_runtime_context( *, tool_name: str, @@ -116,69 +125,60 @@ def _validate_runtime_context( return None -def _format_event_brief(event_items: list[dict[str, Any]], limit: int = 3) -> str: - briefs: list[str] = [] - for item in event_items[:limit]: - event_id = str(item.get("id") or "") - title = str(item.get("title") or "") - start_at = str(item.get("startAt") or "") - end_at = str(item.get("endAt") or "") - timezone = str(item.get("timezone") or "") - status = str(item.get("status") or "") - description = str(item.get("description") or "") - location = str(item.get("location") or "") - reminder_minutes = item.get("reminderMinutes") - color = str(item.get("color") or "") - source_type = str(item.get("sourceType") or "") - updated_at = str(item.get("updatedAt") or "") - permission = item.get("permission") - is_owner = item.get("isOwner") - if not event_id: - continue - briefs.append( - "{" - f"id={event_id},title={title},startAt={start_at},endAt={end_at}," - f"timezone={timezone},status={status},description={description}," - f"location={location},reminderMinutes={reminder_minutes},color={color}," - f"sourceType={source_type},updatedAt={updated_at},permission={permission}," - f"isOwner={is_owner}" - "}" - ) - return ",".join(briefs) - - async def calendar_read( - query: Annotated[ - str | None, - Field(description="Optional keyword to filter calendar events."), - ] = None, - page: Annotated[ - int, - Field(description="Page number, starting from 1.", ge=1), - ] = 1, - page_size: Annotated[ - int, - Field(description="Number of items per page (1-100).", ge=1, le=100), - ] = 20, + start_at: Annotated[ + str, + Field( + description="Start of date range in ISO8601 with timezone, e.g. 2026-03-30T00:00:00+08:00." + ), + ], + end_at: Annotated[ + str, + Field( + description="End of date range in ISO8601 with timezone, e.g. 2026-03-30T23:59:59+08:00." + ), + ], session: Any = None, owner_id: Any = None, ) -> ToolResponse: - """Read calendar events with optional keyword filtering and pagination. + """Read calendar events within a date range. - Status semantics for returned events: - - active: Event is actionable. - - archived: Event is historical/expired and should not trigger reminders. + Returns subscribed calendar events (owned or shared) with permission info. + + Status: active=actionable, archived=past/expired. + + Permission flags: is_owner, can_view, can_edit, can_invite, can_delete. Args: - query: Optional keyword used to filter events by text fields. - page: Page number starting from 1. - page_size: Number of items per page, between 1 and 100. + start_at: Start of date range (required). + end_at: End of date range (required). Returns: - ToolResponse with serialized ToolAgentOutput payload. + ToolResponse with JSON result: + { + "total": int, + "items": [{ + "id": "uuid", + "owner_id": "uuid", + "title": "string", + "description": "string|null", + "start_at": "ISO8601 datetime", + "end_at": "ISO8601 datetime|null", + "timezone": "IANA timezone", + "status": "active|archived", + "source_type": "manual|imported|agent_generated", + "permission": {"can_view", "can_edit", "can_invite", "can_delete", "is_owner"}, + "is_owner": boolean, + "metadata": {color, location, reminder_minutes}|null, + "subscribers": [{user_id, username, phone, permission, status}], + "created_at": "ISO8601 datetime", + "updated_at": "ISO8601 datetime" + }] + } """ tool_name = "calendar_read" - tool_call_args = {"query": query, "page": page, "page_size": page_size} + tool_call_args: dict[str, Any] = {"start_at": start_at, "end_at": end_at} + runtime_error = _validate_runtime_context( tool_name=tool_name, tool_call_args=tool_call_args, @@ -189,30 +189,30 @@ async def calendar_read( return runtime_error try: + parsed_start = parse_iso_datetime(start_at) + parsed_end = parse_iso_datetime(end_at) + if parsed_start is None or parsed_end is None: + raise ValueError("start_at 和 end_at 都是必填项") + if parsed_start >= parsed_end: + raise ValueError("start_at 必须早于 end_at") + service = create_schedule_service( cast(AsyncSession, session), cast(UUID, owner_id) ) - items, total = await service.list_paginated( - page=page, - page_size=page_size, - query=query, - ) - total_pages = (total + page_size - 1) // page_size if total else 0 + request = ScheduleItemListRequest(start_at=parsed_start, end_at=parsed_end) + items = await service.list_by_date_range(request) event_items = [schedule_event_to_dict(item) for item in items] - event_brief = _format_event_brief(event_items) - summary = ( - f"status=success total={total} total_pages={total_pages or 1} " - f"returned={len(event_items)} has_next={str(page < (total_pages or 1)).lower()}" + result = json.dumps( + {"total": len(event_items), "items": event_items}, + ensure_ascii=False, ) - if event_brief: - summary = f"{summary} items=[{event_brief}]" return dump_tool_output( ToolAgentOutput( tool_name=tool_name, tool_call_id=get_current_tool_call_id(tool_name=tool_name), tool_call_args=tool_call_args, status=ToolStatus.SUCCESS, - result=summary, + result=result, ) ) except Exception as exc: @@ -532,10 +532,25 @@ async def calendar_share( ToolResponse with serialized ToolAgentOutput payload. """ tool_name = "calendar_share" + try: + parsed_args = CalendarShareArgs.model_validate( + {"event_id": event_id, "invitees": invitees} + ) + except Exception as exc: # noqa: BLE001 + code, message, retryable = map_calendar_exception(exc) + return calendar_error_output( + tool_name=tool_name, + tool_call_args={"event_id": event_id, "invitees": invitees}, + code=code, + message=message, + retryable=retryable, + ) + tool_call_args = { - "event_id": event_id, + "event_id": parsed_args.event_id, "invitees": [ - invitee.model_dump(mode="json", by_alias=True) for invitee in invitees + invitee.model_dump(mode="json", by_alias=True) + for invitee in parsed_args.invitees ], } runtime_error = _validate_runtime_context( @@ -551,11 +566,11 @@ async def calendar_share( service = create_schedule_service( cast(AsyncSession, session), cast(UUID, owner_id) ) - target_uuid = UUID(event_id) + target_uuid = UUID(parsed_args.event_id) invited: list[str] = [] result_items: list[dict[str, str]] = [] - for invitee in invitees: + for invitee in parsed_args.invitees: raw_phone = invitee.phone.strip() normalized_phone = raw_phone for separator in (" ", "-", "(", ")"): diff --git a/backend/src/core/agentscope/tools/utils/calendar_domain.py b/backend/src/core/agentscope/tools/utils/calendar_domain.py index 1abdbe5..503ff96 100644 --- a/backend/src/core/agentscope/tools/utils/calendar_domain.py +++ b/backend/src/core/agentscope/tools/utils/calendar_domain.py @@ -4,6 +4,7 @@ import re from datetime import datetime, timezone from typing import Any from uuid import UUID +from zoneinfo import ZoneInfo from fastapi import HTTPException from sqlalchemy.ext.asyncio import AsyncSession @@ -12,8 +13,9 @@ from core.auth.models import CurrentUser from core.http.errors import ApiProblemError from v1.inbox_messages.repository import SQLAlchemyInboxMessageRepository from v1.schedule_items.repository import SQLAlchemyScheduleItemRepository -from v1.schedule_items.schemas import ScheduleItemMetadata +from v1.schedule_items.schemas import ScheduleItemMetadata, parse_permission from v1.schedule_items.service import ScheduleItemService +from v1.users.repository import SQLAlchemyUserRepository _HEX_COLOR_PATTERN = re.compile(r"^#[0-9A-Fa-f]{6}$") @@ -39,31 +41,66 @@ def create_schedule_service( session=session, current_user=CurrentUser(id=owner_id), inbox_repository=SQLAlchemyInboxMessageRepository(session), + user_repository=SQLAlchemyUserRepository(session), ) +def _convert_to_event_timezone(dt: datetime, event_timezone: str) -> datetime: + if dt.tzinfo is None: + dt = dt.replace(tzinfo=timezone.utc) + tz = ZoneInfo(event_timezone) if event_timezone else ZoneInfo("UTC") + return dt.astimezone(tz) + + def schedule_event_to_dict(event: object) -> dict[str, Any]: - event_id = str(getattr(event, "id")) + event_timezone = str(getattr(event, "timezone") or "UTC") + start_at_utc = getattr(event, "start_at") + end_at_utc = getattr(event, "end_at") + permission_int = getattr(event, "permission", 1) + is_owner = getattr(event, "is_owner", permission_int == 15) metadata = getattr(event, "metadata", None) + subscribers = getattr(event, "subscribers", []) or [] + + def _serialize_dt(dt: datetime | None) -> str | None: + if dt is None: + return None + return _convert_to_event_timezone(dt, event_timezone).isoformat() + + def _serialize_subscriber(sub: object) -> dict[str, Any]: + return { + "user_id": str(getattr(sub, "user_id", "")), + "username": getattr(sub, "username", None), + "avatar_url": getattr(sub, "avatar_url", None), + "phone": getattr(sub, "phone", None), + "permission": getattr(sub, "permission", 1), + "status": str(getattr(sub, "status", "active")), + "subscribed_at": _serialize_dt(getattr(sub, "subscribed_at", None)), + } + status_value = getattr(event, "status", None) - if hasattr(status_value, "value"): - status_value = getattr(status_value, "value") - location_value = getattr(metadata, "location", None) - color_value = getattr(metadata, "color", None) or "#4F46E5" - reminder_minutes_value = getattr(metadata, "reminder_minutes", None) + if status_value is not None and hasattr(status_value, "value"): + status_value = status_value.value + + source_type_value = getattr(event, "source_type", None) + if source_type_value is not None and hasattr(source_type_value, "value"): + source_type_value = source_type_value.value + return { - "id": event_id, - "title": getattr(event, "title"), - "description": getattr(event, "description"), - "startAt": getattr(event, "start_at").isoformat(), - "endAt": getattr(event, "end_at").isoformat() - if getattr(event, "end_at") is not None - else None, - "timezone": getattr(event, "timezone"), + "id": str(getattr(event, "id", "")), + "owner_id": str(getattr(event, "owner_id", "")), + "title": getattr(event, "title", ""), + "description": getattr(event, "description", None), + "start_at": _serialize_dt(start_at_utc), + "end_at": _serialize_dt(end_at_utc), + "timezone": event_timezone, + "metadata": metadata.model_dump(mode="json") if metadata else None, "status": status_value, - "location": location_value, - "color": color_value, - "reminderMinutes": reminder_minutes_value, + "source_type": source_type_value, + "created_at": _serialize_dt(getattr(event, "created_at", None)), + "updated_at": _serialize_dt(getattr(event, "updated_at", None)), + "permission": parse_permission(permission_int), + "is_owner": is_owner, + "subscribers": [_serialize_subscriber(sub) for sub in subscribers], } diff --git a/backend/src/core/config/static/automation/memory_extraction.yaml b/backend/src/core/config/static/automation/memory_extraction.yaml index e351332..c4abc4a 100644 --- a/backend/src/core/config/static/automation/memory_extraction.yaml +++ b/backend/src/core/config/static/automation/memory_extraction.yaml @@ -1,33 +1,34 @@ input_template: | - 你正在执行一次“自动化记忆回顾与整理”任务。 + 你正在执行一次"自动化记忆回顾与整理"任务。 - 任务目标: - 1) 回顾最近两天的聊天与上下文,识别用户长期偏好、习惯和关键事实的变化。 - 2) 对已经失效、被否定或明显过期的信息执行遗忘。 - 3) 对新增且有证据支持的信息执行写入。 - 4) 严禁编造;没有证据就不要写入。 - 5) 只更新最小必要字段,避免过度覆盖。 + 任务目标: + 1) 回顾最近两天的聊天与上下文,识别用户长期偏好、习惯和关键事实的变化。 + 2) 对已经失效、被否定或明显过期的信息执行遗忘。 + 3) 对新增且有证据支持的信息执行写入。 + 4) 严禁编造;没有证据就不要写入。 + 5) 只更新最小必要字段,避免过度覆盖。 - 输出要求: - - 必须使用以下固定格式输出;每一行都要有: - 【记忆回顾】<一句人性化总结,说明今天主要发生了什么> - 【新增记忆】<按“X条:要点1;要点2”描述;没有则写“0条”> - 【遗忘记忆】<按“X条:要点1;要点2”描述;没有则写“0条”> - 【未来展望】<基于本次记忆变化,给出1-2条温和、可执行的后续建议;若暂无建议则说明“可继续观察”> + 输出要求: + - 必须使用以下固定格式输出: + <----------【周期任务输出】----------> + 【记忆回顾】<一句人性化总结,说明今天主要发生了什么> + 【新增记忆】<按"X条:要点1;要点2"描述;没有则写"0条"> + 【遗忘记忆】<按"X条:要点1;要点2"描述;没有则写"0条"> + 【未来展望】<基于本次记忆变化,给出1-2条温和、可执行的后续建议;若暂无建议则说明"可继续观察"> - 表达风格: - - 语言自然、温和、可读,像助理在做每日回顾。 - - 结论先行,避免空话,不要输出与任务无关的闲聊内容。 + 表达风格: + - 语言自然、温和、可读,像助理在做每日回顾。 + - 结论先行,避免空话,不要输出与任务无关的闲聊内容。 enabled_tools: - - memory.write - - memory.forget + - memory.write + - memory.forget context: - source: latest_chat - window_mode: day - window_count: 2 + source: latest_chat + window_mode: day + window_count: 2 schedule: - type: daily - run_at: - hour: 8 - minute: 0 - weekdays: null + type: daily + run_at: + hour: 8 + minute: 0 + weekdays: null diff --git a/backend/src/core/runtime/cli.py b/backend/src/core/runtime/cli.py index cf6adb9..0fe7a9a 100644 --- a/backend/src/core/runtime/cli.py +++ b/backend/src/core/runtime/cli.py @@ -5,7 +5,7 @@ import subprocess import sys from pathlib import Path -from apscheduler.schedulers.blocking import BlockingScheduler +from apscheduler.schedulers.asyncio import AsyncIOScheduler from apscheduler.triggers.interval import IntervalTrigger from core.automation.scheduler import run_automation_scheduler_scan from core.config.initial.init_data import initialize_data @@ -118,22 +118,30 @@ async def run_automation_scheduler_forever() -> None: batch_limit=batch_limit, ) - def scan_job() -> None: + async def scan_job() -> None: try: - asyncio.run(run_automation_scheduler_scan(limit=batch_limit)) + await run_automation_scheduler_scan(limit=batch_limit) except Exception as exc: logger.exception("Automation scheduler scan failed", error=str(exc)) - scheduler = BlockingScheduler() + scheduler = AsyncIOScheduler() scheduler.add_job( scan_job, trigger=IntervalTrigger(seconds=interval_seconds), id="automation_scheduler_scan", name="Automation scheduler scan", replace_existing=True, + max_instances=1, + coalesce=True, ) scheduler.start() + stop_event = asyncio.Event() + try: + await stop_event.wait() + finally: + scheduler.shutdown(wait=False) + def main() -> int: """CLI entry point.""" diff --git a/backend/src/schemas/agent/runtime_models.py b/backend/src/schemas/agent/runtime_models.py index 8d47c06..fb8c4a2 100644 --- a/backend/src/schemas/agent/runtime_models.py +++ b/backend/src/schemas/agent/runtime_models.py @@ -100,6 +100,13 @@ class ConstraintItem(BaseModel): value: str required: bool = True + @field_validator("value", mode="before") + @classmethod + def normalize_value(cls, value: object) -> object: + if isinstance(value, bool | int | float): + return str(value) + return value + class NormalizedTaskInput(BaseModel): model_config = ConfigDict(extra="forbid") diff --git a/backend/src/schemas/agent/ui_hints.py b/backend/src/schemas/agent/ui_hints.py index a5624ec..54f461b 100644 --- a/backend/src/schemas/agent/ui_hints.py +++ b/backend/src/schemas/agent/ui_hints.py @@ -211,6 +211,20 @@ class UiHintListItem(UiHintBaseModel): status: UiHintStatus | None = Field(default=None) actions: list[UiHintAction] = Field(default_factory=list) + @field_validator("status", mode="before") + @classmethod + def normalize_status(cls, value: object) -> object: + if value is None: + return None + if isinstance(value, dict): + status_type = value.get("type") + if isinstance(status_type, str): + return status_type + status_value = value.get("status") + if isinstance(status_value, str): + return status_value + return value + class UiHintSection(UiHintBaseModel): title: str | None = Field(default=None, description="Section title.") diff --git a/backend/src/v1/friendships/dependencies.py b/backend/src/v1/friendships/dependencies.py index 362014f..f712b8b 100644 --- a/backend/src/v1/friendships/dependencies.py +++ b/backend/src/v1/friendships/dependencies.py @@ -7,6 +7,7 @@ from sqlalchemy.ext.asyncio import AsyncSession from core.auth.models import CurrentUser from core.db import get_db +from v1.auth.gateway import SupabaseAuthGateway from v1.friendships.repository import SQLAlchemyFriendshipRepository from v1.friendships.service import FriendshipService from v1.users.dependencies import get_current_user @@ -25,9 +26,11 @@ async def get_friendship_service( ) -> FriendshipService: friendship_repository = SQLAlchemyFriendshipRepository(session) user_repository = SQLAlchemyUserRepository(session) + auth_gateway = SupabaseAuthGateway() return FriendshipService( repository=friendship_repository, user_repository=user_repository, session=session, current_user=current_user, + auth_gateway=auth_gateway, ) diff --git a/backend/src/v1/friendships/service.py b/backend/src/v1/friendships/service.py index 8f7cbb1..639f308 100644 --- a/backend/src/v1/friendships/service.py +++ b/backend/src/v1/friendships/service.py @@ -9,10 +9,17 @@ from sqlalchemy.exc import SQLAlchemyError from core.auth.models import CurrentUser from core.db.base_service import BaseService from core.http.errors import ApiProblemError, problem_payload +from v1.inbox_messages.realtime import ( + InboxMessageEventSnapshot, + publish_inbox_message_created, + publish_inbox_message_status_changed, + snapshot_from_inbox_message, +) from core.logging import get_logger from models.friendships import Friendship from models.inbox_messages import InboxMessage from schemas.enums import FriendshipStatus, InboxMessageStatus, InboxMessageType +from v1.auth.gateway import SupabaseAuthGateway from v1.friendships.repository import FriendshipRepository from v1.friendships.schemas import ( FriendRequestCreate, @@ -42,6 +49,7 @@ class FriendshipService(BaseService): _repository: FriendshipRepository _user_repository: UserRepository _session: AsyncSession + _auth_gateway: SupabaseAuthGateway | None def __init__( self, @@ -49,11 +57,13 @@ class FriendshipService(BaseService): user_repository: UserRepository, session: AsyncSession, current_user: CurrentUser | None, + auth_gateway: SupabaseAuthGateway | None = None, ) -> None: super().__init__(current_user=current_user) self._repository = repository self._user_repository = user_repository self._session = session + self._auth_gateway = auth_gateway async def send_request(self, request: FriendRequestCreate) -> FriendRequestResponse: user_id = self.require_user_id() @@ -103,6 +113,8 @@ class FriendshipService(BaseService): friendship, inbox = await self._repository.reactivate_request( existing, user_id, request.content ) + await self._session.flush() + inbox_event = snapshot_from_inbox_message(inbox) await self._session.commit() except SQLAlchemyError: await self._session.rollback() @@ -121,6 +133,7 @@ class FriendshipService(BaseService): "target_id": str(target_user_id), }, ) + await self._publish_created_events([inbox_event]) return await self._build_friend_request_response( friendship, inbox, user_id, target_user_id ) @@ -129,6 +142,8 @@ class FriendshipService(BaseService): friendship, inbox = await self._repository.create_request( user_id, target_user_id, request.content ) + await self._session.flush() + inbox_event = snapshot_from_inbox_message(inbox) await self._session.commit() except SQLAlchemyError: await self._session.rollback() @@ -144,6 +159,7 @@ class FriendshipService(BaseService): "friend_request_sent", extra={"initiator_id": str(user_id), "target_id": str(target_user_id)}, ) + await self._publish_created_events([inbox_event]) return await self._build_friend_request_response( friendship, inbox, user_id, target_user_id @@ -172,11 +188,7 @@ class FriendshipService(BaseService): ), ) - recipient_id = ( - friendship.user_low_id - if friendship.initiator_id == friendship.user_high_id - else friendship.user_high_id - ) + recipient_id = self._get_recipient_id(friendship) if recipient_id != user_id: logger.warning( @@ -218,6 +230,7 @@ class FriendshipService(BaseService): friendship.status = FriendshipStatus.ACCEPTED inbox.status = InboxMessageStatus.ACCEPTED + inbox_event = snapshot_from_inbox_message(inbox) try: await self._session.commit() @@ -249,6 +262,7 @@ class FriendshipService(BaseService): "initiator_id": str(sender_id), }, ) + await self._publish_status_events([inbox_event]) sender = await self._user_repository.get_by_user_id(sender_id) recipient = await self._user_repository.get_by_user_id(user_id) @@ -285,11 +299,7 @@ class FriendshipService(BaseService): ), ) - recipient_id = ( - friendship.user_low_id - if friendship.initiator_id == friendship.user_high_id - else friendship.user_high_id - ) + recipient_id = self._get_recipient_id(friendship) if recipient_id != user_id: logger.warning( @@ -322,8 +332,10 @@ class FriendshipService(BaseService): ) friendship.status = FriendshipStatus.DECLINED + inbox_event: InboxMessageEventSnapshot | None = None if inbox: inbox.status = InboxMessageStatus.REJECTED + inbox_event = snapshot_from_inbox_message(inbox) try: await self._session.commit() @@ -355,6 +367,8 @@ class FriendshipService(BaseService): "initiator_id": str(sender_id), }, ) + if inbox_event: + await self._publish_status_events([inbox_event]) sender = await self._user_repository.get_by_user_id(sender_id) recipient = await self._user_repository.get_by_user_id(user_id) @@ -422,8 +436,10 @@ class FriendshipService(BaseService): ) friendship.status = FriendshipStatus.CANCELED + inbox_event: InboxMessageEventSnapshot | None = None if inbox: inbox.status = InboxMessageStatus.DISMISSED + inbox_event = snapshot_from_inbox_message(inbox) try: await self._session.commit() @@ -457,6 +473,8 @@ class FriendshipService(BaseService): "target_id": str(recipient_id), }, ) + if inbox_event: + await self._publish_status_events([inbox_event]) return FriendRequestResponse( id=friendship.id, @@ -583,11 +601,7 @@ class FriendshipService(BaseService): ) sender = await self._user_repository.get_by_user_id(initiator_id) - recipient_id = ( - friendship.user_low_id - if friendship.user_low_id != initiator_id - else friendship.user_high_id - ) + recipient_id = self._get_recipient_id(friendship) recipient = await self._user_repository.get_by_user_id(recipient_id) # Map FriendshipStatus to response status @@ -676,15 +690,24 @@ class FriendshipService(BaseService): ] profiles_by_id = await self._user_repository.get_by_user_ids(friend_ids) + auth_users_by_id: dict[str, str | None] = {} + if self._auth_gateway is not None: + auth_users = await self._auth_gateway.get_users_by_ids( + [str(fid) for fid in friend_ids] + ) + for uid, auth_user in auth_users.items(): + auth_users_by_id[uid] = auth_user.phone + result: list[FriendResponse] = [] for friendship in friendships: friend_id = self._get_other_user_id(friendship, user_id) friend = profiles_by_id.get(friend_id) + phone = auth_users_by_id.get(str(friend_id)) result.append( FriendResponse( id=friendship.id, - friend=self._build_user_basic_info(friend), + friend=self._build_user_basic_info(friend, phone), status="active", created_at=friendship.created_at, accepted_at=friendship.updated_at, @@ -760,7 +783,9 @@ class FriendshipService(BaseService): accepted_at=friendship.updated_at, ) - def _build_user_basic_info(self, profile: Any) -> "UserContext": + def _build_user_basic_info( + self, profile: Any, phone: str | None = None + ) -> "UserContext": from schemas.shared.user import UserContext if profile is None: @@ -770,7 +795,9 @@ class FriendshipService(BaseService): return UserContext( id=str(p.id), username=p.username, + phone=phone, avatar_url=p.avatar_url if hasattr(p, "avatar_url") else None, + bio=p.bio if hasattr(p, "bio") else None, ) async def _build_friend_request_response( @@ -800,3 +827,50 @@ class FriendshipService(BaseService): if friendship.user_low_id == current_user_id else friendship.user_low_id ) + + def _get_recipient_id(self, friendship: Friendship) -> UUID: + return ( + friendship.user_low_id + if friendship.initiator_id == friendship.user_high_id + else friendship.user_high_id + ) + + async def _publish_created_events( + self, messages: list[InboxMessageEventSnapshot] + ) -> None: + for message in messages: + try: + await publish_inbox_message_created(message) + except Exception as exc: # noqa: BLE001 + logger.exception( + "Failed to publish inbox created event", + message_id=str(message.message_id), + recipient_id=str(message.recipient_id), + ) + raise ApiProblemError( + status_code=503, + detail=problem_payload( + code="INBOX_EVENT_STREAM_UNAVAILABLE", + detail="Inbox event stream unavailable", + ), + ) from exc + + async def _publish_status_events( + self, messages: list[InboxMessageEventSnapshot] + ) -> None: + for message in messages: + try: + await publish_inbox_message_status_changed(message) + except Exception as exc: # noqa: BLE001 + logger.exception( + "Failed to publish inbox status event", + message_id=str(message.message_id), + recipient_id=str(message.recipient_id), + ) + raise ApiProblemError( + status_code=503, + detail=problem_payload( + code="INBOX_EVENT_STREAM_UNAVAILABLE", + detail="Inbox event stream unavailable", + ), + ) from exc diff --git a/backend/src/v1/inbox_messages/realtime.py b/backend/src/v1/inbox_messages/realtime.py new file mode 100644 index 0000000..6f660a6 --- /dev/null +++ b/backend/src/v1/inbox_messages/realtime.py @@ -0,0 +1,280 @@ +from __future__ import annotations + +import asyncio +from dataclasses import dataclass +from datetime import UTC, datetime +import inspect +import json +from typing import Any +from uuid import UUID, uuid4 + +from redis.exceptions import TimeoutError as RedisTimeoutError + +from core.config.settings import config +from core.logging import get_logger +from models.inbox_messages import InboxMessage +from services.base.redis import get_or_init_redis_client + +logger = get_logger("v1.inbox_messages.realtime") + +INBOX_STREAM_PREFIX = "inbox:events" + +EVENT_MESSAGE_CREATED = "INBOX_MESSAGE_CREATED" +EVENT_MESSAGE_READ_CHANGED = "INBOX_MESSAGE_READ_CHANGED" +EVENT_MESSAGE_STATUS_CHANGED = "INBOX_MESSAGE_STATUS_CHANGED" +EVENT_SNAPSHOT_REQUIRED = "INBOX_SNAPSHOT_REQUIRED" + + +@dataclass(frozen=True) +class InboxMessageEventSnapshot: + message_id: UUID + recipient_id: UUID + sender_id: UUID | None + message_type: str + schedule_item_id: UUID | None + friendship_id: UUID | None + content: dict[str, Any] | None + is_read: bool + status: str + created_at: datetime + occurred_at: datetime + + +def snapshot_from_inbox_message(message: InboxMessage) -> InboxMessageEventSnapshot: + message_type = ( + message.message_type.value + if hasattr(message.message_type, "value") + else str(message.message_type) + ) + status = ( + message.status.value + if hasattr(message.status, "value") + else str(message.status) + ) + if status in {"None", ""}: + status = "pending" + created_at = ( + message.created_at + if isinstance(message.created_at, datetime) + else datetime.now(UTC) + ) + occurred_at = ( + message.updated_at if isinstance(message.updated_at, datetime) else created_at + ) + message_id = message.id if isinstance(message.id, UUID) else uuid4() + return InboxMessageEventSnapshot( + message_id=message_id, + recipient_id=message.recipient_id, + sender_id=message.sender_id, + message_type=message_type, + schedule_item_id=message.schedule_item_id, + friendship_id=message.friendship_id, + content=message.content, + is_read=bool(message.is_read), + status=status, + created_at=created_at, + occurred_at=occurred_at, + ) + + +def to_inbox_sse_event(stream_id: str, event_type: str, payload: dict[str, Any]) -> str: + safe_stream_id = str(stream_id).replace("\r", "").replace("\n", "") + safe_event_type = str(event_type).replace("\r", "").replace("\n", "") + data = json.dumps(payload, ensure_ascii=True, separators=(",", ":")) + return f"id: {safe_stream_id}\nevent: {safe_event_type}\ndata: {data}\n\n" + + +def _stream_name(recipient_id: UUID) -> str: + return f"{INBOX_STREAM_PREFIX}:{recipient_id}" + + +def _to_epoch_ms(value: datetime) -> int: + normalized = value.astimezone(UTC) + return int(normalized.timestamp() * 1000) + + +def _resolve_occurred_at(snapshot: InboxMessageEventSnapshot) -> datetime: + if isinstance(snapshot.occurred_at, datetime): + return snapshot.occurred_at + if isinstance(snapshot.created_at, datetime): + return snapshot.created_at + return datetime.now(UTC) + + +def _safe_stream_block_ms(requested_ms: int) -> int: + try: + socket_timeout_ms = max(int(float(config.redis.socket_timeout) * 1000), 1) + except (TypeError, ValueError): + socket_timeout_ms = 5000 + safe_max = max(socket_timeout_ms - 100, 1) + return max(1, min(int(requested_ms), safe_max)) + + +def _message_to_payload(snapshot: InboxMessageEventSnapshot) -> dict[str, Any]: + return { + "id": str(snapshot.message_id), + "recipient_id": str(snapshot.recipient_id), + "sender_id": str(snapshot.sender_id) if snapshot.sender_id else None, + "message_type": snapshot.message_type, + "schedule_item_id": str(snapshot.schedule_item_id) + if snapshot.schedule_item_id + else None, + "friendship_id": str(snapshot.friendship_id) + if snapshot.friendship_id + else None, + "content": snapshot.content, + "is_read": bool(snapshot.is_read), + "status": snapshot.status, + "created_at": snapshot.created_at.isoformat(), + } + + +async def _publish_event(recipient_id: UUID, payload: dict[str, Any]) -> str: + redis = await get_or_init_redis_client() + stream_name = _stream_name(recipient_id) + event_json = json.dumps(payload, ensure_ascii=True, separators=(",", ":")) + result = redis.xadd(stream_name, {"event": event_json}) + if inspect.isawaitable(result): + return str(await result) + return str(result) + + +async def publish_inbox_message_created( + message: InboxMessage | InboxMessageEventSnapshot, +) -> str: + snapshot = ( + message + if isinstance(message, InboxMessageEventSnapshot) + else snapshot_from_inbox_message(message) + ) + occurred_at = _resolve_occurred_at(snapshot) + version = _to_epoch_ms(occurred_at) + payload = { + "event_id": str(uuid4()), + "occurred_at": occurred_at.isoformat(), + "user_id": str(snapshot.recipient_id), + "message_id": str(snapshot.message_id), + "event_type": EVENT_MESSAGE_CREATED, + "op": "created", + "version": version, + "data": {"message": _message_to_payload(snapshot)}, + } + return await _publish_event(snapshot.recipient_id, payload) + + +async def publish_inbox_message_read_changed( + message: InboxMessage | InboxMessageEventSnapshot, +) -> str: + snapshot = ( + message + if isinstance(message, InboxMessageEventSnapshot) + else snapshot_from_inbox_message(message) + ) + occurred_at = _resolve_occurred_at(snapshot) + payload = { + "event_id": str(uuid4()), + "occurred_at": occurred_at.isoformat(), + "user_id": str(snapshot.recipient_id), + "message_id": str(snapshot.message_id), + "event_type": EVENT_MESSAGE_READ_CHANGED, + "op": "read_changed", + "version": _to_epoch_ms(occurred_at), + "data": {"is_read": bool(snapshot.is_read)}, + } + return await _publish_event(snapshot.recipient_id, payload) + + +async def publish_inbox_message_status_changed( + message: InboxMessage | InboxMessageEventSnapshot, +) -> str: + snapshot = ( + message + if isinstance(message, InboxMessageEventSnapshot) + else snapshot_from_inbox_message(message) + ) + occurred_at = _resolve_occurred_at(snapshot) + payload = { + "event_id": str(uuid4()), + "occurred_at": occurred_at.isoformat(), + "user_id": str(snapshot.recipient_id), + "message_id": str(snapshot.message_id), + "event_type": EVENT_MESSAGE_STATUS_CHANGED, + "op": "status_changed", + "version": _to_epoch_ms(occurred_at), + "data": {"status": snapshot.status}, + } + return await _publish_event(snapshot.recipient_id, payload) + + +async def publish_inbox_snapshot_required( + *, recipient_id: UUID, message_id: UUID +) -> str: + now = datetime.now(UTC) + payload = { + "event_id": str(uuid4()), + "occurred_at": now.isoformat(), + "user_id": str(recipient_id), + "message_id": str(message_id), + "event_type": EVENT_SNAPSHOT_REQUIRED, + "op": "snapshot_required", + "version": _to_epoch_ms(now), + "data": {}, + } + return await _publish_event(recipient_id, payload) + + +async def read_inbox_events( + *, + recipient_id: UUID, + last_event_id: str | None, + count: int = 100, + block_ms: int = 5000, +) -> list[dict[str, Any]]: + redis = await get_or_init_redis_client() + stream = _stream_name(recipient_id) + start_id = "0-0" if not last_event_id else last_event_id + safe_block_ms = _safe_stream_block_ms(block_ms) + try: + raw = redis.xread({stream: start_id}, count=count, block=safe_block_ms) + response = await raw if inspect.isawaitable(raw) else raw + except (TimeoutError, asyncio.TimeoutError, RedisTimeoutError): + return [] + if not response: + return [] + + first = response[0] + if not isinstance(first, (list, tuple)) or len(first) != 2: + return [] + entries_raw = first[1] + if not isinstance(entries_raw, list): + return [] + + rows: list[dict[str, Any]] = [] + for entry in entries_raw: + if not isinstance(entry, (list, tuple)) or len(entry) != 2: + continue + entry_id_raw, fields = entry + if isinstance(entry_id_raw, bytes): + stream_id = entry_id_raw.decode("utf-8", errors="replace") + elif isinstance(entry_id_raw, str): + stream_id = entry_id_raw + else: + continue + if not isinstance(fields, dict): + continue + payload_raw = fields.get("event") + if isinstance(payload_raw, bytes): + payload_raw = payload_raw.decode("utf-8", errors="replace") + if not isinstance(payload_raw, str): + continue + try: + payload = json.loads(payload_raw) + except (TypeError, ValueError): + logger.warning( + "Discard malformed inbox stream payload", stream_id=stream_id + ) + continue + if not isinstance(payload, dict): + continue + rows.append({"id": stream_id, "event": payload}) + return rows diff --git a/backend/src/v1/inbox_messages/router.py b/backend/src/v1/inbox_messages/router.py index 06f2b51..2cc77e0 100644 --- a/backend/src/v1/inbox_messages/router.py +++ b/backend/src/v1/inbox_messages/router.py @@ -1,15 +1,54 @@ from __future__ import annotations +import asyncio +import re +from collections.abc import AsyncIterator from typing import Annotated from uuid import UUID -from fastapi import APIRouter, Depends, Query +from fastapi import APIRouter, Depends, Header, Query, Request +from fastapi.responses import StreamingResponse + +from core.http.errors import ApiProblemError, problem_payload +from services.base.redis import get_or_init_redis_client +from v1.inbox_messages.realtime import to_inbox_sse_event from v1.inbox_messages.dependencies import get_inbox_message_service from v1.inbox_messages.schemas import InboxMessageResponse from v1.inbox_messages.service import InboxMessageService router = APIRouter(prefix="/inbox/messages", tags=["inbox-messages"]) +_LAST_EVENT_ID_RE = re.compile(r"^\d+-\d+$") +_MAX_SSE_CONNECTIONS_PER_USER = 3 +_SSE_SLOT_TTL_SECONDS = 15 * 60 + + +async def _acquire_sse_slot(*, user_id: str) -> bool: + redis = await get_or_init_redis_client() + key = f"inbox:sse-active:{user_id}" + count = await redis.incr(key) + if count == 1: + await redis.expire(key, _SSE_SLOT_TTL_SECONDS) + elif count > _MAX_SSE_CONNECTIONS_PER_USER: + await redis.decr(key) + return False + else: + ttl = await redis.ttl(key) + if ttl < 0: + await redis.expire(key, _SSE_SLOT_TTL_SECONDS) + return True + + +async def _release_sse_slot(*, user_id: str) -> None: + redis = await get_or_init_redis_client() + key = f"inbox:sse-active:{user_id}" + count = await redis.decr(key) + if count <= 0: + await redis.delete(key) + else: + ttl = await redis.ttl(key) + if ttl < 0: + await redis.expire(key, _SSE_SLOT_TTL_SECONDS) @router.get("", response_model=list[InboxMessageResponse]) @@ -26,3 +65,64 @@ async def mark_as_read( service: Annotated[InboxMessageService, Depends(get_inbox_message_service)], ) -> InboxMessageResponse: return await service.mark_as_read(message_id) + + +@router.get("/stream") +async def stream_inbox_events( + request: Request, + service: Annotated[InboxMessageService, Depends(get_inbox_message_service)], + last_event_id: str | None = Header(default=None, alias="Last-Event-ID"), + idle_limit: int = Query(default=300, ge=1, le=3600), +) -> StreamingResponse: + if last_event_id is not None and ( + len(last_event_id) > 32 or _LAST_EVENT_ID_RE.fullmatch(last_event_id) is None + ): + raise ApiProblemError( + status_code=422, + detail=problem_payload( + code="INBOX_INVALID_LAST_EVENT_ID", + detail="Invalid Last-Event-ID", + ), + ) + + user_id = str(service.require_user_id()) + slot_acquired = await _acquire_sse_slot(user_id=user_id) + if not slot_acquired: + raise ApiProblemError( + status_code=429, + detail=problem_payload( + code="INBOX_SSE_CONNECTION_LIMIT", + detail="Too many SSE connections", + ), + ) + + async def _event_iter() -> AsyncIterator[str]: + cursor = last_event_id + idle_polls = 0 + try: + while not await request.is_disconnected() and idle_polls < idle_limit: + rows = await service.stream_events(last_event_id=cursor) + if not rows: + idle_polls += 1 + yield ": keep-alive\n\n" + await asyncio.sleep(0.2) + continue + + idle_polls = 0 + for row in rows: + stream_id = row.get("id") + event = row.get("event") + if not isinstance(stream_id, str) or not isinstance(event, dict): + continue + cursor = stream_id + event_type = event.get("event_type") + if not isinstance(event_type, str) or not event_type: + event_type = "INBOX_MESSAGE" + yield to_inbox_sse_event(stream_id, event_type, event) + finally: + await _release_sse_slot(user_id=user_id) + + response = StreamingResponse(_event_iter(), media_type="text/event-stream") + response.headers["Cache-Control"] = "no-cache" + response.headers["Connection"] = "keep-alive" + return response diff --git a/backend/src/v1/inbox_messages/service.py b/backend/src/v1/inbox_messages/service.py index 67b281b..c5ec5c6 100644 --- a/backend/src/v1/inbox_messages/service.py +++ b/backend/src/v1/inbox_messages/service.py @@ -1,6 +1,7 @@ from __future__ import annotations from typing import TYPE_CHECKING +from typing import Any from uuid import UUID import json @@ -10,6 +11,11 @@ from sqlalchemy.exc import SQLAlchemyError from core.auth.models import CurrentUser from core.db.base_service import BaseService from core.http.errors import ApiProblemError +from v1.inbox_messages.realtime import ( + publish_inbox_message_read_changed, + read_inbox_events, + snapshot_from_inbox_message, +) from core.logging import get_logger from models.inbox_messages import InboxMessage from v1.inbox_messages.repository import InboxMessageRepository @@ -71,6 +77,8 @@ class InboxMessageService(BaseService): code="INBOX_MESSAGE_NOT_FOUND", detail="Inbox message not found", ) + event_snapshot = snapshot_from_inbox_message(updated) + response = self._to_response(updated) await self._session.commit() except SQLAlchemyError: await self._session.rollback() @@ -85,7 +93,44 @@ class InboxMessageService(BaseService): detail="Inbox message store unavailable", ) - return self._to_response(updated) + try: + await publish_inbox_message_read_changed(event_snapshot) + except Exception as exc: # noqa: BLE001 + logger.exception( + "Failed to publish inbox read-changed event", + message_id=str(event_snapshot.message_id), + user_id=str(event_snapshot.recipient_id), + ) + raise _inbox_error( + status_code=503, + code="INBOX_EVENT_STREAM_UNAVAILABLE", + detail="Inbox event stream unavailable", + ) from exc + + return response + + async def stream_events( + self, + *, + last_event_id: str | None, + ) -> list[dict[str, Any]]: + user_id = self.require_user_id() + try: + return await read_inbox_events( + recipient_id=user_id, + last_event_id=last_event_id, + ) + except Exception as exc: # noqa: BLE001 + logger.warning( + "Failed to read inbox event stream", + user_id=str(user_id), + reason=str(exc), + ) + raise _inbox_error( + status_code=503, + code="INBOX_EVENT_STREAM_UNAVAILABLE", + detail="Inbox event stream unavailable", + ) from exc def _to_response(self, message: InboxMessage) -> InboxMessageResponse: status_value = ( diff --git a/backend/src/v1/schedule_items/repository.py b/backend/src/v1/schedule_items/repository.py index bc309e7..12f7776 100644 --- a/backend/src/v1/schedule_items/repository.py +++ b/backend/src/v1/schedule_items/repository.py @@ -4,7 +4,7 @@ from datetime import datetime, timezone from typing import TYPE_CHECKING, Protocol, Sequence from uuid import UUID -from sqlalchemy import func, or_, select, update, delete +from sqlalchemy import or_, select, update, delete from sqlalchemy.exc import SQLAlchemyError from core.db.base_repository import BaseRepository @@ -28,14 +28,6 @@ class ScheduleItemRepository(Protocol): async def list_by_date_range( self, owner_id: UUID, start_at: datetime, end_at: datetime ) -> list[ScheduleItem]: ... - async def list_paginated( - self, - owner_id: UUID, - *, - page: int, - page_size: int, - query: str | None = None, - ) -> tuple[list[ScheduleItem], int]: ... async def create_subscription(self, data: dict) -> ScheduleSubscription: ... async def get_subscriptions_by_item_id( self, item_id: UUID @@ -154,62 +146,6 @@ class SQLAlchemyScheduleItemRepository(BaseRepository[ScheduleItem]): logger.exception("Schedule item list failed", owner_id=str(owner_id)) raise - async def list_paginated( - self, - owner_id: UUID, - *, - page: int, - page_size: int, - query: str | None = None, - ) -> tuple[list[ScheduleItem], int]: - offset = (page - 1) * page_size - normalized_query = (query or "").strip() - has_query = bool(normalized_query) - query_like = f"%{normalized_query}%" - try: - count_stmt = ( - select(func.count()) - .select_from(ScheduleItem) - .where(ScheduleItem.owner_id == owner_id) - .where(ScheduleItem.deleted_at.is_(None)) - ) - if has_query: - count_stmt = count_stmt.where( - or_( - ScheduleItem.title.ilike(query_like), - ScheduleItem.description.ilike(query_like), - ) - ) - count_result = await self._session.execute(count_stmt) - total = int(count_result.scalar_one() or 0) - - items_stmt = ( - select(ScheduleItem) - .where(ScheduleItem.owner_id == owner_id) - .where(ScheduleItem.deleted_at.is_(None)) - .order_by(ScheduleItem.start_at.asc(), ScheduleItem.id.asc()) - .offset(offset) - .limit(page_size) - ) - if has_query: - items_stmt = items_stmt.where( - or_( - ScheduleItem.title.ilike(query_like), - ScheduleItem.description.ilike(query_like), - ) - ) - items_result = await self._session.execute(items_stmt) - items = list(items_result.scalars().all()) - return items, total - except SQLAlchemyError: - logger.exception( - "Schedule item paginated list failed", - owner_id=str(owner_id), - page=page, - page_size=page_size, - ) - raise - async def create_subscription(self, data: dict) -> ScheduleSubscription: sub = ScheduleSubscription(**data) self._session.add(sub) diff --git a/backend/src/v1/schedule_items/schemas.py b/backend/src/v1/schedule_items/schemas.py index a494535..42bf10a 100644 --- a/backend/src/v1/schedule_items/schemas.py +++ b/backend/src/v1/schedule_items/schemas.py @@ -21,6 +21,7 @@ from schemas.domain.schedule import ( ScheduleItemSourceType, ScheduleItemStatus, ) +from schemas.enums import SubscriptionPermission __all__ = [ "AttachmentType", @@ -41,9 +42,20 @@ __all__ = [ "ScheduleItemShareRequest", "ScheduleItemShareResponse", "SubscriberInfo", + "parse_permission", ] +def parse_permission(permission_int: int) -> dict[str, bool]: + return { + "can_view": bool(permission_int & SubscriptionPermission.VIEW.value), + "can_invite": bool(permission_int & SubscriptionPermission.INVITE.value), + "can_edit": bool(permission_int & SubscriptionPermission.EDIT.value), + "can_delete": bool(permission_int & SubscriptionPermission.DELETE.value), + "is_owner": permission_int == SubscriptionPermission.OWNER.value, + } + + class ScheduleItemCreateRequest(BaseModel): model_config: ClassVar[ConfigDict] = ConfigDict(extra="forbid") @@ -160,11 +172,6 @@ class ScheduleItemListRequest(BaseModel): return value -_PERMISSION_VIEW = 1 -_PERMISSION_INVITE = 2 -_PERMISSION_EDIT = 4 - - class ScheduleItemShareRequest(BaseModel): model_config: ClassVar[ConfigDict] = ConfigDict(extra="forbid") @@ -180,11 +187,11 @@ class ScheduleItemShareRequest(BaseModel): def _permission_value(self) -> int: value = 0 if self.permission_view: - value |= _PERMISSION_VIEW + value |= SubscriptionPermission.VIEW.value if self.permission_edit: - value |= _PERMISSION_EDIT + value |= SubscriptionPermission.EDIT.value if self.permission_invite: - value |= _PERMISSION_INVITE + value |= SubscriptionPermission.INVITE.value return value diff --git a/backend/src/v1/schedule_items/service.py b/backend/src/v1/schedule_items/service.py index 110f4cb..f65b2ca 100644 --- a/backend/src/v1/schedule_items/service.py +++ b/backend/src/v1/schedule_items/service.py @@ -1,7 +1,7 @@ from __future__ import annotations from datetime import datetime, timezone -from typing import TYPE_CHECKING, Protocol, Literal +from typing import TYPE_CHECKING, Any, Protocol, Literal from uuid import UUID from sqlalchemy.exc import SQLAlchemyError @@ -9,6 +9,12 @@ from sqlalchemy.exc import SQLAlchemyError from core.auth.models import CurrentUser from core.db.base_service import BaseService from core.http.errors import ApiProblemError, problem_payload +from v1.inbox_messages.realtime import ( + InboxMessageEventSnapshot, + publish_inbox_message_created, + publish_inbox_message_status_changed, + snapshot_from_inbox_message, +) from core.logging import get_logger from models.inbox_messages import InboxMessage from models.profile import Profile @@ -196,7 +202,14 @@ class ScheduleItemService(BaseService): subscriber_ids ) except SQLAlchemyError: - logger.exception("Failed to get subscriber profiles") + logger.exception("Failed to fetch subscriber profiles") + raise ApiProblemError( + status_code=503, + detail=problem_payload( + code="SCHEDULE_ITEM_STORE_UNAVAILABLE", + detail="Schedule item store unavailable", + ), + ) resolved_contacts = await resolve_contacts_by_user_ids( user_ids=subscriber_ids, profiles_by_id=profiles, @@ -302,10 +315,30 @@ class ScheduleItemService(BaseService): if not update_data: return self._to_response(existing, is_owner=is_owner) + before_state = self._capture_calendar_state(existing) item = await self._repository.update_item(item_id, update_data) - - await self._notify_subscribers(item_id, existing.title, "updated") + if item is None: + raise ApiProblemError( + status_code=404, + detail=problem_payload( + code="SCHEDULE_ITEM_NOT_FOUND", + detail="Schedule item not found", + ), + ) + changes = self._build_calendar_changes(before_state, item, update_data) + created_messages = await self._notify_subscribers( + item_id, + item.title, + "updated", + changes=changes, + ) + if created_messages: + await self._session.flush() + created_snapshots = [ + snapshot_from_inbox_message(message) for message in created_messages + ] await self._session.commit() + await self._publish_created_events(created_snapshots) except SQLAlchemyError: await self._session.rollback() logger.exception("Failed to update schedule item", item_id=str(item_id)) @@ -317,15 +350,6 @@ class ScheduleItemService(BaseService): ), ) - if item is None: - raise ApiProblemError( - status_code=404, - detail=problem_payload( - code="SCHEDULE_ITEM_NOT_FOUND", - detail="Schedule item not found", - ), - ) - return self._to_response(item, is_owner=is_owner) async def delete(self, item_id: UUID) -> None: @@ -359,9 +383,20 @@ class ScheduleItemService(BaseService): title = existing.title await self._repository.delete_subscriptions_by_item_id(item_id) - await self._notify_subscribers(item_id, title, "deleted") + created_messages = await self._notify_subscribers( + item_id, + title, + "deleted", + changes=[], + ) await self._repository.delete_item(item_id) + if created_messages: + await self._session.flush() + created_snapshots = [ + snapshot_from_inbox_message(message) for message in created_messages + ] await self._session.commit() + await self._publish_created_events(created_snapshots) except SQLAlchemyError: await self._session.rollback() logger.exception("Failed to delete schedule item", item_id=str(item_id)) @@ -427,54 +462,6 @@ class ScheduleItemService(BaseService): ), ) - async def list_paginated( - self, - *, - page: int, - page_size: int, - query: str | None = None, - ) -> tuple[list[ScheduleItemResponse], int]: - user_id = self.require_user_id() - if page < 1: - raise ApiProblemError( - status_code=400, - detail=problem_payload( - code="SCHEDULE_ITEM_PAGE_INVALID", - detail="page must be >= 1", - params={"min": 1, "page": page}, - ), - ) - if page_size < 1 or page_size > 100: - raise ApiProblemError( - status_code=400, - detail=problem_payload( - code="SCHEDULE_ITEM_PAGE_SIZE_INVALID", - detail="page_size must be 1..100", - params={"min": 1, "max": 100, "page_size": page_size}, - ), - ) - try: - items, total = await self._repository.list_paginated( - user_id, - page=page, - page_size=page_size, - query=query, - ) - except SQLAlchemyError: - logger.exception( - "Failed to list schedule items with pagination", - page=page, - page_size=page_size, - ) - raise ApiProblemError( - status_code=503, - detail=problem_payload( - code="SCHEDULE_ITEM_STORE_UNAVAILABLE", - detail="Schedule item store unavailable", - ), - ) - return [self._to_response(item) for item in items], total - async def share( self, item_id: UUID, request: ScheduleItemShareRequest ) -> ScheduleItemShareResponse: @@ -518,6 +505,10 @@ class ScheduleItemService(BaseService): ), ) + actor_username = await self._resolve_actor_username(user_id) + actor = await self._auth_gateway.get_user_by_id(str(user_id)) + actor_phone = actor.phone + target_user = await self._auth_gateway.get_user_by_phone(request.phone) recipient_id = UUID(target_user.id) @@ -563,6 +554,8 @@ class ScheduleItemService(BaseService): existing_msg = await self._inbox_repository.get_calendar_invite( item.id, recipient_id ) + event_target_message: InboxMessage | None = None + event_is_created = False if existing_msg: if existing_msg.status == InboxMessageStatus.ACCEPTED: raise ApiProblemError( @@ -584,9 +577,25 @@ class ScheduleItemService(BaseService): existing_msg.status = InboxMessageStatus.PENDING existing_msg.content = { "type": "invite", + "schema_version": 2, + "item": { + "id": str(item.id), + "title": item.title, + "description": item.description, + "start_at": item.start_at.isoformat(), + "end_at": item.end_at.isoformat() if item.end_at else None, + "timezone": item.timezone, + }, + "actor": { + "user_id": str(user_id), + "username": actor_username, + "phone": actor_phone, + }, + "summary": f"{item.title} 邀请您加入日历", "permission": request_permission, "action": "pending", } + event_target_message = existing_msg else: message = InboxMessage( recipient_id=recipient_id, @@ -595,14 +604,44 @@ class ScheduleItemService(BaseService): schedule_item_id=item.id, content={ "type": "invite", + "schema_version": 2, + "item": { + "id": str(item.id), + "title": item.title, + "description": item.description, + "start_at": item.start_at.isoformat(), + "end_at": item.end_at.isoformat() if item.end_at else None, + "timezone": item.timezone, + }, + "actor": { + "user_id": str(user_id), + "username": actor_username, + "phone": actor_phone, + }, + "summary": f"{item.title} 邀请您加入日历", "permission": request_permission, "action": "pending", }, created_by=user_id, ) self._session.add(message) + event_target_message = message + event_is_created = True + if event_target_message is not None and event_is_created: + await self._session.flush() + event_snapshot = ( + snapshot_from_inbox_message(event_target_message) + if event_target_message is not None + else None + ) await self._session.commit() + if event_target_message is not None: + assert event_snapshot is not None + if event_is_created: + await self._publish_created_events([event_snapshot]) + else: + await self._publish_status_events([event_snapshot]) except ApiProblemError: raise except SQLAlchemyError: @@ -703,7 +742,9 @@ class ScheduleItemService(BaseService): ) inbox.status = InboxMessageStatus.ACCEPTED + event_snapshot = snapshot_from_inbox_message(inbox) await self._session.commit() + await self._publish_status_events([event_snapshot]) return {"message": "Subscription accepted"} except ApiProblemError: @@ -742,7 +783,9 @@ class ScheduleItemService(BaseService): ) inbox.status = InboxMessageStatus.REJECTED + event_snapshot = snapshot_from_inbox_message(inbox) await self._session.commit() + await self._publish_status_events([event_snapshot]) return {"message": "Subscription rejected"} except ApiProblemError: @@ -763,18 +806,36 @@ class ScheduleItemService(BaseService): item_id: UUID, title: str, action_type: Literal["updated", "deleted"], - ): + *, + changes: list[dict[str, Any]], + ) -> list[InboxMessage]: user_id = self.require_user_id() subscriptions = await self._repository.get_subscriptions_by_item_id(item_id) + if not subscriptions: + return [] + actor_username = await self._resolve_actor_username(user_id) + created_messages: list[InboxMessage] = [] for sub in subscriptions: if sub.subscriber_id == user_id: continue content = { "type": action_type, - "title": title, + "schema_version": 2, + "item": { + "id": str(item_id), + "title": title, + }, + "actor": { + "user_id": str(user_id), + "username": actor_username, + }, + "summary": f"{actor_username} 更新了日历 {title}" + if action_type == "updated" + else f"{actor_username} 删除了日历 {title}", + "changes": changes, "action": action_type, } @@ -787,9 +848,126 @@ class ScheduleItemService(BaseService): created_by=user_id, ) self._session.add(message) + created_messages.append(message) + return created_messages - if subscriptions: - await self._session.commit() + async def _resolve_actor_username(self, user_id: UUID) -> str: + if self._user_repository is None: + raise ApiProblemError( + status_code=503, + detail=problem_payload( + code="SCHEDULE_ITEM_ACTOR_LOOKUP_UNAVAILABLE", + detail="Actor lookup unavailable", + ), + ) + profile = await self._user_repository.get_by_user_id(user_id) + if profile is not None and profile.username: + return profile.username + raise ApiProblemError( + status_code=503, + detail=problem_payload( + code="SCHEDULE_ITEM_ACTOR_LOOKUP_UNAVAILABLE", + detail="Actor lookup unavailable", + ), + ) + + def _capture_calendar_state(self, item: ScheduleItem) -> dict[str, Any]: + return { + "title": item.title, + "description": item.description, + "start_at": item.start_at, + "end_at": item.end_at, + "timezone": item.timezone, + "status": item.status, + } + + def _build_calendar_changes( + self, + before: dict[str, Any], + after: ScheduleItem, + update_data: dict[str, Any], + ) -> list[dict[str, Any]]: + label_map = { + "title": "标题", + "description": "描述", + "start_at": "开始时间", + "end_at": "结束时间", + "timezone": "时区", + "status": "状态", + } + changes: list[dict[str, Any]] = [] + for field in label_map: + if field not in update_data: + continue + before_value = before.get(field) + after_value = getattr(after, field) + if before_value == after_value: + continue + change_type = "modified" + if before_value is None and after_value is not None: + change_type = "added" + elif before_value is not None and after_value is None: + change_type = "removed" + changes.append( + { + "field": field, + "label": label_map[field], + "before": before_value.isoformat() + if isinstance(before_value, datetime) + else before_value, + "after": after_value.isoformat() + if isinstance(after_value, datetime) + else after_value, + "display_before": str(before_value) + if before_value is not None + else None, + "display_after": str(after_value) + if after_value is not None + else None, + "change_type": change_type, + } + ) + return changes + + async def _publish_created_events( + self, messages: list[InboxMessageEventSnapshot] + ) -> None: + for message in messages: + try: + await publish_inbox_message_created(message) + except Exception as exc: # noqa: BLE001 + logger.exception( + "Failed to publish inbox created event", + message_id=str(message.message_id), + recipient_id=str(message.recipient_id), + ) + raise ApiProblemError( + status_code=503, + detail=problem_payload( + code="INBOX_EVENT_STREAM_UNAVAILABLE", + detail="Inbox event stream unavailable", + ), + ) from exc + + async def _publish_status_events( + self, messages: list[InboxMessageEventSnapshot] + ) -> None: + for message in messages: + try: + await publish_inbox_message_status_changed(message) + except Exception as exc: # noqa: BLE001 + logger.exception( + "Failed to publish inbox status event", + message_id=str(message.message_id), + recipient_id=str(message.recipient_id), + ) + raise ApiProblemError( + status_code=503, + detail=problem_payload( + code="INBOX_EVENT_STREAM_UNAVAILABLE", + detail="Inbox event stream unavailable", + ), + ) from exc def _to_utc(self, dt: datetime | None) -> datetime | None: if dt is None: diff --git a/backend/src/v1/users/schemas.py b/backend/src/v1/users/schemas.py index 404f598..d79be83 100644 --- a/backend/src/v1/users/schemas.py +++ b/backend/src/v1/users/schemas.py @@ -19,7 +19,7 @@ class UserSearchRequest(BaseModel): class UserUpdateRequest(BaseModel): model_config: ClassVar[ConfigDict] = ConfigDict(extra="forbid") - username: str | None = Field(default=None, min_length=3, max_length=30) + username: str | None = Field(default=None, max_length=30) avatar_url: str | None = Field(default=None) bio: str | None = Field(default=None, max_length=200) diff --git a/backend/src/v1/users/service.py b/backend/src/v1/users/service.py index 538a7af..4873067 100644 --- a/backend/src/v1/users/service.py +++ b/backend/src/v1/users/service.py @@ -12,7 +12,7 @@ from core.agentscope.caches.user_context_cache import ( from core.auth.models import CurrentUser from core.config.settings import config from core.db.base_service import BaseService -from core.http.errors import ApiProblemError +from core.http.errors import ApiProblemError, problem_payload from core.logging import get_logger from schemas.shared.user import UserContext, parse_profile_settings from services.base.supabase import supabase_service @@ -23,27 +23,13 @@ if TYPE_CHECKING: from sqlalchemy.ext.asyncio import AsyncSession from schemas.shared.user import UserContext + from v1.auth.schemas import UserByIdResponse logger = get_logger("v1.users.service") _PHONE_QUERY_PATTERN = re.compile(r"^[+()\-\s\d]{4,32}$") -def _user_error( - *, - status_code: int, - code: str, - detail: str, - params: dict[str, object] | None = None, -) -> ApiProblemError: - return ApiProblemError( - status_code=status_code, - code=code, - detail=detail, - params=params, - ) - - def _mime_to_suffix(mime_type: str) -> str: """Convert MIME type to file suffix.""" mapping = { @@ -59,11 +45,7 @@ class AuthLookupGateway(Protocol): self, query: str, limit: int = 20 ) -> list[str]: ... - -class AuthByPhoneGateway(Protocol): - async def search_user_ids_by_phone( - self, query: str, limit: int = 20 - ) -> list[str]: ... + async def get_user_by_id(self, user_id: str) -> "UserByIdResponse": ... class UserContextInvalidator(Protocol): @@ -71,7 +53,7 @@ class UserContextInvalidator(Protocol): class AuthLookupAdapter: - def __init__(self, gateway: AuthByPhoneGateway) -> None: + def __init__(self, gateway: AuthLookupGateway) -> None: self._gateway = gateway async def search_user_ids_by_phone(self, query: str, limit: int = 20) -> list[str]: @@ -80,6 +62,12 @@ class AuthLookupAdapter: except ApiProblemError: return [] + async def get_user_by_id(self, user_id: str) -> "UserByIdResponse | None": + try: + return await self._gateway.get_user_by_id(user_id) + except ApiProblemError: + return None + class UserService(BaseService): """User service handling business logic and transactions. @@ -117,17 +105,21 @@ class UserService(BaseService): try: user = await self._repository.get_by_user_id(user_id) except SQLAlchemyError: - raise _user_error( + raise ApiProblemError( status_code=503, - code="USER_STORE_UNAVAILABLE", - detail="User store unavailable", + detail=problem_payload( + code="USER_STORE_UNAVAILABLE", + detail="User store unavailable", + ), ) if user is None: - raise _user_error( + raise ApiProblemError( status_code=404, - code="USER_NOT_FOUND", - detail="User not found", + detail=problem_payload( + code="USER_NOT_FOUND", + detail="User not found", + ), ) phone = self._current_user.phone if self._current_user else None return UserContext( @@ -145,22 +137,38 @@ class UserService(BaseService): try: profile = await self._repository.get_by_user_id(user_id) except SQLAlchemyError: - raise _user_error( + raise ApiProblemError( status_code=503, - code="USER_STORE_UNAVAILABLE", - detail="User store unavailable", + detail=problem_payload( + code="USER_STORE_UNAVAILABLE", + detail="User store unavailable", + ), ) if profile is None: - raise _user_error( + raise ApiProblemError( status_code=404, - code="USER_NOT_FOUND", - detail="User not found", + detail=problem_payload( + code="USER_NOT_FOUND", + detail="User not found", + ), ) + phone: str | None = None + if self._auth_gateway is not None: + try: + auth_user = await self._auth_gateway.get_user_by_id(str(user_id)) + phone = auth_user.phone + except Exception: + logger.warning( + "Failed to resolve auth phone", + user_id=str(user_id), + ) return UserContext( id=str(profile.id), username=profile.username, avatar_url=profile.avatar_url, + phone=phone, + bio=profile.bio, ) async def update_me(self, update: UserUpdateRequest) -> UserContext: @@ -176,10 +184,12 @@ class UserService(BaseService): } if not update_data: - raise _user_error( + raise ApiProblemError( status_code=400, - code="USER_UPDATE_FIELDS_EMPTY", - detail="No fields to update", + detail=problem_payload( + code="USER_UPDATE_FIELDS_EMPTY", + detail="No fields to update", + ), ) try: @@ -187,17 +197,21 @@ class UserService(BaseService): await self._session.commit() except SQLAlchemyError: await self._session.rollback() - raise _user_error( + raise ApiProblemError( status_code=503, - code="USER_STORE_UNAVAILABLE", - detail="User store unavailable", + detail=problem_payload( + code="USER_STORE_UNAVAILABLE", + detail="User store unavailable", + ), ) if user is None: - raise _user_error( + raise ApiProblemError( status_code=404, - code="USER_NOT_FOUND", - detail="User not found", + detail=problem_payload( + code="USER_NOT_FOUND", + detail="User not found", + ), ) try: @@ -229,38 +243,46 @@ class UserService(BaseService): user_id = self.require_user_id() if not isinstance(content_type, str): - raise _user_error( + raise ApiProblemError( status_code=422, - code="USER_AVATAR_UNSUPPORTED_TYPE", - detail="Unsupported image type", + detail=problem_payload( + code="USER_AVATAR_UNSUPPORTED_TYPE", + detail="Unsupported image type", + ), ) mime_type = content_type.lower() allowed_types = {"image/jpeg", "image/png", "image/webp"} if mime_type not in allowed_types: - raise _user_error( + raise ApiProblemError( status_code=422, - code="USER_AVATAR_UNSUPPORTED_TYPE", - detail="Unsupported image type. Allowed: JPEG, PNG, WebP", - params={"allowed": ["image/jpeg", "image/png", "image/webp"]}, + detail=problem_payload( + code="USER_AVATAR_UNSUPPORTED_TYPE", + detail="Unsupported image type. Allowed: JPEG, PNG, WebP", + params={"allowed": ["image/jpeg", "image/png", "image/webp"]}, + ), ) max_size_bytes = config.storage.avatar.max_size_mb * 1024 * 1024 if len(payload) > max_size_bytes: - raise _user_error( + raise ApiProblemError( status_code=413, - code="USER_AVATAR_TOO_LARGE", - detail=( - f"Image too large. Maximum size: {config.storage.avatar.max_size_mb}MB" + detail=problem_payload( + code="USER_AVATAR_TOO_LARGE", + detail=( + f"Image too large. Maximum size: {config.storage.avatar.max_size_mb}MB" + ), + params={"max_size_mb": config.storage.avatar.max_size_mb}, ), - params={"max_size_mb": config.storage.avatar.max_size_mb}, ) if not payload: - raise _user_error( + raise ApiProblemError( status_code=422, - code="USER_AVATAR_EMPTY", - detail="Empty image", + detail=problem_payload( + code="USER_AVATAR_EMPTY", + detail="Empty image", + ), ) suffix = _mime_to_suffix(mime_type) @@ -284,13 +306,16 @@ class UserService(BaseService): "user_id": str(user_id), }, ) - raise _user_error( + raise ApiProblemError( status_code=502, - code="USER_AVATAR_UPLOAD_FAILED", - detail="Failed to upload avatar", + detail=problem_payload( + code="USER_AVATAR_UPLOAD_FAILED", + detail="Failed to upload avatar", + ), ) - public_url = f"{config.supabase.public_url}/storage/v1/object/public/{bucket_name}/{stored_path}" + base_url = str(config.supabase.public_url).rstrip("/") + public_url = f"{base_url}/storage/v1/object/public/{bucket_name}/{stored_path}" update_data: dict[str, str | None] = {"avatar_url": public_url} try: @@ -298,17 +323,21 @@ class UserService(BaseService): await self._session.commit() except SQLAlchemyError: await self._session.rollback() - raise _user_error( + raise ApiProblemError( status_code=503, - code="USER_STORE_UNAVAILABLE", - detail="User store unavailable", + detail=problem_payload( + code="USER_STORE_UNAVAILABLE", + detail="User store unavailable", + ), ) if user is None: - raise _user_error( + raise ApiProblemError( status_code=404, - code="USER_NOT_FOUND", - detail="User not found", + detail=problem_payload( + code="USER_NOT_FOUND", + detail="User not found", + ), ) try: @@ -326,17 +355,19 @@ class UserService(BaseService): try: user = await self._repository.get_by_username(username) except SQLAlchemyError: - raise _user_error( + raise ApiProblemError( status_code=503, code="USER_STORE_UNAVAILABLE", detail="User store unavailable", ) if user is None: - raise _user_error( + raise ApiProblemError( status_code=404, - code="USER_NOT_FOUND", - detail="User not found", + detail=problem_payload( + code="USER_NOT_FOUND", + detail="User not found", + ), ) return UserContext( id=str(user.id), @@ -365,10 +396,12 @@ class UserService(BaseService): async def _search_by_phone(self, phone: str) -> list[UserContext]: if self._auth_gateway is None: - raise _user_error( + raise ApiProblemError( status_code=503, - code="USER_AUTH_LOOKUP_UNAVAILABLE", - detail="Auth lookup unavailable", + detail=problem_payload( + code="USER_AUTH_LOOKUP_UNAVAILABLE", + detail="Auth lookup unavailable", + ), ) user_id_values = await self._auth_gateway.search_user_ids_by_phone( @@ -389,10 +422,12 @@ class UserService(BaseService): try: users_by_id = await self._repository.get_by_user_ids(user_ids) except SQLAlchemyError: - raise _user_error( + raise ApiProblemError( status_code=503, - code="USER_STORE_UNAVAILABLE", - detail="User store unavailable", + detail=problem_payload( + code="USER_STORE_UNAVAILABLE", + detail="User store unavailable", + ), ) results: list[UserContext] = [] @@ -415,10 +450,12 @@ class UserService(BaseService): try: users = await self._repository.search_users(query, limit=20) except SQLAlchemyError: - raise _user_error( + raise ApiProblemError( status_code=503, - code="USER_STORE_UNAVAILABLE", - detail="User store unavailable", + detail=problem_payload( + code="USER_STORE_UNAVAILABLE", + detail="User store unavailable", + ), ) return [ diff --git a/backend/tests/integration/test_inbox_messages_routes.py b/backend/tests/integration/test_inbox_messages_routes.py index ed60b6b..623c367 100644 --- a/backend/tests/integration/test_inbox_messages_routes.py +++ b/backend/tests/integration/test_inbox_messages_routes.py @@ -25,6 +25,13 @@ class FakeInboxMessageService: ) -> None: self._messages = messages self._read_message = read_message + self._stream_rows: list[dict[str, object]] = [] + + def set_stream_rows(self, rows: list[dict[str, object]]) -> None: + self._stream_rows = rows + + def require_user_id(self) -> UUID: + return self._read_message.recipient_id async def list_messages( self, is_read: bool | None = None @@ -38,6 +45,16 @@ class FakeInboxMessageService: raise HTTPException(status_code=404, detail="Inbox message not found") return self._read_message + async def stream_events( + self, + *, + last_event_id: str | None, + ) -> list[dict[str, object]]: + del last_event_id + rows = self._stream_rows + self._stream_rows = [] + return rows + def _override_inbox_message_service( service: FakeInboxMessageService, @@ -58,7 +75,7 @@ def _build_message( sender_id=uuid4(), message_type=InboxMessageType.CALENDAR, schedule_item_id=uuid4(), - content='{"permission": 1}', + content={"permission": 1}, is_read=False, status=status, created_at=datetime(2026, 2, 28, 9, 0, 0, tzinfo=timezone.utc), @@ -108,3 +125,62 @@ def test_mark_as_read_returns_200() -> None: assert body["is_read"] is True finally: app.dependency_overrides = {} + + +def test_stream_inbox_events_returns_sse_payload() -> None: + read_message = _build_message(uuid4(), InboxMessageStatus.PENDING) + service = FakeInboxMessageService( + messages=[read_message], read_message=read_message + ) + service.set_stream_rows( + [ + { + "id": "1743313300000-0", + "event": { + "event_id": str(uuid4()), + "occurred_at": "2026-03-30T07:00:00+00:00", + "user_id": str(read_message.recipient_id), + "message_id": str(read_message.id), + "event_type": "INBOX_MESSAGE_CREATED", + "op": "created", + "version": 1743313300000, + "data": {"message": {"id": str(read_message.id)}}, + }, + } + ] + ) + app.dependency_overrides[get_inbox_message_service] = ( + _override_inbox_message_service(service) + ) + + client = TestClient(app) + try: + response = client.get("/api/v1/inbox/messages/stream?idle_limit=1") + assert response.status_code == 200 + assert response.headers["content-type"].startswith("text/event-stream") + payload = response.text + assert "event: INBOX_MESSAGE_CREATED" in payload + assert '"op":"created"' in payload + finally: + app.dependency_overrides = {} + + +def test_stream_inbox_events_rejects_invalid_last_event_id() -> None: + read_message = _build_message(uuid4(), InboxMessageStatus.PENDING) + service = FakeInboxMessageService( + messages=[read_message], read_message=read_message + ) + app.dependency_overrides[get_inbox_message_service] = ( + _override_inbox_message_service(service) + ) + client = TestClient(app) + try: + response = client.get( + "/api/v1/inbox/messages/stream", + headers={"Last-Event-ID": "not-a-stream-id"}, + ) + assert response.status_code == 422 + body = response.json() + assert body.get("code") == "INBOX_INVALID_LAST_EVENT_ID" + finally: + app.dependency_overrides = {} diff --git a/backend/tests/unit/core/agentscope/test_calendar_tools.py b/backend/tests/unit/core/agentscope/test_calendar_tools.py index ed6169b..dcc6c2c 100644 --- a/backend/tests/unit/core/agentscope/test_calendar_tools.py +++ b/backend/tests/unit/core/agentscope/test_calendar_tools.py @@ -4,7 +4,7 @@ import json from dataclasses import dataclass, field from datetime import datetime, timezone from types import SimpleNamespace -from typing import Any +from typing import Any, cast from uuid import UUID, uuid4 import pytest @@ -27,6 +27,7 @@ class _FakeService: created_request: Any = None created_id: str = field(default_factory=lambda: str(uuid4())) list_calls: list[dict[str, Any]] = field(default_factory=list) + range_calls: list[dict[str, Any]] = field(default_factory=list) deleted_ids: list[str] = field(default_factory=list) async def list_paginated( @@ -47,6 +48,29 @@ class _FakeService: ) return [item], 1 + async def list_by_date_range(self, request: Any): + self.range_calls.append( + { + "start_at": request.start_at, + "end_at": request.end_at, + } + ) + return [ + SimpleNamespace( + id=UUID(self.created_id), + owner_id=uuid4(), + title="会议", + description="今天下午五点的会议", + start_at=datetime(2026, 3, 17, 9, 0, tzinfo=timezone.utc), + end_at=datetime(2026, 3, 17, 9, 30, tzinfo=timezone.utc), + timezone="Asia/Shanghai", + status="active", + source_type="manual", + metadata=None, + subscribers=[], + ) + ] + async def create_agent_generated(self, request): self.created_request = request return SimpleNamespace( @@ -235,22 +259,48 @@ async def test_calendar_read_returns_structured_result_with_ids( ) result = await calendar_module.calendar_read( - query="会议", - page=1, - page_size=20, + start_at="2026-03-17T00:00:00+08:00", + end_at="2026-03-18T00:00:00+08:00", + session=SimpleNamespace(), + owner_id=uuid4(), + ) + payload = _decode_tool_response(result) + result_data = json.loads(payload["result"]) + + assert payload["status"] == "success" + assert result_data["total"] == 1 + assert result_data["items"][0]["id"] == fake_service.created_id + assert result_data["items"][0]["timezone"] == "Asia/Shanghai" + assert result_data["items"][0]["description"] == "今天下午五点的会议" + assert result_data["items"][0]["status"] == "active" + assert fake_service.range_calls == [ + { + "start_at": datetime(2026, 3, 16, 16, 0, tzinfo=timezone.utc), + "end_at": datetime(2026, 3, 17, 16, 0, tzinfo=timezone.utc), + } + ] + + +@pytest.mark.asyncio +async def test_calendar_read_rejects_naive_datetime_string( + monkeypatch: pytest.MonkeyPatch, +) -> None: + fake_service = _FakeService() + monkeypatch.setattr( + calendar_module, "create_schedule_service", lambda *_: fake_service + ) + + result = await calendar_module.calendar_read( + start_at="2026-03-17T00:00:00", + end_at="2026-03-18T00:00:00+08:00", session=SimpleNamespace(), owner_id=uuid4(), ) payload = _decode_tool_response(result) - assert payload["status"] == "success" - assert payload["result"].startswith("status=success") - assert "total=1" in payload["result"] - assert "timezone=Asia/Shanghai" in payload["result"] - assert "description=今天下午五点的会议" in payload["result"] - assert "status=active" in payload["result"] - assert fake_service.created_id in payload["result"] - assert fake_service.list_calls == [{"page": 1, "page_size": 20, "query": "会议"}] + assert payload["status"] == "failure" + assert payload["error"]["code"] == "INVALID_ARGUMENT" + assert "时区" in payload["error"]["message"] @pytest.mark.asyncio @@ -312,3 +362,39 @@ async def test_calendar_share_rejects_invalid_phone( assert payload["status"] == "failure" assert payload["error"]["code"] == "INVALID_ARGUMENT" + + +@pytest.mark.asyncio +async def test_calendar_share_accepts_json_invitee_payload( + monkeypatch: pytest.MonkeyPatch, +) -> None: + fake_service = _FakeService() + monkeypatch.setattr( + calendar_module, "create_schedule_service", lambda *_: fake_service + ) + event_id = str(uuid4()) + + result = await calendar_module.calendar_share( + event_id=event_id, + invitees=cast( + Any, + [ + { + "phone": "8613900001234", + "permissionView": True, + "permissionEdit": False, + "permissionInvite": False, + } + ], + ), + session=SimpleNamespace(), + owner_id=uuid4(), + ) + payload = _decode_tool_response(result) + + assert payload["status"] == "success" + assert payload["result"].startswith("status=success success=1 failed=0") + assert len(fake_service.share_calls) == 1 + share_call = fake_service.share_calls[0] + assert share_call["item_id"] == event_id + assert share_call["request"].phone == "+8613900001234" diff --git a/backend/tests/unit/core/runtime/test_cli.py b/backend/tests/unit/core/runtime/test_cli.py new file mode 100644 index 0000000..87d48a9 --- /dev/null +++ b/backend/tests/unit/core/runtime/test_cli.py @@ -0,0 +1,96 @@ +from __future__ import annotations + +import asyncio +from typing import Any + +import pytest + +from core.runtime import cli + + +class _FakeScheduler: + def __init__(self) -> None: + self.started = False + self.shutdown_called = False + self.jobs: list[dict[str, Any]] = [] + + def add_job(self, func: Any, **kwargs: Any) -> None: + self.jobs.append({"func": func, **kwargs}) + + def start(self) -> None: + self.started = True + + def shutdown(self, *, wait: bool) -> None: + self.shutdown_called = True + self.shutdown_wait = wait + + +class _StopEvent: + async def wait(self) -> None: + raise asyncio.CancelledError + + +@pytest.mark.asyncio +async def test_run_automation_scheduler_forever_uses_async_scheduler( + monkeypatch: pytest.MonkeyPatch, +) -> None: + fake_scheduler = _FakeScheduler() + dispatch_limits: list[int] = [] + + async def _fake_scan(*, limit: int) -> None: + dispatch_limits.append(limit) + + monkeypatch.setattr(cli, "AsyncIOScheduler", lambda: fake_scheduler) + monkeypatch.setattr(cli, "run_automation_scheduler_scan", _fake_scan) + monkeypatch.setattr(cli.asyncio, "Event", lambda: _StopEvent()) + + settings = cli.config.automation_scheduler + old_enabled = settings.enabled + old_interval = settings.interval_seconds + old_limit = settings.batch_limit + settings.enabled = True + settings.interval_seconds = 9 + settings.batch_limit = 7 + + try: + with pytest.raises(asyncio.CancelledError): + await cli.run_automation_scheduler_forever() + finally: + settings.enabled = old_enabled + settings.interval_seconds = old_interval + settings.batch_limit = old_limit + + assert fake_scheduler.started is True + assert fake_scheduler.shutdown_called is True + assert len(fake_scheduler.jobs) == 1 + assert fake_scheduler.jobs[0]["max_instances"] == 1 + assert fake_scheduler.jobs[0]["coalesce"] is True + + scan_job = fake_scheduler.jobs[0]["func"] + await scan_job() + assert dispatch_limits == [7] + + +@pytest.mark.asyncio +async def test_run_automation_scheduler_forever_disabled_noop( + monkeypatch: pytest.MonkeyPatch, +) -> None: + settings = cli.config.automation_scheduler + old_enabled = settings.enabled + settings.enabled = False + + called = False + + def _unexpected_scheduler() -> _FakeScheduler: + nonlocal called + called = True + return _FakeScheduler() + + monkeypatch.setattr(cli, "AsyncIOScheduler", _unexpected_scheduler) + + try: + await cli.run_automation_scheduler_forever() + finally: + settings.enabled = old_enabled + + assert called is False diff --git a/backend/tests/unit/schemas/agent/test_runtime_models.py b/backend/tests/unit/schemas/agent/test_runtime_models.py index ca1fb8e..b26f267 100644 --- a/backend/tests/unit/schemas/agent/test_runtime_models.py +++ b/backend/tests/unit/schemas/agent/test_runtime_models.py @@ -1,6 +1,6 @@ from __future__ import annotations -from schemas.agent.runtime_models import RouterAgentOutput +from schemas.agent.runtime_models import RouterAgentOutput, WorkerAgentOutputRich def test_router_agent_output_coerces_key_entity_value_to_string() -> None: @@ -32,3 +32,59 @@ def test_router_agent_output_coerces_key_entity_value_to_string() -> None: model = RouterAgentOutput.model_validate(payload) assert model.key_entities[0].value == "8" + + +def test_router_agent_output_coerces_constraint_value_to_string() -> None: + payload = { + "normalized_task_input": { + "user_text": "test", + "multimodal_summary": [], + "context_summary": "", + }, + "key_entities": [], + "constraints": [ + { + "key": "strict_mode", + "value": True, + "required": True, + } + ], + "task_typing": { + "primary": "planning", + "secondary": [], + }, + "execution_mode": "onestep", + "result_typing": { + "primary": "summary", + "secondary": [], + }, + } + + model = RouterAgentOutput.model_validate(payload) + + assert model.constraints[0].value == "True" + + +def test_worker_agent_output_rich_accepts_list_item_status_object() -> None: + payload = { + "status": "success", + "answer": "done", + "result_type": "summary", + "ui_hints": { + "intent": "status", + "status": "info", + "title": "状态", + "listItems": [ + { + "title": "任务A", + "status": {"type": "info", "value": "已归档"}, + } + ], + }, + } + + model = WorkerAgentOutputRich.model_validate(payload) + + assert model.ui_hints is not None + assert model.ui_hints.list_items[0].status is not None + assert model.ui_hints.list_items[0].status.value == "info" diff --git a/backend/tests/unit/v1/friendships/test_friendship_service.py b/backend/tests/unit/v1/friendships/test_friendship_service.py index 66390f7..3c7dd6c 100644 --- a/backend/tests/unit/v1/friendships/test_friendship_service.py +++ b/backend/tests/unit/v1/friendships/test_friendship_service.py @@ -1,6 +1,6 @@ from __future__ import annotations -from datetime import datetime +from datetime import datetime, timezone from typing import cast from unittest.mock import AsyncMock, MagicMock from uuid import UUID, uuid4 @@ -64,10 +64,14 @@ class FakeFriendshipRepo: inbox.id = uuid4() inbox.recipient_id = recipient_id inbox.sender_id = initiator_id + inbox.schedule_item_id = None inbox.status = InboxMessageStatus.PENDING inbox.message_type = InboxMessageType.FRIEND_REQUEST inbox.friendship_id = friendship.id inbox.content = {"type": "request", "message": content} + inbox.is_read = False + inbox.created_at = datetime.now(timezone.utc) + inbox.updated_at = datetime.now(timezone.utc) self._inbox_messages.append(inbox) return friendship, inbox @@ -91,10 +95,14 @@ class FakeFriendshipRepo: inbox.id = uuid4() inbox.recipient_id = recipient_id inbox.sender_id = initiator_id + inbox.schedule_item_id = None inbox.status = InboxMessageStatus.PENDING inbox.message_type = InboxMessageType.FRIEND_REQUEST inbox.friendship_id = friendship.id inbox.content = {"type": "request", "message": content} + inbox.is_read = False + inbox.created_at = datetime.now(timezone.utc) + inbox.updated_at = datetime.now(timezone.utc) self._inbox_messages.append(inbox) return friendship, inbox diff --git a/backend/tests/unit/v1/inbox_messages/test_realtime.py b/backend/tests/unit/v1/inbox_messages/test_realtime.py new file mode 100644 index 0000000..6835ea1 --- /dev/null +++ b/backend/tests/unit/v1/inbox_messages/test_realtime.py @@ -0,0 +1,109 @@ +from __future__ import annotations + +from datetime import UTC, datetime +from uuid import uuid4 + +import pytest + +from models.inbox_messages import InboxMessage +from schemas.enums import InboxMessageStatus, InboxMessageType +from v1.inbox_messages import realtime + + +class _FakeRedis: + def __init__(self) -> None: + self.last_stream: str | None = None + self.last_payload: str | None = None + self.last_block: int | None = None + + async def xadd(self, stream: str, fields: dict[str, str]) -> str: + self.last_stream = stream + self.last_payload = fields.get("event") + return "1743313300000-0" + + async def xread(self, _streams: dict[str, str], count: int, block: int): + del count + self.last_block = block + return [ + ( + "inbox:events:test", + [ + ( + "1743313300000-0", + { + "event": '{"event_id":"e1","event_type":"INBOX_MESSAGE_CREATED","op":"created"}', + }, + ) + ], + ) + ] + + +@pytest.mark.asyncio +async def test_publish_inbox_message_created_writes_stream(monkeypatch) -> None: + fake_redis = _FakeRedis() + + async def _fake_get_redis(): + return fake_redis + + monkeypatch.setattr(realtime, "get_or_init_redis_client", _fake_get_redis) + message = InboxMessage( + id=uuid4(), + recipient_id=uuid4(), + sender_id=uuid4(), + message_type=InboxMessageType.CALENDAR, + friendship_id=None, + schedule_item_id=uuid4(), + group_id=None, + content={"type": "invite"}, + is_read=False, + status=InboxMessageStatus.PENDING, + created_by=uuid4(), + ) + message.created_at = datetime(2026, 3, 30, 7, 0, tzinfo=UTC) + message.updated_at = datetime(2026, 3, 30, 7, 0, tzinfo=UTC) + + stream_id = await realtime.publish_inbox_message_created(message) + + assert stream_id == "1743313300000-0" + assert fake_redis.last_stream == f"inbox:events:{message.recipient_id}" + assert fake_redis.last_payload is not None + assert '"op":"created"' in fake_redis.last_payload + + +@pytest.mark.asyncio +async def test_read_inbox_events_decodes_rows(monkeypatch) -> None: + fake_redis = _FakeRedis() + + async def _fake_get_redis(): + return fake_redis + + monkeypatch.setattr(realtime, "get_or_init_redis_client", _fake_get_redis) + + rows = await realtime.read_inbox_events( + recipient_id=uuid4(), + last_event_id=None, + ) + + assert len(rows) == 1 + assert rows[0]["id"] == "1743313300000-0" + assert rows[0]["event"]["event_type"] == "INBOX_MESSAGE_CREATED" + + +@pytest.mark.asyncio +async def test_read_inbox_events_handles_redis_timeout(monkeypatch) -> None: + class _TimeoutRedis(_FakeRedis): + async def xread(self, _streams: dict[str, str], count: int, block: int): + del _streams, count, block + raise TimeoutError("read timeout") + + fake_redis = _TimeoutRedis() + + async def _fake_get_redis(): + return fake_redis + + monkeypatch.setattr(realtime, "get_or_init_redis_client", _fake_get_redis) + + rows = await realtime.read_inbox_events(recipient_id=uuid4(), last_event_id=None) + + assert rows == [] diff --git a/backend/tests/unit/v1/schedule_items/test_service.py b/backend/tests/unit/v1/schedule_items/test_service.py index f5c4d3f..17a04d1 100644 --- a/backend/tests/unit/v1/schedule_items/test_service.py +++ b/backend/tests/unit/v1/schedule_items/test_service.py @@ -59,6 +59,9 @@ class FakeRepo: return self._item return None + async def get_item(self, item_id: UUID) -> ScheduleItem | None: + return await self.get_by_id(item_id) + async def create(self, data: dict) -> ScheduleItem: return _create_mock_schedule_item( owner_id=data["owner_id"], @@ -74,6 +77,23 @@ class FakeRepo: self._item.title = data["title"] return self._item + async def update_item(self, item_id: UUID, data: dict) -> ScheduleItem | None: + if self._item is None: + return None + if "title" in data: + self._item.title = data["title"] + if "description" in data: + self._item.description = data["description"] + if "start_at" in data: + self._item.start_at = data["start_at"] + if "end_at" in data: + self._item.end_at = data["end_at"] + if "timezone" in data: + self._item.timezone = data["timezone"] + if "extra_metadata" in data: + self._item.extra_metadata = data["extra_metadata"] + return self._item + async def delete_by_item_id( self, item_id: UUID, owner_id: UUID ) -> ScheduleItem | None: @@ -81,6 +101,9 @@ class FakeRepo: return None return self._item + async def delete_item(self, item_id: UUID) -> None: + del item_id + async def list_by_date_range( self, owner_id: UUID, start_at: datetime, end_at: datetime ) -> list[ScheduleItem]: @@ -327,12 +350,11 @@ async def test_update_maps_metadata_to_extra_metadata( captured: dict | None = None class CaptureRepo(FakeRepo): - async def update_by_item_id( - self, item_id: UUID, owner_id: UUID, data: dict - ) -> ScheduleItem | None: + async def update_item(self, item_id: UUID, data: dict) -> ScheduleItem | None: nonlocal captured + del item_id captured = data - return await super().update_by_item_id(item_id, owner_id, data) + return await super().update_item(item.id, data) service = ScheduleItemService( repository=CaptureRepo(item), @@ -370,12 +392,11 @@ async def test_update_maps_null_metadata_to_extra_metadata_null( captured: dict | None = None class CaptureRepo(FakeRepo): - async def update_by_item_id( - self, item_id: UUID, owner_id: UUID, data: dict - ) -> ScheduleItem | None: + async def update_item(self, item_id: UUID, data: dict) -> ScheduleItem | None: nonlocal captured + del item_id captured = data - return await super().update_by_item_id(item_id, owner_id, data) + return await super().update_item(item.id, data) service = ScheduleItemService( repository=CaptureRepo(item), diff --git a/backend/tests/unit/v1/schedule_items/test_share.py b/backend/tests/unit/v1/schedule_items/test_share.py index 8727001..ab0b8ce 100644 --- a/backend/tests/unit/v1/schedule_items/test_share.py +++ b/backend/tests/unit/v1/schedule_items/test_share.py @@ -157,6 +157,14 @@ class FriendshipRepoStub: return friendship +class UserRepoStub: + async def get_by_user_id(self, user_id: UUID): + profile = MagicMock() + profile.id = user_id + profile.username = "owner" + return profile + + @pytest.mark.asyncio async def test_share_forbidden_when_not_owner() -> None: owner_id = UUID("00000000-0000-0000-0000-000000000001") @@ -172,6 +180,7 @@ async def test_share_forbidden_when_not_owner() -> None: auth_gateway=cast(Any, AuthGatewayStub()), inbox_repository=InboxRepoStub(), friendship_repository=cast(Any, FriendshipRepoStub()), + user_repository=cast(Any, UserRepoStub()), ) with pytest.raises(ApiProblemError) as exc_info: @@ -204,6 +213,7 @@ async def test_share_success_creates_calendar_invitation_message() -> None: auth_gateway=cast(Any, AuthGatewayStub()), inbox_repository=InboxRepoStub(), friendship_repository=cast(Any, FriendshipRepoStub()), + user_repository=cast(Any, UserRepoStub()), ) result = await service.share( @@ -223,7 +233,17 @@ async def test_share_success_creates_calendar_invitation_message() -> None: assert message.sender_id == owner_id assert message.schedule_item_id == item_id assert message.message_type == InboxMessageType.CALENDAR - assert message.content == {"type": "invite", "permission": 5, "action": "pending"} + assert message.content is not None + assert message.content["type"] == "invite" + assert message.content["schema_version"] == 2 + assert message.content["permission"] == 5 + assert message.content["item"]["id"] == str(item_id) + assert message.content["item"]["title"] == "test" + assert message.content["item"]["start_at"] == "2026-02-28T16:00:00+00:00" + assert message.content["item"]["end_at"] is None + assert message.content["item"]["timezone"] == "UTC" + assert message.content["actor"]["username"] == "owner" + assert message.content["actor"]["phone"] == "+8613810000000" session.commit.assert_awaited_once() @@ -237,6 +257,7 @@ async def test_share_returns_not_found_when_item_missing() -> None: auth_gateway=cast(Any, AuthGatewayStub()), inbox_repository=InboxRepoStub(), friendship_repository=cast(Any, FriendshipRepoStub()), + user_repository=cast(Any, UserRepoStub()), ) with pytest.raises(ApiProblemError) as exc_info: @@ -268,6 +289,7 @@ async def test_share_invalid_auth_user_id_returns_503() -> None: auth_gateway=cast(Any, AuthGatewayInvalidIdStub()), inbox_repository=InboxRepoStub(), friendship_repository=cast(Any, FriendshipRepoStub()), + user_repository=cast(Any, UserRepoStub()), ) with pytest.raises(ApiProblemError) as exc_info: @@ -302,6 +324,7 @@ async def test_share_sqlalchemy_error_rolls_back() -> None: auth_gateway=cast(Any, AuthGatewayStub()), inbox_repository=InboxRepoStub(), friendship_repository=cast(Any, FriendshipRepoStub()), + user_repository=cast(Any, UserRepoStub()), ) with pytest.raises(ApiProblemError) as exc_info: @@ -334,6 +357,7 @@ async def test_share_returns_forbidden_when_target_is_not_friend() -> None: auth_gateway=cast(Any, AuthGatewayStub()), inbox_repository=InboxRepoStub(), friendship_repository=cast(Any, FriendshipRepoStub(accepted=False)), + user_repository=cast(Any, UserRepoStub()), ) with pytest.raises(ApiProblemError) as exc_info: diff --git a/deploy/build-android-release.sh b/deploy/build-android-release.sh index 339aa59..52d1735 100755 --- a/deploy/build-android-release.sh +++ b/deploy/build-android-release.sh @@ -76,14 +76,13 @@ flutter build apk --release \ --build-name="$VERSION_NAME" \ --build-number="$NEXT_VERSION_CODE" \ --dart-define="BACKEND_URL=$BACKEND_URL" \ - --target-platform=android-arm64 \ - --split-per-abi \ --target "lib/main.dart" popd >/dev/null -SOURCE_APK="$APPS_DIR/build/app/outputs/flutter-apk/app-arm64-v8a-release.apk" +SOURCE_APK="$OUTPUT_APK" if [[ ! -f "$SOURCE_APK" ]]; then - SOURCE_APK="$OUTPUT_APK" + printf 'Expected APK not found: %s\n' "$SOURCE_APK" >&2 + exit 1 fi cp "$SOURCE_APK" "$TARGET_APK" diff --git a/deploy/static/releases/manifest.json b/deploy/static/releases/manifest.json index 95fa120..6426a95 100644 --- a/deploy/static/releases/manifest.json +++ b/deploy/static/releases/manifest.json @@ -3,35 +3,46 @@ { "platform": "android", "channel": "release", - "version_name": "0.1.0", + "version_name": "0.1.2", + "version_code": 1, + "min_supported_version_code": 1, + "file_name": "social-app-android-v0.1.2+1-release.apk", + "release_notes": null, + "file_size": 61845992, + "sha256": "01d960a914dc984babefd2e348f0e89864a5a4078cb8dcad2e375ec1873f7947" + }, + { + "platform": "android", + "channel": "release", + "version_name": "0.1.2", + "version_code": 2, + "min_supported_version_code": 2, + "file_name": "social-app-android-v0.1.2+2-release.apk", + "release_notes": null, + "file_size": 61845996, + "sha256": "f06bb8d71f69e631aba9902e1ef99279df586c1c96d7eaa1efc3253dd2316a97" + }, + { + "platform": "android", + "channel": "release", + "version_name": "0.1.2", "version_code": 3, "min_supported_version_code": 3, - "file_name": "social-app-android-v0.1.0+3-release.apk", - "release_notes": null, - "file_size": 21371568, - "sha256": "34691f96004b3dc3b2070d84ae0e7f0d2943f6c9978160eb78550081bc72a74a" + "file_name": "social-app-android-v0.1.2+3-release.apk", + "release_notes": "\u4fee\u590d\u5b89\u88c5\u5347\u7ea7\u7b7e\u540d\uff0c\u652f\u6301\u8986\u76d6\u5b89\u88c5\u4fdd\u7559\u767b\u5f55\u6001", + "file_size": 61845996, + "sha256": "b5e268874a01ef4267c553ce3431df4ad0b2b90819c1e9cbfa4fc12bc573f931" }, { "platform": "android", "channel": "release", - "version_name": "0.1.1", + "version_name": "0.1.2", "version_code": 4, "min_supported_version_code": 4, - "file_name": "social-app-android-v0.1.1+4-release.apk", - "release_notes": null, - "file_size": 21572828, - "sha256": "2b59596044d473c8aa477a12d01958b9dc08b2aee528226039c37bdaa1372da8" - }, - { - "platform": "android", - "channel": "release", - "version_name": "0.1.1", - "version_code": 5, - "min_supported_version_code": 5, - "file_name": "social-app-android-v0.1.1+5-release.apk", - "release_notes": null, - "file_size": 22909435, - "sha256": "6982e21662d9a49b0bd91da6d04d1b51b83b084bd326004c1049822a8563771f" + "file_name": "social-app-android-v0.1.2+4-release.apk", + "release_notes": "\u5347\u7ea7\u6d4b\u8bd5\u5305\uff1a\u9a8c\u8bc1\u8986\u76d6\u5b89\u88c5\u540e\u4fdd\u6301\u767b\u5f55\u6001", + "file_size": 61845996, + "sha256": "be9e725062e4cefef1486aed4a04fa5b7323ad1e11328a41552543d3c71e5060" } ] } diff --git a/docs/plans/2026-03-29-calendar-share-redesign.md b/docs/plans/2026-03-29-calendar-share-redesign.md deleted file mode 100644 index c2ace76..0000000 --- a/docs/plans/2026-03-29-calendar-share-redesign.md +++ /dev/null @@ -1,556 +0,0 @@ -# Calendar Share Redesign - Implementation Plan - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** 重写日历分享功能,支持选择多个好友/用户并单独设置权限,同时后端新增批量分享接口。 - -**Architecture:** -- **后端**: 新增 `POST /api/v1/schedule-items/{id}/share/batch` 批量分享接口,原有单用户接口保留。 -- **前端**: 重构 `CalendarEventShareScreen` 和 `CalendarShareDialog`,移除硬编码手机号输入,改为用户搜索 + 多选 + 独立权限设置。 - -**Tech Stack:** Flutter (Riverpod/BLoC), FastAPI (Pydantic), PostgreSQL, 现有 API 契约 - ---- - -## 背景与现状 - -### 现有流程 -``` -用户 → CalendarEventShareScreen → CalendarShareDialog - ├── 手机号输入 + 前缀选择 - ├── 权限开关 (View/Edit/Invite) - └── POST /api/v1/schedule-items/{id}/share -``` - -### 问题 -1. 仅支持单用户分享 -2. 需要手动输入手机号,不支持好友选择 -3. 权限是全局的,无法为不同用户设置不同权限 - -### 目标流程 -``` -用户 → CalendarEventShareScreen → CalendarShareDialog - ├── 搜索框 (好友/用户名/手机号搜索) - ├── 用户列表 (可多选) - │ └── 每行: 头像 + 名字 + 权限开关 (选中后显示) - ├── 已选用户 Chips - └── POST /api/v1/schedule-items/{id}/share/batch -``` - ---- - -## Task 1: 后端 - 新增批量分享接口 - -**Files:** -- Create: `backend/src/v1/schedule_items/schemas.py` (新增 `ScheduleItemShareBatchRequest`) -- Modify: `backend/src/v1/schedule_items/router.py` (新增 `/share/batch` 端点) -- Modify: `backend/src/v1/schedule_items/service.py` (新增 `share_batch` 方法) -- Verify: `backend/tests/**` - -- [ ] **Step 1: 创建批量分享请求 Schema** - -```python -# backend/src/v1/schedule_items/schemas.py - -class ScheduleItemShareBatchRequest(BaseModel): - shares: List[ShareTarget] = Field(..., description="List of users to share with") - permission_view: bool = Field(True, description="Default view permission") - permission_edit: bool = Field(False, description="Default edit permission") - permission_invite: bool = Field(False, description="Default invite permission") - -class ShareTarget(BaseModel): - phone: str = Field(..., pattern=r"^\+861[3-9]\d{9}$") - permission_view: Optional[bool] = None - permission_edit: Optional[bool] = None - permission_invite: Optional[bool] = None - -class ScheduleItemShareBatchResponse(BaseModel): - message: str - success_count: int - failure_count: int - failures: List[ShareFailure] = [] - -class ShareFailure(BaseModel): - phone: str - reason: str -``` - -- [ ] **Step 2: 实现批量分享服务方法** - -```python -# backend/src/v1/schedule_items/service.py - -async def share_batch( - db: AsyncSession, - current_user: User, - schedule_item_id: UUID, - shares: List[ShareTarget], - default_permission_view: bool = True, - default_permission_edit: bool = False, - default_permission_invite: bool = False, -) -> Tuple[int, int, List[Dict]]: - """批量分享日程给多个用户""" - # 1. 验证当前用户是否有邀请权限 - # 2. 对每个 share 调用现有的 share_single 逻辑 - # 3. 返回 (成功数, 失败数, 失败详情列表) -``` - -- [ ] **Step 3: 新增批量分享路由** - -```python -# backend/src/v1/schedule_items/router.py - -@router.post("/{item_id}/share/batch", response_model=ScheduleItemShareBatchResponse) -async def share_batch( - item_id: UUID, - request: ScheduleItemShareBatchRequest, - current_user: User = Depends(get_current_user), - service: ScheduleItemService = Depends(), -): - """批量分享日程给多个用户""" - success_count, failure_count, failures = await service.share_batch( - db=db, - current_user=current_user, - schedule_item_id=item_id, - shares=request.shares, - default_permission_view=request.permission_view, - default_permission_edit=request.permission_edit, - default_permission_invite=request.permission_invite, - ) - return ScheduleItemShareBatchResponse( - message="Batch share completed", - success_count=success_count, - failure_count=failure_count, - failures=[ShareFailure(**f) for f in failures], - ) -``` - -- [ ] **Step 4: 编写测试** - -```python -# backend/tests/v1/test_schedule_items.py - -@pytest.mark.asyncio -async def test_share_batch_success(): - """测试批量分享成功""" - # Arrange - # Act - # Assert - -@pytest.mark.asyncio -async def test_share_batch_partial_failure(): - """测试批量分享部分失败(非好友用户应被拒绝)""" - # Arrange - # Act - # Assert -``` - -- [ ] **Step 5: 运行测试验证** - -```bash -cd backend && uv run pytest tests/v1/test_schedule_items.py -v -k "share_batch" -``` - ---- - -## Task 2: 前端 - 新增批量分享 API 方法 - -**Files:** -- Modify: `apps/lib/features/calendar/data/apis/calendar_api.dart` - -- [ ] **Step 1: 新增批量分享 API 方法** - -```dart -// apps/lib/features/calendar/data/apis/calendar_api.dart - -class CalendarApi { - // 现有单用户分享保留 - Future share(...) { ... } - - // 新增批量分享 - Future shareBatch({ - required String eventId, - required List shares, - bool permissionView = true, - bool permissionEdit = false, - bool permissionInvite = false, - }) async { - final response = await _dio.post( - '/api/v1/schedule-items/$eventId/share/batch', - data: { - 'shares': shares.map((s) => s.toJson()).toList(), - 'permission_view': permissionView, - 'permission_edit': permissionEdit, - 'permission_invite': permissionInvite, - }, - ); - return ShareBatchResponse.fromJson(response.data); - } -} - -class ShareTarget { - final String phone; - final bool? permissionView; - final bool? permissionEdit; - final bool? permissionInvite; - - Map toJson() => { - 'phone': phone, - if (permissionView != null) 'permission_view': permissionView, - if (permissionEdit != null) 'permission_edit': permissionEdit, - if (permissionInvite != null) 'permission_invite': permissionInvite, - }; -} - -class ShareBatchResponse { - final String message; - final int successCount; - final int failureCount; - final List failures; -} -``` - -- [ ] **Step 2: 验证 API 编译** - -```bash -cd apps && flutter analyze lib/features/calendar/data/apis/calendar_api.dart -``` - ---- - -## Task 3: 前端 - 创建分享页面状态管理 - -**Files:** -- Create: `apps/lib/features/calendar/presentation/cubits/calendar_share_cubit.dart` -- Create: `apps/lib/features/calendar/presentation/cubits/calendar_share_state.dart` - -- [ ] **Step 1: 创建 State** - -```dart -// apps/lib/features/calendar/presentation/cubits/calendar_share_state.dart - -enum CalendarShareStatus { initial, loading, success, failure } - -class SelectedUser { - final String id; - final String username; - final String? avatarUrl; - final String phone; - bool permissionView; - bool permissionEdit; - bool permissionInvite; -} - -class CalendarShareState { - final CalendarShareStatus status; - final List friends; - final List searchResults; - final List selectedUsers; - final String searchQuery; - final String? errorMessage; - - // 权限默认值(应用于新选中的用户) - bool defaultPermissionView; - bool defaultPermissionEdit; - bool defaultPermissionInvite; -} -``` - -- [ ] **Step 2: 创建 Cubit** - -```dart -// apps/lib/features/calendar/presentation/cubits/calendar_share_cubit.dart - -class CalendarShareCubit extends Cubit { - final FriendsApi _friendsApi; - final UsersApi _usersApi; - final CalendarApi _calendarApi; - - CalendarShareCubit({ - required FriendsApi friendsApi, - required UsersApi usersApi, - required CalendarApi calendarApi, - }) : super(CalendarShareState.initial()); - - // 加载好友列表 - Future loadFriends() async { ... } - - // 搜索用户 - Future searchUsers(String query) async { ... } - - // 切换用户选中状态 - void toggleUser(String userId) { ... } - - // 更新单个用户的权限 - void updateUserPermission(String userId, {bool? view, bool? edit, bool? invite}) { ... } - - // 移除已选用户 - void removeUser(String userId) { ... } - - // 设置默认权限(对新选用户生效) - void setDefaultPermissions({bool? view, bool? edit, bool? invite}) { ... } - - // 发送批量分享 - Future share(String eventId) async { ... } -} -``` - -- [ ] **Step 3: 验证编译** - -```bash -cd apps && flutter analyze lib/features/calendar/presentation/cubits/calendar_share_cubit.dart -``` - ---- - -## Task 4: 前端 - 重构分享对话框 UI - -**Files:** -- Create: `apps/lib/features/calendar/presentation/widgets/user_select_list.dart` -- Create: `apps/lib/features/calendar/presentation/widgets/user_select_item.dart` -- Modify: `apps/lib/features/calendar/presentation/widgets/calendar_share_dialog.dart` -- Modify: `apps/lib/features/calendar/presentation/screens/calendar_event_share_screen.dart` - -### 4.1 用户选择列表组件 - -- [ ] **Step 1: 创建 UserSelectItem** - -```dart -// apps/lib/features/calendar/presentation/widgets/user_select_item.dart - -class UserSelectItem extends StatelessWidget { - final UserBasicInfo user; - final bool isSelected; - final bool showPermissions; // 选中后才显示权限开关 - final SelectedUser? selectedUser; // 如果选中,包含权限状态 - final ValueChanged onToggle; - final Function(PermissionType, bool) onPermissionChanged; - - @override - Widget build(BuildContext context) { - return ListTile( - leading: Avatar(url: user.avatarUrl, name: user.username), - title: Text(user.username), - subtitle: showPermissions && isSelected - ? Row( - children: [ - _PermissionChip('V', selectedUser?.permissionView ?? true, (v) => onPermissionChanged(PermissionType.view, v)), - _PermissionChip('E', selectedUser?.permissionEdit ?? false, (v) => onPermissionChanged(PermissionType.edit, v)), - _PermissionChip('I', selectedUser?.permissionInvite ?? false, (v) => onPermissionChanged(PermissionType.invite, v)), - ], - ) - : null, - trailing: Checkbox(value: isSelected, onChanged: (v) => onToggle(v ?? false)), - ); - } -} -``` - -### 4.2 重构 CalendarShareDialog - -- [ ] **Step 2: 重构对话框** - -```dart -// apps/lib/features/calendar/presentation/widgets/calendar_share_dialog.dart - -class CalendarShareDialog extends StatefulWidget { - // ... existing props - - @override - State createState() => _CalendarShareDialogState(); -} - -class _CalendarShareDialogState extends State { - late final CalendarShareCubit _cubit; - final _searchController = TextEditingController(); - - @override - void initState() { - super.initState(); - _cubit = context.read(); - _cubit.loadFriends(); - } - - @override - Widget build(BuildContext context) { - return BlocProvider.value( - value: _cubit, - child: BlocBuilder( - builder: (context, state) { - return Column( - mainAxisSize: MainAxisSize.min, - children: [ - // Header - _buildHeader(context, state), - - // 搜索框 - TextField( - controller: _searchController, - decoration: InputDecoration( - hintText: '搜索好友或输入手机号...', - prefixIcon: Icon(Icons.search), - ), - onChanged: (value) => _cubit.searchUsers(value), - ), - - // 已选用户 Chips - if (state.selectedUsers.isNotEmpty) ...[ - Wrap( - spacing: 8, - children: state.selectedUsers.map((user) { - return Chip( - label: Text('${user.username} (${_getPermissionSummary(user)})'), - onDeleted: () => _cubit.removeUser(user.id), - ); - }).toList(), - ), - ], - - // 用户列表 - Flexible( - child: _buildUserList(state), - ), - - // 发送按钮 - AppButton( - text: '发送邀请 (${state.selectedUsers.length}人)', - onPressed: state.selectedUsers.isEmpty - ? null - : () => _handleShare(context, state), - ), - ], - ); - }, - ), - ); - } - - Widget _buildUserList(CalendarShareState state) { - // 如果有搜索结果,显示搜索结果;否则显示好友列表 - final users = state.searchQuery.isNotEmpty - ? state.searchResults - : state.friends.map((f) => f.friend).toList(); - - return ListView.builder( - shrinkWrap: true, - itemCount: users.length, - itemBuilder: (context, index) { - final user = users[index]; - final selectedUser = state.selectedUsers.cast().firstWhere( - (s) => s?.id == user.id, - orElse: () => null, - ); - final isSelected = selectedUser != null; - - return UserSelectItem( - user: user, - isSelected: isSelected, - showPermissions: isSelected, - selectedUser: selectedUser, - onToggle: (selected) { - if (selected) { - _cubit.addUser(user, state.defaultPermissionView, state.defaultPermissionEdit, state.defaultPermissionInvite); - } else { - _cubit.removeUser(user.id); - } - }, - onPermissionChanged: (type, value) { - _cubit.updateUserPermission(user.id, type: type, value: value); - }, - ); - }, - ); - } -} -``` - -- [ ] **Step 3: 移除顶部栏,简化 CalendarEventShareScreen** - -```dart -// apps/lib/features/calendar/presentation/screens/calendar_event_share_screen.dart - -class CalendarEventShareScreen extends StatelessWidget { - @override - Widget build(BuildContext context) { - return Scaffold( - body: SafeArea( - child: Column( - children: [ - // 直接显示分享对话框,不需要额外顶部栏 - Expanded( - child: CalendarShareDialog( - eventId: eventId, - eventTitle: event.title, - canInvite: event.canInvite, - canEdit: event.canEdit, - ), - ), - ], - ), - ), - ); - } -} -``` - -- [ ] **Step 4: 验证编译** - -```bash -cd apps && flutter analyze lib/features/calendar/presentation/widgets/calendar_share_dialog.dart -``` - ---- - -## Task 5: 集成测试 - -**Files:** -- Create: `apps/test/features/calendar/presentation/widgets/calendar_share_dialog_test.dart` - -- [ ] **Step 1: 编写 Widget 测试** - -```dart -void main() { - testWidgets('选择用户后显示权限开关', (tester) async { - // Arrange - // Act - // Assert - }); - - testWidgets('已选用户显示在 Chips 中', (tester) async { - // Arrange - // Act - // Assert - }); -} -``` - -- [ ] **Step 2: 运行测试** - -```bash -cd apps && flutter test test/features/calendar/presentation/widgets/calendar_share_dialog_test.dart -``` - ---- - -## 依赖关系 - -``` -Task 1 (后端批量接口) - ↓ -Task 2 (前端批量分享 API) - ↓ -Task 3 (分享状态管理 Cubit) - ↓ -Task 4 (分享对话框 UI) - ↓ -Task 5 (集成测试) -``` - ---- - -## 风险与注意事项 - -1. **后端兼容性**: 批量接口上线前,前端使用原有单用户接口 -2. **搜索防抖**: 搜索用户时需要 debounce 避免频繁请求 -3. **好友列表分页**: 如果好友数量大,需要分页加载 -4. **权限验证**: 前端权限不能超过当前用户的权限(由后端控制) diff --git a/docs/plans/2026-03-30-agent-calendar-inbox-stability.md b/docs/plans/2026-03-30-agent-calendar-inbox-stability.md new file mode 100644 index 0000000..bd5b28e --- /dev/null +++ b/docs/plans/2026-03-30-agent-calendar-inbox-stability.md @@ -0,0 +1,74 @@ +# Agent Calendar/Inbox Stability Implementation Plan + +> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. + +**Goal:** 修复 agent 日历分享失败、对话后日历不刷新、邀请信息不完整,并新增设置页一键清理本地缓存后强制重新拉取。 + +**Architecture:** 后端补齐 `calendar_share` 依赖注入与邀请 payload 字段,确保工具链与 API 路由行为一致。前端在 Chat 工具结果事件上增加日历刷新钩子,并在设置页提供缓存清理入口,复用既有 prewarm 机制触发重新拉取。协议文档同步更新 inbox 邀请结构,避免前后端契约漂移。 + +**Tech Stack:** FastAPI + SQLAlchemy + AgentScope tools, Flutter + CachedRepository + SharedPreferences cache. + +--- + +### Task 1: 修复 calendar_share 在 Agent 工具链中的依赖缺失 + +**Files:** +- Modify: `backend/src/core/agentscope/tools/utils/calendar_domain.py` + +**Steps:** +1. 在 `create_schedule_service` 注入 `SQLAlchemyUserRepository`。 +2. 保持路由层与工具层对 `ScheduleItemService` 的依赖一致。 +3. 回归验证 `calendar_share` 不再因为 actor lookup 依赖缺失而失败。 + +### Task 2: 扩充 calendar invite payload(邀请人 + 时间 + 描述) + +**Files:** +- Modify: `backend/src/v1/schedule_items/service.py` +- Modify: `docs/protocols/models/inbox-messages.md` +- Test: `backend/tests/unit/v1/schedule_items/test_share.py` + +**Steps:** +1. 在 `share` 中构建邀请消息时写入 `actor.phone`。 +2. 在 `item` 中写入 `description/start_at/end_at/timezone`。 +3. 更新协议文档 `CalendarInviteContent`。 +4. 补充/更新单测断言新增字段。 + +### Task 3: 对话工具成功后触发日历缓存刷新钩子 + +**Files:** +- Modify: `apps/lib/features/chat/presentation/bloc/chat_bloc.dart` +- Modify: `apps/lib/features/chat/presentation/bloc/chat_bloc_events.dart` +- Modify: `apps/lib/app/di/injection.dart` +- Test: `apps/test/features/chat/presentation/bloc/chat_bloc_test.dart` + +**Steps:** +1. 给 `ChatBloc` 增加可注入回调 `onCalendarMutated`。 +2. 在 `ToolCallResultEvent` 中识别 `calendar_write` 成功/部分成功并触发回调。 +3. DI 中将回调绑定为 `CalendarRepository.getDayEvents/getMonthEvents(forceRefresh: true)`。 +4. 添加回归测试验证回调触发。 + +### Task 4: 设置页新增“清理缓存”并触发重新拉取 + +**Files:** +- Modify: `apps/lib/data/cache/cache_store.dart` +- Modify: `apps/lib/features/settings/presentation/screens/settings_screen.dart` +- Modify: `apps/lib/l10n/app_zh.arb` +- Modify: `apps/lib/l10n/app_en.arb` + +**Steps:** +1. 在 `HybridCacheStore` 增加按前缀清理能力(`cache:`)。 +2. 设置页在“检查更新”下新增“清理缓存”。 +3. 点击后清理缓存并触发 prewarm + inbox 快照刷新。 +4. 同步中英文文案并生成本地化代码。 + +### Task 5: 验证 + +**Commands:** +- `uv run pytest backend/tests/unit/v1/schedule_items/test_share.py backend/tests/unit/core/agentscope/test_calendar_tools.py -k "share or calendar_share"` +- `flutter test test/features/chat/presentation/bloc/chat_bloc_test.dart` +- `flutter analyze` + +**Expected:** +- 后端分享链路测试通过,新增邀请字段存在。 +- ChatBloc 回归测试通过,`calendar_write` 成功时触发刷新回调。 +- Flutter 静态检查通过,无新增错误。 diff --git a/docs/plans/2026-03-30-calendar-detail-show-subscribers.md b/docs/plans/2026-03-30-calendar-detail-show-subscribers.md deleted file mode 100644 index 864094b..0000000 --- a/docs/plans/2026-03-30-calendar-detail-show-subscribers.md +++ /dev/null @@ -1,214 +0,0 @@ -# Calendar Detail - Show Subscribers Implementation Plan - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. - -**Goal:** 在日历详情页显示已订阅此日历的用户列表 - -**Architecture:** -- 后端:在 `GET /api/v1/schedule-items/{id}` 响应中返回 `subscribers` 列表 -- 前端:在详情页渲染订阅者列表组件 - -**Tech Stack:** Flutter, FastAPI, PostgreSQL - ---- - -## Task 1: 后端 - 返回订阅者列表 - -**Files:** -- Modify: `backend/src/v1/schedule_items/schemas.py` -- Modify: `backend/src/v1/schedule_items/service.py` -- Verify: `backend/tests/integration/v1/test_schedule_items_routes.py` - -- [ ] **Step 1: 新增 SubscriberInfo Schema** - -```python -# backend/src/v1/schedule_items/schemas.py - -class SubscriberInfo(BaseModel): - """订阅者信息""" - user_id: UUID - username: str - avatar_url: str | None = None - permission: int # 位标志: 1=view, 2=invite, 4=edit - status: str # active, pending, paused, unsubscribed - subscribed_at: datetime -``` - -- [ ] **Step 2: 修改 ScheduleItemResponse** - -```python -class ScheduleItemResponse(BaseModel): - # ... existing fields ... - subscribers: List[SubscriberInfo] = [] # 新增 -``` - -- [ ] **Step 3: 修改 service.get_by_id 填充 subscribers** - -```python -# backend/src/v1/schedule_items/service.py - -async def get_by_id(self, item_id: UUID) -> ScheduleItemResponse: - item = await self._repository.get_by_id(item_id) - if not item: - raise ScheduleItemNotFoundError(item_id) - - # 获取订阅者列表 - subscriptions = await self._repository.get_subscriptions_by_item_id(item_id) - subscribers = [] - for sub in subscriptions: - if sub.status == 'active': # 只返回活跃订阅者 - user = await self._user_repo.get_by_id(sub.subscriber_id) - if user: - subscribers.append(SubscriberInfo( - user_id=user.id, - username=user.username, - avatar_url=user.avatar_url, - permission=sub.permission, - status=sub.status, - subscribed_at=sub.created_at, - )) - - return ScheduleItemResponse( - # ... existing fields ..., - subscribers=subscribers, - ) -``` - -- [ ] **Step 4: 编写集成测试** - -```python -# backend/tests/integration/v1/test_schedule_items_routes.py - -async def test_get_item_returns_subscribers(self): - """测试获取日程时返回订阅者列表""" - # Arrange: 创建日程,添加订阅者 - # Act: GET /api/v1/schedule-items/{id} - # Assert: 响应包含 subscribers 字段 -``` - ---- - -## Task 2: 前端 - Model 更新 - -**Files:** -- Modify: `apps/lib/features/calendar/data/models/schedule_item_model.dart` - -- [ ] **Step 1: 新增 Subscriber 模型** - -```dart -class Subscriber { - final String userId; - final String username; - final String? avatarUrl; - final int permission; - final String status; - final DateTime subscribedAt; - - bool get canView => (permission & 1) != 0; - bool get canEdit => (permission & 4) != 0; - bool get canInvite => (permission & 2) != 0; -} -``` - -- [ ] **Step 2: 在 ScheduleItemModel 中添加 subscribers 字段** - -```dart -class ScheduleItemModel { - // ... existing fields ... - final List subscribers; -} -``` - -- [ ] **Step 3: 更新 fromJson** - -```dart -factory ScheduleItemModel.fromJson(Map json) { - return ScheduleItemModel( - // ... existing fields ..., - subscribers: (json['subscribers'] as List?) - ?.map((s) => Subscriber.fromJson(s as Map)) - .toList() ?? [], - ); -} -``` - ---- - -## Task 3: 前端 - 详情页渲染订阅者 - -**Files:** -- Modify: `apps/lib/features/calendar/presentation/screens/calendar_event_detail_screen.dart` - -- [ ] **Step 1: 在 _buildMetaSurface 或新方法中渲染订阅者** - -```dart -Widget _buildSubscribersSurface(ScheduleItemModel event) { - if (event.subscribers.isEmpty) { - return const SizedBox.shrink(); - } - - return Container( - padding: EdgeInsets.all(AppSpacing.lg), - decoration: BoxDecoration( - color: _colorScheme.surface, - borderRadius: BorderRadius.circular(AppRadius.xl), - border: Border.all(color: _colorScheme.outlineVariant), - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - context.l10n.calendarDetailSubscribers, - style: TextStyle( - fontSize: 13, - fontWeight: FontWeight.w600, - color: _colorScheme.onSurfaceVariant, - ), - ), - const SizedBox(height: AppSpacing.md), - ...event.subscribers.map((sub) => _buildSubscriberRow(sub)), - ], - ), - ); -} - -Widget _buildSubscriberRow(Subscriber subscriber) { - return Padding( - padding: const EdgeInsets.symmetric(vertical: AppSpacing.xs), - child: Row( - children: [ - Avatar(url: subscriber.avatarUrl, name: subscriber.username), - const SizedBox(width: AppSpacing.sm), - Expanded(child: Text(subscriber.username)), - if (subscriber.canEdit) Icon(Icons.edit, size: 16), - if (subscriber.canInvite) Icon(Icons.person_add, size: 16), - ], - ), - ); -} -``` - -- [ ] **Step 2: 在 build 方法中添加** - -```dart -// 在 _buildExtraSurface 之后添加 -if (event.subscribers.isNotEmpty) [ - const SizedBox(height: AppSpacing.md), - _buildSubscribersSurface(event), -], -``` - -- [ ] **Step 3: 添加 l10n 文本** - -```arb -// apps/lib/l10n/app_zh.arb -"calendarDetailSubscribers": "已订阅 ({count}人)" -``` - ---- - -## Task 4: 验证 - -- [ ] 运行后端测试: `cd backend && uv run pytest -v -k "subscriber"` -- [ ] 运行前端分析: `cd apps && flutter analyze` -- [ ] 手动测试分享流程 diff --git a/docs/plans/2026-03-30-calendar-permission-refactor.md b/docs/plans/2026-03-30-calendar-permission-refactor.md deleted file mode 100644 index ea80e48..0000000 --- a/docs/plans/2026-03-30-calendar-permission-refactor.md +++ /dev/null @@ -1,473 +0,0 @@ -# Calendar Permission Refactoring Plan - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** 统一权限判断逻辑,将 `is_owner` 和 `permission` 的双重判断合并为纯 `permission` 判断,并新增 Owner 徽章 UI。 - -**Architecture:** -- 后端:扩展权限位掩码,添加 DELETE=8,OWNER=15。简化 Repository 和 Service 层的重复方法。 -- 前端:Model 层 `canEdit`/`canDelete` 改为纯 permission 判断,`isOwner` 仅保留用于 UI 徽章显示。 -- Protocol:更新位掩码说明文档。 - -**Tech Stack:** Python (FastAPI/SQLAlchemy), Flutter, Supabase - ---- - -## 变更范围总结 - -### 后端 (5 个文件) - -| 文件 | 改动 | -|------|------| -| `backend/src/schemas/enums.py` | `DELETE=8`, `OWNER=15` | -| `backend/src/v1/schedule_items/service.py` | 移除双重判断,统一 permission 逻辑 | -| `backend/src/v1/schedule_items/repository.py` | 合并 `get_by_item_id` + `update_by_item_id` → 统一方法 | -| `backend/tests/integration/test_schedule_items_routes.py` | `permission=7` → `permission=15` | -| `docs/protocols/calendar/schedule-items.md` | 更新位掩码说明 | - -### 前端 (4 个文件) - -| 文件 | 改动 | -|------|------| -| `apps/lib/features/calendar/data/models/schedule_item_model.dart` | `canEdit/canDelete` 改为纯 permission | -| `apps/lib/features/calendar/presentation/screens/calendar_event_detail_screen.dart` | 添加 Owner 徽章 | -| `apps/lib/l10n/app_zh.arb` | 新增 owner badge 文案 | -| `apps/lib/l10n/app_en.arb` | 新增 owner badge 文案 | - ---- - -## Task 1: 后端 - 更新权限枚举 - -**Files:** -- Modify: `backend/src/schemas/enums.py:96-100` - -- [ ] **Step 1: 更新 SubscriptionPermission 枚举** - -```python -class SubscriptionPermission(int, Enum): - VIEW = 1 - INVITE = 2 - EDIT = 4 - DELETE = 8 - OWNER = 15 # VIEW | INVITE | EDIT | DELETE -``` - -- [ ] **Step 2: 运行语法检查** - -```bash -cd /Users/zl-q/Code/social-app/backend && uv run python -m py_compile src/schemas/enums.py -``` - -Expected: 无错误输出 - ---- - -## Task 2: 后端 - 简化 Repository 层 - -**Files:** -- Modify: `backend/src/v1/schedule_items/repository.py` - -**原始方法 (需合并/删除):** -- `get_by_item_id(item_id, owner_id)` - 仅用于 owner 判断,可删除 -- `update_by_item_id(item_id, owner_id, data)` - 仅用于 owner 更新,可删除 -- `update_item_by_id(item_id, data)` - 保留,订阅者更新用 - -**新逻辑:** -- `get_item(item_id)` - 获取 item,不带 owner 过滤 -- `update_item(item_id, data)` - 统一更新,不带 owner 过滤(权限判断移至 Service 层) -- `delete_item(item_id)` - 统一删除,不带 owner 过滤(权限判断移至 Service 层) - -- [ ] **Step 1: 添加统一方法** - -在 `SQLAlchemyScheduleItemRepository` 类中添加: - -```python -async def get_item(self, item_id: UUID) -> ScheduleItem | None: - """Get item by id without owner filter. Permission check done at service layer.""" - try: - stmt = ( - select(ScheduleItem) - .where(ScheduleItem.id == item_id) - .where(ScheduleItem.deleted_at.is_(None)) - ) - result = await self._session.execute(stmt) - return result.scalar_one_or_none() - except SQLAlchemyError: - logger.exception("Schedule item lookup failed", item_id=str(item_id)) - raise - -async def update_item(self, item_id: UUID, data: dict) -> ScheduleItem | None: - """Update item by id without owner filter. Permission check done at service layer.""" - if not data: - return await self.get_item(item_id) - try: - existing = await self.get_item(item_id) - if existing is None: - return None - stmt = ( - update(ScheduleItem) - .where(ScheduleItem.id == item_id) - .where(ScheduleItem.deleted_at.is_(None)) - .values(**data) - .returning(ScheduleItem) - ) - result = await self._session.execute(stmt) - await self._session.flush() - return result.scalar_one_or_none() - except SQLAlchemyError: - logger.exception("Schedule item update failed", item_id=str(item_id)) - raise - -async def delete_item(self, item_id: UUID) -> ScheduleItem | None: - """Soft delete item by id without owner filter. Permission check done at service layer.""" - try: - stmt = ( - update(ScheduleItem) - .where(ScheduleItem.id == item_id) - .where(ScheduleItem.deleted_at.is_(None)) - .values(deleted_at=datetime.now(timezone.utc)) - .returning(ScheduleItem) - ) - result = await self._session.execute(stmt) - await self._session.flush() - return result.scalar_one_or_none() - except SQLAlchemyError: - logger.exception("Schedule item delete failed", item_id=str(item_id)) - raise -``` - -- [ ] **Step 2: 更新 Repository Protocol** - -修改 `ScheduleItemRepository` Protocol 定义: - -```python -class ScheduleItemRepository(Protocol): - async def get_by_id(self, entity_id: UUID) -> ScheduleItem | None: ... - async def get_item(self, item_id: UUID) -> ScheduleItem | None: ... # 新增 - async def get_subscription(self, item_id: UUID, subscriber_id: UUID) -> ScheduleSubscription | None: ... - async def create(self, data: dict) -> ScheduleItem: ... - async def update_item(self, item_id: UUID, data: dict) -> ScheduleItem | None: ... # 合并 - async def delete_item(self, item_id: UUID) -> ScheduleItem | None: ... # 合并 - # ... 其他方法保持不变 -``` - -- [ ] **Step 3: 运行语法检查** - -```bash -cd /Users/zl-q/Code/social-app/backend && uv run python -m py_compile src/v1/schedule_items/repository.py -``` - -Expected: 无错误输出 - ---- - -## Task 3: 后端 - 简化 Service 层 - -**Files:** -- Modify: `backend/src/v1/schedule_items/service.py` - -### 3.1 get_by_id 方法简化 - -**原始逻辑 (lines 175-180):** -```python -is_owner = item.owner_id == user_id -permission = 1 -if not is_owner: - subscription = await self._repository.get_subscription(item_id, user_id) - if subscription: - permission = subscription.permission -``` - -**新逻辑:** -```python -subscription = await self._repository.get_subscription(item_id, user_id) -permission = subscription.permission if subscription else 1 -is_owner = item.owner_id == user_id -``` - -### 3.2 update 方法简化 - -**原始逻辑 (lines 231-264):** -```python -existing = await self._repository.get_by_item_id(item_id, user_id) -is_owner = existing is not None - -if not is_owner: - subscription = await self._repository.get_subscription(item_id, user_id) - if subscription is None or subscription.status != SubscriptionStatus.ACTIVE: - raise 404 - if not (subscription.permission & SubscriptionPermission.EDIT): - raise 403 - existing = await self._repository.get_by_id(item_id) - ... -# owner 用 update_by_item_id,订阅者用 update_item_by_id -if is_owner: - item = await self._repository.update_by_item_id(item_id, user_id, update_data) -else: - item = await self._repository.update_item_by_id(item_id, update_data) -``` - -**新逻辑:** -```python -subscription = await self._repository.get_subscription(item_id, user_id) -if subscription is None or subscription.status != SubscriptionStatus.ACTIVE: - raise 404 -if not (subscription.permission & SubscriptionPermission.EDIT): - raise 403 -# 统一用 update_item -item = await self._repository.update_item(item_id, update_data) -is_owner = item.owner_id == user_id if item else False -``` - -### 3.3 delete 方法简化 - -**原始逻辑 (lines 338-366):** -```python -existing = await self._repository.get_by_item_id(item_id, user_id) -if existing is None: - raise 404 -... -await self._repository.delete_by_item_id(item_id, user_id) -``` - -**新逻辑:** -```python -subscription = await self._repository.get_subscription(item_id, user_id) -if subscription is None or not (subscription.permission & SubscriptionPermission.DELETE): - raise 403 -item = await self._repository.delete_item(item_id) -if item is None: - raise 404 -``` - -- [ ] **Step 1: 修改 get_by_id (lines 175-180)** - -```python -# 替换为: -subscription = await self._repository.get_subscription(item_id, user_id) -permission = subscription.permission if subscription else 1 -is_owner = item.owner_id == user_id -``` - -- [ ] **Step 2: 修改 update 方法 (lines 226-336)** - -简化权限检查逻辑,统一使用 `update_item` 方法 - -- [ ] **Step 3: 修改 delete 方法 (lines 338-366)** - -简化权限检查逻辑,统一使用 `delete_item` 方法 - -- [ ] **Step 4: 更新 _to_response 响应方法 (line 645)** - -```python -# 原来 -permission=permission if not is_owner else 7, -# 改为 (如果 subscription 有值就用其 permission,否则默认 1) -permission=subscription.permission if subscription else 1, -``` - -- [ ] **Step 5: 运行语法检查** - -```bash -cd /Users/zl-q/Code/social-app/backend && uv run python -m py_compile src/v1/schedule_items/service.py -``` - -Expected: 无错误输出 - ---- - -## Task 4: 后端 - 更新测试 - -**Files:** -- Modify: `backend/tests/integration/test_schedule_items_routes.py` - -- [ ] **Step 1: 更新所有 permission=7 为 permission=15** - -```bash -# 使用 replaceAll 功能 -# 将 permission=7 替换为 permission=15 -``` - -- [ ] **Step 2: 运行测试验证** - -```bash -cd /Users/zl-q/Code/social-app/backend && uv run pytest tests/integration/test_schedule_items_routes.py -v -``` - -Expected: 所有测试通过 - ---- - -## Task 5: 前端 - 简化 Model 权限判断 - -**Files:** -- Modify: `apps/lib/features/calendar/data/models/schedule_item_model.dart` - -- [ ] **Step 1: 添加 DELETE 权限常量** - -```dart -static const int permissionDelete = 8; -``` - -- [ ] **Step 2: 修改 canEdit/canDelete** - -```dart -// 原来 -bool get canEdit => isOwner || (permission & permissionEdit) != 0; -bool get canDelete => isOwner; - -// 改为 -bool get canEdit => (permission & permissionEdit) != 0; -bool get canDelete => (permission & permissionDelete) != 0; -``` - -- [ ] **Step 3: 运行 flutter analyze** - -```bash -cd /Users/zl-q/Code/social-app/apps && flutter analyze lib/features/calendar/data/models/schedule_item_model.dart -``` - -Expected: No issues found - ---- - -## Task 6: 前端 - 添加 Owner 徽章 - -**Files:** -- Modify: `apps/lib/features/calendar/presentation/screens/calendar_event_detail_screen.dart` - -- [ ] **Step 1: 在标题栏添加 Owner 徽章** - -找到标题显示的位置(约 line 250-280),在标题后添加: - -```dart -if (event.isOwner) - Container( - margin: const EdgeInsets.only(left: 8), - padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2), - decoration: BoxDecoration( - color: Theme.of(context).colorScheme.primaryContainer, - borderRadius: BorderRadius.circular(4), - ), - child: Text( - context.l10n.calendarOwnerBadge, - style: TextStyle( - fontSize: 12, - color: Theme.of(context).colorScheme.onPrimaryContainer, - ), - ), - ), -``` - -- [ ] **Step 2: 运行 flutter analyze** - -```bash -cd /Users/zl-q/Code/social-app/apps && flutter analyze lib/features/calendar/presentation/screens/calendar_event_detail_screen.dart -``` - -Expected: No issues found - ---- - -## Task 7: 前端 - 添加 L10n 文案 - -**Files:** -- Modify: `apps/lib/l10n/app_zh.arb` -- Modify: `apps/lib/l10n/app_en.arb` - -- [ ] **Step 1: 添加中文文案** - -在 `app_zh.arb` 末尾添加: - -```json -"calendarOwnerBadge": "我的日历", -"@calendarOwnerBadge": { - "description": "Owner badge shown when user owns the calendar" -} -``` - -- [ ] **Step 2: 添加英文文案** - -在 `app_en.arb` 末尾添加: - -```json -"calendarOwnerBadge": "My Calendar", -"@calendarOwnerBadge": { - "description": "Owner badge shown when user owns the calendar" -} -``` - -- [ ] **Step 3: 生成 l10n** - -```bash -cd /Users/zl-q/Code/social-app/apps && flutter gen-l10n -``` - ---- - -## Task 8: 更新 Protocol 文档 - -**Files:** -- Modify: `docs/protocols/calendar/schedule-items.md` - -- [ ] **Step 1: 更新 SubscriberInfo 的 permission 说明** - -找到 SubscriberInfo 部分,添加 DELETE 权限说明: - -``` -permission: "int (位掩码: 1=view, 2=invite, 4=edit, 8=delete)" -``` - -- [ ] **Step 2: 更新 PATCH `/{item_id}` 的 Authorization 说明** - -``` -- **Owner**: 可更新所有字段 (permission=15) -- **Subscriber (DELETE permission)**: 可删除日程(权限位掩码包含 `8`) -``` - ---- - -## Task 9: 最终验证 - -- [ ] **Step 1: 后端语法检查** - -```bash -cd /Users/zl-q/Code/social-app/backend && uv run python -m py_compile src/schemas/enums.py src/v1/schedule_items/repository.py src/v1/schedule_items/service.py -``` - -- [ ] **Step 2: 后端测试** - -```bash -cd /Users/zl-q/Code/social-app/backend && uv run pytest tests/integration/test_schedule_items_routes.py -v -``` - -- [ ] **Step 3: 前端分析** - -```bash -cd /Users/zl-q/Code/social-app/apps && flutter analyze lib/features/calendar -``` - -- [ ] **Step 4: Git 状态检查** - -```bash -cd /Users/zl-q/Code/social-app && git status -``` - ---- - -## 回滚计划 - -如果出现问题: - -1. **后端回滚**: `git checkout HEAD~1 -- backend/` -2. **前端回滚**: `git checkout HEAD~1 -- apps/` -3. **Protocol 回滚**: `git checkout HEAD~1 -- docs/` - ---- - -## 注意事项 - -1. Repository 层的方法合并后,Service 层必须确保权限判断正确 -2. Owner 创建日程时,subscription.permission 应该设置为 15 (OWNER) -3. 测试中的 permission=7 需要全部更新为 15 -4. 前端 isOwner 字段仍保留,但仅用于 UI 显示,不参与权限判断 diff --git a/docs/protocols/app/update-check.md b/docs/protocols/app/update-check.md index 47835db..d1eca9d 100644 --- a/docs/protocols/app/update-check.md +++ b/docs/protocols/app/update-check.md @@ -104,3 +104,62 @@ Android 安装包命名规范: - `versionName`(如 `0.1.0`)由开发者手动维护 - `buildNumber`(如 `+2`)由打包脚本自动递增 + +## 7. Android Production Signing And Upgrade Strategy + +为了保证 APK 升级是覆盖安装(保留应用数据和登录态),生产环境必须满足以下条件: + +1. `applicationId` 保持不变(当前为 `com.xunmee.xisocial`) +2. 所有 `release` 包使用同一套正式签名证书(固定 keystore) +3. `versionCode` 严格递增 + +若证书变化,Android 通常无法覆盖安装,会要求先卸载旧包再安装新包,导致本地 token/缓存数据丢失。 + +### 7.1 Signing Files + +- 签名配置文件:`apps/android/key.properties`(本地文件,不入库) +- 模板文件:`apps/android/key.properties.example` +- 证书文件建议:`apps/android/release.jks`(本地文件,不入库) + +`key.properties` 示例: + +```properties +storeFile=release.jks +storePassword= +keyAlias= +keyPassword= +``` + +### 7.2 Release Build Contract + +- `apps/android/app/build.gradle.kts` 的 `release` 构建必须使用 `signingConfigs.release` +- 当 `apps/android/key.properties` 缺失时,构建必须失败(禁止回退到 debug 签名) + +### 7.3 Production Packaging Command + +```bash +bash deploy/build-android-release.sh \ + --backend-host \ + --channel release \ + --release-notes "" +``` + +产物位置: + +- APK:`deploy/static/releases/social-app-android-v{versionName}+{versionCode}-release.apk` +- 清单:`deploy/static/releases/manifest.json` + +### 7.4 First Migration To Stable Signing + +如果历史版本使用 debug 签名,而新版本改为正式签名: + +- 首次升级通常需要卸载旧包后安装新包(一次性迁移) +- 迁移完成后,后续版本在同签名条件下可覆盖安装并保留登录态 + +### 7.5 Operational Checklist (Mandatory) + +1. 确认 `applicationId` 未变 +2. 确认 `release` 签名证书与上个生产版本一致 +3. 确认 `versionCode` 大于上个生产版本 +4. 在测试机执行一次覆盖安装验证(旧版登录 -> 升级 -> 登录态保留) +5. 上传新 APK 到 `deploy/static/releases/` 并校验 `manifest.json` 对应条目 diff --git a/docs/protocols/calendar/reminder-alert-lifecycle.md b/docs/protocols/calendar/reminder-alert-lifecycle.md index f221dcc..2da17f8 100644 --- a/docs/protocols/calendar/reminder-alert-lifecycle.md +++ b/docs/protocols/calendar/reminder-alert-lifecycle.md @@ -19,6 +19,8 @@ 2. 展示文案“取消”必须映射到内部动作 `archive`,并归档对应日程(`status=archived`)。 3. 归档后的 UI 渲染必须灰色显示;不强制改写原始 metadata 颜色。 4. 前端动作上报后端采用最终一致性:本地 outbox + 重放机制。 +5. 每轮提醒铃声时长默认 15 秒;若用户未点击通知,系统按 10 分钟节奏进入下一轮提醒。 +6. 点击系统通知必须定位到对应日程,并打开提醒详情页(展示日程详情 + 底部动作:归档/稍后提醒)。 --- @@ -55,6 +57,7 @@ - `archive`: 内部归档动作(UI 展示文案为“取消”) - `snooze10m`: 用户点击稍后提醒,重排到 `now + 10m` +- `defaultRetry10m`: 用户未点击通知时,15 秒铃声结束后自动进入 `now + 10m` 下一轮提醒(内部调度语义,不额外上报动作) ### UI Label Mapping @@ -105,6 +108,9 @@ ### Normal schedule - `remindAt = startAt - reminderMinutes` +- 当 `remindAt <= now < endAt` 时,启动补偿提醒建议 `now + 5s` +- 每轮提醒后均进入 10 分钟节奏,直到超出 `endAt` +- 截止条件:`fireAt > endAt` 时不再调度后续提醒 ### Bootstrap/reinstall compensation @@ -151,6 +157,7 @@ - 优先 full-screen intent;系统可能因策略降级为 heads-up/横幅。 - 声音和振动受通知通道及系统设置影响。 +- 本协议当前实现不依赖 full-screen intent 权限,采用标准高优先级通知 + 点击跳转提醒详情页。 ### iOS diff --git a/docs/protocols/common/http-error-codes.md b/docs/protocols/common/http-error-codes.md index ccaffc0..fc08cba 100644 --- a/docs/protocols/common/http-error-codes.md +++ b/docs/protocols/common/http-error-codes.md @@ -122,6 +122,7 @@ When creating/modifying/deprecating any code, this table must be updated in the | `SCHEDULE_ITEM_INVITE_ALREADY_SUBSCRIBED` | schedule_items | 400 | Recipient already accepted calendar invite | | `SCHEDULE_ITEM_INVITE_ALREADY_PENDING` | schedule_items | 400 | Recipient already has pending calendar invite | | `SCHEDULE_ITEM_AUTH_LOOKUP_UNAVAILABLE` | schedule_items | 503 | Auth/identity lookup unavailable when sharing | +| `SCHEDULE_ITEM_ACTOR_LOOKUP_UNAVAILABLE` | schedule_items | 503 | Actor profile lookup unavailable when constructing inbox change payload | | `SCHEDULE_ITEM_PENDING_INVITE_NOT_FOUND` | schedule_items | 404 | No pending invitation exists for target item/user | | `SCHEDULE_ITEM_ACCEPT_SUBSCRIPTION_FAILED` | schedule_items | 503 | Subscription accept flow failed unexpectedly | | `SCHEDULE_ITEM_REJECT_SUBSCRIPTION_FAILED` | schedule_items | 503 | Subscription reject flow failed unexpectedly | @@ -129,6 +130,9 @@ When creating/modifying/deprecating any code, this table must be updated in the | `SCHEDULE_ITEM_DATETIME_REQUIRED` | schedule_items | 400 | Required datetime input missing | | `INBOX_MESSAGE_NOT_FOUND` | inbox_messages | 404 | Inbox message does not exist for current user | | `INBOX_MESSAGE_STORE_UNAVAILABLE` | inbox_messages | 503 | Inbox message persistence unavailable | +| `INBOX_SSE_CONNECTION_LIMIT` | inbox_messages | 429 | SSE connections exceed per-user limit | +| `INBOX_INVALID_LAST_EVENT_ID` | inbox_messages | 422 | SSE Last-Event-ID format invalid | +| `INBOX_EVENT_STREAM_UNAVAILABLE` | inbox_messages | 503 | Inbox SSE stream read unavailable | | `MEMORIES_USER_NOT_FOUND` | memories | 404 | User memory record does not exist | | `MEMORIES_WORK_NOT_FOUND` | memories | 404 | Work memory record does not exist | | `MEMORIES_SERVICE_UNAVAILABLE` | memories | 503 | Memories persistence unavailable | diff --git a/docs/protocols/models/inbox-messages.md b/docs/protocols/models/inbox-messages.md index cfa9ca6..1a31e51 100644 --- a/docs/protocols/models/inbox-messages.md +++ b/docs/protocols/models/inbox-messages.md @@ -12,6 +12,7 @@ Base URL: `/api/v1/inbox/messages` |---|---|---| | GET | `` | 获取消息列表 | | PATCH | `/{message_id}/read` | 标记消息为已读 | +| GET | `/stream` | 订阅收件箱实时事件(SSE) | --- @@ -39,32 +40,80 @@ Base URL: `/api/v1/inbox/messages` ## 消息内容类型 -### CalendarInviteContent +### CalendarInviteContent (schema_version=2) ```json { "type": "invite", + "schema_version": 2, + "item": { + "id": "uuid", + "title": "string", + "description": "string | null", + "start_at": "datetime", + "end_at": "datetime | null", + "timezone": "string" + }, + "actor": { + "user_id": "uuid", + "username": "string", + "phone": "string | null" + }, + "summary": "string", "permission": "int (1=view, 4=edit, 8=invite)", "action": "pending" } ``` -### CalendarUpdateContent +说明:`description/start_at/end_at/timezone/actor.phone` 为 `invite` 类型的扩展字段, +用于前端展示邀请详情(邀请人、联系电话、时间区间、描述)。 + +### CalendarUpdateContent (schema_version=2) ```json { - "type": "update", - "title": "string", + "type": "updated", + "schema_version": 2, + "item": { + "id": "uuid", + "title": "string" + }, + "actor": { + "user_id": "uuid", + "username": "string" + }, + "summary": "string", + "changes": [ + { + "field": "title | description | start_at | end_at | timezone | status", + "label": "string", + "before": "any | null", + "after": "any | null", + "display_before": "string | null", + "display_after": "string | null", + "change_type": "added | removed | modified" + } + ], "action": "updated" } ``` -### CalendarDeleteContent +### CalendarDeleteContent (schema_version=2) ```json { - "type": "delete", - "title": "string", + "type": "deleted", + "schema_version": 2, + "item": { + "id": "uuid", + "title": "string" + }, + "actor": { + "user_id": "uuid", + "username": "string" + }, + "summary": "string", + "changes": [], "action": "deleted" } ``` @@ -127,3 +176,103 @@ Base URL: `/api/v1/inbox/messages` ### Response `InboxMessageResponse` 对象。 + +--- + +## 3) GET `/stream` (SSE) + +订阅当前登录用户的 inbox 实时增量事件。 + +### Headers + +- `Accept: text/event-stream` +- `Last-Event-ID`(可选):断点续流游标,格式 `\d+-\d+` + +### Query Parameters + +- `idle_limit`: `1..3600`(可选,默认 `300`),连续空轮询上限,超过后服务端主动结束连接。 + +### SSE 事件帧 + +```text +id: 1743313300000-0 +event: INBOX_MESSAGE_CREATED +data: {"event_id":"6f0d...","occurred_at":"2026-03-30T07:00:00Z","user_id":"...","message_id":"...","op":"created","version":1743313300000,"data":{"message":{...}}} + +``` + +### 事件类型 + +- `INBOX_MESSAGE_CREATED` +- `INBOX_MESSAGE_READ_CHANGED` +- `INBOX_MESSAGE_STATUS_CHANGED` +- `INBOX_SNAPSHOT_REQUIRED` + +### Event Envelope + +```json +{ + "event_id": "uuid", + "occurred_at": "datetime", + "user_id": "uuid", + "message_id": "uuid", + "op": "created | read_changed | status_changed | snapshot_required", + "version": 1743313300000, + "data": {} +} +``` + +### Delta 约定 + +- `created`: + +```json +{ + "message": { + "id": "uuid", + "recipient_id": "uuid", + "sender_id": "uuid | null", + "message_type": "InboxMessageType", + "schedule_item_id": "uuid | null", + "friendship_id": "uuid | null", + "content": {}, + "is_read": false, + "status": "pending", + "created_at": "datetime" + } +} +``` + +- `read_changed`: + +```json +{ + "is_read": true +} +``` + +- `status_changed`: + +```json +{ + "status": "accepted" +} +``` + +- `snapshot_required`: + +```json +{} +``` + +### 幂等与补偿策略 + +- 客户端按 `message_id + version` 做幂等合并;旧版本事件必须丢弃。 +- 若检测到版本跳跃或本地状态不可信,客户端应回退到 `GET /api/v1/inbox/messages` 全量快照。 +- `id` 字段可用于 `Last-Event-ID` 断点续流。 + +### 前端渲染约束(强制) + +- 对 `message_type=calendar`,前端必须按 `content.type` 严格分发:`invite | updated | deleted`。 +- 若 `content` 缺少协议必填字段(`schema_version/item/actor/summary`,以及 `updated` 的 `changes`),前端必须进入协议异常展示路径。 +- 禁止将协议异常消息兜底渲染为“默认日历邀请”或其他正常业务消息。