diff --git a/apps/lib/core/di/injection.dart b/apps/lib/core/di/injection.dart index 597a752..0194e4f 100644 --- a/apps/lib/core/di/injection.dart +++ b/apps/lib/core/di/injection.dart @@ -17,6 +17,7 @@ import '../../features/auth/data/auth_repository_impl.dart'; import '../../features/auth/presentation/bloc/auth_bloc.dart'; import '../../features/auth/presentation/bloc/auth_event.dart'; import '../../features/calendar/data/calendar_api.dart'; +import '../../features/calendar/data/services/calendar_repository.dart'; import '../../features/calendar/data/services/calendar_service.dart'; import '../../features/calendar/reminders/reminder_action_executor.dart'; import '../../features/calendar/reminders/reminder_outbox_store.dart'; @@ -89,6 +90,13 @@ Future configureDependencies() async { final calendarService = CalendarService(apiClient: apiClient); sl.registerSingleton(calendarService); + final calendarRepository = CalendarRepository( + store: hybridCacheStore, + loadDayFromRemote: calendarService.getEventsForDay, + loadMonthFromRemote: calendarService.getEventsForRange, + ); + sl.registerSingleton(calendarRepository); + final reminderOutboxStore = ReminderOutboxStore(sharedPreferences); sl.registerSingleton(reminderOutboxStore); diff --git a/apps/lib/features/calendar/data/services/calendar_repository.dart b/apps/lib/features/calendar/data/services/calendar_repository.dart new file mode 100644 index 0000000..02a63dc --- /dev/null +++ b/apps/lib/features/calendar/data/services/calendar_repository.dart @@ -0,0 +1,144 @@ +import 'dart:async'; + +import '../../../../core/cache/cache_entry.dart'; +import '../../../../core/cache/cache_policy.dart'; +import '../../../../core/cache/hybrid_cache_store.dart'; +import '../models/schedule_item_model.dart'; + +class CalendarRepository { + final HybridCacheStore store; + final CachePolicy policy; + final DateTime Function() now; + final Future> Function(DateTime date) + loadDayFromRemote; + final Future> Function(DateTime start, DateTime end) + loadMonthFromRemote; + + final Map> _refreshInFlight = >{}; + + CalendarRepository({ + required this.store, + required this.loadDayFromRemote, + required this.loadMonthFromRemote, + CachePolicy? policy, + DateTime Function()? now, + }) : policy = + policy ?? + const CachePolicy( + softTtl: Duration(minutes: 2), + hardTtl: Duration(minutes: 30), + minRefreshInterval: Duration(minutes: 1), + ), + now = now ?? DateTime.now; + + static String dayKey(DateTime date) { + final day = + '${date.year}-${date.month.toString().padLeft(2, '0')}-${date.day.toString().padLeft(2, '0')}'; + return 'calendar:day:$day'; + } + + static String monthKey(DateTime date) { + return 'calendar:month:${date.year}-${date.month.toString().padLeft(2, '0')}'; + } + + Future> getDayEvents( + DateTime date, { + bool forceRefresh = false, + }) async { + final key = dayKey(date); + if (forceRefresh) { + return _refreshDayAndRead(date, key); + } + + final cached = await store.read>>(key); + if (cached == null) { + return _refreshDayAndRead(date, key); + } + + final decision = policy.evaluate(now: now(), fetchedAt: cached.fetchedAt); + if (decision.shouldRefreshInBackground) { + _refreshDayInBackground(date, key); + } + if (decision.mustBlockForNetwork || !decision.canUseCached) { + return _refreshDayAndRead(date, key); + } + return cached.value; + } + + Future> getMonthEvents( + DateTime monthStart, { + bool forceRefresh = false, + }) async { + final key = monthKey(monthStart); + if (forceRefresh) { + return _refreshMonthAndRead(monthStart, key); + } + final cached = await store.read>>(key); + if (cached == null) { + return _refreshMonthAndRead(monthStart, key); + } + final decision = policy.evaluate(now: now(), fetchedAt: cached.fetchedAt); + if (decision.shouldRefreshInBackground) { + _refreshMonthInBackground(monthStart, key); + } + if (decision.mustBlockForNetwork || !decision.canUseCached) { + return _refreshMonthAndRead(monthStart, key); + } + return cached.value; + } + + Future> _refreshDayAndRead( + DateTime date, + String key, + ) async { + await _refreshDay(date, key); + final cached = await store.read>>(key); + return cached?.value ?? const []; + } + + Future> _refreshMonthAndRead( + DateTime monthStart, + String key, + ) async { + await _refreshMonth(monthStart, key); + final cached = await store.read>>(key); + return cached?.value ?? const []; + } + + Future _refreshDay(DateTime date, String key) async { + final remote = await loadDayFromRemote(date); + await store.write>>( + key, + CacheEntry>(value: remote, fetchedAt: now()), + ); + } + + Future _refreshMonth(DateTime monthStart, String key) async { + final start = DateTime(monthStart.year, monthStart.month, 1); + final end = DateTime(monthStart.year, monthStart.month + 1, 0, 23, 59, 59); + final remote = await loadMonthFromRemote(start, end); + await store.write>>( + key, + CacheEntry>(value: remote, fetchedAt: now()), + ); + } + + void _refreshDayInBackground(DateTime date, String key) { + _refreshInBackground(key, () => _refreshDay(date, key)); + } + + void _refreshMonthInBackground(DateTime monthStart, String key) { + _refreshInBackground(key, () => _refreshMonth(monthStart, key)); + } + + void _refreshInBackground(String key, Future Function() taskFactory) { + if (_refreshInFlight.containsKey(key)) { + return; + } + final task = taskFactory().whenComplete(() { + _refreshInFlight.remove(key); + }); + _refreshInFlight[key] = task; + unawaited(task); + } +} diff --git a/apps/lib/features/calendar/ui/calendar_state_manager.dart b/apps/lib/features/calendar/ui/calendar_state_manager.dart index c343c8e..259d22b 100644 --- a/apps/lib/features/calendar/ui/calendar_state_manager.dart +++ b/apps/lib/features/calendar/ui/calendar_state_manager.dart @@ -48,4 +48,8 @@ class CalendarStateManager extends ChangeNotifier { ); notifyListeners(); } + + void refresh() { + notifyListeners(); + } } diff --git a/apps/lib/features/calendar/ui/screens/calendar_dayweek_screen.dart b/apps/lib/features/calendar/ui/screens/calendar_dayweek_screen.dart index 4b4da5d..85b705c 100644 --- a/apps/lib/features/calendar/ui/screens/calendar_dayweek_screen.dart +++ b/apps/lib/features/calendar/ui/screens/calendar_dayweek_screen.dart @@ -8,7 +8,7 @@ import '../../../../core/di/injection.dart'; import '../../../../core/theme/design_tokens.dart'; import '../../../../shared/widgets/app_pressable.dart'; import '../../data/models/schedule_item_model.dart'; -import '../../data/services/calendar_service.dart'; +import '../../data/services/calendar_repository.dart'; import '../calendar_state_manager.dart'; import '../calendar_time_utils.dart'; import '../utils/event_color_resolver.dart'; @@ -67,25 +67,18 @@ class _CalendarDayWeekScreenState extends State WidgetsBinding.instance.addPostFrameCallback((_) { _scrollToSelectedDate(); - _setupRouteListener(); }); } - void _setupRouteListener() { - final router = GoRouter.of(context); - router.routerDelegate.addListener(_onRouteChange); - } - - void _onRouteChange() { - _loadEvents(); - } - void _updateMonthDates() { _monthDates = monthDatesFor(_selectedDate); } - Future _loadEvents() async { - final events = await sl().getEventsForDay(_selectedDate); + Future _loadEvents({bool forceRefresh = false}) async { + final events = await sl().getDayEvents( + _selectedDate, + forceRefresh: forceRefresh, + ); if (!mounted) { return; } @@ -96,9 +89,6 @@ class _CalendarDayWeekScreenState extends State @override void dispose() { - try { - GoRouter.of(context).routerDelegate.removeListener(_onRouteChange); - } catch (_) {} WidgetsBinding.instance.removeObserver(this); _dayStripController.dispose(); super.dispose(); @@ -107,7 +97,7 @@ class _CalendarDayWeekScreenState extends State @override void didChangeAppLifecycleState(AppLifecycleState state) { if (state == AppLifecycleState.resumed) { - _loadEvents(); + _loadEvents(forceRefresh: true); } } diff --git a/apps/lib/features/calendar/ui/screens/calendar_month_screen.dart b/apps/lib/features/calendar/ui/screens/calendar_month_screen.dart index c4dab8a..e113f76 100644 --- a/apps/lib/features/calendar/ui/screens/calendar_month_screen.dart +++ b/apps/lib/features/calendar/ui/screens/calendar_month_screen.dart @@ -12,7 +12,7 @@ import '../calendar_time_utils.dart'; import '../utils/event_color_resolver.dart'; import '../widgets/bottom_dock.dart'; import '../../data/models/schedule_item_model.dart'; -import '../../data/services/calendar_service.dart'; +import '../../data/services/calendar_repository.dart'; class CalendarMonthScreen extends StatefulWidget { final bool resetToToday; @@ -44,32 +44,13 @@ class _CalendarMonthScreenState extends State _selectedDate = savedDate; _currentMonth = DateTime(savedDate.year, savedDate.month, 1); _loadMonthEvents(); - - WidgetsBinding.instance.addPostFrameCallback((_) { - _setupRouteListener(); - }); } - void _setupRouteListener() { - final router = GoRouter.of(context); - router.routerDelegate.addListener(_onRouteChange); - } - - void _onRouteChange() { - _loadMonthEvents(); - } - - Future _loadMonthEvents() async { - final start = DateTime(_currentMonth.year, _currentMonth.month, 1); - final end = DateTime( - _currentMonth.year, - _currentMonth.month + 1, - 0, - 23, - 59, - 59, + Future _loadMonthEvents({bool forceRefresh = false}) async { + final events = await sl().getMonthEvents( + _currentMonth, + forceRefresh: forceRefresh, ); - final events = await sl().getEventsForRange(start, end); if (!mounted) { return; } @@ -83,9 +64,6 @@ class _CalendarMonthScreenState extends State @override void dispose() { - try { - GoRouter.of(context).routerDelegate.removeListener(_onRouteChange); - } catch (_) {} WidgetsBinding.instance.removeObserver(this); super.dispose(); } @@ -93,7 +71,7 @@ class _CalendarMonthScreenState extends State @override void didChangeAppLifecycleState(AppLifecycleState state) { if (state == AppLifecycleState.resumed) { - _loadMonthEvents(); + _loadMonthEvents(forceRefresh: true); } } diff --git a/apps/test/features/calendar/data/services/calendar_repository_test.dart b/apps/test/features/calendar/data/services/calendar_repository_test.dart new file mode 100644 index 0000000..b178f29 --- /dev/null +++ b/apps/test/features/calendar/data/services/calendar_repository_test.dart @@ -0,0 +1,60 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:social_app/core/cache/cache_entry.dart'; +import 'package:social_app/core/cache/cache_policy.dart'; +import 'package:social_app/core/cache/hybrid_cache_store.dart'; +import 'package:social_app/core/cache/memory_cache_store.dart'; +import 'package:social_app/core/cache/persistent_cache_store.dart'; +import 'package:social_app/features/calendar/data/models/schedule_item_model.dart'; +import 'package:social_app/features/calendar/data/services/calendar_repository.dart'; + +void main() { + test( + 'getDayEvents returns cache immediately and refreshes in background', + () async { + final store = HybridCacheStore( + memory: MemoryCacheStore(), + persistent: PersistentCacheStore(), + ); + final date = DateTime(2026, 3, 20); + final key = CalendarRepository.dayKey(date); + await store.persistent.write>>( + key, + CacheEntry( + value: [ + ScheduleItemModel( + id: 'evt_cached', + ownerId: 'owner_1', + title: 'cached', + startAt: DateTime(2026, 3, 20, 10), + endAt: DateTime(2026, 3, 20, 11), + status: ScheduleStatus.active, + ), + ], + fetchedAt: DateTime(2026, 3, 20, 11, 0), + ), + ); + + var remoteCalls = 0; + final repository = CalendarRepository( + store: store, + now: () => DateTime(2026, 3, 20, 11, 5), + policy: const CachePolicy( + softTtl: Duration(minutes: 2), + hardTtl: Duration(minutes: 30), + minRefreshInterval: Duration(minutes: 1), + ), + loadDayFromRemote: (_) async { + remoteCalls += 1; + return const []; + }, + loadMonthFromRemote: (_, __) async => const [], + ); + + final result = await repository.getDayEvents(date); + await Future.delayed(const Duration(milliseconds: 10)); + + expect(result.first.id, 'evt_cached'); + expect(remoteCalls, 1); + }, + ); +}