refactor: decouple calendar screens from route-driven reload
This commit is contained in:
@@ -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_bloc.dart';
|
||||||
import '../../features/auth/presentation/bloc/auth_event.dart';
|
import '../../features/auth/presentation/bloc/auth_event.dart';
|
||||||
import '../../features/calendar/data/calendar_api.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/data/services/calendar_service.dart';
|
||||||
import '../../features/calendar/reminders/reminder_action_executor.dart';
|
import '../../features/calendar/reminders/reminder_action_executor.dart';
|
||||||
import '../../features/calendar/reminders/reminder_outbox_store.dart';
|
import '../../features/calendar/reminders/reminder_outbox_store.dart';
|
||||||
@@ -89,6 +90,13 @@ Future<void> configureDependencies() async {
|
|||||||
final calendarService = CalendarService(apiClient: apiClient);
|
final calendarService = CalendarService(apiClient: apiClient);
|
||||||
sl.registerSingleton<CalendarService>(calendarService);
|
sl.registerSingleton<CalendarService>(calendarService);
|
||||||
|
|
||||||
|
final calendarRepository = CalendarRepository(
|
||||||
|
store: hybridCacheStore,
|
||||||
|
loadDayFromRemote: calendarService.getEventsForDay,
|
||||||
|
loadMonthFromRemote: calendarService.getEventsForRange,
|
||||||
|
);
|
||||||
|
sl.registerSingleton<CalendarRepository>(calendarRepository);
|
||||||
|
|
||||||
final reminderOutboxStore = ReminderOutboxStore(sharedPreferences);
|
final reminderOutboxStore = ReminderOutboxStore(sharedPreferences);
|
||||||
sl.registerSingleton<ReminderOutboxStore>(reminderOutboxStore);
|
sl.registerSingleton<ReminderOutboxStore>(reminderOutboxStore);
|
||||||
|
|
||||||
|
|||||||
@@ -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<List<ScheduleItemModel>> Function(DateTime date)
|
||||||
|
loadDayFromRemote;
|
||||||
|
final Future<List<ScheduleItemModel>> Function(DateTime start, DateTime end)
|
||||||
|
loadMonthFromRemote;
|
||||||
|
|
||||||
|
final Map<String, Future<void>> _refreshInFlight = <String, Future<void>>{};
|
||||||
|
|
||||||
|
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<List<ScheduleItemModel>> getDayEvents(
|
||||||
|
DateTime date, {
|
||||||
|
bool forceRefresh = false,
|
||||||
|
}) async {
|
||||||
|
final key = dayKey(date);
|
||||||
|
if (forceRefresh) {
|
||||||
|
return _refreshDayAndRead(date, key);
|
||||||
|
}
|
||||||
|
|
||||||
|
final cached = await store.read<CacheEntry<List<ScheduleItemModel>>>(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<List<ScheduleItemModel>> getMonthEvents(
|
||||||
|
DateTime monthStart, {
|
||||||
|
bool forceRefresh = false,
|
||||||
|
}) async {
|
||||||
|
final key = monthKey(monthStart);
|
||||||
|
if (forceRefresh) {
|
||||||
|
return _refreshMonthAndRead(monthStart, key);
|
||||||
|
}
|
||||||
|
final cached = await store.read<CacheEntry<List<ScheduleItemModel>>>(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<List<ScheduleItemModel>> _refreshDayAndRead(
|
||||||
|
DateTime date,
|
||||||
|
String key,
|
||||||
|
) async {
|
||||||
|
await _refreshDay(date, key);
|
||||||
|
final cached = await store.read<CacheEntry<List<ScheduleItemModel>>>(key);
|
||||||
|
return cached?.value ?? const <ScheduleItemModel>[];
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<List<ScheduleItemModel>> _refreshMonthAndRead(
|
||||||
|
DateTime monthStart,
|
||||||
|
String key,
|
||||||
|
) async {
|
||||||
|
await _refreshMonth(monthStart, key);
|
||||||
|
final cached = await store.read<CacheEntry<List<ScheduleItemModel>>>(key);
|
||||||
|
return cached?.value ?? const <ScheduleItemModel>[];
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _refreshDay(DateTime date, String key) async {
|
||||||
|
final remote = await loadDayFromRemote(date);
|
||||||
|
await store.write<CacheEntry<List<ScheduleItemModel>>>(
|
||||||
|
key,
|
||||||
|
CacheEntry<List<ScheduleItemModel>>(value: remote, fetchedAt: now()),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _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<CacheEntry<List<ScheduleItemModel>>>(
|
||||||
|
key,
|
||||||
|
CacheEntry<List<ScheduleItemModel>>(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<void> Function() taskFactory) {
|
||||||
|
if (_refreshInFlight.containsKey(key)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
final task = taskFactory().whenComplete(() {
|
||||||
|
_refreshInFlight.remove(key);
|
||||||
|
});
|
||||||
|
_refreshInFlight[key] = task;
|
||||||
|
unawaited(task);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -48,4 +48,8 @@ class CalendarStateManager extends ChangeNotifier {
|
|||||||
);
|
);
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void refresh() {
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import '../../../../core/di/injection.dart';
|
|||||||
import '../../../../core/theme/design_tokens.dart';
|
import '../../../../core/theme/design_tokens.dart';
|
||||||
import '../../../../shared/widgets/app_pressable.dart';
|
import '../../../../shared/widgets/app_pressable.dart';
|
||||||
import '../../data/models/schedule_item_model.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_state_manager.dart';
|
||||||
import '../calendar_time_utils.dart';
|
import '../calendar_time_utils.dart';
|
||||||
import '../utils/event_color_resolver.dart';
|
import '../utils/event_color_resolver.dart';
|
||||||
@@ -67,25 +67,18 @@ class _CalendarDayWeekScreenState extends State<CalendarDayWeekScreen>
|
|||||||
|
|
||||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
_scrollToSelectedDate();
|
_scrollToSelectedDate();
|
||||||
_setupRouteListener();
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
void _setupRouteListener() {
|
|
||||||
final router = GoRouter.of(context);
|
|
||||||
router.routerDelegate.addListener(_onRouteChange);
|
|
||||||
}
|
|
||||||
|
|
||||||
void _onRouteChange() {
|
|
||||||
_loadEvents();
|
|
||||||
}
|
|
||||||
|
|
||||||
void _updateMonthDates() {
|
void _updateMonthDates() {
|
||||||
_monthDates = monthDatesFor(_selectedDate);
|
_monthDates = monthDatesFor(_selectedDate);
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _loadEvents() async {
|
Future<void> _loadEvents({bool forceRefresh = false}) async {
|
||||||
final events = await sl<CalendarService>().getEventsForDay(_selectedDate);
|
final events = await sl<CalendarRepository>().getDayEvents(
|
||||||
|
_selectedDate,
|
||||||
|
forceRefresh: forceRefresh,
|
||||||
|
);
|
||||||
if (!mounted) {
|
if (!mounted) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -96,9 +89,6 @@ class _CalendarDayWeekScreenState extends State<CalendarDayWeekScreen>
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
try {
|
|
||||||
GoRouter.of(context).routerDelegate.removeListener(_onRouteChange);
|
|
||||||
} catch (_) {}
|
|
||||||
WidgetsBinding.instance.removeObserver(this);
|
WidgetsBinding.instance.removeObserver(this);
|
||||||
_dayStripController.dispose();
|
_dayStripController.dispose();
|
||||||
super.dispose();
|
super.dispose();
|
||||||
@@ -107,7 +97,7 @@ class _CalendarDayWeekScreenState extends State<CalendarDayWeekScreen>
|
|||||||
@override
|
@override
|
||||||
void didChangeAppLifecycleState(AppLifecycleState state) {
|
void didChangeAppLifecycleState(AppLifecycleState state) {
|
||||||
if (state == AppLifecycleState.resumed) {
|
if (state == AppLifecycleState.resumed) {
|
||||||
_loadEvents();
|
_loadEvents(forceRefresh: true);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ import '../calendar_time_utils.dart';
|
|||||||
import '../utils/event_color_resolver.dart';
|
import '../utils/event_color_resolver.dart';
|
||||||
import '../widgets/bottom_dock.dart';
|
import '../widgets/bottom_dock.dart';
|
||||||
import '../../data/models/schedule_item_model.dart';
|
import '../../data/models/schedule_item_model.dart';
|
||||||
import '../../data/services/calendar_service.dart';
|
import '../../data/services/calendar_repository.dart';
|
||||||
|
|
||||||
class CalendarMonthScreen extends StatefulWidget {
|
class CalendarMonthScreen extends StatefulWidget {
|
||||||
final bool resetToToday;
|
final bool resetToToday;
|
||||||
@@ -44,32 +44,13 @@ class _CalendarMonthScreenState extends State<CalendarMonthScreen>
|
|||||||
_selectedDate = savedDate;
|
_selectedDate = savedDate;
|
||||||
_currentMonth = DateTime(savedDate.year, savedDate.month, 1);
|
_currentMonth = DateTime(savedDate.year, savedDate.month, 1);
|
||||||
_loadMonthEvents();
|
_loadMonthEvents();
|
||||||
|
|
||||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
|
||||||
_setupRouteListener();
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
void _setupRouteListener() {
|
Future<void> _loadMonthEvents({bool forceRefresh = false}) async {
|
||||||
final router = GoRouter.of(context);
|
final events = await sl<CalendarRepository>().getMonthEvents(
|
||||||
router.routerDelegate.addListener(_onRouteChange);
|
_currentMonth,
|
||||||
}
|
forceRefresh: forceRefresh,
|
||||||
|
|
||||||
void _onRouteChange() {
|
|
||||||
_loadMonthEvents();
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> _loadMonthEvents() async {
|
|
||||||
final start = DateTime(_currentMonth.year, _currentMonth.month, 1);
|
|
||||||
final end = DateTime(
|
|
||||||
_currentMonth.year,
|
|
||||||
_currentMonth.month + 1,
|
|
||||||
0,
|
|
||||||
23,
|
|
||||||
59,
|
|
||||||
59,
|
|
||||||
);
|
);
|
||||||
final events = await sl<CalendarService>().getEventsForRange(start, end);
|
|
||||||
if (!mounted) {
|
if (!mounted) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -83,9 +64,6 @@ class _CalendarMonthScreenState extends State<CalendarMonthScreen>
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
try {
|
|
||||||
GoRouter.of(context).routerDelegate.removeListener(_onRouteChange);
|
|
||||||
} catch (_) {}
|
|
||||||
WidgetsBinding.instance.removeObserver(this);
|
WidgetsBinding.instance.removeObserver(this);
|
||||||
super.dispose();
|
super.dispose();
|
||||||
}
|
}
|
||||||
@@ -93,7 +71,7 @@ class _CalendarMonthScreenState extends State<CalendarMonthScreen>
|
|||||||
@override
|
@override
|
||||||
void didChangeAppLifecycleState(AppLifecycleState state) {
|
void didChangeAppLifecycleState(AppLifecycleState state) {
|
||||||
if (state == AppLifecycleState.resumed) {
|
if (state == AppLifecycleState.resumed) {
|
||||||
_loadMonthEvents();
|
_loadMonthEvents(forceRefresh: true);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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<CacheEntry<List<ScheduleItemModel>>>(
|
||||||
|
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 <ScheduleItemModel>[];
|
||||||
|
},
|
||||||
|
loadMonthFromRemote: (_, __) async => const <ScheduleItemModel>[],
|
||||||
|
);
|
||||||
|
|
||||||
|
final result = await repository.getDayEvents(date);
|
||||||
|
await Future<void>.delayed(const Duration(milliseconds: 10));
|
||||||
|
|
||||||
|
expect(result.first.id, 'evt_cached');
|
||||||
|
expect(remoteCalls, 1);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user