From 4db9a13bfe210812fd9c1d8d22e43a4fe9234b09 Mon Sep 17 00:00:00 2001 From: zl-q Date: Sun, 29 Mar 2026 20:26:30 +0800 Subject: [PATCH] =?UTF-8?q?refactor(apps):=20=E9=87=8D=E6=9E=84=E6=95=B0?= =?UTF-8?q?=E6=8D=AE=E5=B1=82=E7=9B=AE=E5=BD=95=E7=BB=93=E6=9E=84=E5=B9=B6?= =?UTF-8?q?=E6=96=B0=E5=A2=9E=E5=90=AF=E5=8A=A8=E9=A2=84=E7=83=AD=E7=BC=96?= =?UTF-8?q?=E6=8E=92=E5=99=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/AGENTS.md | 28 +- apps/ios/Runner/Info.plist | 4 +- apps/lib/app/app.dart | 71 +--- apps/lib/app/di/injection.dart | 92 ++--- apps/lib/app/router/app_router.dart | 17 +- .../services/app_prewarm_orchestrator.dart | 118 ++++++ .../models/reminder_payload.dart | 0 .../services/reminder_queue_manager.dart | 2 +- apps/lib/core/storage/app_preferences.dart | 97 ----- .../ui_schema/models}/actions.dart | 2 +- .../ui_schema/models}/builders.dart | 2 +- .../ui_schema/models}/common_types.dart | 2 +- .../ui_schema/models}/document.dart | 2 +- .../ui_schema/models}/enums.dart | 2 +- .../ui_schema/models}/nodes.dart | 2 +- .../ui_schema}/models/ui_schema.dart | 12 +- .../navigation/ui_schema_navigation.dart | 0 apps/lib/data/cache/cache_entry.dart | 6 - apps/lib/data/cache/cache_invalidator.dart | 27 -- apps/lib/data/cache/cache_store.dart | 337 ++++++++++++++++++ apps/lib/data/cache/cached_repository.dart | 37 +- apps/lib/data/cache/hybrid_cache_store.dart | 55 --- apps/lib/data/cache/memory_cache_store.dart | 24 -- .../data/cache/persistent_cache_store.dart | 24 -- .../{core => data}/network/api_client.dart | 0 .../{core => data}/network/api_exception.dart | 2 +- .../network/api_interceptor.dart | 0 .../network/error_code_mapper.dart | 2 +- .../{core => data}/network/i_api_client.dart | 0 .../calendar_event_repository.dart | 60 ---- .../data/repositories/inbox_repository.dart | 42 --- .../repositories/models/calendar_event.dart | 40 --- .../ios_notification_payload_bridge.dart | 20 -- .../services/local_notification_service.dart | 307 ---------------- .../reminder_notification_callbacks.dart | 146 -------- .../{core => data}/storage/token_storage.dart | 0 .../auth/data/{ => apis}/auth_api.dart | 8 +- .../{ => repositories}/auth_repository.dart | 2 +- .../auth_repository_impl.dart | 10 +- .../auth/presentation/bloc/auth_bloc.dart | 2 +- .../auth/presentation/cubits/login_cubit.dart | 4 +- .../screens/auth_boot_screen.dart | 43 ++- .../presentation/screens/login_screen.dart | 2 +- .../data/{ => apis}/calendar_api.dart | 4 +- .../data}/models/schedule_item_model.dart | 0 .../repositories/calendar_repository.dart | 90 ++++- .../data/services/calendar_service.dart | 6 +- .../dayweek/day_event_layout_engine.dart | 2 +- .../screens/calendar_dayweek_screen.dart | 4 +- .../screens/calendar_event_detail_screen.dart | 8 +- .../screens/calendar_event_edit_screen.dart | 4 +- .../screens/calendar_event_share_screen.dart | 4 +- .../screens/calendar_month_screen.dart | 4 +- .../utils/event_color_resolver.dart | 2 +- .../widgets/calendar_share_dialog.dart | 2 +- .../widgets/create_event_sheet.dart | 23 +- .../repositories/chat_history_repository.dart | 114 ++++++ .../chat/data/services/ag_ui_service.dart | 36 +- .../chat/presentation/bloc/chat_bloc.dart | 17 +- .../contacts/data/{ => apis}/friends_api.dart | 2 +- .../data/{users => apis}/users_api.dart | 4 +- .../contacts/data}/models/friend_request.dart | 0 .../contacts}/data/models/user_profile.dart | 0 .../contacts/data}/models/user_summary.dart | 0 .../data/repositories/friend_repository.dart | 66 +++- .../data/repositories/user_repository.dart | 4 +- .../users_repository.dart | 2 +- .../users_repository_impl.dart | 4 +- .../presentation/screens/contacts_screen.dart | 6 +- .../presentation/screens/home_screen.dart | 4 +- .../widgets/home_chat_item_renderer.dart | 2 +- .../messages/data/{ => apis}/inbox_api.dart | 2 +- .../messages/data}/models/inbox_message.dart | 0 .../data/repositories/inbox_repository.dart | 130 +++++++ .../screens/message_invite_detail_screen.dart | 12 +- .../screens/message_invite_list_screen.dart | 8 +- .../widgets/calendar_message_card.dart | 2 +- .../domain/models/reminder_action.dart | 23 -- .../services/reminder_action_executor.dart | 75 ---- .../automation_jobs_api.dart | 2 +- .../data/{ => apis}/settings_api.dart | 2 +- .../user_profile_cache_repository.dart | 21 +- .../data/services/memory_service.dart | 2 +- .../data/services/user_profile_service.dart | 4 +- .../cubits/automation_jobs_cubit.dart | 2 +- .../presentation/cubits/job_detail_cubit.dart | 2 +- .../screens/edit_profile_screen.dart | 4 +- .../presentation/screens/features_screen.dart | 2 +- .../screens/job_detail_screen.dart | 2 +- .../presentation/screens/settings_screen.dart | 10 +- .../todo/data/{ => apis}/todo_api.dart | 2 +- .../data/repositories/todo_repository.dart | 104 ++++++ .../features/todo/data/todo_repository.dart | 67 ---- .../screens/todo_detail_screen.dart | 2 +- .../screens/todo_edit_screen.dart | 11 +- .../screens/todo_quadrants_screen.dart | 4 +- .../presentation/widgets/todo_drag_item.dart | 2 +- .../notification}/reminder_overlay.dart | 10 +- .../ui_schema}/ui_schema_renderer.dart | 0 .../app/router/app_router_redirect_test.dart | 2 +- .../app_prewarm_orchestrator_test.dart | 103 ++++++ .../data/cache/cached_repository_test.dart | 4 +- .../data/cache/hybrid_cache_store_test.dart | 28 ++ .../cache/shared_prefs_cache_store_test.dart | 39 ++ .../shared_repositories_test.dart | 53 ++- .../chat_history_repository_test.dart | 89 +++++ .../user_profile_cache_repository_test.dart | 8 +- ...6-03-29-frontend-cache-swr-boot-prewarm.md | 73 ++++ 108 files changed, 1653 insertions(+), 1320 deletions(-) create mode 100644 apps/lib/app/services/app_prewarm_orchestrator.dart rename apps/lib/{data => core/notification}/models/reminder_payload.dart (100%) rename apps/lib/{features/notification/domain => core/notification}/services/reminder_queue_manager.dart (92%) delete mode 100644 apps/lib/core/storage/app_preferences.dart rename apps/lib/{features/ui_schema/domain/models/ui_schema => core/ui_schema/models}/actions.dart (99%) rename apps/lib/{features/ui_schema/domain/models/ui_schema => core/ui_schema/models}/builders.dart (97%) rename apps/lib/{features/ui_schema/domain/models/ui_schema => core/ui_schema/models}/common_types.dart (99%) rename apps/lib/{features/ui_schema/domain/models/ui_schema => core/ui_schema/models}/document.dart (99%) rename apps/lib/{features/ui_schema/domain/models/ui_schema => core/ui_schema/models}/enums.dart (98%) rename apps/lib/{features/ui_schema/domain/models/ui_schema => core/ui_schema/models}/nodes.dart (99%) rename apps/lib/{features/ui_schema/domain => core/ui_schema}/models/ui_schema.dart (51%) rename apps/lib/{features/ui_schema/presentation => core/ui_schema}/navigation/ui_schema_navigation.dart (100%) delete mode 100644 apps/lib/data/cache/cache_entry.dart delete mode 100644 apps/lib/data/cache/cache_invalidator.dart delete mode 100644 apps/lib/data/cache/hybrid_cache_store.dart delete mode 100644 apps/lib/data/cache/memory_cache_store.dart delete mode 100644 apps/lib/data/cache/persistent_cache_store.dart rename apps/lib/{core => data}/network/api_client.dart (100%) rename apps/lib/{core => data}/network/api_exception.dart (99%) rename apps/lib/{core => data}/network/api_interceptor.dart (100%) rename apps/lib/{core => data}/network/error_code_mapper.dart (99%) rename apps/lib/{core => data}/network/i_api_client.dart (100%) delete mode 100644 apps/lib/data/repositories/calendar_event_repository.dart delete mode 100644 apps/lib/data/repositories/inbox_repository.dart delete mode 100644 apps/lib/data/repositories/models/calendar_event.dart delete mode 100644 apps/lib/data/services/ios_notification_payload_bridge.dart delete mode 100644 apps/lib/data/services/local_notification_service.dart delete mode 100644 apps/lib/data/services/reminder_notification_callbacks.dart rename apps/lib/{core => data}/storage/token_storage.dart (100%) rename apps/lib/features/auth/data/{ => apis}/auth_api.dart (82%) rename apps/lib/features/auth/data/{ => repositories}/auth_repository.dart (84%) rename apps/lib/features/auth/data/{ => repositories}/auth_repository_impl.dart (90%) rename apps/lib/features/calendar/data/{ => apis}/calendar_api.dart (93%) rename apps/lib/{data/repositories => features/calendar/data}/models/schedule_item_model.dart (100%) rename apps/lib/{ => features/calendar}/data/repositories/calendar_repository.dart (51%) rename apps/lib/{ => features/calendar}/data/services/calendar_service.dart (95%) create mode 100644 apps/lib/features/chat/data/repositories/chat_history_repository.dart rename apps/lib/features/contacts/data/{ => apis}/friends_api.dart (98%) rename apps/lib/features/contacts/data/{users => apis}/users_api.dart (94%) rename apps/lib/{data/repositories => features/contacts/data}/models/friend_request.dart (100%) rename apps/lib/{ => features/contacts}/data/models/user_profile.dart (100%) rename apps/lib/{data/repositories => features/contacts/data}/models/user_summary.dart (100%) rename apps/lib/{ => features/contacts}/data/repositories/friend_repository.dart (54%) rename apps/lib/{ => features/contacts}/data/repositories/user_repository.dart (90%) rename apps/lib/features/contacts/data/{users => repositories}/users_repository.dart (77%) rename apps/lib/features/contacts/data/{users => repositories}/users_repository_impl.dart (85%) rename apps/lib/features/messages/data/{ => apis}/inbox_api.dart (98%) rename apps/lib/{data/repositories => features/messages/data}/models/inbox_message.dart (100%) create mode 100644 apps/lib/features/messages/data/repositories/inbox_repository.dart delete mode 100644 apps/lib/features/notification/domain/models/reminder_action.dart delete mode 100644 apps/lib/features/notification/domain/services/reminder_action_executor.dart rename apps/lib/features/settings/data/{services => apis}/automation_jobs_api.dart (94%) rename apps/lib/features/settings/data/{ => apis}/settings_api.dart (97%) rename apps/lib/features/settings/data/{services => repositories}/user_profile_cache_repository.dart (74%) rename apps/lib/features/todo/data/{ => apis}/todo_api.dart (98%) create mode 100644 apps/lib/features/todo/data/repositories/todo_repository.dart delete mode 100644 apps/lib/features/todo/data/todo_repository.dart rename apps/lib/{features/notification/presentation/widgets => shared/widgets/notification}/reminder_overlay.dart (95%) rename apps/lib/{features/ui_schema/presentation/widgets => shared/widgets/ui_schema}/ui_schema_renderer.dart (100%) create mode 100644 apps/test/app/services/app_prewarm_orchestrator_test.dart create mode 100644 apps/test/data/cache/hybrid_cache_store_test.dart create mode 100644 apps/test/data/cache/shared_prefs_cache_store_test.dart create mode 100644 apps/test/features/chat/data/repositories/chat_history_repository_test.dart rename apps/test/features/settings/data/{services => repositories}/user_profile_cache_repository_test.dart (84%) create mode 100644 docs/superpowers/plans/2026-03-29-frontend-cache-swr-boot-prewarm.md diff --git a/apps/AGENTS.md b/apps/AGENTS.md index 1deeabd..49beafd 100644 --- a/apps/AGENTS.md +++ b/apps/AGENTS.md @@ -14,6 +14,29 @@ This file governs `apps/**` (Flutter). Keep rules strict, short, and reusable. - `apps/lib/main.dart` is the only allowed root entry file. - Do not add new second-level directories under `apps/lib` without explicit approval. +## Module Responsibilities (Must) + +- `app/`: app bootstrap, DI wiring, global lifecycle orchestration, router composition. +- `core/`: cross-feature business primitives/protocols/orchestrators (no feature-specific page logic). +- `data/`: shared infrastructure only (cache/network/storage/adapters), not feature business repositories/models. +- `features/`: user-facing bounded feature modules with clear product ownership. +- `shared/`: reusable UI widgets and presentation helpers without feature business orchestration. +- Cross-cutting capabilities (e.g. notification orchestration, UI schema protocol) must live in `core/` + `shared/`, not under `features/`. + +## Placement Rules (Must) + +- Put code in `features/` only when it belongs to one bounded product capability/screen flow. +- Put code in `core/` when it is cross-feature protocol, policy, or orchestration that does not belong to one feature. +- Put reusable UI renderers in `shared/widgets/`; they must not contain feature-only business orchestration. +- In feature data layers, use semantic subfolders: `data/apis/`, `data/repositories/`, `data/services/`, `data/models/`. +- Avoid deep redundant nesting like `models//...`; prefer flat by concern. + +## Shared Data Layer Boundary (Must) + +- Do not place feature business repositories/models under `apps/lib/data/`. +- Feature business repositories/models must live under each feature's `data/` tree. +- `apps/lib/data/` is only for infrastructure abstractions and implementations (cache/network/storage), reusable by features. + ## UI Design System (Must) - **Semantic colors**: always use `Theme.of(context).colorScheme.*` (primary, surface, error, etc.). Never hardcode hex or `Colors.*`. @@ -66,7 +89,10 @@ This file governs `apps/**` (Flutter). Keep rules strict, short, and reusable. - Reads/writes that affect consistency must go through repository layer. - Cache keys and invalidation policy belong to repository, not UI/Bloc. - Shared cache infrastructure must live under `apps/lib/data/cache/`; feature modules must not duplicate low-level cache store logic. -- Cross-feature data access must go through `apps/lib/data/repositories/`; do not import another feature's data implementation directly from UI/Bloc. +- Shared cache infrastructure (`apps/lib/data/cache/`) must remain domain-agnostic: do not import `features/**` or business model DTOs there. +- Domain object serialization/deserialization belongs to repository/feature layer via local mappers/codecs; do not centralize feature-specific codecs in shared cache layer. +- Shared cache layer may only encode/decode primitives, collections, and cache metadata wrappers. +- Cross-feature data access must go through app-level facade/usecase boundaries; do not import another feature's data implementation directly from UI/Bloc. - Repository instances should be resolved from DI singletons to reuse cache and avoid per-feature re-creation. ## Testing Policy diff --git a/apps/ios/Runner/Info.plist b/apps/ios/Runner/Info.plist index 58e29be..f45610b 100644 --- a/apps/ios/Runner/Info.plist +++ b/apps/ios/Runner/Info.plist @@ -7,7 +7,7 @@ CFBundleDevelopmentRegion $(DEVELOPMENT_LANGUAGE) CFBundleDisplayName - 灵可析 + 林小夕 CFBundleExecutable $(EXECUTABLE_NAME) CFBundleIdentifier @@ -15,7 +15,7 @@ CFBundleInfoDictionaryVersion 6.0 CFBundleName - 灵可析 + 林小夕 CFBundlePackageType APPL CFBundleShortVersionString diff --git a/apps/lib/app/app.dart b/apps/lib/app/app.dart index b13e4ec..a2ab9be 100644 --- a/apps/lib/app/app.dart +++ b/apps/lib/app/app.dart @@ -1,21 +1,15 @@ import 'dart:async'; -import 'dart:convert'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:go_router/go_router.dart'; import 'di/injection.dart'; -import '../data/models/reminder_payload.dart'; -import '../data/services/calendar_service.dart'; -import '../data/services/local_notification_service.dart'; -import '../data/services/reminder_notification_callbacks.dart'; import '../core/l10n/l10n.dart'; import '../l10n/app_localizations.dart'; import '../features/auth/presentation/bloc/auth_bloc.dart'; import '../features/auth/presentation/bloc/auth_event.dart'; import '../features/auth/presentation/bloc/auth_state.dart'; -import '../features/notification/domain/models/reminder_action.dart'; -import '../features/notification/domain/services/reminder_action_executor.dart'; +import 'services/app_prewarm_orchestrator.dart'; import 'router/app_router.dart'; import '../core/theme/app_theme.dart'; @@ -29,7 +23,6 @@ class LinksyApp extends StatefulWidget { class _LinksyAppState extends State { late final AuthBloc _authBloc; late final GoRouter _router; - String? _reminderBootstrapUserId; @override void initState() { @@ -37,7 +30,6 @@ class _LinksyAppState extends State { _authBloc = sl(); _authBloc.add(AuthStarted()); _router = createAppRouter(_authBloc); - unawaited(_bindNotificationResponseHandler()); } @override @@ -52,13 +44,13 @@ class _LinksyAppState extends State { value: _authBloc, child: BlocListener( listener: (context, state) { - if (state is AuthAuthenticated && - state.user.id != _reminderBootstrapUserId) { - _reminderBootstrapUserId = state.user.id; - unawaited(_rebuildUpcomingReminders()); + if (state is AuthAuthenticated) { + unawaited( + sl().ensureStartedFor(state.user.id), + ); } if (state is AuthUnauthenticated) { - _reminderBootstrapUserId = null; + sl().reset(); } }, child: MaterialApp.router( @@ -79,55 +71,4 @@ class _LinksyAppState extends State { ), ); } - - Future _rebuildUpcomingReminders() async { - final now = DateTime.now(); - final start = now.subtract(const Duration(days: 90)); - final end = now.add(const Duration(days: 90)); - try { - final events = await sl().getEventsForRange(start, end); - await sl().rebuildUpcomingReminders(events); - } catch (error) { - debugPrint('reminder bootstrap skipped: $error'); - } - } - - Future _bindNotificationResponseHandler() async { - await ReminderNotificationCallbacks.bindResponseHandler((response) async { - final payloadRaw = response.payload; - if (payloadRaw == null || payloadRaw.isEmpty) { - return; - } - - ReminderPayload payload; - try { - payload = ReminderPayload.fromJson( - Map.from(jsonDecode(payloadRaw) as Map), - ); - } catch (_) { - return; - } - - final actionId = response.actionId; - ReminderAction? action; - if (actionId != null) { - try { - action = ReminderAction.fromValue(actionId); - } catch (_) { - action = null; - } - } - if (action == null) { - ReminderNotificationCallbacks.onNotificationPayloadReceived?.call( - payload, - ); - return; - } - - await sl().handleAction( - action: action, - payload: payload, - ); - }); - } } diff --git a/apps/lib/app/di/injection.dart b/apps/lib/app/di/injection.dart index 4891628..9f0e4ca 100644 --- a/apps/lib/app/di/injection.dart +++ b/apps/lib/app/di/injection.dart @@ -2,42 +2,37 @@ import 'package:dio/dio.dart'; import 'package:flutter_secure_storage/flutter_secure_storage.dart'; import 'package:get_it/get_it.dart'; import 'package:shared_preferences/shared_preferences.dart'; -import '../../data/cache/cache_invalidator.dart'; -import '../../data/cache/hybrid_cache_store.dart'; -import '../../data/cache/memory_cache_store.dart'; -import '../../data/cache/persistent_cache_store.dart'; -import '../../data/repositories/calendar_event_repository.dart'; -import '../../data/repositories/calendar_repository.dart'; -import '../../data/repositories/friend_repository.dart'; -import '../../data/repositories/inbox_repository.dart'; -import '../../data/repositories/user_repository.dart'; +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 '../../features/contacts/data/repositories/user_repository.dart'; import '../../core/auth/session_controller.dart'; -import '../../core/network/api_client.dart'; -import '../../core/network/i_api_client.dart'; -import '../../core/storage/app_preferences.dart'; -import '../../core/storage/token_storage.dart'; +import '../../data/network/api_client.dart'; +import '../../data/network/i_api_client.dart'; +import '../../data/storage/token_storage.dart'; import '../../core/config/env.dart'; -import '../../data/services/local_notification_service.dart'; -import '../../features/auth/data/auth_api.dart'; -import '../../features/auth/data/auth_repository.dart'; -import '../../features/auth/data/auth_repository_impl.dart'; +import '../../features/auth/data/apis/auth_api.dart'; +import '../../features/auth/data/repositories/auth_repository.dart'; +import '../../features/auth/data/repositories/auth_repository_impl.dart'; import '../../features/auth/presentation/bloc/auth_bloc.dart'; import '../../features/auth/presentation/bloc/auth_event.dart'; import '../../features/chat/presentation/bloc/chat_bloc.dart'; -import '../../features/calendar/data/calendar_api.dart'; -import '../../data/services/calendar_service.dart'; -import '../../features/notification/domain/services/reminder_action_executor.dart'; +import '../../features/chat/data/repositories/chat_history_repository.dart'; +import '../../features/calendar/data/apis/calendar_api.dart'; +import '../../features/calendar/data/services/calendar_service.dart'; import '../../shared/state/calendar_state_manager.dart'; -import '../../features/contacts/data/friends_api.dart'; -import '../../features/messages/data/inbox_api.dart'; -import '../../features/settings/data/settings_api.dart'; -import '../../features/settings/data/services/automation_jobs_api.dart'; -import '../../features/settings/data/services/user_profile_cache_repository.dart'; +import '../../features/contacts/data/apis/friends_api.dart'; +import '../../features/messages/data/apis/inbox_api.dart'; +import '../../features/settings/data/apis/settings_api.dart'; +import '../../features/settings/data/apis/automation_jobs_api.dart'; +import '../../features/settings/data/repositories/user_profile_cache_repository.dart'; import '../../features/settings/data/services/user_profile_service.dart'; import '../../features/settings/data/services/memory_service.dart'; -import '../../features/contacts/data/users/users_api.dart'; -import '../../features/todo/data/todo_api.dart'; -import '../../features/todo/data/todo_repository.dart'; +import '../../features/contacts/data/apis/users_api.dart'; +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'; final sl = GetIt.instance; @@ -71,10 +66,9 @@ Future configureDependencies() async { final sharedPreferences = await SharedPreferences.getInstance(); sl.registerSingleton(sharedPreferences); - sl.registerSingleton(AppPreferences(sharedPreferences)); final memoryCacheStore = MemoryCacheStore(); - final persistentCacheStore = PersistentCacheStore(); + final persistentCacheStore = PersistentCacheStore(prefs: sharedPreferences); final hybridCacheStore = HybridCacheStore( memory: memoryCacheStore, persistent: persistentCacheStore, @@ -100,10 +94,6 @@ Future configureDependencies() async { final calendarApi = CalendarApi(apiClient); sl.registerSingleton(calendarApi); - sl.registerSingleton( - CalendarEventRepositoryImpl(apiClient), - ); - final calendarService = CalendarService( apiClient: apiClient, invalidator: sl(), @@ -116,17 +106,11 @@ Future configureDependencies() async { ); sl.registerSingleton(calendarRepository); - sl.registerSingleton(LocalNotificationService()); - - final reminderActionExecutor = ReminderActionExecutor( - calendarService: calendarService, - notificationService: sl(), - ); - sl.registerSingleton(reminderActionExecutor); - final friendsApi = FriendsApi(apiClient); sl.registerSingleton(friendsApi); - sl.registerSingleton(FriendRepositoryImpl(apiClient)); + sl.registerSingleton( + FriendRepositoryImpl(apiClient: apiClient, store: hybridCacheStore), + ); final settingsApi = SettingsApi(apiClient); sl.registerSingleton(settingsApi); @@ -139,7 +123,15 @@ Future configureDependencies() async { final inboxApi = InboxApi(apiClient); sl.registerSingleton(inboxApi); - sl.registerSingleton(InboxRepositoryImpl(apiClient)); + sl.registerSingleton( + InboxRepositoryImpl(apiClient: apiClient, store: hybridCacheStore), + ); + + final chatHistoryRepository = ChatHistoryRepository( + apiClient: apiClient, + store: hybridCacheStore, + ); + sl.registerSingleton(chatHistoryRepository); final todoApi = TodoApi(apiClient); sl.registerSingleton(todoApi); @@ -163,10 +155,20 @@ Future configureDependencies() async { ); sl.registerSingleton(authRepository); + sl.registerSingleton( + AppPrewarmOrchestrator( + calendarRepository: calendarRepository, + inboxRepository: sl(), + chatHistoryRepository: chatHistoryRepository, + ), + ); + final authBloc = AuthBloc(authRepository); sl.registerSingleton(authBloc); sl.registerSingleton(AuthSessionController(authBloc)); - sl.registerSingleton(ChatBloc(apiClient: apiClient)); + sl.registerSingleton( + ChatBloc(apiClient: apiClient, historyRepository: chatHistoryRepository), + ); apiClient.setRefreshCallback((token) async { try { diff --git a/apps/lib/app/router/app_router.dart b/apps/lib/app/router/app_router.dart index 23ccccd..a375330 100644 --- a/apps/lib/app/router/app_router.dart +++ b/apps/lib/app/router/app_router.dart @@ -34,6 +34,7 @@ import '../../../features/settings/presentation/screens/work_memory_view_screen. import '../../../features/settings/presentation/screens/user_memory_detail_screen.dart'; import '../../../features/settings/presentation/screens/work_memory_detail_screen.dart'; import '../../../features/settings/presentation/screens/edit_profile_screen.dart'; +import '../services/app_prewarm_orchestrator.dart'; final _homeSecondLevelRoutes = [ AppRoutes.shellCalendarBranch, @@ -60,6 +61,7 @@ final _protectedRoutes = [ String? resolveAuthRedirect({ required AuthState authState, required String matchedLocation, + AppPrewarmOrchestrator? prewarm, }) { final isAuthenticated = authState is AuthAuthenticated; final isAuthChecking = authState is AuthInitial || authState is AuthLoading; @@ -71,11 +73,21 @@ String? resolveAuthRedirect({ final isProtected = isHomeRoute || _protectedRoutes.any((route) => matchedLocation.startsWith(route)); + final prewarmStatus = prewarm?.status ?? AppPrewarmStatus.completed; + final shouldBlockForPrewarm = + isAuthenticated && prewarmStatus == AppPrewarmStatus.running; + + if (shouldBlockForPrewarm && !isBootRoute) { + return AppRoutes.authBoot; + } if (isAuthChecking && !isBootRoute) { return AppRoutes.authBoot; } if (!isAuthChecking && isBootRoute) { + if (shouldBlockForPrewarm) { + return null; + } return isAuthenticated ? AppRoutes.homeMain : AppRoutes.authLogin; } if (!isAuthenticated && isProtected) { @@ -95,14 +107,17 @@ Widget buildHomeRouteScreen() { } GoRouter createAppRouter(AuthBloc authBloc) { + final authRefresh = GoRouterRefreshStream(authBloc.stream); + final prewarm = sl(); return GoRouter( initialLocation: AppRoutes.authBoot, observers: [appRouteObserver], - refreshListenable: GoRouterRefreshStream(authBloc.stream), + refreshListenable: Listenable.merge([authRefresh, prewarm]), redirect: (context, state) { return resolveAuthRedirect( authState: authBloc.state, matchedLocation: state.matchedLocation, + prewarm: prewarm, ); }, routes: [ diff --git a/apps/lib/app/services/app_prewarm_orchestrator.dart b/apps/lib/app/services/app_prewarm_orchestrator.dart new file mode 100644 index 0000000..df88fca --- /dev/null +++ b/apps/lib/app/services/app_prewarm_orchestrator.dart @@ -0,0 +1,118 @@ +import 'dart:async'; + +import 'package:flutter/foundation.dart'; + +import '../../features/calendar/data/repositories/calendar_repository.dart'; +import '../../features/messages/data/repositories/inbox_repository.dart'; +import '../../features/chat/data/repositories/chat_history_repository.dart'; + +enum AppPrewarmStatus { idle, running, completed, timedOut, failed } + +class AppPrewarmOrchestrator extends ChangeNotifier { + AppPrewarmOrchestrator({ + required CalendarRepository calendarRepository, + required InboxRepository inboxRepository, + required ChatHistoryRepository chatHistoryRepository, + this.bootBudget = const Duration(milliseconds: 1200), + Future Function()? prewarmChatHistory, + Future Function()? prewarmCalendarToday, + Future Function()? prewarmUnreadInbox, + }) : _calendarRepository = calendarRepository, + _inboxRepository = inboxRepository, + _chatHistoryRepository = chatHistoryRepository, + _prewarmChatHistory = prewarmChatHistory, + _prewarmCalendarToday = prewarmCalendarToday, + _prewarmUnreadInbox = prewarmUnreadInbox; + + final CalendarRepository _calendarRepository; + final InboxRepository _inboxRepository; + final ChatHistoryRepository _chatHistoryRepository; + final Duration bootBudget; + final Future Function()? _prewarmChatHistory; + final Future Function()? _prewarmCalendarToday; + final Future Function()? _prewarmUnreadInbox; + + AppPrewarmStatus _status = AppPrewarmStatus.idle; + AppPrewarmStatus get status => _status; + + String? _userId; + Future? _running; + + bool get isBootBlocking => _status == AppPrewarmStatus.running; + + Future ensureStartedFor(String userId) { + if (_userId == userId && + (_status == AppPrewarmStatus.completed || + _status == AppPrewarmStatus.timedOut)) { + return Future.value(); + } + if (_userId == userId && _running != null) { + return _running!; + } + + _userId = userId; + _status = AppPrewarmStatus.running; + notifyListeners(); + + final tasks = Future.wait([ + _runPrewarmChatHistory(), + _runPrewarmCalendarToday(), + _runPrewarmUnreadInbox(), + ]); + + final running = _runWithBudget(tasks); + _running = running; + return running.whenComplete(() { + if (identical(_running, running)) { + _running = null; + } + }); + } + + Future _runPrewarmChatHistory() { + final override = _prewarmChatHistory; + if (override != null) { + return override(); + } + return _chatHistoryRepository.loadHistory(); + } + + Future _runPrewarmCalendarToday() { + final override = _prewarmCalendarToday; + if (override != null) { + return override(); + } + return _calendarRepository.getDayEvents(DateTime.now()); + } + + Future _runPrewarmUnreadInbox() { + final override = _prewarmUnreadInbox; + if (override != null) { + return override(); + } + return _inboxRepository.getMessages(isRead: false); + } + + Future _runWithBudget(Future tasks) async { + try { + await tasks.timeout(bootBudget); + _status = AppPrewarmStatus.completed; + notifyListeners(); + } on TimeoutException { + _status = AppPrewarmStatus.timedOut; + notifyListeners(); + } catch (_) { + _status = AppPrewarmStatus.failed; + notifyListeners(); + } + } + + void reset() { + _userId = null; + _running = null; + if (_status != AppPrewarmStatus.idle) { + _status = AppPrewarmStatus.idle; + notifyListeners(); + } + } +} diff --git a/apps/lib/data/models/reminder_payload.dart b/apps/lib/core/notification/models/reminder_payload.dart similarity index 100% rename from apps/lib/data/models/reminder_payload.dart rename to apps/lib/core/notification/models/reminder_payload.dart diff --git a/apps/lib/features/notification/domain/services/reminder_queue_manager.dart b/apps/lib/core/notification/services/reminder_queue_manager.dart similarity index 92% rename from apps/lib/features/notification/domain/services/reminder_queue_manager.dart rename to apps/lib/core/notification/services/reminder_queue_manager.dart index 8edfd5e..f7df247 100644 --- a/apps/lib/features/notification/domain/services/reminder_queue_manager.dart +++ b/apps/lib/core/notification/services/reminder_queue_manager.dart @@ -1,4 +1,4 @@ -import '../../../../data/models/reminder_payload.dart'; +import '../models/reminder_payload.dart'; class ReminderQueueManager { ReminderPayload? _currentPayload; diff --git a/apps/lib/core/storage/app_preferences.dart b/apps/lib/core/storage/app_preferences.dart deleted file mode 100644 index b891d6f..0000000 --- a/apps/lib/core/storage/app_preferences.dart +++ /dev/null @@ -1,97 +0,0 @@ -import 'dart:convert'; - -import 'package:flutter_local_notifications/flutter_local_notifications.dart'; -import 'package:shared_preferences/shared_preferences.dart'; - -import '../../data/models/reminder_payload.dart'; - -class AppPreferences { - static const String _pendingNotificationsKey = - 'calendar_reminder_pending_notification_responses_v1'; - static const String _pendingNotificationPayloadKey = - 'pending_notification_payload'; - - final SharedPreferences _prefs; - - AppPreferences(this._prefs); - - List get pendingNotifications { - final list = _prefs.getStringList(_pendingNotificationsKey) ?? []; - return list - .map(_decodePendingNotification) - .whereType() - .toList(); - } - - Future setPendingNotifications( - List value, - ) { - return _prefs.setStringList( - _pendingNotificationsKey, - value.map(_encodePendingNotification).toList(), - ); - } - - Future clearPendingNotifications() { - return _prefs.remove(_pendingNotificationsKey); - } - - ReminderPayload? get pendingNotificationPayload { - final raw = _prefs.getString(_pendingNotificationPayloadKey); - if (raw == null || raw.isEmpty) { - return null; - } - try { - final json = Map.from(jsonDecode(raw) as Map); - return ReminderPayload.fromJson(json); - } catch (_) { - return null; - } - } - - Future setPendingNotificationPayload(ReminderPayload payload) { - return _prefs.setString( - _pendingNotificationPayloadKey, - jsonEncode(payload.toJson()), - ); - } - - Future clearPendingNotificationPayload() { - return _prefs.remove(_pendingNotificationPayloadKey); - } - - static String _encodePendingNotification( - PendingNotificationResponse response, - ) { - return jsonEncode({ - 'id': response.id, - 'actionId': response.actionId, - 'payload': response.payload, - 'type': response.notificationResponseType.index, - 'input': response.input, - }); - } - - static PendingNotificationResponse? _decodePendingNotification(String raw) { - try { - final parsed = Map.from(jsonDecode(raw) as Map); - final id = parsed['id'] as int?; - final actionId = parsed['actionId'] as String?; - final payload = parsed['payload'] as String?; - final typeIndex = (parsed['type'] as int?) ?? 0; - final input = parsed['input'] as String?; - final type = NotificationResponseType.values[typeIndex.clamp(0, 1)]; - return NotificationResponse( - id: id, - actionId: actionId, - payload: payload, - input: input, - notificationResponseType: type, - ); - } catch (_) { - return null; - } - } -} - -typedef PendingNotificationResponse = NotificationResponse; diff --git a/apps/lib/features/ui_schema/domain/models/ui_schema/actions.dart b/apps/lib/core/ui_schema/models/actions.dart similarity index 99% rename from apps/lib/features/ui_schema/domain/models/ui_schema/actions.dart rename to apps/lib/core/ui_schema/models/actions.dart index f227dda..0593efe 100644 --- a/apps/lib/features/ui_schema/domain/models/ui_schema/actions.dart +++ b/apps/lib/core/ui_schema/models/actions.dart @@ -1,4 +1,4 @@ -part of '../ui_schema.dart'; +part of 'ui_schema.dart'; abstract class ActionSpec { String get type; diff --git a/apps/lib/features/ui_schema/domain/models/ui_schema/builders.dart b/apps/lib/core/ui_schema/models/builders.dart similarity index 97% rename from apps/lib/features/ui_schema/domain/models/ui_schema/builders.dart rename to apps/lib/core/ui_schema/models/builders.dart index 237be92..4f975c8 100644 --- a/apps/lib/features/ui_schema/domain/models/ui_schema/builders.dart +++ b/apps/lib/core/ui_schema/models/builders.dart @@ -1,4 +1,4 @@ -part of '../ui_schema.dart'; +part of 'ui_schema.dart'; UiSchemaDocument buildSuccessDocument( List nodes, { diff --git a/apps/lib/features/ui_schema/domain/models/ui_schema/common_types.dart b/apps/lib/core/ui_schema/models/common_types.dart similarity index 99% rename from apps/lib/features/ui_schema/domain/models/ui_schema/common_types.dart rename to apps/lib/core/ui_schema/models/common_types.dart index 9da555c..76a69f2 100644 --- a/apps/lib/features/ui_schema/domain/models/ui_schema/common_types.dart +++ b/apps/lib/core/ui_schema/models/common_types.dart @@ -1,4 +1,4 @@ -part of '../ui_schema.dart'; +part of 'ui_schema.dart'; class UiIcon { final IconSource source; diff --git a/apps/lib/features/ui_schema/domain/models/ui_schema/document.dart b/apps/lib/core/ui_schema/models/document.dart similarity index 99% rename from apps/lib/features/ui_schema/domain/models/ui_schema/document.dart rename to apps/lib/core/ui_schema/models/document.dart index 9f2a75d..d57edf1 100644 --- a/apps/lib/features/ui_schema/domain/models/ui_schema/document.dart +++ b/apps/lib/core/ui_schema/models/document.dart @@ -1,4 +1,4 @@ -part of '../ui_schema.dart'; +part of 'ui_schema.dart'; class RendererConfig { final String? renderer; diff --git a/apps/lib/features/ui_schema/domain/models/ui_schema/enums.dart b/apps/lib/core/ui_schema/models/enums.dart similarity index 98% rename from apps/lib/features/ui_schema/domain/models/ui_schema/enums.dart rename to apps/lib/core/ui_schema/models/enums.dart index a8d91d4..d8b584d 100644 --- a/apps/lib/features/ui_schema/domain/models/ui_schema/enums.dart +++ b/apps/lib/core/ui_schema/models/enums.dart @@ -1,4 +1,4 @@ -part of '../ui_schema.dart'; +part of 'ui_schema.dart'; enum SchemaType { toolResult('tool_result'), diff --git a/apps/lib/features/ui_schema/domain/models/ui_schema/nodes.dart b/apps/lib/core/ui_schema/models/nodes.dart similarity index 99% rename from apps/lib/features/ui_schema/domain/models/ui_schema/nodes.dart rename to apps/lib/core/ui_schema/models/nodes.dart index e1e11c1..77a4b71 100644 --- a/apps/lib/features/ui_schema/domain/models/ui_schema/nodes.dart +++ b/apps/lib/core/ui_schema/models/nodes.dart @@ -1,4 +1,4 @@ -part of '../ui_schema.dart'; +part of 'ui_schema.dart'; abstract class UiNode { String get type; diff --git a/apps/lib/features/ui_schema/domain/models/ui_schema.dart b/apps/lib/core/ui_schema/models/ui_schema.dart similarity index 51% rename from apps/lib/features/ui_schema/domain/models/ui_schema.dart rename to apps/lib/core/ui_schema/models/ui_schema.dart index f1401eb..df808f8 100644 --- a/apps/lib/features/ui_schema/domain/models/ui_schema.dart +++ b/apps/lib/core/ui_schema/models/ui_schema.dart @@ -6,9 +6,9 @@ /// Version: 1.0 library; -part 'ui_schema/enums.dart'; -part 'ui_schema/common_types.dart'; -part 'ui_schema/actions.dart'; -part 'ui_schema/nodes.dart'; -part 'ui_schema/document.dart'; -part 'ui_schema/builders.dart'; +part 'enums.dart'; +part 'common_types.dart'; +part 'actions.dart'; +part 'nodes.dart'; +part 'document.dart'; +part 'builders.dart'; diff --git a/apps/lib/features/ui_schema/presentation/navigation/ui_schema_navigation.dart b/apps/lib/core/ui_schema/navigation/ui_schema_navigation.dart similarity index 100% rename from apps/lib/features/ui_schema/presentation/navigation/ui_schema_navigation.dart rename to apps/lib/core/ui_schema/navigation/ui_schema_navigation.dart diff --git a/apps/lib/data/cache/cache_entry.dart b/apps/lib/data/cache/cache_entry.dart deleted file mode 100644 index 95051c1..0000000 --- a/apps/lib/data/cache/cache_entry.dart +++ /dev/null @@ -1,6 +0,0 @@ -class CacheEntry { - final T value; - final DateTime fetchedAt; - - const CacheEntry({required this.value, required this.fetchedAt}); -} diff --git a/apps/lib/data/cache/cache_invalidator.dart b/apps/lib/data/cache/cache_invalidator.dart deleted file mode 100644 index b7ba30e..0000000 --- a/apps/lib/data/cache/cache_invalidator.dart +++ /dev/null @@ -1,27 +0,0 @@ -import 'dart:async'; - -import 'hybrid_cache_store.dart'; - -class CacheInvalidator { - final HybridCacheStore? _store; - final Set _invalidated = {}; - - CacheInvalidator({HybridCacheStore? store}) : _store = store; - - void invalidate(String key) { - _invalidated.add(key); - final store = _store; - if (store != null) { - unawaited(store.remove(key)); - } - } - - void invalidateCalendarDay(DateTime date) { - final month = '${date.year}-${date.month.toString().padLeft(2, '0')}'; - final day = '$month-${date.day.toString().padLeft(2, '0')}'; - invalidate('calendar:day:$day'); - invalidate('calendar:month:$month'); - } - - bool wasInvalidated(String key) => _invalidated.contains(key); -} diff --git a/apps/lib/data/cache/cache_store.dart b/apps/lib/data/cache/cache_store.dart index 6b4f768..a6351e9 100644 --- a/apps/lib/data/cache/cache_store.dart +++ b/apps/lib/data/cache/cache_store.dart @@ -1,5 +1,342 @@ +import 'dart:async'; +import 'dart:convert'; + +import 'package:shared_preferences/shared_preferences.dart'; + +class CacheEntry { + const CacheEntry({required this.value, required this.fetchedAt}); + + final T value; + final DateTime fetchedAt; +} + abstract class CacheStore { Future read(String key); Future write(String key, T value); Future remove(String key); } + +class MemoryCacheStore implements CacheStore { + final Map _values = {}; + + @override + Future read(String key) async { + final value = _values[key]; + if (value is T) { + return value; + } + return null; + } + + @override + Future write(String key, T value) async { + _values[key] = value; + } + + @override + Future remove(String key) async { + _values.remove(key); + } +} + +class SharedPrefsCacheStore implements CacheStore { + SharedPrefsCacheStore({required SharedPreferences prefs}) : _prefs = prefs; + + final SharedPreferences _prefs; + + @override + Future read(String key) async { + final raw = _prefs.getString(key); + if (raw == null) { + return null; + } + try { + return CacheCodec.decode(raw); + } catch (_) { + await _prefs.remove(key); + return null; + } + } + + @override + Future write(String key, T value) async { + final encoded = CacheCodec.encode(value); + await _prefs.setString(key, encoded); + } + + @override + Future remove(String key) { + return _prefs.remove(key); + } +} + +class PersistentCacheStore implements CacheStore { + SharedPreferences? _prefs; + Future? _prefsFuture; + final Map _fallbackValues = {}; + + PersistentCacheStore({SharedPreferences? prefs}) : _prefs = prefs; + + Future _getPrefs() { + if (_prefs != null) { + return Future.value(_prefs); + } + final inFlight = _prefsFuture; + if (inFlight != null) { + return inFlight.then((prefs) => prefs); + } + final created = SharedPreferences.getInstance(); + _prefsFuture = created; + return created + .then((prefs) { + _prefs = prefs; + return prefs; + }) + .catchError((_) { + _prefsFuture = null; + return null; + }); + } + + @override + Future read(String key) async { + final prefs = await _getPrefs(); + if (prefs == null) { + final value = _fallbackValues[key]; + if (value is T) { + return value; + } + return null; + } + final store = SharedPrefsCacheStore(prefs: prefs); + return store.read(key); + } + + @override + Future write(String key, T value) async { + final prefs = await _getPrefs(); + if (prefs == null) { + _fallbackValues[key] = value; + return; + } + final store = SharedPrefsCacheStore(prefs: prefs); + await store.write(key, value); + } + + @override + Future remove(String key) async { + final prefs = await _getPrefs(); + if (prefs == null) { + _fallbackValues.remove(key); + return; + } + final store = SharedPrefsCacheStore(prefs: prefs); + await store.remove(key); + } +} + +class HybridCacheStore { + final CacheStore memory; + final CacheStore persistent; + final Map> _inflight = >{}; + + HybridCacheStore({required this.memory, required this.persistent}); + + Future read(String key) async { + final memoryValue = await memory.read(key); + if (memoryValue != null) { + return memoryValue; + } + final persistentValue = await persistent.read(key); + if (persistentValue != null) { + await memory.write(key, persistentValue); + } + return persistentValue; + } + + Future write(String key, T value) async { + await memory.write(key, value); + await persistent.write(key, value); + } + + Future remove(String key) async { + await memory.remove(key); + await persistent.remove(key); + } + + Future getOrLoad(String key, {required Future Function() loader}) { + final running = _inflight[key]; + if (running != null) { + return running.then((value) => value as T); + } + + final future = () async { + final cached = await read(key); + if (cached != null) { + return cached; + } + + final loaded = await loader(); + await write(key, loaded); + return loaded; + }(); + + _inflight[key] = future; + return future.whenComplete(() { + _inflight.remove(key); + }); + } +} + +class CacheInvalidator { + final HybridCacheStore? _store; + + CacheInvalidator({HybridCacheStore? store}) : _store = store; + + void invalidate(String key) { + final store = _store; + if (store != null) { + unawaited(store.remove(key)); + } + } + + void invalidateCalendarDay(DateTime date) { + final month = '${date.year}-${date.month.toString().padLeft(2, '0')}'; + final day = '$month-${date.day.toString().padLeft(2, '0')}'; + invalidate('calendar:day:$day'); + invalidate('calendar:month:$month'); + } +} + +class CacheCodec { + static String encode(T value) { + final payload = {'type': '$T'}; + if (value is CacheEntry) { + payload['entryType'] = '${value.value.runtimeType}'; + payload['fetchedAt'] = value.fetchedAt.toIso8601String(); + payload['value'] = _encodeValue(value.value); + } else { + payload['value'] = _encodeValue(value); + } + return jsonEncode(payload); + } + + static T decode(String raw) { + final decoded = jsonDecode(raw); + if (decoded is! Map) { + throw const FormatException('Invalid cache payload'); + } + + final value = decoded['value']; + + if (T.toString().startsWith('CacheEntry<')) { + final fetchedAtRaw = decoded['fetchedAt']; + final fetchedAt = DateTime.parse(fetchedAtRaw as String); + final entryType = (decoded['entryType'] as String?) ?? 'Object?'; + final decodedValue = _decodeValue(entryType, value); + + switch (entryType) { + case 'String': + return CacheEntry( + value: decodedValue as String, + fetchedAt: fetchedAt, + ) + as T; + case 'int': + return CacheEntry( + value: decodedValue as int, + fetchedAt: fetchedAt, + ) + as T; + case 'double': + return CacheEntry( + value: decodedValue as double, + fetchedAt: fetchedAt, + ) + as T; + case 'bool': + return CacheEntry( + value: decodedValue as bool, + fetchedAt: fetchedAt, + ) + as T; + default: + return CacheEntry(value: decodedValue, fetchedAt: fetchedAt) + as T; + } + } + + final type = (decoded['type'] as String?) ?? '$T'; + return _decodeValue(type, value) as T; + } + + static Object? _encodeValue(Object? value) { + if (value == null || value is String || value is num || value is bool) { + return value; + } + if (value is DateTime) { + return value.toIso8601String(); + } + if (value is List) { + return value.map(_encodeValue).toList(); + } + if (value is Map) { + return value.map((k, v) => MapEntry(k.toString(), _encodeValue(v))); + } + + throw StateError('Unsupported cached value type: ${value.runtimeType}'); + } + + static Object? _decodeValue(String type, Object? raw) { + switch (type) { + case 'String': + return raw as String? ?? ''; + case 'int': + if (raw is int) { + return raw; + } + if (raw is num) { + return raw.toInt(); + } + throw const FormatException('Invalid int cache payload'); + case 'double': + if (raw is double) { + return raw; + } + if (raw is num) { + return raw.toDouble(); + } + throw const FormatException('Invalid double cache payload'); + case 'bool': + return raw as bool? ?? false; + case 'DateTime': + return DateTime.parse(raw as String); + default: + break; + } + + final listType = _extractListType(type); + if (listType != null) { + if (raw is! List) { + throw FormatException('Invalid list cache payload for type $type'); + } + return raw + .map((item) => _decodeValue(listType, item)) + .toList(growable: false); + } + + if (raw is Map) { + return Map.from(raw); + } + + return raw; + } + + static String? _extractListType(String type) { + final listMatch = RegExp(r'^List<(.+)>$').firstMatch(type); + if (listMatch == null) { + return null; + } + return listMatch.group(1); + } +} diff --git a/apps/lib/data/cache/cached_repository.dart b/apps/lib/data/cache/cached_repository.dart index e80c2fc..ffeb8e4 100644 --- a/apps/lib/data/cache/cached_repository.dart +++ b/apps/lib/data/cache/cached_repository.dart @@ -1,20 +1,29 @@ import 'dart:async'; -import 'cache_entry.dart'; import 'cache_policy.dart'; -import 'hybrid_cache_store.dart'; +import 'cache_store.dart'; abstract class CachedRepository { final HybridCacheStore store; final CachePolicy policy; final DateTime Function() now; + final Object? Function(T value) encodeValue; + final T Function(Object? raw) decodeValue; final Map> _refreshInFlight = >{}; CachedRepository({ required this.store, required this.policy, DateTime Function()? now, - }) : now = now ?? DateTime.now; + Object? Function(T value)? encodeValue, + T Function(Object? raw)? decodeValue, + }) : now = now ?? DateTime.now, + encodeValue = encodeValue ?? _defaultEncode, + decodeValue = decodeValue ?? _defaultDecode; + + static Object? _defaultEncode(T value) => value; + + static T _defaultDecode(Object? raw) => raw as T; Future getOrLoad({ required String key, @@ -58,16 +67,32 @@ abstract class CachedRepository { } Future?> readCacheEntry(String key) { - return store.read>(key); + return _readDecodedEntry(key); } Future writeCacheEntry(String key, T value) { - return store.write>( + return store.write>( key, - CacheEntry(value: value, fetchedAt: now()), + CacheEntry(value: encodeValue(value), fetchedAt: now()), ); } + Future?> _readDecodedEntry(String key) async { + final entry = await store.read>(key); + if (entry == null) { + return null; + } + try { + return CacheEntry( + value: decodeValue(entry.value), + fetchedAt: entry.fetchedAt, + ); + } catch (_) { + await store.remove(key); + return null; + } + } + Future removeCacheKey(String key) { return store.remove(key); } diff --git a/apps/lib/data/cache/hybrid_cache_store.dart b/apps/lib/data/cache/hybrid_cache_store.dart deleted file mode 100644 index a148fcc..0000000 --- a/apps/lib/data/cache/hybrid_cache_store.dart +++ /dev/null @@ -1,55 +0,0 @@ -import 'memory_cache_store.dart'; -import 'persistent_cache_store.dart'; - -class HybridCacheStore { - final MemoryCacheStore memory; - final PersistentCacheStore persistent; - final Map> _inflight = >{}; - - HybridCacheStore({required this.memory, required this.persistent}); - - Future read(String key) async { - final memoryValue = await memory.read(key); - if (memoryValue != null) { - return memoryValue; - } - final persistentValue = await persistent.read(key); - if (persistentValue != null) { - await memory.write(key, persistentValue); - } - return persistentValue; - } - - Future write(String key, T value) async { - await memory.write(key, value); - await persistent.write(key, value); - } - - Future remove(String key) async { - await memory.remove(key); - await persistent.remove(key); - } - - Future getOrLoad(String key, {required Future Function() loader}) { - final running = _inflight[key]; - if (running != null) { - return running.then((value) => value as T); - } - - final future = () async { - final cached = await read(key); - if (cached != null) { - return cached; - } - - final loaded = await loader(); - await write(key, loaded); - return loaded; - }(); - - _inflight[key] = future; - return future.whenComplete(() { - _inflight.remove(key); - }); - } -} diff --git a/apps/lib/data/cache/memory_cache_store.dart b/apps/lib/data/cache/memory_cache_store.dart deleted file mode 100644 index d91f467..0000000 --- a/apps/lib/data/cache/memory_cache_store.dart +++ /dev/null @@ -1,24 +0,0 @@ -import 'cache_store.dart'; - -class MemoryCacheStore implements CacheStore { - final Map _values = {}; - - @override - Future read(String key) async { - final value = _values[key]; - if (value is T) { - return value; - } - return null; - } - - @override - Future write(String key, T value) async { - _values[key] = value; - } - - @override - Future remove(String key) async { - _values.remove(key); - } -} diff --git a/apps/lib/data/cache/persistent_cache_store.dart b/apps/lib/data/cache/persistent_cache_store.dart deleted file mode 100644 index 160a7f9..0000000 --- a/apps/lib/data/cache/persistent_cache_store.dart +++ /dev/null @@ -1,24 +0,0 @@ -import 'cache_store.dart'; - -class PersistentCacheStore implements CacheStore { - final Map _values = {}; - - @override - Future read(String key) async { - final value = _values[key]; - if (value is T) { - return value; - } - return null; - } - - @override - Future write(String key, T value) async { - _values[key] = value; - } - - @override - Future remove(String key) async { - _values.remove(key); - } -} diff --git a/apps/lib/core/network/api_client.dart b/apps/lib/data/network/api_client.dart similarity index 100% rename from apps/lib/core/network/api_client.dart rename to apps/lib/data/network/api_client.dart diff --git a/apps/lib/core/network/api_exception.dart b/apps/lib/data/network/api_exception.dart similarity index 99% rename from apps/lib/core/network/api_exception.dart rename to apps/lib/data/network/api_exception.dart index ce0d3e2..333149e 100644 --- a/apps/lib/core/network/api_exception.dart +++ b/apps/lib/data/network/api_exception.dart @@ -1,7 +1,7 @@ import 'dart:convert'; import 'package:dio/dio.dart'; -import '../l10n/l10n.dart'; +import '../../core/l10n/l10n.dart'; import 'error_code_mapper.dart'; abstract class ApiException implements Exception { diff --git a/apps/lib/core/network/api_interceptor.dart b/apps/lib/data/network/api_interceptor.dart similarity index 100% rename from apps/lib/core/network/api_interceptor.dart rename to apps/lib/data/network/api_interceptor.dart diff --git a/apps/lib/core/network/error_code_mapper.dart b/apps/lib/data/network/error_code_mapper.dart similarity index 99% rename from apps/lib/core/network/error_code_mapper.dart rename to apps/lib/data/network/error_code_mapper.dart index 27bc02a..3ae3b41 100644 --- a/apps/lib/core/network/error_code_mapper.dart +++ b/apps/lib/data/network/error_code_mapper.dart @@ -1,4 +1,4 @@ -import '../l10n/l10n.dart'; +import '../../core/l10n/l10n.dart'; String? mapErrorCodeToL10nKey( String? errorCode, { diff --git a/apps/lib/core/network/i_api_client.dart b/apps/lib/data/network/i_api_client.dart similarity index 100% rename from apps/lib/core/network/i_api_client.dart rename to apps/lib/data/network/i_api_client.dart diff --git a/apps/lib/data/repositories/calendar_event_repository.dart b/apps/lib/data/repositories/calendar_event_repository.dart deleted file mode 100644 index 9797dd4..0000000 --- a/apps/lib/data/repositories/calendar_event_repository.dart +++ /dev/null @@ -1,60 +0,0 @@ -import '../../core/network/i_api_client.dart'; -import 'models/calendar_event.dart'; - -abstract class CalendarEventRepository { - Future> listByRange({ - required DateTime startAt, - required DateTime endAt, - }); - - Future getById(String id); - Future acceptSubscription(String itemId); - Future rejectSubscription(String itemId); -} - -class CalendarEventRepositoryImpl implements CalendarEventRepository { - final IApiClient _apiClient; - static const _prefix = '/api/v1/schedule-items'; - - CalendarEventRepositoryImpl(this._apiClient); - - @override - Future> listByRange({ - required DateTime startAt, - required DateTime endAt, - }) async { - final start = Uri.encodeQueryComponent(startAt.toUtc().toIso8601String()); - final end = Uri.encodeQueryComponent(endAt.toUtc().toIso8601String()); - final response = await _apiClient.get>( - '$_prefix?start_at=$start&end_at=$end', - ); - final data = response.data; - if (data == null) { - throw StateError('Invalid listByRange response: empty payload'); - } - return data - .whereType>() - .map(CalendarEvent.fromJson) - .toList(growable: false); - } - - @override - Future getById(String id) async { - final response = await _apiClient.get>('$_prefix/$id'); - final event = response.data; - if (event == null) { - throw StateError('Invalid getById response: empty payload'); - } - return CalendarEvent.fromJson(event); - } - - @override - Future acceptSubscription(String itemId) { - return _apiClient.post('$_prefix/$itemId/accept'); - } - - @override - Future rejectSubscription(String itemId) { - return _apiClient.post('$_prefix/$itemId/reject'); - } -} diff --git a/apps/lib/data/repositories/inbox_repository.dart b/apps/lib/data/repositories/inbox_repository.dart deleted file mode 100644 index 1101c05..0000000 --- a/apps/lib/data/repositories/inbox_repository.dart +++ /dev/null @@ -1,42 +0,0 @@ -import '../../core/network/i_api_client.dart'; -import 'models/inbox_message.dart'; - -abstract class InboxRepository { - Future> getMessages({bool? isRead}); - Future markAsRead(String messageId); -} - -class InboxRepositoryImpl implements InboxRepository { - final IApiClient _apiClient; - static const _prefix = '/api/v1/inbox/messages'; - - InboxRepositoryImpl(this._apiClient); - - @override - Future> getMessages({bool? isRead}) async { - final queryParams = isRead != null ? '?is_read=$isRead' : ''; - final response = await _apiClient.get>( - '$_prefix$queryParams', - ); - final data = response.data; - if (data == null) { - throw StateError('Invalid getMessages response: empty payload'); - } - return data - .whereType>() - .map(InboxMessage.fromJson) - .toList(growable: false); - } - - @override - Future markAsRead(String messageId) async { - final response = await _apiClient.patch>( - '$_prefix/$messageId/read', - ); - final data = response.data; - if (data == null) { - throw StateError('Invalid markAsRead response: empty payload'); - } - return InboxMessage.fromJson(data); - } -} diff --git a/apps/lib/data/repositories/models/calendar_event.dart b/apps/lib/data/repositories/models/calendar_event.dart deleted file mode 100644 index 5dbcec9..0000000 --- a/apps/lib/data/repositories/models/calendar_event.dart +++ /dev/null @@ -1,40 +0,0 @@ -enum CalendarEventStatus { active, archived } - -class CalendarEvent { - final String id; - final String title; - final DateTime startAt; - final DateTime? endAt; - final CalendarEventStatus status; - - const CalendarEvent({ - required this.id, - required this.title, - required this.startAt, - required this.endAt, - required this.status, - }); - - factory CalendarEvent.fromJson(Map json) { - return CalendarEvent( - id: json['id'] as String, - title: json['title'] as String, - startAt: DateTime.parse(json['start_at'] as String).toLocal(), - endAt: json['end_at'] == null - ? null - : DateTime.parse(json['end_at'] as String).toLocal(), - status: _calendarEventStatusFromApi(json['status'] as String), - ); - } -} - -CalendarEventStatus _calendarEventStatusFromApi(String raw) { - switch (raw) { - case 'active': - return CalendarEventStatus.active; - case 'archived': - return CalendarEventStatus.archived; - default: - throw StateError('Unsupported calendar event status: $raw'); - } -} diff --git a/apps/lib/data/services/ios_notification_payload_bridge.dart b/apps/lib/data/services/ios_notification_payload_bridge.dart deleted file mode 100644 index 9e15b7b..0000000 --- a/apps/lib/data/services/ios_notification_payload_bridge.dart +++ /dev/null @@ -1,20 +0,0 @@ -import '../../core/storage/app_preferences.dart'; -import '../models/reminder_payload.dart'; - -class IOSNotificationPayloadBridge { - final AppPreferences _prefs; - - IOSNotificationPayloadBridge(this._prefs); - - ReminderPayload? getPendingPayload() { - return _prefs.pendingNotificationPayload; - } - - Future setPendingPayload(ReminderPayload payload) { - return _prefs.setPendingNotificationPayload(payload); - } - - Future clearPendingPayload() { - return _prefs.clearPendingNotificationPayload(); - } -} diff --git a/apps/lib/data/services/local_notification_service.dart b/apps/lib/data/services/local_notification_service.dart deleted file mode 100644 index 9754730..0000000 --- a/apps/lib/data/services/local_notification_service.dart +++ /dev/null @@ -1,307 +0,0 @@ -import 'dart:convert'; - -import 'package:flutter/foundation.dart'; -import 'package:flutter_local_notifications/flutter_local_notifications.dart'; -import 'package:timezone/data/latest.dart' as tz_data; -import 'package:timezone/timezone.dart' as tz; - -import '../../core/l10n/l10n.dart'; -import '../models/reminder_payload.dart'; -import '../repositories/models/schedule_item_model.dart'; -import 'reminder_notification_callbacks.dart'; - -class LocalNotificationService { - final FlutterLocalNotificationsPlugin _plugin; - bool _initialized = false; - - LocalNotificationService({FlutterLocalNotificationsPlugin? plugin}) - : _plugin = plugin ?? FlutterLocalNotificationsPlugin(); - - Future initialize() async { - if (_initialized) { - return; - } - tz_data.initializeTimeZones(); - - const android = AndroidInitializationSettings('@mipmap/ic_launcher'); - const ios = DarwinInitializationSettings( - requestAlertPermission: false, - requestBadgePermission: false, - requestSoundPermission: false, - ); - final settings = InitializationSettings(android: android, iOS: ios); - - await _plugin.initialize( - settings, - onDidReceiveNotificationResponse: - ReminderNotificationCallbacks.onForegroundResponse, - onDidReceiveBackgroundNotificationResponse: - reminderNotificationTapBackground, - ); - - final androidImpl = _plugin - .resolvePlatformSpecificImplementation< - AndroidFlutterLocalNotificationsPlugin - >(); - await androidImpl?.requestNotificationsPermission(); - await androidImpl?.requestExactAlarmsPermission(); - await androidImpl?.requestFullScreenIntentPermission(); - - final iosImpl = _plugin - .resolvePlatformSpecificImplementation< - IOSFlutterLocalNotificationsPlugin - >(); - await iosImpl?.requestPermissions(alert: true, badge: true, sound: true); - - _initialized = true; - } - - Future upsertEventReminder(ScheduleItemModel event) async { - await initialize(); - if (event.status != ScheduleStatus.active || - event.metadata?.reminderMinutes == null) { - await cancelEventReminder(event.id); - return; - } - - final now = DateTime.now(); - final reminderMinutes = event.metadata?.reminderMinutes ?? 0; - final fireAt = event.startAt.subtract(Duration(minutes: reminderMinutes)); - if (fireAt.isBefore(now)) { - await cancelEventReminder(event.id); - return; - } - - await cancelEventReminder(event.id); - await _scheduleRemindersFrom(event: event, firstFireAt: fireAt); - } - - Future scheduleReminderAt( - ScheduleItemModel event, - DateTime fireAt, - ) async { - await initialize(); - await cancelEventReminder(event.id); - await _scheduleRemindersFrom(event: event, firstFireAt: fireAt); - } - - Future cancelEventReminder(String eventId) async { - await initialize(); - - final pending = await _plugin.pendingNotificationRequests(); - for (final request in pending) { - final payload = _decodePayload(request.payload); - if (payload == null) { - continue; - } - if (payload.eventId == eventId || - payload.aggregateIds.contains(eventId)) { - await _plugin.cancel(request.id); - } - } - - await _plugin.cancel(_notificationIdForEvent(eventId)); - } - - Future rebuildUpcomingReminders( - Iterable events, - ) async { - await initialize(); - for (final event in events) { - await upsertEventReminder(event); - } - } - - int _notificationIdForEvent(String eventId) { - return eventId.hashCode & 0x7fffffff; - } - - int _notificationIdForEventCycle( - String eventId, - DateTime fireAt, - ReminderPayloadMode mode, - ) { - final cycleMinute = - fireAt.millisecondsSinceEpoch ~/ - const Duration(minutes: 1).inMilliseconds; - return '$eventId|$cycleMinute|${mode.value}'.hashCode & 0x7fffffff; - } - - Future _resolveAndroidScheduleMode() async { - final androidImpl = _plugin - .resolvePlatformSpecificImplementation< - AndroidFlutterLocalNotificationsPlugin - >(); - if (androidImpl == null) { - return AndroidScheduleMode.exactAllowWhileIdle; - } - - final canScheduleExact = - await androidImpl.canScheduleExactNotifications() ?? false; - return canScheduleExact - ? AndroidScheduleMode.exactAllowWhileIdle - : AndroidScheduleMode.inexactAllowWhileIdle; - } - - NotificationDetails _buildNotificationDetails(DateTime fireAt) { - return NotificationDetails( - android: AndroidNotificationDetails( - 'calendar_alarm_channel_v2', - L10n.current.notificationChannelName, - channelDescription: L10n.current.notificationChannelDescription, - importance: Importance.max, - priority: Priority.max, - category: AndroidNotificationCategory.alarm, - audioAttributesUsage: AudioAttributesUsage.alarm, - fullScreenIntent: true, - playSound: true, - enableVibration: true, - vibrationPattern: Int64List.fromList([0, 1000, 500, 1200]), - timeoutAfter: 30000, - autoCancel: true, - groupKey: _getGroupKey(fireAt), - ), - iOS: DarwinNotificationDetails( - presentAlert: true, - presentSound: true, - presentBadge: true, - threadIdentifier: _getThreadIdentifier(fireAt), - ), - ); - } - - String _getThreadIdentifier(DateTime fireAt) { - final bucket = - fireAt.millisecondsSinceEpoch ~/ - const Duration(minutes: 1).inMilliseconds; - return 'calendar_reminder_$bucket'; - } - - String _getGroupKey(DateTime fireAt) { - final bucket = - fireAt.millisecondsSinceEpoch ~/ - const Duration(minutes: 1).inMilliseconds; - return 'com.socialapp.calendar.$bucket'; - } - - Future _scheduleSingleReminder({ - required ScheduleItemModel event, - required DateTime fireAt, - }) async { - final notificationId = _notificationIdForEventCycle( - event.id, - fireAt, - ReminderPayloadMode.single, - ); - final payload = ReminderPayload( - eventId: event.id, - title: event.title, - startAt: event.startAt, - endAt: event.endAt, - timezone: event.timezone, - location: event.metadata?.location, - notes: event.metadata?.notes, - color: event.metadata?.color, - mode: ReminderPayloadMode.single, - fireTimeBucket: - fireAt.millisecondsSinceEpoch ~/ - const Duration(minutes: 1).inMilliseconds, - version: 1, - ); - - final details = _buildNotificationDetails(fireAt); - final scheduledAt = tz.TZDateTime.from(fireAt, tz.local); - final mode = await _resolveAndroidScheduleMode(); - - try { - await _plugin.zonedSchedule( - notificationId, - event.title, - _buildReminderBody(event, event.metadata?.reminderMinutes ?? 0), - scheduledAt, - details, - payload: jsonEncode(payload.toJson()), - androidScheduleMode: mode, - uiLocalNotificationDateInterpretation: - UILocalNotificationDateInterpretation.absoluteTime, - ); - } catch (_) { - await _plugin.zonedSchedule( - notificationId, - event.title, - _buildReminderBody(event, event.metadata?.reminderMinutes ?? 0), - scheduledAt, - details, - payload: jsonEncode(payload.toJson()), - androidScheduleMode: AndroidScheduleMode.inexactAllowWhileIdle, - uiLocalNotificationDateInterpretation: - UILocalNotificationDateInterpretation.absoluteTime, - ); - } - } - - Future _scheduleRemindersFrom({ - required ScheduleItemModel event, - required DateTime firstFireAt, - }) async { - final endAt = event.endAt; - var cursor = firstFireAt; - if (endAt == null) { - await _scheduleSingleReminder(event: event, fireAt: cursor); - return; - } - - while (cursor.isBefore(endAt)) { - await _scheduleSingleReminder(event: event, fireAt: cursor); - cursor = cursor.add(const Duration(minutes: 10)); - } - } - - ReminderPayload? _decodePayload(String? raw) { - if (raw == null || raw.isEmpty) { - return null; - } - try { - return ReminderPayload.fromJson(jsonDecode(raw) as Map); - } catch (_) { - return null; - } - } - - String _buildReminderBody(ScheduleItemModel event, int reminderMinutes) { - final when = reminderMinutes == 0 - ? L10n.current.notificationStartsNow - : L10n.current.notificationStartsInMinutes(reminderMinutes); - final location = event.metadata?.location; - final notes = event.metadata?.notes; - final buffer = StringBuffer(when); - if (location != null && location.isNotEmpty) { - buffer.write('\n${L10n.current.notificationLocation(location)}'); - } - if (notes != null && notes.isNotEmpty) { - final preview = notes.length > 30 - ? '${notes.substring(0, 30)}...' - : notes; - buffer.write('\n${L10n.current.notificationNotes(preview)}'); - } - return buffer.toString(); - } - - Future handleNotificationResponse(NotificationResponse response) async { - final payloadRaw = response.payload; - if (payloadRaw == null || payloadRaw.isEmpty) { - return; - } - ReminderPayload payload; - try { - payload = ReminderPayload.fromJson( - Map.from(jsonDecode(payloadRaw) as Map), - ); - } catch (_) { - debugPrint('failed to handle reminder notification response'); - return; - } - - ReminderNotificationCallbacks.onNotificationPayloadReceived?.call(payload); - } -} diff --git a/apps/lib/data/services/reminder_notification_callbacks.dart b/apps/lib/data/services/reminder_notification_callbacks.dart deleted file mode 100644 index ccae7cb..0000000 --- a/apps/lib/data/services/reminder_notification_callbacks.dart +++ /dev/null @@ -1,146 +0,0 @@ -import 'dart:async'; - -import 'package:flutter/foundation.dart'; -import 'package:flutter_local_notifications/flutter_local_notifications.dart'; -import 'package:shared_preferences/shared_preferences.dart'; - -import '../../core/storage/app_preferences.dart'; -import '../models/reminder_payload.dart'; - -typedef ReminderNotificationResponseHandler = - Future Function(NotificationResponse response); - -class ReminderNotificationCallbacks { - static ReminderNotificationResponseHandler? _responseHandler; - static Future _pendingStorageLock = Future.value(); - static void Function(ReminderPayload)? onNotificationPayloadReceived; - static AppPreferences? _appPreferences; - - static Future _resolvePrefs() async { - final prefs = _appPreferences; - if (prefs != null) { - return prefs; - } - final sharedPreferences = await SharedPreferences.getInstance(); - final fallback = AppPreferences(sharedPreferences); - _appPreferences = fallback; - return fallback; - } - - static void setAppPreferences(AppPreferences prefs) { - _appPreferences = prefs; - } - - @visibleForTesting - static Future resetForTest() async { - _responseHandler = null; - _pendingStorageLock = Future.value(); - final prefs = await _resolvePrefs(); - await prefs.clearPendingNotifications(); - } - - static Future bindResponseHandler( - ReminderNotificationResponseHandler handler, - ) async { - _responseHandler = handler; - await _drainPendingResponses(); - } - - static Future onForegroundResponse( - NotificationResponse response, - ) async { - final handler = _responseHandler; - if (handler == null) { - await _enqueuePendingResponse(response); - return; - } - try { - await handler(response); - } catch (_) { - await _enqueuePendingResponse(response); - } - } - - static Future onBackgroundResponse( - NotificationResponse response, - ) async { - final handler = _responseHandler; - if (handler == null) { - await _enqueuePendingResponse(response); - return; - } - try { - await handler(response); - } catch (_) { - await _enqueuePendingResponse(response); - } - } - - static Future _withPendingStorageLock(Future Function() operation) { - final completer = Completer(); - final waitForTurn = _pendingStorageLock; - _pendingStorageLock = waitForTurn.then((_) => completer.future); - - return waitForTurn.then((_) => operation()).whenComplete(() { - if (!completer.isCompleted) { - completer.complete(); - } - }); - } - - static Future _enqueuePendingResponse( - NotificationResponse response, - ) async { - final prefs = await _resolvePrefs(); - await _withPendingStorageLock(() async { - final current = prefs.pendingNotifications; - await prefs.setPendingNotifications([ - ...current, - NotificationResponse( - id: response.id, - actionId: response.actionId, - payload: response.payload, - input: response.input, - notificationResponseType: response.notificationResponseType, - ), - ]); - }); - } - - static Future _drainPendingResponses() async { - final handler = _responseHandler; - if (handler == null) { - return; - } - final prefs = await _resolvePrefs(); - await _withPendingStorageLock(() async { - final pending = prefs.pendingNotifications; - if (pending.isEmpty) { - return; - } - - final remaining = []; - for (final response in pending) { - try { - await handler(response); - } catch (_) { - remaining.add(response); - } - } - - if (remaining.isEmpty) { - await prefs.clearPendingNotifications(); - return; - } - - await prefs.setPendingNotifications(remaining); - }); - } -} - -@pragma('vm:entry-point') -Future reminderNotificationTapBackground( - NotificationResponse response, -) async { - await ReminderNotificationCallbacks.onBackgroundResponse(response); -} diff --git a/apps/lib/core/storage/token_storage.dart b/apps/lib/data/storage/token_storage.dart similarity index 100% rename from apps/lib/core/storage/token_storage.dart rename to apps/lib/data/storage/token_storage.dart diff --git a/apps/lib/features/auth/data/auth_api.dart b/apps/lib/features/auth/data/apis/auth_api.dart similarity index 82% rename from apps/lib/features/auth/data/auth_api.dart rename to apps/lib/features/auth/data/apis/auth_api.dart index 26e3d3f..e1c41f8 100644 --- a/apps/lib/features/auth/data/auth_api.dart +++ b/apps/lib/features/auth/data/apis/auth_api.dart @@ -1,7 +1,7 @@ -import 'package:social_app/core/network/i_api_client.dart'; -import 'models/signup_request.dart'; -import 'models/login_request.dart'; -import 'models/auth_response.dart'; +import 'package:social_app/data/network/i_api_client.dart'; +import '../models/signup_request.dart'; +import '../models/login_request.dart'; +import '../models/auth_response.dart'; class AuthApi { final IApiClient _client; diff --git a/apps/lib/features/auth/data/auth_repository.dart b/apps/lib/features/auth/data/repositories/auth_repository.dart similarity index 84% rename from apps/lib/features/auth/data/auth_repository.dart rename to apps/lib/features/auth/data/repositories/auth_repository.dart index b339e28..3b2d089 100644 --- a/apps/lib/features/auth/data/auth_repository.dart +++ b/apps/lib/features/auth/data/repositories/auth_repository.dart @@ -1,4 +1,4 @@ -import 'package:social_app/features/auth/data/models/auth_response.dart'; +import '../models/auth_response.dart'; abstract class AuthRepository { Future sendOtp(String phone); diff --git a/apps/lib/features/auth/data/auth_repository_impl.dart b/apps/lib/features/auth/data/repositories/auth_repository_impl.dart similarity index 90% rename from apps/lib/features/auth/data/auth_repository_impl.dart rename to apps/lib/features/auth/data/repositories/auth_repository_impl.dart index 4945054..2df8d25 100644 --- a/apps/lib/features/auth/data/auth_repository_impl.dart +++ b/apps/lib/features/auth/data/repositories/auth_repository_impl.dart @@ -1,9 +1,9 @@ -import 'package:social_app/core/storage/token_storage.dart'; -import 'auth_api.dart'; +import 'package:social_app/data/storage/token_storage.dart'; +import '../apis/auth_api.dart'; import 'auth_repository.dart'; -import 'models/signup_request.dart'; -import 'models/login_request.dart'; -import 'models/auth_response.dart'; +import '../models/signup_request.dart'; +import '../models/login_request.dart'; +import '../models/auth_response.dart'; class AuthRepositoryImpl implements AuthRepository { final AuthApi _api; diff --git a/apps/lib/features/auth/presentation/bloc/auth_bloc.dart b/apps/lib/features/auth/presentation/bloc/auth_bloc.dart index ca0f232..d275180 100644 --- a/apps/lib/features/auth/presentation/bloc/auth_bloc.dart +++ b/apps/lib/features/auth/presentation/bloc/auth_bloc.dart @@ -1,5 +1,5 @@ import 'package:flutter_bloc/flutter_bloc.dart'; -import '../../data/auth_repository.dart'; +import '../../data/repositories/auth_repository.dart'; import 'auth_event.dart'; import 'auth_state.dart'; diff --git a/apps/lib/features/auth/presentation/cubits/login_cubit.dart b/apps/lib/features/auth/presentation/cubits/login_cubit.dart index c3cf144..5b28e33 100644 --- a/apps/lib/features/auth/presentation/cubits/login_cubit.dart +++ b/apps/lib/features/auth/presentation/cubits/login_cubit.dart @@ -3,9 +3,9 @@ import 'dart:async'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:formz/formz.dart'; import 'package:equatable/equatable.dart'; -import '../../../../core/network/api_exception.dart'; +import '../../../../data/network/api_exception.dart'; import '../../../../core/l10n/l10n.dart'; -import '../../data/auth_repository.dart'; +import '../../data/repositories/auth_repository.dart'; import '../../data/models/auth_response.dart'; import '../../../../shared/forms/inputs.dart'; diff --git a/apps/lib/features/auth/presentation/screens/auth_boot_screen.dart b/apps/lib/features/auth/presentation/screens/auth_boot_screen.dart index 4bf3154..a2c489c 100644 --- a/apps/lib/features/auth/presentation/screens/auth_boot_screen.dart +++ b/apps/lib/features/auth/presentation/screens/auth_boot_screen.dart @@ -1,18 +1,45 @@ import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; -class AuthBootScreen extends StatelessWidget { +import '../../../../app/di/injection.dart'; +import '../../../../app/services/app_prewarm_orchestrator.dart'; +import '../bloc/auth_bloc.dart'; +import '../bloc/auth_state.dart'; + +class AuthBootScreen extends StatefulWidget { const AuthBootScreen({super.key}); + @override + State createState() => _AuthBootScreenState(); +} + +class _AuthBootScreenState extends State { + @override + void initState() { + super.initState(); + _triggerPrewarmIfAuthenticated(context.read().state); + } + + void _triggerPrewarmIfAuthenticated(AuthState state) { + if (state is! AuthAuthenticated) { + return; + } + sl().ensureStartedFor(state.user.id); + } + @override Widget build(BuildContext context) { final colorScheme = Theme.of(context).colorScheme; - return Scaffold( - backgroundColor: colorScheme.surface, - body: SafeArea( - child: Center( - child: Image.asset( - 'assets/branding/assistant_octopus_foreground.png', - width: 260, + return BlocListener( + listener: (context, state) => _triggerPrewarmIfAuthenticated(state), + child: Scaffold( + backgroundColor: colorScheme.surface, + body: SafeArea( + child: Center( + child: Image.asset( + 'assets/branding/assistant_octopus_foreground.png', + width: 260, + ), ), ), ), diff --git a/apps/lib/features/auth/presentation/screens/login_screen.dart b/apps/lib/features/auth/presentation/screens/login_screen.dart index 8ce2003..7cb9af3 100644 --- a/apps/lib/features/auth/presentation/screens/login_screen.dart +++ b/apps/lib/features/auth/presentation/screens/login_screen.dart @@ -14,7 +14,7 @@ import '../../../../shared/widgets/confirm_sheet.dart'; import '../../../../shared/widgets/link_button.dart'; import '../../../../shared/widgets/phone_prefix_selector.dart'; import '../../../../shared/widgets/toast/toast_type.dart'; -import '../../data/auth_repository.dart'; +import '../../data/repositories/auth_repository.dart'; import '../../presentation/bloc/auth_bloc.dart'; import '../../presentation/bloc/auth_event.dart'; import '../../presentation/cubits/login_cubit.dart'; diff --git a/apps/lib/features/calendar/data/calendar_api.dart b/apps/lib/features/calendar/data/apis/calendar_api.dart similarity index 93% rename from apps/lib/features/calendar/data/calendar_api.dart rename to apps/lib/features/calendar/data/apis/calendar_api.dart index 209da7f..c187800 100644 --- a/apps/lib/features/calendar/data/calendar_api.dart +++ b/apps/lib/features/calendar/data/apis/calendar_api.dart @@ -1,5 +1,5 @@ -import 'package:social_app/core/network/i_api_client.dart'; -import 'package:social_app/data/repositories/models/schedule_item_model.dart'; +import 'package:social_app/data/network/i_api_client.dart'; +import 'package:social_app/features/calendar/data/models/schedule_item_model.dart'; class CalendarApi { final IApiClient _client; diff --git a/apps/lib/data/repositories/models/schedule_item_model.dart b/apps/lib/features/calendar/data/models/schedule_item_model.dart similarity index 100% rename from apps/lib/data/repositories/models/schedule_item_model.dart rename to apps/lib/features/calendar/data/models/schedule_item_model.dart diff --git a/apps/lib/data/repositories/calendar_repository.dart b/apps/lib/features/calendar/data/repositories/calendar_repository.dart similarity index 51% rename from apps/lib/data/repositories/calendar_repository.dart rename to apps/lib/features/calendar/data/repositories/calendar_repository.dart index 9be41ce..7407538 100644 --- a/apps/lib/data/repositories/calendar_repository.dart +++ b/apps/lib/features/calendar/data/repositories/calendar_repository.dart @@ -1,7 +1,7 @@ -import '../cache/cache_policy.dart'; -import '../cache/cached_repository.dart'; -import '../../core/network/i_api_client.dart'; -import 'models/schedule_item_model.dart'; +import '../../../../data/cache/cache_policy.dart'; +import '../../../../data/cache/cached_repository.dart'; +import '../../../../data/network/i_api_client.dart'; +import '../models/schedule_item_model.dart'; class CalendarRepository extends CachedRepository> { final IApiClient _apiClient; @@ -17,10 +17,12 @@ class CalendarRepository extends CachedRepository> { policy: policy ?? const CachePolicy( - softTtl: Duration(minutes: 2), - hardTtl: Duration(minutes: 30), - minRefreshInterval: Duration(minutes: 1), + softTtl: Duration(minutes: 1), + hardTtl: Duration(minutes: 10), + minRefreshInterval: Duration(seconds: 30), ), + encodeValue: _encodeEventList, + decodeValue: _decodeEventList, ); static String dayKey(DateTime date) { @@ -70,6 +72,10 @@ class CalendarRepository extends CachedRepository> { return ScheduleItemModel.fromJson(data); } + Future getById(String id) { + return getEventById(id); + } + Future> listEventsByRange({ required DateTime start, required DateTime end, @@ -77,6 +83,21 @@ class CalendarRepository extends CachedRepository> { return _listByRange(startAt: start, endAt: end); } + Future> listByRange({ + required DateTime startAt, + required DateTime endAt, + }) { + return _listByRange(startAt: startAt, endAt: endAt); + } + + Future acceptSubscription(String itemId) { + return _apiClient.post('$_prefix/$itemId/accept'); + } + + Future rejectSubscription(String itemId) { + return _apiClient.post('$_prefix/$itemId/reject'); + } + Future> _listByRange({ required DateTime startAt, required DateTime endAt, @@ -95,4 +116,59 @@ class CalendarRepository extends CachedRepository> { .map(ScheduleItemModel.fromJson) .toList(growable: false); } + + static Object? _encodeEventList(List events) { + return events.map(_encodeEvent).toList(growable: false); + } + + static List _decodeEventList(Object? raw) { + if (raw is! List) { + throw const FormatException('Invalid cached calendar event list payload'); + } + return raw + .whereType() + .map( + (item) => ScheduleItemModel.fromJson(Map.from(item)), + ) + .toList(growable: false); + } + + static Map _encodeEvent(ScheduleItemModel item) { + return { + 'id': item.id, + 'owner_id': item.ownerId, + 'permission': item.permission, + 'is_owner': item.isOwner, + 'title': item.title, + 'description': item.description, + 'start_at': item.startAt.toIso8601String(), + 'end_at': item.endAt?.toIso8601String(), + 'timezone': item.timezone, + 'metadata': item.metadata?.toJson(), + 'source_type': _sourceTypeToApi(item.sourceType), + 'status': _statusToApi(item.status), + 'created_at': item.createdAt.toIso8601String(), + 'updated_at': item.updatedAt.toIso8601String(), + }; + } + + static String _sourceTypeToApi(ScheduleSourceType sourceType) { + switch (sourceType) { + case ScheduleSourceType.manual: + return 'manual'; + case ScheduleSourceType.imported: + return 'imported'; + case ScheduleSourceType.agentGenerated: + return 'agent_generated'; + } + } + + static String _statusToApi(ScheduleStatus status) { + switch (status) { + case ScheduleStatus.active: + return 'active'; + case ScheduleStatus.archived: + return 'archived'; + } + } } diff --git a/apps/lib/data/services/calendar_service.dart b/apps/lib/features/calendar/data/services/calendar_service.dart similarity index 95% rename from apps/lib/data/services/calendar_service.dart rename to apps/lib/features/calendar/data/services/calendar_service.dart index 53ae478..215c85e 100644 --- a/apps/lib/data/services/calendar_service.dart +++ b/apps/lib/features/calendar/data/services/calendar_service.dart @@ -1,6 +1,6 @@ -import '../../core/network/i_api_client.dart'; -import '../cache/cache_invalidator.dart'; -import '../repositories/models/schedule_item_model.dart'; +import '../../../../data/network/i_api_client.dart'; +import '../../../../data/cache/cache_store.dart'; +import '../models/schedule_item_model.dart'; class CalendarService { static const _prefix = '/api/v1/schedule-items'; diff --git a/apps/lib/features/calendar/presentation/dayweek/day_event_layout_engine.dart b/apps/lib/features/calendar/presentation/dayweek/day_event_layout_engine.dart index 22dbf56..7210d45 100644 --- a/apps/lib/features/calendar/presentation/dayweek/day_event_layout_engine.dart +++ b/apps/lib/features/calendar/presentation/dayweek/day_event_layout_engine.dart @@ -1,4 +1,4 @@ -import '../../../../data/repositories/models/schedule_item_model.dart'; +import '../../../../features/calendar/data/models/schedule_item_model.dart'; import 'day_timeline_metrics.dart'; import 'day_view_scale.dart'; 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 e55e6db..db09d31 100644 --- a/apps/lib/features/calendar/presentation/screens/calendar_dayweek_screen.dart +++ b/apps/lib/features/calendar/presentation/screens/calendar_dayweek_screen.dart @@ -7,11 +7,11 @@ import '../../../../app/router/home_return_policy.dart'; import '../../../../app/di/injection.dart'; import '../../../../core/l10n/l10n.dart'; import '../../../../core/theme/design_tokens.dart'; -import '../../../../data/repositories/calendar_repository.dart'; +import '../../../../features/calendar/data/repositories/calendar_repository.dart'; import '../../../../shared/widgets/app_pressable.dart'; import '../../../../shared/widgets/bottom_dock.dart'; import '../../../../shared/state/calendar_state_manager.dart'; -import '../../../../data/repositories/models/schedule_item_model.dart'; +import '../../../../features/calendar/data/models/schedule_item_model.dart'; import '../calendar_time_utils.dart'; import '../utils/event_color_resolver.dart'; import '../dayweek/day_event_layout_engine.dart'; diff --git a/apps/lib/features/calendar/presentation/screens/calendar_event_detail_screen.dart b/apps/lib/features/calendar/presentation/screens/calendar_event_detail_screen.dart index 0c579bb..12b980b 100644 --- a/apps/lib/features/calendar/presentation/screens/calendar_event_detail_screen.dart +++ b/apps/lib/features/calendar/presentation/screens/calendar_event_detail_screen.dart @@ -4,7 +4,6 @@ import 'package:go_router/go_router.dart'; import 'package:social_app/core/l10n/l10n.dart'; import '../../../../app/di/injection.dart'; import '../../../../app/router/app_routes.dart'; -import '../../../../data/services/local_notification_service.dart'; import '../../../../core/theme/design_tokens.dart'; import '../../../../shared/widgets/app_loading_indicator.dart'; import '../../../../shared/widgets/back_title_page_header.dart'; @@ -12,8 +11,8 @@ import '../../../../shared/widgets/detail_header_action_menu.dart'; import '../../../../shared/widgets/destructive_action_sheet.dart'; import '../../../../shared/widgets/toast/toast.dart'; import '../../../../shared/widgets/toast/toast_type.dart'; -import '../../../../data/services/calendar_service.dart'; -import '../../../../data/repositories/models/schedule_item_model.dart'; +import '../../../../features/calendar/data/services/calendar_service.dart'; +import '../../../../features/calendar/data/models/schedule_item_model.dart'; import '../utils/event_color_resolver.dart'; enum _CalendarHeaderAction { edit, delete, share, archive } @@ -505,9 +504,6 @@ class _CalendarEventDetailScreenState extends State { return; } await sl().deleteEvent(widget.eventId); - try { - await sl().cancelEventReminder(widget.eventId); - } catch (_) {} if (!mounted) { return; } diff --git a/apps/lib/features/calendar/presentation/screens/calendar_event_edit_screen.dart b/apps/lib/features/calendar/presentation/screens/calendar_event_edit_screen.dart index d19ec5f..70c0c52 100644 --- a/apps/lib/features/calendar/presentation/screens/calendar_event_edit_screen.dart +++ b/apps/lib/features/calendar/presentation/screens/calendar_event_edit_screen.dart @@ -4,8 +4,8 @@ import '../../../../app/di/injection.dart'; import '../../../../core/l10n/l10n.dart'; import '../../../../shared/widgets/app_loading_indicator.dart'; import '../widgets/create_event_sheet.dart'; -import '../../../../data/repositories/models/schedule_item_model.dart'; -import '../../../../data/services/calendar_service.dart'; +import '../../../../features/calendar/data/models/schedule_item_model.dart'; +import '../../../../features/calendar/data/services/calendar_service.dart'; class CalendarEventEditScreen extends StatefulWidget { final String eventId; diff --git a/apps/lib/features/calendar/presentation/screens/calendar_event_share_screen.dart b/apps/lib/features/calendar/presentation/screens/calendar_event_share_screen.dart index cb71cc7..bb2ae79 100644 --- a/apps/lib/features/calendar/presentation/screens/calendar_event_share_screen.dart +++ b/apps/lib/features/calendar/presentation/screens/calendar_event_share_screen.dart @@ -4,8 +4,8 @@ import '../../../../app/di/injection.dart'; import '../../../../core/l10n/l10n.dart'; import '../../../../shared/widgets/app_loading_indicator.dart'; import '../../../../shared/widgets/back_title_page_header.dart'; -import '../../../../data/repositories/models/schedule_item_model.dart'; -import '../../../../data/services/calendar_service.dart'; +import '../../../../features/calendar/data/models/schedule_item_model.dart'; +import '../../../../features/calendar/data/services/calendar_service.dart'; import '../widgets/calendar_share_dialog.dart'; class CalendarEventShareScreen extends StatefulWidget { diff --git a/apps/lib/features/calendar/presentation/screens/calendar_month_screen.dart b/apps/lib/features/calendar/presentation/screens/calendar_month_screen.dart index 710f209..0c70d1c 100644 --- a/apps/lib/features/calendar/presentation/screens/calendar_month_screen.dart +++ b/apps/lib/features/calendar/presentation/screens/calendar_month_screen.dart @@ -6,14 +6,14 @@ import 'package:social_app/core/l10n/l10n.dart'; import '../../../../app/di/injection.dart'; import '../../../../app/router/app_routes.dart'; import '../../../../core/theme/design_tokens.dart'; -import '../../../../data/repositories/calendar_repository.dart'; +import '../../../../features/calendar/data/repositories/calendar_repository.dart'; import '../../../../shared/widgets/app_pressable.dart'; import '../../../../shared/widgets/bottom_dock.dart'; import '../../../../shared/state/calendar_state_manager.dart'; import '../../../../app/router/home_return_policy.dart'; import '../calendar_time_utils.dart'; import '../utils/event_color_resolver.dart'; -import '../../../../data/repositories/models/schedule_item_model.dart'; +import '../../../../features/calendar/data/models/schedule_item_model.dart'; class CalendarMonthScreen extends StatefulWidget { final bool resetToToday; diff --git a/apps/lib/features/calendar/presentation/utils/event_color_resolver.dart b/apps/lib/features/calendar/presentation/utils/event_color_resolver.dart index eed81ed..6780e62 100644 --- a/apps/lib/features/calendar/presentation/utils/event_color_resolver.dart +++ b/apps/lib/features/calendar/presentation/utils/event_color_resolver.dart @@ -1,6 +1,6 @@ import 'package:flutter/material.dart'; -import '../../../../data/repositories/models/schedule_item_model.dart'; +import '../../../../features/calendar/data/models/schedule_item_model.dart'; Color resolveEventColor({ required ScheduleStatus status, diff --git a/apps/lib/features/calendar/presentation/widgets/calendar_share_dialog.dart b/apps/lib/features/calendar/presentation/widgets/calendar_share_dialog.dart index 1b11e4b..f22c2ca 100644 --- a/apps/lib/features/calendar/presentation/widgets/calendar_share_dialog.dart +++ b/apps/lib/features/calendar/presentation/widgets/calendar_share_dialog.dart @@ -6,7 +6,7 @@ import '../../../../core/theme/design_tokens.dart'; import '../../../../shared/widgets/app_button.dart'; import '../../../../shared/widgets/toast/toast.dart'; import '../../../../shared/widgets/toast/toast_type.dart'; -import '../../data/calendar_api.dart'; +import '../../data/apis/calendar_api.dart'; class CalendarShareDialog extends StatefulWidget { final String eventId; diff --git a/apps/lib/features/calendar/presentation/widgets/create_event_sheet.dart b/apps/lib/features/calendar/presentation/widgets/create_event_sheet.dart index d4bd961..87fdf23 100644 --- a/apps/lib/features/calendar/presentation/widgets/create_event_sheet.dart +++ b/apps/lib/features/calendar/presentation/widgets/create_event_sheet.dart @@ -2,7 +2,6 @@ import 'package:flutter/material.dart'; import 'package:lucide_icons/lucide_icons.dart'; import 'package:social_app/core/l10n/l10n.dart'; import '../../../../app/di/injection.dart'; -import '../../../../data/services/local_notification_service.dart'; import '../../../../core/theme/design_tokens.dart'; import '../../../../shared/widgets/app_loading_indicator.dart'; import '../../../../shared/widgets/app_selection_sheet.dart'; @@ -11,8 +10,8 @@ import '../../../../shared/widgets/back_title_page_header.dart'; import '../../../../shared/widgets/toast/toast.dart'; import '../../../../shared/widgets/toast/toast_type.dart'; import 'date_time_picker_sheet.dart'; -import '../../../../data/repositories/models/schedule_item_model.dart'; -import '../../../../data/services/calendar_service.dart'; +import '../../../../features/calendar/data/models/schedule_item_model.dart'; +import '../../../../features/calendar/data/services/calendar_service.dart'; final _defaultColors = AppColorPalette.light.eventPresetColors; @@ -786,25 +785,11 @@ class _CreateEventSheetState extends State try { final service = sl(); - late final ScheduleItemModel saved; if (_isEditing) { - saved = await service.updateEvent(event); + await service.updateEvent(event); } else { - saved = await service.addEvent(event); - } - - try { - final notificationService = sl(); - await notificationService.upsertEventReminder(saved); - } catch (_) { - if (mounted) { - Toast.show( - context, - context.l10n.calendarCreateReminderPermissionFailed, - type: ToastType.warning, - ); - } + await service.addEvent(event); } widget.onSaved?.call(); diff --git a/apps/lib/features/chat/data/repositories/chat_history_repository.dart b/apps/lib/features/chat/data/repositories/chat_history_repository.dart new file mode 100644 index 0000000..94ad51e --- /dev/null +++ b/apps/lib/features/chat/data/repositories/chat_history_repository.dart @@ -0,0 +1,114 @@ +import 'package:social_app/data/network/i_api_client.dart'; +import 'package:social_app/data/cache/cache_policy.dart'; +import 'package:social_app/data/cache/cached_repository.dart'; + +import '../models/ag_ui_event.dart'; + +class ChatHistoryRepository extends CachedRepository { + ChatHistoryRepository({required IApiClient apiClient, required super.store}) + : _apiClient = apiClient, + super( + policy: const CachePolicy( + softTtl: Duration(seconds: 30), + hardTtl: Duration(minutes: 5), + minRefreshInterval: Duration(seconds: 15), + ), + encodeValue: _encodeSnapshot, + decodeValue: _decodeSnapshot, + ); + + final IApiClient _apiClient; + + Future loadHistory({ + String? threadId, + DateTime? beforeDate, + bool forceRefresh = false, + }) { + final key = _keyFor(threadId: threadId, beforeDate: beforeDate); + return getOrLoad( + key: key, + forceRefresh: forceRefresh, + loadFromRemote: () => + _loadHistoryRemote(threadId: threadId, beforeDate: beforeDate), + ); + } + + Future _loadHistoryRemote({ + String? threadId, + DateTime? beforeDate, + }) async { + final path = _buildHistoryPath(threadId: threadId, beforeDate: beforeDate); + final response = await _apiClient.get>(path); + final payload = response.data; + if (payload is! Map) { + throw StateError('Invalid /agent/history response'); + } + return HistorySnapshot.fromJson(payload); + } + + static String _buildHistoryPath({String? threadId, DateTime? beforeDate}) { + final query = []; + if (threadId != null && threadId.isNotEmpty) { + query.add('threadId=$threadId'); + } + if (beforeDate != null) { + final day = DateTime(beforeDate.year, beforeDate.month, beforeDate.day); + query.add('before=${day.toIso8601String().substring(0, 10)}'); + } + if (query.isEmpty) { + return '/api/v1/agent/history'; + } + return '/api/v1/agent/history?${query.join('&')}'; + } + + static String _keyFor({String? threadId, DateTime? beforeDate}) { + final threadPart = (threadId == null || threadId.isEmpty) + ? 'default' + : threadId; + if (beforeDate == null) { + return 'chat:history:first:$threadPart'; + } + final day = DateTime( + beforeDate.year, + beforeDate.month, + beforeDate.day, + ).toIso8601String().substring(0, 10); + return 'chat:history:before:$threadPart:$day'; + } + + static Object? _encodeSnapshot(HistorySnapshot snapshot) { + return { + 'scope': snapshot.scope, + 'threadId': snapshot.threadId, + 'day': snapshot.day, + 'hasMore': snapshot.hasMore, + 'messages': snapshot.messages.map(_encodeMessage).toList(growable: false), + }; + } + + static HistorySnapshot _decodeSnapshot(Object? raw) { + if (raw is! Map) { + throw const FormatException('Invalid cached history snapshot payload'); + } + return HistorySnapshot.fromJson(Map.from(raw)); + } + + static Map _encodeMessage(HistoryMessage message) { + return { + 'id': message.id, + 'seq': message.seq, + 'role': message.role, + 'content': message.content, + 'timestamp': message.timestamp.toIso8601String(), + 'attachments': message.attachments + .map( + (attachment) => { + 'url': attachment.url, + 'mimeType': attachment.mimeType, + }, + ) + .toList(growable: false), + 'ui_schema': message.uiSchema, + }; + } +} diff --git a/apps/lib/features/chat/data/services/ag_ui_service.dart b/apps/lib/features/chat/data/services/ag_ui_service.dart index acf940e..c4a27a2 100644 --- a/apps/lib/features/chat/data/services/ag_ui_service.dart +++ b/apps/lib/features/chat/data/services/ag_ui_service.dart @@ -5,9 +5,10 @@ import 'dart:typed_data'; import 'package:dio/dio.dart'; import 'package:image_picker/image_picker.dart'; -import 'package:social_app/core/network/i_api_client.dart'; +import 'package:social_app/data/network/i_api_client.dart'; import '../models/ag_ui_event.dart'; +import '../repositories/chat_history_repository.dart'; typedef EventCallback = void Function(AgUiEvent event); @@ -43,6 +44,7 @@ class _RunInputPayload { class AgUiService { final IApiClient _apiClient; + final ChatHistoryRepository? _historyRepository; EventCallback onEvent; final Map _lastEventIdByThread = {}; int _activeStreamToken = 0; @@ -54,9 +56,13 @@ class AgUiService { String? _activeRunId; bool _hasMoreHistory = false; - AgUiService({EventCallback? onEvent, required IApiClient apiClient}) - : onEvent = onEvent ?? ((_) {}), - _apiClient = apiClient; + AgUiService({ + EventCallback? onEvent, + required IApiClient apiClient, + ChatHistoryRepository? historyRepository, + }) : onEvent = onEvent ?? ((_) {}), + _apiClient = apiClient, + _historyRepository = historyRepository; Future sendMessage( String content, { @@ -105,18 +111,28 @@ class AgUiService { } Future loadHistory({DateTime? beforeDate}) async { + final repository = _historyRepository; + final snapshot = repository != null + ? await repository.loadHistory( + threadId: _threadId, + beforeDate: beforeDate, + ) + : await _loadHistoryFromApi(beforeDate: beforeDate); + if (snapshot.threadId != null && snapshot.threadId!.isNotEmpty) { + _threadId = snapshot.threadId; + } + _hasMoreHistory = snapshot.hasMore; + return snapshot; + } + + Future _loadHistoryFromApi({DateTime? beforeDate}) async { final path = _buildHistoryPath(beforeDate: beforeDate); final response = await _apiClient.get>(path); final payload = response.data; if (payload is! Map) { throw StateError('Invalid /agent/history response'); } - final snapshot = HistorySnapshot.fromJson(payload); - if (snapshot.threadId != null && snapshot.threadId!.isNotEmpty) { - _threadId = snapshot.threadId; - } - _hasMoreHistory = snapshot.hasMore; - return snapshot; + return HistorySnapshot.fromJson(payload); } Future fetchAttachmentPreview(String previewPath) async { diff --git a/apps/lib/features/chat/presentation/bloc/chat_bloc.dart b/apps/lib/features/chat/presentation/bloc/chat_bloc.dart index 63720c8..2f1aa39 100644 --- a/apps/lib/features/chat/presentation/bloc/chat_bloc.dart +++ b/apps/lib/features/chat/presentation/bloc/chat_bloc.dart @@ -5,10 +5,11 @@ import 'package:image_picker/image_picker.dart'; import 'package:social_app/core/chat/agent_stage.dart'; import 'package:social_app/core/chat/chat_list_item.dart'; import 'package:social_app/core/chat/chat_orchestrator.dart'; -import 'package:social_app/core/network/i_api_client.dart'; +import 'package:social_app/data/network/i_api_client.dart'; import 'package:social_app/core/l10n/l10n.dart'; import '../../data/models/ag_ui_event.dart'; +import '../../data/repositories/chat_history_repository.dart'; import '../../data/services/ag_ui_service.dart'; class ChatState implements ChatOrchestratorState { @@ -95,9 +96,17 @@ class ChatState implements ChatOrchestratorState { } class ChatBloc extends Cubit implements ChatOrchestrator { - ChatBloc({AgUiService? service, required IApiClient apiClient}) - : _service = service ?? AgUiService(apiClient: apiClient), - super(const ChatState()) { + ChatBloc({ + AgUiService? service, + required IApiClient apiClient, + ChatHistoryRepository? historyRepository, + }) : _service = + service ?? + AgUiService( + apiClient: apiClient, + historyRepository: historyRepository, + ), + super(const ChatState()) { _service.onEvent = _handleEvent; } diff --git a/apps/lib/features/contacts/data/friends_api.dart b/apps/lib/features/contacts/data/apis/friends_api.dart similarity index 98% rename from apps/lib/features/contacts/data/friends_api.dart rename to apps/lib/features/contacts/data/apis/friends_api.dart index 6f30a0b..bfb9157 100644 --- a/apps/lib/features/contacts/data/friends_api.dart +++ b/apps/lib/features/contacts/data/apis/friends_api.dart @@ -1,4 +1,4 @@ -import 'package:social_app/core/network/i_api_client.dart'; +import 'package:social_app/data/network/i_api_client.dart'; class FriendsApi { final IApiClient _client; diff --git a/apps/lib/features/contacts/data/users/users_api.dart b/apps/lib/features/contacts/data/apis/users_api.dart similarity index 94% rename from apps/lib/features/contacts/data/users/users_api.dart rename to apps/lib/features/contacts/data/apis/users_api.dart index c2a8f6a..24eddb0 100644 --- a/apps/lib/features/contacts/data/users/users_api.dart +++ b/apps/lib/features/contacts/data/apis/users_api.dart @@ -1,7 +1,7 @@ import 'dart:io'; import 'package:dio/dio.dart'; -import 'package:social_app/core/network/i_api_client.dart'; -import 'package:social_app/data/models/user_profile.dart'; +import 'package:social_app/data/network/i_api_client.dart'; +import '../models/user_profile.dart'; class UserBasicInfo { final String id; diff --git a/apps/lib/data/repositories/models/friend_request.dart b/apps/lib/features/contacts/data/models/friend_request.dart similarity index 100% rename from apps/lib/data/repositories/models/friend_request.dart rename to apps/lib/features/contacts/data/models/friend_request.dart diff --git a/apps/lib/data/models/user_profile.dart b/apps/lib/features/contacts/data/models/user_profile.dart similarity index 100% rename from apps/lib/data/models/user_profile.dart rename to apps/lib/features/contacts/data/models/user_profile.dart diff --git a/apps/lib/data/repositories/models/user_summary.dart b/apps/lib/features/contacts/data/models/user_summary.dart similarity index 100% rename from apps/lib/data/repositories/models/user_summary.dart rename to apps/lib/features/contacts/data/models/user_summary.dart diff --git a/apps/lib/data/repositories/friend_repository.dart b/apps/lib/features/contacts/data/repositories/friend_repository.dart similarity index 54% rename from apps/lib/data/repositories/friend_repository.dart rename to apps/lib/features/contacts/data/repositories/friend_repository.dart index 855a203..07b9a3b 100644 --- a/apps/lib/data/repositories/friend_repository.dart +++ b/apps/lib/features/contacts/data/repositories/friend_repository.dart @@ -1,5 +1,7 @@ -import '../../core/network/i_api_client.dart'; -import 'models/friend_request.dart'; +import '../../../../data/network/i_api_client.dart'; +import '../../../../data/cache/cache_policy.dart'; +import '../../../../data/cache/cached_repository.dart'; +import '../models/friend_request.dart'; abstract class FriendRepository { Future> getFriends(); @@ -11,14 +13,29 @@ abstract class FriendRepository { Future declineRequest(String friendshipId); } -class FriendRepositoryImpl implements FriendRepository { +class FriendRepositoryImpl extends CachedRepository> + implements FriendRepository { final IApiClient _apiClient; static const _prefix = '/api/v1/friends'; - FriendRepositoryImpl(this._apiClient); + FriendRepositoryImpl({required IApiClient apiClient, required super.store}) + : _apiClient = apiClient, + super( + policy: const CachePolicy( + softTtl: Duration(seconds: 30), + hardTtl: Duration(minutes: 5), + minRefreshInterval: Duration(seconds: 15), + ), + encodeValue: _encodeFriendUsers, + decodeValue: _decodeFriendUsers, + ); @override Future> getFriends() async { + return getOrLoad(key: _friendsKey, loadFromRemote: _loadFriendsFromRemote); + } + + Future> _loadFriendsFromRemote() async { final response = await _apiClient.get>(_prefix); final data = response.data; if (data == null) { @@ -34,6 +51,10 @@ class FriendRepositoryImpl implements FriendRepository { @override Future getRequestById(String friendshipId) async { + return _loadRequestById(friendshipId); + } + + Future _loadRequestById(String friendshipId) async { final response = await _apiClient.get>( '$_prefix/requests/$friendshipId', ); @@ -71,7 +92,9 @@ class FriendRepositoryImpl implements FriendRepository { if (data == null) { throw StateError('Invalid acceptRequest response: empty payload'); } - return FriendRequest.fromJson(data); + final request = FriendRequest.fromJson(data); + await _invalidateFriendCaches(friendshipId); + return request; } @override @@ -83,6 +106,37 @@ class FriendRepositoryImpl implements FriendRepository { if (data == null) { throw StateError('Invalid declineRequest response: empty payload'); } - return FriendRequest.fromJson(data); + final request = FriendRequest.fromJson(data); + await _invalidateFriendCaches(friendshipId); + return request; + } + + Future _invalidateFriendCaches(String friendshipId) { + final _ = friendshipId; + return removeCacheKey(_friendsKey); + } + + static const _friendsKey = 'friends:list'; + + static Object? _encodeFriendUsers(List users) { + return users.map(_encodeFriendUser).toList(growable: false); + } + + static List _decodeFriendUsers(Object? raw) { + if (raw is! List) { + throw const FormatException('Invalid cached friend list payload'); + } + return raw + .whereType() + .map((item) => FriendUser.fromJson(Map.from(item))) + .toList(growable: false); + } + + static Map _encodeFriendUser(FriendUser user) { + return { + 'id': user.id, + 'username': user.username, + 'avatar_url': user.avatarUrl, + }; } } diff --git a/apps/lib/data/repositories/user_repository.dart b/apps/lib/features/contacts/data/repositories/user_repository.dart similarity index 90% rename from apps/lib/data/repositories/user_repository.dart rename to apps/lib/features/contacts/data/repositories/user_repository.dart index 86512fc..77fcdfa 100644 --- a/apps/lib/data/repositories/user_repository.dart +++ b/apps/lib/features/contacts/data/repositories/user_repository.dart @@ -1,5 +1,5 @@ -import '../../core/network/i_api_client.dart'; -import 'models/user_summary.dart'; +import '../../../../data/network/i_api_client.dart'; +import '../models/user_summary.dart'; abstract class UserRepository { Future getById(String userId); diff --git a/apps/lib/features/contacts/data/users/users_repository.dart b/apps/lib/features/contacts/data/repositories/users_repository.dart similarity index 77% rename from apps/lib/features/contacts/data/users/users_repository.dart rename to apps/lib/features/contacts/data/repositories/users_repository.dart index bc79107..7d38347 100644 --- a/apps/lib/features/contacts/data/users/users_repository.dart +++ b/apps/lib/features/contacts/data/repositories/users_repository.dart @@ -1,4 +1,4 @@ -import '../../../../data/models/user_profile.dart'; +import '../models/user_profile.dart'; abstract class UsersRepository { Future getMe(); diff --git a/apps/lib/features/contacts/data/users/users_repository_impl.dart b/apps/lib/features/contacts/data/repositories/users_repository_impl.dart similarity index 85% rename from apps/lib/features/contacts/data/users/users_repository_impl.dart rename to apps/lib/features/contacts/data/repositories/users_repository_impl.dart index dbd15d9..5548987 100644 --- a/apps/lib/features/contacts/data/users/users_repository_impl.dart +++ b/apps/lib/features/contacts/data/repositories/users_repository_impl.dart @@ -1,6 +1,6 @@ -import 'users_api.dart'; +import '../apis/users_api.dart'; import 'users_repository.dart'; -import '../../../../data/models/user_profile.dart'; +import '../models/user_profile.dart'; class UsersRepositoryImpl implements UsersRepository { final UsersApi _api; diff --git a/apps/lib/features/contacts/presentation/screens/contacts_screen.dart b/apps/lib/features/contacts/presentation/screens/contacts_screen.dart index f873bae..1e091ce 100644 --- a/apps/lib/features/contacts/presentation/screens/contacts_screen.dart +++ b/apps/lib/features/contacts/presentation/screens/contacts_screen.dart @@ -2,14 +2,14 @@ import 'package:flutter/material.dart'; import 'package:go_router/go_router.dart'; import '../../../../app/di/injection.dart'; import '../../../../core/l10n/l10n.dart'; -import '../../../../data/models/user_profile.dart'; +import '../../data/models/user_profile.dart'; import '../../../../core/theme/design_tokens.dart'; 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 '../../../contacts/data/friends_api.dart'; -import '../../../contacts/data/users/users_api.dart'; +import '../../../contacts/data/apis/friends_api.dart'; +import '../../../contacts/data/apis/users_api.dart'; class ContactsScreen extends StatefulWidget { const ContactsScreen({super.key}); diff --git a/apps/lib/features/home/presentation/screens/home_screen.dart b/apps/lib/features/home/presentation/screens/home_screen.dart index 69d7ffb..8827b99 100644 --- a/apps/lib/features/home/presentation/screens/home_screen.dart +++ b/apps/lib/features/home/presentation/screens/home_screen.dart @@ -6,13 +6,13 @@ import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:go_router/go_router.dart'; import 'package:image_picker/image_picker.dart'; import 'package:social_app/core/chat/agent_stage.dart'; -import '../../../../core/network/api_exception.dart'; +import '../../../../data/network/api_exception.dart'; import '../../../../app/di/injection.dart'; 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 '../../../../data/repositories/inbox_repository.dart'; +import '../../../../features/messages/data/repositories/inbox_repository.dart'; import '../../../chat/presentation/bloc/chat_bloc.dart'; import '../../data/voice_recorder.dart'; import '../controllers/home_keyboard_inset_calculator.dart'; diff --git a/apps/lib/features/home/presentation/widgets/home_chat_item_renderer.dart b/apps/lib/features/home/presentation/widgets/home_chat_item_renderer.dart index 5eb3006..7468d69 100644 --- a/apps/lib/features/home/presentation/widgets/home_chat_item_renderer.dart +++ b/apps/lib/features/home/presentation/widgets/home_chat_item_renderer.dart @@ -8,7 +8,7 @@ import '../../../../core/l10n/l10n.dart'; import '../../../../core/theme/design_tokens.dart'; import '../../../../core/utils/tool_name_localizer.dart'; import '../../../../shared/widgets/app_loading_indicator.dart'; -import '../../../ui_schema/presentation/widgets/ui_schema_renderer.dart'; +import '../../../../shared/widgets/ui_schema/ui_schema_renderer.dart'; const _messagePaddingH = 13.0; const _messagePaddingV = 9.0; diff --git a/apps/lib/features/messages/data/inbox_api.dart b/apps/lib/features/messages/data/apis/inbox_api.dart similarity index 98% rename from apps/lib/features/messages/data/inbox_api.dart rename to apps/lib/features/messages/data/apis/inbox_api.dart index 449d0f8..ce5c5c6 100644 --- a/apps/lib/features/messages/data/inbox_api.dart +++ b/apps/lib/features/messages/data/apis/inbox_api.dart @@ -1,4 +1,4 @@ -import 'package:social_app/core/network/i_api_client.dart'; +import 'package:social_app/data/network/i_api_client.dart'; class InboxApi { final IApiClient _client; diff --git a/apps/lib/data/repositories/models/inbox_message.dart b/apps/lib/features/messages/data/models/inbox_message.dart similarity index 100% rename from apps/lib/data/repositories/models/inbox_message.dart rename to apps/lib/features/messages/data/models/inbox_message.dart diff --git a/apps/lib/features/messages/data/repositories/inbox_repository.dart b/apps/lib/features/messages/data/repositories/inbox_repository.dart new file mode 100644 index 0000000..5975ae7 --- /dev/null +++ b/apps/lib/features/messages/data/repositories/inbox_repository.dart @@ -0,0 +1,130 @@ +import '../../../../data/network/i_api_client.dart'; +import '../../../../data/cache/cache_policy.dart'; +import '../../../../data/cache/cached_repository.dart'; +import '../models/inbox_message.dart'; + +abstract class InboxRepository { + Future> getMessages({bool? isRead}); + Future markAsRead(String messageId); +} + +class InboxRepositoryImpl extends CachedRepository> + implements InboxRepository { + final IApiClient _apiClient; + static const _prefix = '/api/v1/inbox/messages'; + + InboxRepositoryImpl({required IApiClient apiClient, required super.store}) + : _apiClient = apiClient, + super( + policy: const CachePolicy( + softTtl: Duration(seconds: 15), + hardTtl: Duration(minutes: 2), + minRefreshInterval: Duration(seconds: 10), + ), + encodeValue: _encodeMessages, + decodeValue: _decodeMessages, + ); + + @override + Future> getMessages({bool? isRead}) async { + return getOrLoad( + key: _messagesKey(isRead), + loadFromRemote: () => _loadMessagesFromRemote(isRead: isRead), + ); + } + + Future> _loadMessagesFromRemote({bool? isRead}) async { + final queryParams = isRead != null ? '?is_read=$isRead' : ''; + final response = await _apiClient.get>( + '$_prefix$queryParams', + ); + final data = response.data; + if (data == null) { + throw StateError('Invalid getMessages response: empty payload'); + } + return data + .whereType>() + .map(InboxMessage.fromJson) + .toList(growable: false); + } + + @override + Future markAsRead(String messageId) async { + final response = await _apiClient.patch>( + '$_prefix/$messageId/read', + ); + final data = response.data; + if (data == null) { + throw StateError('Invalid markAsRead response: empty payload'); + } + final message = InboxMessage.fromJson(data); + await Future.wait([ + removeCacheKey(_messagesKey(false)), + removeCacheKey(_messagesKey(true)), + removeCacheKey(_messagesKey(null)), + ]); + return message; + } + + static String _messagesKey(bool? isRead) { + if (isRead == null) { + return 'inbox:list:all'; + } + return isRead ? 'inbox:list:read' : 'inbox:list:unread'; + } + + static Object? _encodeMessages(List messages) { + return messages.map(_encodeMessage).toList(growable: false); + } + + static List _decodeMessages(Object? raw) { + if (raw is! List) { + throw const FormatException('Invalid cached inbox message payload'); + } + return raw + .whereType() + .map((item) => InboxMessage.fromJson(Map.from(item))) + .toList(growable: false); + } + + static Map _encodeMessage(InboxMessage message) { + return { + 'id': message.id, + 'recipient_id': message.recipientId, + 'sender_id': message.senderId, + 'message_type': _messageTypeToApi(message.messageType), + 'schedule_item_id': message.scheduleItemId, + 'friendship_id': message.friendshipId, + 'content': message.content, + 'is_read': message.isRead, + 'status': _messageStatusToApi(message.status), + 'created_at': message.createdAt.toIso8601String(), + }; + } + + static String _messageTypeToApi(InboxMessageType type) { + switch (type) { + case InboxMessageType.friendRequest: + return 'friend_request'; + case InboxMessageType.calendar: + return 'calendar'; + case InboxMessageType.system: + return 'system'; + case InboxMessageType.group: + return 'group'; + } + } + + static String _messageStatusToApi(InboxMessageStatus status) { + switch (status) { + case InboxMessageStatus.pending: + return 'pending'; + case InboxMessageStatus.accepted: + return 'accepted'; + case InboxMessageStatus.rejected: + return 'rejected'; + case InboxMessageStatus.dismissed: + return 'dismissed'; + } + } +} 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 index 7b020c3..02ecb25 100644 --- a/apps/lib/features/messages/presentation/screens/message_invite_detail_screen.dart +++ b/apps/lib/features/messages/presentation/screens/message_invite_detail_screen.dart @@ -3,10 +3,10 @@ import 'package:go_router/go_router.dart'; import 'package:intl/intl.dart'; import '../../../../app/di/injection.dart'; -import '../../../../data/repositories/calendar_event_repository.dart'; -import '../../../../data/repositories/models/inbox_message.dart'; -import '../../../../data/repositories/inbox_repository.dart'; -import '../../../../data/repositories/user_repository.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'; @@ -25,7 +25,7 @@ class MessageInviteDetailScreen extends StatefulWidget { class _MessageInviteDetailScreenState extends State { late final InboxRepository _inboxRepository; - late final CalendarEventRepository _calendarRepository; + late final CalendarRepository _calendarRepository; late final UserRepository _userRepository; InboxMessage? _message; @@ -43,7 +43,7 @@ class _MessageInviteDetailScreenState extends State { void initState() { super.initState(); _inboxRepository = sl(); - _calendarRepository = sl(); + _calendarRepository = sl(); _userRepository = sl(); _loadDetail(); } 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 01e2458..7896bb2 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 @@ -3,10 +3,10 @@ import 'package:go_router/go_router.dart'; import '../../../../app/di/injection.dart'; import '../../../../app/router/app_routes.dart'; -import '../../../../data/repositories/friend_repository.dart'; -import '../../../../data/repositories/inbox_repository.dart'; -import '../../../../data/repositories/models/friend_request.dart'; -import '../../../../data/repositories/models/inbox_message.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'; +import '../../../../features/messages/data/models/inbox_message.dart'; import '../../../../core/l10n/l10n.dart'; import '../../../../shared/widgets/app_loading_indicator.dart'; import '../../../../shared/widgets/app_pull_refresh_feedback.dart'; diff --git a/apps/lib/features/messages/presentation/widgets/calendar_message_card.dart b/apps/lib/features/messages/presentation/widgets/calendar_message_card.dart index 4b5eb80..4c5f56c 100644 --- a/apps/lib/features/messages/presentation/widgets/calendar_message_card.dart +++ b/apps/lib/features/messages/presentation/widgets/calendar_message_card.dart @@ -3,7 +3,7 @@ import 'package:social_app/core/l10n/l10n.dart'; import '../../../../core/theme/design_tokens.dart'; import '../../../../shared/widgets/app_button.dart'; -import '../../data/inbox_api.dart'; +import '../../data/apis/inbox_api.dart'; class CalendarInviteCard extends StatelessWidget { final InboxMessageResponse message; diff --git a/apps/lib/features/notification/domain/models/reminder_action.dart b/apps/lib/features/notification/domain/models/reminder_action.dart deleted file mode 100644 index 6e9da43..0000000 --- a/apps/lib/features/notification/domain/models/reminder_action.dart +++ /dev/null @@ -1,23 +0,0 @@ -enum ReminderAction { - archive('archive'), - snooze10m('snooze10m'); - - const ReminderAction(this.value); - - final String value; - - static ReminderAction fromValue(String raw) { - switch (raw) { - case 'archive': - case 'cancel': - case 'auto_archive': - return ReminderAction.archive; - case 'snooze10m': - case 'snooze_10m': - case 'timeout_30s': - return ReminderAction.snooze10m; - default: - throw ArgumentError.value(raw, 'raw', 'Unsupported reminder action'); - } - } -} diff --git a/apps/lib/features/notification/domain/services/reminder_action_executor.dart b/apps/lib/features/notification/domain/services/reminder_action_executor.dart deleted file mode 100644 index 95f4ac8..0000000 --- a/apps/lib/features/notification/domain/services/reminder_action_executor.dart +++ /dev/null @@ -1,75 +0,0 @@ -import '../../../../data/services/calendar_service.dart'; -import '../../../../data/models/reminder_payload.dart'; -import '../../../../data/repositories/models/schedule_item_model.dart'; -import '../../../../data/services/local_notification_service.dart'; -import '../models/reminder_action.dart'; - -class ReminderActionExecutor { - final CalendarService _calendarService; - final LocalNotificationService _notificationService; - - ReminderActionExecutor({ - required CalendarService calendarService, - required LocalNotificationService notificationService, - }) : _calendarService = calendarService, - _notificationService = notificationService; - - Future handleAction({ - required ReminderAction action, - required ReminderPayload payload, - }) async { - final ids = payload.mode == ReminderPayloadMode.aggregate - ? (payload.aggregateIds.isNotEmpty - ? payload.aggregateIds - : [payload.eventId]) - : [payload.eventId]; - - if (action == ReminderAction.archive) { - for (final id in ids) { - await _notificationService.cancelEventReminder(id); - await _archiveEvent(id); - } - return; - } - - if (action == ReminderAction.snooze10m) { - for (final id in ids) { - await _snoozeEvent(id); - } - } - } - - Future _snoozeEvent(String eventId) async { - late final ScheduleItemModel event; - try { - event = await _calendarService.getEventById(eventId); - } catch (_) { - await _notificationService.cancelEventReminder(eventId); - return; - } - final now = DateTime.now(); - final endAt = event.endAt; - if (endAt != null && !now.isBefore(endAt)) { - await _notificationService.cancelEventReminder(eventId); - await _archiveEvent(eventId); - return; - } - - final nextAt = now.add(const Duration(minutes: 10)); - if (endAt != null && !nextAt.isBefore(endAt)) { - await _notificationService.cancelEventReminder(eventId); - await _archiveEvent(eventId); - return; - } - - await _notificationService.scheduleReminderAt(event, nextAt); - } - - Future _archiveEvent(String eventId) async { - try { - await _calendarService.archiveEvent(eventId); - } catch (_) { - await _notificationService.cancelEventReminder(eventId); - } - } -} diff --git a/apps/lib/features/settings/data/services/automation_jobs_api.dart b/apps/lib/features/settings/data/apis/automation_jobs_api.dart similarity index 94% rename from apps/lib/features/settings/data/services/automation_jobs_api.dart rename to apps/lib/features/settings/data/apis/automation_jobs_api.dart index 62de2b3..4f032cd 100644 --- a/apps/lib/features/settings/data/services/automation_jobs_api.dart +++ b/apps/lib/features/settings/data/apis/automation_jobs_api.dart @@ -1,4 +1,4 @@ -import 'package:social_app/core/network/i_api_client.dart'; +import 'package:social_app/data/network/i_api_client.dart'; import '../models/automation_job_model.dart'; class AutomationJobsApi { diff --git a/apps/lib/features/settings/data/settings_api.dart b/apps/lib/features/settings/data/apis/settings_api.dart similarity index 97% rename from apps/lib/features/settings/data/settings_api.dart rename to apps/lib/features/settings/data/apis/settings_api.dart index 376af4d..2ad4c13 100644 --- a/apps/lib/features/settings/data/settings_api.dart +++ b/apps/lib/features/settings/data/apis/settings_api.dart @@ -1,4 +1,4 @@ -import 'package:social_app/core/network/i_api_client.dart'; +import 'package:social_app/data/network/i_api_client.dart'; class AppVersionResponse { final bool hasUpdate; diff --git a/apps/lib/features/settings/data/services/user_profile_cache_repository.dart b/apps/lib/features/settings/data/repositories/user_profile_cache_repository.dart similarity index 74% rename from apps/lib/features/settings/data/services/user_profile_cache_repository.dart rename to apps/lib/features/settings/data/repositories/user_profile_cache_repository.dart index 52d650f..df4be3e 100644 --- a/apps/lib/features/settings/data/services/user_profile_cache_repository.dart +++ b/apps/lib/features/settings/data/repositories/user_profile_cache_repository.dart @@ -1,6 +1,6 @@ import '../../../../data/cache/cache_policy.dart'; import '../../../../data/cache/cached_repository.dart'; -import '../../../../data/models/user_profile.dart'; +import '../../../contacts/data/models/user_profile.dart'; class UserProfileCacheRepository extends CachedRepository { static const String cacheKey = 'settings:user_profile'; @@ -22,6 +22,8 @@ class UserProfileCacheRepository extends CachedRepository { hardTtl: Duration(minutes: 30), minRefreshInterval: Duration(minutes: 1), ), + encodeValue: _encodeUserProfile, + decodeValue: _decodeUserProfile, ); UserProfile? get cachedUser => _cachedUser; @@ -64,4 +66,21 @@ class UserProfileCacheRepository extends CachedRepository { } return remote; } + + static Object? _encodeUserProfile(UserProfile user) { + return { + 'id': user.id, + 'username': user.username, + 'phone': user.phone, + 'avatar_url': user.avatarUrl, + 'bio': user.bio, + }; + } + + static UserProfile _decodeUserProfile(Object? raw) { + if (raw is! Map) { + throw const FormatException('Invalid cached user profile payload'); + } + return UserProfile.fromJson(Map.from(raw)); + } } diff --git a/apps/lib/features/settings/data/services/memory_service.dart b/apps/lib/features/settings/data/services/memory_service.dart index 5f02e8c..0e72279 100644 --- a/apps/lib/features/settings/data/services/memory_service.dart +++ b/apps/lib/features/settings/data/services/memory_service.dart @@ -1,4 +1,4 @@ -import 'package:social_app/core/network/i_api_client.dart'; +import 'package:social_app/data/network/i_api_client.dart'; import '../models/memory_models.dart'; class MemoryService { diff --git a/apps/lib/features/settings/data/services/user_profile_service.dart b/apps/lib/features/settings/data/services/user_profile_service.dart index c9fc920..f257d5a 100644 --- a/apps/lib/features/settings/data/services/user_profile_service.dart +++ b/apps/lib/features/settings/data/services/user_profile_service.dart @@ -2,8 +2,8 @@ import 'dart:io'; import 'package:dio/dio.dart'; -import '../../../../core/network/i_api_client.dart'; -import '../../../../data/models/user_profile.dart'; +import '../../../../data/network/i_api_client.dart'; +import '../../../contacts/data/models/user_profile.dart'; class UserProfileService { static const _prefix = '/api/v1/users'; diff --git a/apps/lib/features/settings/presentation/cubits/automation_jobs_cubit.dart b/apps/lib/features/settings/presentation/cubits/automation_jobs_cubit.dart index ed45fb3..6e4334a 100644 --- a/apps/lib/features/settings/presentation/cubits/automation_jobs_cubit.dart +++ b/apps/lib/features/settings/presentation/cubits/automation_jobs_cubit.dart @@ -1,7 +1,7 @@ import 'package:equatable/equatable.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import '../../data/models/automation_job_model.dart'; -import '../../data/services/automation_jobs_api.dart'; +import '../../data/apis/automation_jobs_api.dart'; class AutomationJobsState extends Equatable { final List jobs; diff --git a/apps/lib/features/settings/presentation/cubits/job_detail_cubit.dart b/apps/lib/features/settings/presentation/cubits/job_detail_cubit.dart index 3b90c88..1735e07 100644 --- a/apps/lib/features/settings/presentation/cubits/job_detail_cubit.dart +++ b/apps/lib/features/settings/presentation/cubits/job_detail_cubit.dart @@ -1,7 +1,7 @@ import 'package:equatable/equatable.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import '../../data/models/automation_job_model.dart'; -import '../../data/services/automation_jobs_api.dart'; +import '../../data/apis/automation_jobs_api.dart'; class JobDetailState extends Equatable { final AutomationJobModel? job; 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 92b5d8c..a066981 100644 --- a/apps/lib/features/settings/presentation/screens/edit_profile_screen.dart +++ b/apps/lib/features/settings/presentation/screens/edit_profile_screen.dart @@ -10,8 +10,8 @@ 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/user_profile.dart'; -import '../../data/services/user_profile_cache_repository.dart'; +import '../../../contacts/data/models/user_profile.dart'; +import '../../data/repositories/user_profile_cache_repository.dart'; import '../../data/services/user_profile_service.dart'; import '../widgets/account_section_card.dart'; import '../widgets/settings_page_scaffold.dart'; diff --git a/apps/lib/features/settings/presentation/screens/features_screen.dart b/apps/lib/features/settings/presentation/screens/features_screen.dart index 38be834..c34db2a 100644 --- a/apps/lib/features/settings/presentation/screens/features_screen.dart +++ b/apps/lib/features/settings/presentation/screens/features_screen.dart @@ -12,7 +12,7 @@ import '../../../../shared/widgets/app_toggle_switch.dart'; import '../../../../shared/widgets/toast/toast.dart'; import '../../../../shared/widgets/toast/toast_type.dart'; import '../../data/models/automation_job_model.dart'; -import '../../data/services/automation_jobs_api.dart'; +import '../../data/apis/automation_jobs_api.dart'; import '../../presentation/cubits/automation_jobs_cubit.dart'; import '../widgets/settings_page_scaffold.dart'; diff --git a/apps/lib/features/settings/presentation/screens/job_detail_screen.dart b/apps/lib/features/settings/presentation/screens/job_detail_screen.dart index a9adc1e..94b6f3e 100644 --- a/apps/lib/features/settings/presentation/screens/job_detail_screen.dart +++ b/apps/lib/features/settings/presentation/screens/job_detail_screen.dart @@ -18,7 +18,7 @@ import '../../../../shared/widgets/toast/toast.dart'; import '../../../../shared/widgets/toast/toast_type.dart'; import '../../../../core/utils/tool_name_localizer.dart'; import '../../data/models/automation_job_model.dart'; -import '../../data/services/automation_jobs_api.dart'; +import '../../data/apis/automation_jobs_api.dart'; import '../../presentation/cubits/job_detail_cubit.dart'; import '../widgets/settings_page_scaffold.dart'; diff --git a/apps/lib/features/settings/presentation/screens/settings_screen.dart b/apps/lib/features/settings/presentation/screens/settings_screen.dart index bdb88d2..3071954 100644 --- a/apps/lib/features/settings/presentation/screens/settings_screen.dart +++ b/apps/lib/features/settings/presentation/screens/settings_screen.dart @@ -6,8 +6,8 @@ import 'package:social_app/app/router/app_routes.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/models/user_profile.dart'; -import 'package:social_app/data/repositories/friend_repository.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'; import 'package:social_app/shared/widgets/app_loading_indicator.dart'; import 'package:social_app/shared/widgets/app_pressable.dart'; @@ -15,9 +15,9 @@ 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/core/utils/phone_display_formatter.dart'; -import 'package:social_app/features/settings/data/settings_api.dart'; -import 'package:social_app/features/settings/data/services/automation_jobs_api.dart'; -import 'package:social_app/features/settings/data/services/user_profile_cache_repository.dart'; +import 'package:social_app/features/settings/data/apis/settings_api.dart'; +import 'package:social_app/features/settings/data/apis/automation_jobs_api.dart'; +import 'package:social_app/features/settings/data/repositories/user_profile_cache_repository.dart'; import 'package:social_app/app/router/home_return_policy.dart'; import '../widgets/settings_page_scaffold.dart'; diff --git a/apps/lib/features/todo/data/todo_api.dart b/apps/lib/features/todo/data/apis/todo_api.dart similarity index 98% rename from apps/lib/features/todo/data/todo_api.dart rename to apps/lib/features/todo/data/apis/todo_api.dart index e422a08..edc7dd6 100644 --- a/apps/lib/features/todo/data/todo_api.dart +++ b/apps/lib/features/todo/data/apis/todo_api.dart @@ -1,4 +1,4 @@ -import 'package:social_app/core/network/i_api_client.dart'; +import 'package:social_app/data/network/i_api_client.dart'; class TodoApi { final IApiClient _client; diff --git a/apps/lib/features/todo/data/repositories/todo_repository.dart b/apps/lib/features/todo/data/repositories/todo_repository.dart new file mode 100644 index 0000000..dc64181 --- /dev/null +++ b/apps/lib/features/todo/data/repositories/todo_repository.dart @@ -0,0 +1,104 @@ +import 'dart:async'; + +import '../../../../data/cache/cache_store.dart'; +import '../../../../data/cache/cache_policy.dart'; +import '../../../../data/cache/cached_repository.dart'; +import '../apis/todo_api.dart'; + +class TodoRepository extends CachedRepository> { + static const String pendingListKey = 'todo:list:pending'; + + final TodoApi api; + final CacheInvalidator invalidator; + + TodoRepository({ + required this.api, + required super.store, + required this.invalidator, + super.now, + }) : super( + policy: const CachePolicy( + softTtl: Duration(seconds: 30), + hardTtl: Duration(minutes: 10), + minRefreshInterval: Duration(seconds: 15), + ), + encodeValue: _encodeTodoList, + decodeValue: _decodeTodoList, + ); + + Future> getPendingTodos({ + bool forceRefresh = false, + }) async { + return getOrLoad( + key: pendingListKey, + forceRefresh: forceRefresh, + loadFromRemote: api.getPendingTodos, + ); + } + + Future completeTodo(String id) async { + final CacheEntry>? cached = await readCacheEntry( + pendingListKey, + ); + if (cached != null) { + final next = cached.value + .where((todo) => todo.id != id) + .toList(growable: false); + await writeCacheEntry(pendingListKey, next); + } + + try { + await api.completeTodo(id); + invalidator.invalidate(pendingListKey); + } catch (error) { + if (cached != null) { + await writeCacheEntry(pendingListKey, cached.value); + } + rethrow; + } + } + + Future invalidatePending() { + invalidator.invalidate(pendingListKey); + return Future.value(); + } + + static Object? _encodeTodoList(List todos) { + return todos.map(_encodeTodo).toList(growable: false); + } + + static List _decodeTodoList(Object? raw) { + if (raw is! List) { + throw const FormatException('Invalid cached todo list payload'); + } + return raw + .whereType() + .map((item) => TodoResponse.fromJson(Map.from(item))) + .toList(growable: false); + } + + static Map _encodeTodo(TodoResponse todo) { + return { + 'id': todo.id, + 'owner_id': todo.ownerId, + 'title': todo.title, + 'description': todo.description, + 'order': todo.order, + 'priority': todo.priority, + 'status': todo.status, + 'completed_at': todo.completedAt?.toIso8601String(), + 'created_at': todo.createdAt.toIso8601String(), + 'updated_at': todo.updatedAt.toIso8601String(), + 'schedule_items': todo.scheduleItems.map(_encodeScheduleItem).toList(), + }; + } + + static Map _encodeScheduleItem(ScheduleItemBasic item) { + return { + 'id': item.id, + 'title': item.title, + 'start_at': item.startAt.toIso8601String(), + 'end_at': item.endAt?.toIso8601String(), + }; + } +} diff --git a/apps/lib/features/todo/data/todo_repository.dart b/apps/lib/features/todo/data/todo_repository.dart deleted file mode 100644 index ce95e1c..0000000 --- a/apps/lib/features/todo/data/todo_repository.dart +++ /dev/null @@ -1,67 +0,0 @@ -import 'dart:async'; - -import '../../../data/cache/cache_entry.dart'; -import '../../../data/cache/cache_invalidator.dart'; -import '../../../data/cache/cache_policy.dart'; -import '../../../data/cache/cached_repository.dart'; -import 'todo_api.dart'; - -class TodoRepository extends CachedRepository> { - static const String pendingListKey = 'todo:list:pending'; - - final TodoApi api; - final CacheInvalidator invalidator; - - TodoRepository({ - required this.api, - required super.store, - required this.invalidator, - super.now, - }) : super( - policy: const CachePolicy( - softTtl: Duration(days: 3650), - hardTtl: Duration(days: 3650), - minRefreshInterval: Duration(days: 3650), - ), - ); - - Future> getPendingTodos({ - bool forceRefresh = false, - }) async { - return getOrLoad( - key: pendingListKey, - forceRefresh: forceRefresh, - loadFromRemote: api.getPendingTodos, - ); - } - - Future completeTodo(String id) async { - final CacheEntry>? cached = await readCacheEntry( - pendingListKey, - ); - if (cached != null) { - final next = cached.value - .where((todo) => todo.id != id) - .toList(growable: false); - await writeCacheEntry(pendingListKey, next); - } - - try { - await api.completeTodo(id); - invalidator.invalidate(pendingListKey); - } catch (error) { - if (cached != null) { - await store.write>>( - pendingListKey, - cached, - ); - } - rethrow; - } - } - - Future invalidatePending() { - invalidator.invalidate(pendingListKey); - return Future.value(); - } -} diff --git a/apps/lib/features/todo/presentation/screens/todo_detail_screen.dart b/apps/lib/features/todo/presentation/screens/todo_detail_screen.dart index d6cd3fe..fd45f34 100644 --- a/apps/lib/features/todo/presentation/screens/todo_detail_screen.dart +++ b/apps/lib/features/todo/presentation/screens/todo_detail_screen.dart @@ -12,7 +12,7 @@ import '../../../../shared/widgets/error_retry_surface.dart'; import '../../../../shared/widgets/full_screen_loading.dart'; import '../../../../shared/widgets/toast/toast.dart'; import '../../../../shared/widgets/toast/toast_type.dart'; -import '../../data/todo_api.dart'; +import '../../data/apis/todo_api.dart'; enum _TodoHeaderAction { edit, delete } diff --git a/apps/lib/features/todo/presentation/screens/todo_edit_screen.dart b/apps/lib/features/todo/presentation/screens/todo_edit_screen.dart index 33a84d6..fcd61d4 100644 --- a/apps/lib/features/todo/presentation/screens/todo_edit_screen.dart +++ b/apps/lib/features/todo/presentation/screens/todo_edit_screen.dart @@ -3,8 +3,8 @@ import 'package:go_router/go_router.dart'; import 'package:intl/intl.dart'; import '../../../../app/di/injection.dart'; -import '../../../../data/repositories/calendar_event_repository.dart'; -import '../../../../data/repositories/models/calendar_event.dart'; +import '../../../../features/calendar/data/repositories/calendar_repository.dart'; +import '../../../../features/calendar/data/models/schedule_item_model.dart'; import '../../../../core/l10n/l10n.dart'; import '../../../../core/theme/design_tokens.dart'; import '../../../../shared/widgets/app_button.dart'; @@ -15,7 +15,7 @@ import '../../../../shared/widgets/error_retry_surface.dart'; import '../../../../shared/widgets/full_screen_loading.dart'; import '../../../../shared/widgets/toast/toast.dart'; import '../../../../shared/widgets/toast/toast_type.dart'; -import '../../data/todo_api.dart'; +import '../../data/apis/todo_api.dart'; class TodoEditScreen extends StatefulWidget { final String? todoId; @@ -32,8 +32,7 @@ class TodoEditScreen extends StatefulWidget { class _TodoEditScreenState extends State { final TodoApi _todoApi = sl(); - final CalendarEventRepository _calendarRepository = - sl(); + final CalendarRepository _calendarRepository = sl(); final TextEditingController _titleController = TextEditingController(); final TextEditingController _descriptionController = TextEditingController(); @@ -94,7 +93,7 @@ class _TodoEditScreenState extends State { ..clear() ..addAll(todo?.scheduleItems.map((item) => item.id) ?? const []); _scheduleItems = scheduleItems - .where((item) => item.status == CalendarEventStatus.active) + .where((item) => item.status == ScheduleStatus.active) .map( (item) => _ScheduleItemSimple( id: item.id, 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 0ccc9fb..dfa4070 100644 --- a/apps/lib/features/todo/presentation/screens/todo_quadrants_screen.dart +++ b/apps/lib/features/todo/presentation/screens/todo_quadrants_screen.dart @@ -16,8 +16,8 @@ import '../../../../shared/widgets/bottom_dock.dart'; import '../../../../shared/state/calendar_state_manager.dart'; import '../../../../shared/widgets/toast/toast.dart'; import '../../../../shared/widgets/toast/toast_type.dart'; -import '../../data/todo_api.dart'; -import '../../data/todo_repository.dart'; +import '../../data/apis/todo_api.dart'; +import '../../data/repositories/todo_repository.dart'; class TodoQuadrantsScreen extends StatefulWidget { const TodoQuadrantsScreen({super.key}); diff --git a/apps/lib/features/todo/presentation/widgets/todo_drag_item.dart b/apps/lib/features/todo/presentation/widgets/todo_drag_item.dart index 75b435f..38c3cd0 100644 --- a/apps/lib/features/todo/presentation/widgets/todo_drag_item.dart +++ b/apps/lib/features/todo/presentation/widgets/todo_drag_item.dart @@ -1,6 +1,6 @@ import 'package:flutter/material.dart'; import 'package:social_app/core/theme/design_tokens.dart'; -import 'package:social_app/features/todo/data/todo_api.dart'; +import 'package:social_app/features/todo/data/apis/todo_api.dart'; class TodoDragItem extends StatelessWidget { final TodoResponse todo; diff --git a/apps/lib/features/notification/presentation/widgets/reminder_overlay.dart b/apps/lib/shared/widgets/notification/reminder_overlay.dart similarity index 95% rename from apps/lib/features/notification/presentation/widgets/reminder_overlay.dart rename to apps/lib/shared/widgets/notification/reminder_overlay.dart index 818ad69..d31363d 100644 --- a/apps/lib/features/notification/presentation/widgets/reminder_overlay.dart +++ b/apps/lib/shared/widgets/notification/reminder_overlay.dart @@ -1,10 +1,10 @@ import 'package:flutter/material.dart'; import 'package:intl/intl.dart'; -import '../../../../core/l10n/l10n.dart'; -import '../../../../core/theme/design_tokens.dart'; -import '../../../../data/models/reminder_payload.dart'; -import '../../../../shared/widgets/app_button.dart'; -import '../../domain/services/reminder_queue_manager.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({ diff --git a/apps/lib/features/ui_schema/presentation/widgets/ui_schema_renderer.dart b/apps/lib/shared/widgets/ui_schema/ui_schema_renderer.dart similarity index 100% rename from apps/lib/features/ui_schema/presentation/widgets/ui_schema_renderer.dart rename to apps/lib/shared/widgets/ui_schema/ui_schema_renderer.dart diff --git a/apps/test/app/router/app_router_redirect_test.dart b/apps/test/app/router/app_router_redirect_test.dart index b7e7932..8d3d175 100644 --- a/apps/test/app/router/app_router_redirect_test.dart +++ b/apps/test/app/router/app_router_redirect_test.dart @@ -4,7 +4,7 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:social_app/app/di/injection.dart'; import 'package:social_app/app/router/app_router.dart'; import 'package:social_app/app/router/app_routes.dart'; -import 'package:social_app/core/network/i_api_client.dart'; +import 'package:social_app/data/network/i_api_client.dart'; import 'package:social_app/features/chat/presentation/bloc/chat_bloc.dart'; import 'package:social_app/features/auth/presentation/bloc/auth_state.dart'; diff --git a/apps/test/app/services/app_prewarm_orchestrator_test.dart b/apps/test/app/services/app_prewarm_orchestrator_test.dart new file mode 100644 index 0000000..815d0c2 --- /dev/null +++ b/apps/test/app/services/app_prewarm_orchestrator_test.dart @@ -0,0 +1,103 @@ +import 'dart:async'; + +import 'package:dio/dio.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:social_app/app/services/app_prewarm_orchestrator.dart'; +import 'package:social_app/data/network/i_api_client.dart'; +import 'package:social_app/data/cache/cache_store.dart'; +import 'package:social_app/features/calendar/data/repositories/calendar_repository.dart'; +import 'package:social_app/features/messages/data/repositories/inbox_repository.dart'; +import 'package:social_app/features/chat/data/repositories/chat_history_repository.dart'; + +class _FakeApiClient implements IApiClient { + @override + Future> delete(String path, {data, Options? options}) { + throw UnimplementedError(); + } + + @override + Future> get(String path, {Options? options}) { + throw UnimplementedError(); + } + + @override + Future> getSseLines( + String path, { + Map? headers, + }) { + 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(); + } +} + +void main() { + late HybridCacheStore store; + late _FakeApiClient apiClient; + late CalendarRepository calendarRepository; + late InboxRepository inboxRepository; + late ChatHistoryRepository chatHistoryRepository; + + setUp(() { + apiClient = _FakeApiClient(); + store = HybridCacheStore( + memory: MemoryCacheStore(), + persistent: PersistentCacheStore(), + ); + calendarRepository = CalendarRepository(apiClient: apiClient, store: store); + inboxRepository = InboxRepositoryImpl(apiClient: apiClient, store: store); + chatHistoryRepository = ChatHistoryRepository( + apiClient: apiClient, + store: store, + ); + }); + + test('completes prewarm within budget', () async { + final orchestrator = AppPrewarmOrchestrator( + calendarRepository: calendarRepository, + inboxRepository: inboxRepository, + chatHistoryRepository: chatHistoryRepository, + bootBudget: const Duration(milliseconds: 100), + prewarmChatHistory: () async {}, + prewarmCalendarToday: () async {}, + prewarmUnreadInbox: () async {}, + ); + + await orchestrator.ensureStartedFor('u1'); + + expect(orchestrator.status, AppPrewarmStatus.completed); + expect(orchestrator.isBootBlocking, isFalse); + }); + + test('times out when budget exceeded', () async { + final completer = Completer(); + final orchestrator = AppPrewarmOrchestrator( + calendarRepository: calendarRepository, + inboxRepository: inboxRepository, + chatHistoryRepository: chatHistoryRepository, + bootBudget: const Duration(milliseconds: 30), + prewarmChatHistory: () => completer.future, + prewarmCalendarToday: () => completer.future, + prewarmUnreadInbox: () => completer.future, + ); + + await orchestrator.ensureStartedFor('u1'); + + expect(orchestrator.status, AppPrewarmStatus.timedOut); + expect(orchestrator.isBootBlocking, isFalse); + completer.complete(); + }); +} diff --git a/apps/test/data/cache/cached_repository_test.dart b/apps/test/data/cache/cached_repository_test.dart index a387c43..119fae5 100644 --- a/apps/test/data/cache/cached_repository_test.dart +++ b/apps/test/data/cache/cached_repository_test.dart @@ -1,9 +1,7 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:social_app/data/cache/cache_policy.dart'; import 'package:social_app/data/cache/cached_repository.dart'; -import 'package:social_app/data/cache/hybrid_cache_store.dart'; -import 'package:social_app/data/cache/memory_cache_store.dart'; -import 'package:social_app/data/cache/persistent_cache_store.dart'; +import 'package:social_app/data/cache/cache_store.dart'; class _IntCachedRepository extends CachedRepository { int loadCount = 0; diff --git a/apps/test/data/cache/hybrid_cache_store_test.dart b/apps/test/data/cache/hybrid_cache_store_test.dart new file mode 100644 index 0000000..0e0a119 --- /dev/null +++ b/apps/test/data/cache/hybrid_cache_store_test.dart @@ -0,0 +1,28 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:shared_preferences/shared_preferences.dart'; +import 'package:social_app/data/cache/cache_store.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + group('HybridCacheStore', () { + test('falls back to persistent and backfills memory', () async { + SharedPreferences.setMockInitialValues({}); + final prefs = await SharedPreferences.getInstance(); + final persistent = PersistentCacheStore(prefs: prefs); + await persistent.write('k', 'v'); + + final hybrid = HybridCacheStore( + memory: MemoryCacheStore(), + persistent: persistent, + ); + + final firstRead = await hybrid.read('k'); + expect(firstRead, 'v'); + + await persistent.remove('k'); + final secondRead = await hybrid.read('k'); + expect(secondRead, 'v'); + }); + }); +} diff --git a/apps/test/data/cache/shared_prefs_cache_store_test.dart b/apps/test/data/cache/shared_prefs_cache_store_test.dart new file mode 100644 index 0000000..fb4b699 --- /dev/null +++ b/apps/test/data/cache/shared_prefs_cache_store_test.dart @@ -0,0 +1,39 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:shared_preferences/shared_preferences.dart'; +import 'package:social_app/data/cache/cache_store.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + group('SharedPrefsCacheStore', () { + test('persists entries across instances', () async { + SharedPreferences.setMockInitialValues({}); + final prefs = await SharedPreferences.getInstance(); + + final storeA = SharedPrefsCacheStore(prefs: prefs); + await storeA.write('k', 'v'); + + final storeB = SharedPrefsCacheStore(prefs: prefs); + final value = await storeB.read('k'); + expect(value, 'v'); + }); + + test('reads cache entry persisted payload', () async { + SharedPreferences.setMockInitialValues({}); + final prefs = await SharedPreferences.getInstance(); + final now = DateTime.utc(2026, 3, 29, 12, 0, 0); + + final storeA = SharedPrefsCacheStore(prefs: prefs); + await storeA.write>( + 'entry', + CacheEntry(value: 'payload', fetchedAt: now), + ); + + final storeB = SharedPrefsCacheStore(prefs: prefs); + final entry = await storeB.read>('entry'); + expect(entry, isNotNull); + expect(entry!.value, 'payload'); + expect(entry.fetchedAt, now); + }); + }); +} diff --git a/apps/test/data/repositories/shared_repositories_test.dart b/apps/test/data/repositories/shared_repositories_test.dart index ac24bb7..60a7449 100644 --- a/apps/test/data/repositories/shared_repositories_test.dart +++ b/apps/test/data/repositories/shared_repositories_test.dart @@ -1,13 +1,14 @@ import 'package:dio/dio.dart'; import 'package:flutter_test/flutter_test.dart'; -import 'package:social_app/core/network/i_api_client.dart'; -import 'package:social_app/data/repositories/calendar_event_repository.dart'; -import 'package:social_app/data/repositories/friend_repository.dart'; -import 'package:social_app/data/repositories/inbox_repository.dart'; -import 'package:social_app/data/repositories/models/calendar_event.dart'; -import 'package:social_app/data/repositories/models/friend_request.dart'; -import 'package:social_app/data/repositories/models/inbox_message.dart'; -import 'package:social_app/data/repositories/user_repository.dart'; +import 'package:social_app/data/network/i_api_client.dart'; +import 'package:social_app/data/cache/cache_store.dart'; +import 'package:social_app/features/calendar/data/repositories/calendar_repository.dart'; +import 'package:social_app/features/contacts/data/repositories/friend_repository.dart'; +import 'package:social_app/features/messages/data/repositories/inbox_repository.dart'; +import 'package:social_app/features/contacts/data/models/friend_request.dart'; +import 'package:social_app/features/messages/data/models/inbox_message.dart'; +import 'package:social_app/features/calendar/data/models/schedule_item_model.dart'; +import 'package:social_app/features/contacts/data/repositories/user_repository.dart'; class _FakeApiClient implements IApiClient { final Map _getResponses = {}; @@ -90,7 +91,13 @@ void main() { }, ]); - final repository = InboxRepositoryImpl(client); + final repository = InboxRepositoryImpl( + apiClient: client, + store: HybridCacheStore( + memory: MemoryCacheStore(), + persistent: PersistentCacheStore(), + ), + ); final result = await repository.getMessages(isRead: false); expect(result.single.messageType, InboxMessageType.calendar); @@ -109,7 +116,13 @@ void main() { 'created_at': '2026-03-27T08:00:00Z', }); - final repository = FriendRepositoryImpl(client); + final repository = FriendRepositoryImpl( + apiClient: client, + store: HybridCacheStore( + memory: MemoryCacheStore(), + persistent: PersistentCacheStore(), + ), + ); final request = await repository.getRequestById('f1'); expect(request.status, FriendRequestStatus.accepted); @@ -129,7 +142,13 @@ void main() { 'created_at': '2026-03-27T08:00:00Z', }); - final repository = FriendRepositoryImpl(client); + final repository = FriendRepositoryImpl( + apiClient: client, + store: HybridCacheStore( + memory: MemoryCacheStore(), + persistent: PersistentCacheStore(), + ), + ); await expectLater( repository.getRequestsByIds(['f1', 'missing']), throwsStateError, @@ -137,7 +156,7 @@ void main() { }, ); - test('CalendarEventRepository maps archived status', () async { + test('CalendarRepository maps archived status', () async { final client = _FakeApiClient(); client.setGet('/api/v1/schedule-items/e1', { 'id': 'e1', @@ -156,10 +175,16 @@ void main() { 'updated_at': '2026-03-27T09:00:00Z', }); - final repository = CalendarEventRepositoryImpl(client); + final repository = CalendarRepository( + store: HybridCacheStore( + memory: MemoryCacheStore(), + persistent: PersistentCacheStore(), + ), + apiClient: client, + ); final event = await repository.getById('e1'); - expect(event.status, CalendarEventStatus.archived); + expect(event.status, ScheduleStatus.archived); }); test('UserRepository returns shared user summary', () async { diff --git a/apps/test/features/chat/data/repositories/chat_history_repository_test.dart b/apps/test/features/chat/data/repositories/chat_history_repository_test.dart new file mode 100644 index 0000000..433468c --- /dev/null +++ b/apps/test/features/chat/data/repositories/chat_history_repository_test.dart @@ -0,0 +1,89 @@ +import 'package:dio/dio.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:social_app/data/network/i_api_client.dart'; +import 'package:social_app/data/cache/cache_store.dart'; +import 'package:social_app/features/chat/data/repositories/chat_history_repository.dart'; + +class _FakeApiClient implements IApiClient { + final Map _getResponses = {}; + final Map getCalls = {}; + + void setGet(String path, dynamic data) => _getResponses[path] = data; + + @override + Future> get(String path, {Options? options}) async { + getCalls[path] = (getCalls[path] ?? 0) + 1; + if (!_getResponses.containsKey(path)) { + throw StateError('missing GET mock for $path'); + } + return Response( + requestOptions: RequestOptions(path: path), + data: _getResponses[path] as T, + ); + } + + @override + Future> delete(String path, {data, Options? options}) { + throw UnimplementedError(); + } + + @override + Future> getSseLines( + String path, { + Map? headers, + }) { + 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(); + } +} + +void main() { + test('loads first-page history from cache on second read', () async { + final client = _FakeApiClient(); + client.setGet('/api/v1/agent/history', { + 'scope': 'history_day', + 'threadId': 't1', + 'day': '2026-03-29', + 'hasMore': true, + 'messages': [ + { + 'id': 'm1', + 'seq': 1, + 'role': 'assistant', + 'content': 'hello', + 'timestamp': '2026-03-29T08:00:00Z', + 'attachments': [], + }, + ], + }); + + final repository = ChatHistoryRepository( + apiClient: client, + store: HybridCacheStore( + memory: MemoryCacheStore(), + persistent: PersistentCacheStore(), + ), + ); + + final first = await repository.loadHistory(); + final second = await repository.loadHistory(); + + expect(first.threadId, 't1'); + expect(second.messages.length, 1); + expect(client.getCalls['/api/v1/agent/history'], 1); + }); +} diff --git a/apps/test/features/settings/data/services/user_profile_cache_repository_test.dart b/apps/test/features/settings/data/repositories/user_profile_cache_repository_test.dart similarity index 84% rename from apps/test/features/settings/data/services/user_profile_cache_repository_test.dart rename to apps/test/features/settings/data/repositories/user_profile_cache_repository_test.dart index d0ee9c9..cc143b9 100644 --- a/apps/test/features/settings/data/services/user_profile_cache_repository_test.dart +++ b/apps/test/features/settings/data/repositories/user_profile_cache_repository_test.dart @@ -1,11 +1,9 @@ import 'dart:async'; import 'package:flutter_test/flutter_test.dart'; -import 'package:social_app/data/cache/hybrid_cache_store.dart'; -import 'package:social_app/data/cache/memory_cache_store.dart'; -import 'package:social_app/data/models/user_profile.dart'; -import 'package:social_app/data/cache/persistent_cache_store.dart'; -import 'package:social_app/features/settings/data/services/user_profile_cache_repository.dart'; +import 'package:social_app/data/cache/cache_store.dart'; +import 'package:social_app/features/contacts/data/models/user_profile.dart'; +import 'package:social_app/features/settings/data/repositories/user_profile_cache_repository.dart'; void main() { group('UserProfileCacheRepository', () { diff --git a/docs/superpowers/plans/2026-03-29-frontend-cache-swr-boot-prewarm.md b/docs/superpowers/plans/2026-03-29-frontend-cache-swr-boot-prewarm.md new file mode 100644 index 0000000..898b330 --- /dev/null +++ b/docs/superpowers/plans/2026-03-29-frontend-cache-swr-boot-prewarm.md @@ -0,0 +1,73 @@ +# Frontend Cache + SWR + Boot Prewarm Implementation Plan (Updated) + +> This document replaces the previous version and removes outdated path references. + +## Current Architecture Baseline + +- Shared infrastructure is under `apps/lib/data/` only: + - cache: `apps/lib/data/cache/` + - network: `apps/lib/data/network/` + - storage: `apps/lib/data/storage/` +- Feature business repositories/models live under each feature: + - calendar: `apps/lib/features/calendar/data/repositories/`, `apps/lib/features/calendar/data/models/` + - messages: `apps/lib/features/messages/data/repositories/`, `apps/lib/features/messages/data/models/` + - contacts: `apps/lib/features/contacts/data/repositories/`, `apps/lib/features/contacts/data/models/` + - chat history cache repo: `apps/lib/features/chat/data/repositories/chat_history_repository.dart` + +## Scope + +1. Keep cache infra generic and reusable. +2. Keep TTL policy defined per feature repository (not centralized in shared cache). +3. Keep boot prewarm bounded by timeout with cache-first UX. + +## Tasks + +### Task A: Cache Infra Stability + +**Files:** +- `apps/lib/data/cache/cache_store.dart` +- `apps/lib/data/cache/cache_policy.dart` +- `apps/lib/data/cache/cached_repository.dart` + +**Checks:** +- `flutter test apps/test/data/cache/cached_repository_test.dart` +- `flutter test apps/test/data/cache/hybrid_cache_store_test.dart` +- `flutter test apps/test/data/cache/shared_prefs_cache_store_test.dart` + +### Task B: Feature Repositories Use Local TTL Policies + +**Files:** +- `apps/lib/features/todo/data/repositories/todo_repository.dart` +- `apps/lib/features/messages/data/repositories/inbox_repository.dart` +- `apps/lib/features/contacts/data/repositories/friend_repository.dart` +- `apps/lib/features/settings/data/repositories/user_profile_cache_repository.dart` +- `apps/lib/features/chat/data/repositories/chat_history_repository.dart` +- `apps/lib/features/calendar/data/repositories/calendar_repository.dart` + +**Checks:** +- `flutter test apps/test/data/repositories/shared_repositories_test.dart` +- `flutter test apps/test/features/chat/data/repositories/chat_history_repository_test.dart` +- `flutter test apps/test/features/settings/data/repositories/user_profile_cache_repository_test.dart` + +### Task C: Auth Boot Prewarm Gate + +**Files:** +- `apps/lib/app/services/app_prewarm_orchestrator.dart` +- `apps/lib/app/router/app_router.dart` +- `apps/lib/features/auth/presentation/screens/auth_boot_screen.dart` +- `apps/lib/app/di/injection.dart` + +**Checks:** +- `flutter test apps/test/app/services/app_prewarm_orchestrator_test.dart` +- `flutter test apps/test/app/router/app_router_redirect_test.dart` + +### Task D: Regression Safety + +**Checks:** +- `flutter analyze` +- Run all tests from Tasks A/B/C together before finalizing. + +## Notes + +- Any old path references from previous plan versions are obsolete. +- Notification/reminder data-interaction services are intentionally removed for separate rewrite.