Skip to content

Commit 5f414ac

Browse files
authored
Merge pull request #561 from DevKor-github/feature/issue-527-monthly-subscription
fix: prevent monthly schedule stream accumulation
2 parents a4a86ad + e8be197 commit 5f414ac

4 files changed

Lines changed: 368 additions & 80 deletions

File tree

CONTEXT.md

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -141,8 +141,23 @@ _Avoid_: Fallback, degraded, time sensitive
141141
The user-facing status for schedule delivery through an Alarm.
142142
_Avoid_: Notification, native alarm
143143

144+
**Schedule**:
145+
A user commitment with a planned time and preparation context in OnTime.
146+
_Avoid_: Appointment, event, task
147+
148+
**Monthly Calendar**:
149+
The calendar surface that shows Schedules grouped by day across a calendar month.
150+
_Avoid_: Month view, calendar grid
151+
152+
**Calendar Month Range**:
153+
A contiguous span of calendar months whose Schedules are in scope for a Monthly Calendar.
154+
_Avoid_: Loaded range, stream range, cached range
155+
144156
## Relationships
145157

158+
- A **Monthly Calendar** displays **Schedules** grouped by calendar day.
159+
- A **Calendar Month Range** starts at the first day of its first month and ends before the first day of the month after its last month.
160+
- A **Monthly Calendar** may extend a **Calendar Month Range** when the user moves to an adjacent month.
146161
- An **OnTime User** may own zero or more **Schedules**.
147162
- A **Schedule** has one **Place**.
148163
- A **Schedule** may use one **Preparation**.

lib/presentation/calendar/bloc/monthly_schedules_bloc.dart

Lines changed: 151 additions & 77 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,6 @@ import 'package:equatable/equatable.dart';
44
import 'package:flutter_bloc/flutter_bloc.dart';
55
import 'package:injectable/injectable.dart';
66
import 'package:on_time_front/core/dio/api_error_message.dart';
7-
import 'package:on_time_front/core/logging/app_logger.dart';
87
import 'package:on_time_front/domain/entities/preparation_entity.dart';
98
import 'package:on_time_front/domain/entities/schedule_entity.dart';
109
import 'package:on_time_front/domain/use-cases/delete_schedule_use_case.dart';
@@ -39,6 +38,8 @@ class MonthlySchedulesBloc
3938
_onPreparationsPrefetchRequested,
4039
);
4140
on<MonthlySchedulesPreparationsStreamChanged>(_onPreparationsStreamChanged);
41+
on<_MonthlySchedulesScheduleStreamChanged>(_onScheduleStreamChanged);
42+
on<_MonthlySchedulesScheduleStreamFailed>(_onScheduleStreamFailed);
4243

4344
_preparationSubscription = _streamPreparationsUseCase().listen((
4445
preparations,
@@ -56,101 +57,82 @@ class MonthlySchedulesBloc
5657
final GetPreparationByScheduleIdUseCase _getPreparationByScheduleIdUseCase;
5758
final StreamPreparationsUseCase _streamPreparationsUseCase;
5859

60+
StreamSubscription<List<ScheduleEntity>>? _scheduleSubscription;
5961
StreamSubscription<Map<String, PreparationEntity>>? _preparationSubscription;
62+
int _scheduleRangeRequestId = 0;
6063

6164
Future<void> _onSubscriptionRequested(
6265
MonthlySchedulesSubscriptionRequested event,
6366
Emitter<MonthlySchedulesState> emit,
6467
) async {
65-
emit(state.copyWith(status: () => MonthlySchedulesStatus.loading));
66-
67-
try {
68-
await _loadSchedulesForMonthUseCase(event.date);
69-
} catch (_) {
70-
emit(state.copyWith(status: () => MonthlySchedulesStatus.error));
71-
return;
72-
}
73-
74-
await emit.forEach(
75-
_getSchedulesByDateUseCase(event.startDate, event.endDate),
76-
onData: (schedules) {
77-
final groupedSchedules = _groupSchedulesByDate(schedules);
78-
final nextState = state.copyWith(
79-
status: () => MonthlySchedulesStatus.success,
80-
schedules: () => groupedSchedules,
81-
startDate: () => event.startDate,
82-
endDate: () => event.endDate,
83-
);
84-
_requestVisibleDatePreparationPrefetch(nextState);
85-
return nextState;
86-
},
87-
onError: (error, stackTrace) =>
88-
state.copyWith(status: () => MonthlySchedulesStatus.error),
68+
await _loadAndWatchCalendarMonthRange(
69+
loadDate: event.date,
70+
startDate: event.startDate,
71+
endDate: event.endDate,
72+
emit: emit,
8973
);
9074
}
9175

9276
Future<void> _onMonthAdded(
9377
MonthlySchedulesMonthAdded event,
9478
Emitter<MonthlySchedulesState> emit,
9579
) async {
96-
late DateTime startDate;
97-
late DateTime endDate;
98-
if (!(state.startDate!.isAfter(event.startDate) ||
99-
state.endDate!.isBefore(event.endDate))) {
100-
// If the month is already loaded, we don't need to load the schedules again.
101-
startDate = state.startDate!;
102-
endDate = state.endDate!;
103-
} else if (event.date.month !=
104-
state.startDate!.subtract(Duration(days: 1)).month &&
105-
(event.date.month != state.endDate!.month)) {
106-
// If the month is not consecutive, we need to load the schedules for the
107-
add(MonthlySchedulesSubscriptionRequested(date: event.date));
80+
final currentStartDate = state.startDate;
81+
final currentEndDate = state.endDate;
82+
if (currentStartDate == null || currentEndDate == null) {
83+
await _loadAndWatchCalendarMonthRange(
84+
loadDate: event.date,
85+
startDate: event.startDate,
86+
endDate: event.endDate,
87+
emit: emit,
88+
);
10889
return;
109-
} else {
110-
// If the month is not consecutive, we need to load the schedules for the
111-
// month and update the state with the new schedules.
90+
}
11291

113-
startDate = event.startDate.isBefore(state.startDate!)
114-
? event.startDate
115-
: state.startDate!;
116-
endDate = event.endDate.isAfter(state.endDate!)
117-
? event.endDate
118-
: state.endDate!;
92+
final eventIsInsideCurrentRange =
93+
!currentStartDate.isAfter(event.startDate) &&
94+
!currentEndDate.isBefore(event.endDate);
95+
if (eventIsInsideCurrentRange) {
96+
return;
97+
}
11998

120-
emit(
121-
state.copyWith(
122-
status: () => MonthlySchedulesStatus.loading,
123-
schedules: () => state.schedules,
124-
startDate: () => startDate,
125-
endDate: () => endDate,
126-
),
99+
final eventMonth = DateTime(event.date.year, event.date.month, 1);
100+
final previousAdjacentMonth = DateTime(
101+
currentStartDate.year,
102+
currentStartDate.month - 1,
103+
1,
104+
);
105+
final nextAdjacentMonth = DateTime(
106+
currentEndDate.year,
107+
currentEndDate.month,
108+
1,
109+
);
110+
final eventIsAdjacent =
111+
eventMonth == previousAdjacentMonth || eventMonth == nextAdjacentMonth;
112+
113+
if (!eventIsAdjacent) {
114+
await _loadAndWatchCalendarMonthRange(
115+
loadDate: event.date,
116+
startDate: event.startDate,
117+
endDate: event.endDate,
118+
emit: emit,
127119
);
128-
129-
try {
130-
await _loadSchedulesForMonthUseCase(event.date);
131-
} catch (_) {
132-
emit(state.copyWith(status: () => MonthlySchedulesStatus.error));
133-
return;
134-
}
120+
return;
135121
}
136122

137-
AppLogger.debug('startDate: $startDate, endDate: $endDate');
138-
await emit.forEach(
139-
_getSchedulesByDateUseCase(startDate, endDate),
140-
onData: (schedules) {
141-
final groupedSchedules = _groupSchedulesByDate(schedules);
142-
final nextState = state.copyWith(
143-
status: () => MonthlySchedulesStatus.success,
144-
schedules: () => groupedSchedules,
145-
startDate: () => startDate,
146-
endDate: () => endDate,
147-
);
148-
_requestVisibleDatePreparationPrefetch(nextState);
149-
return nextState;
150-
},
151-
onError: (error, stackTrace) {
152-
return state.copyWith(status: () => MonthlySchedulesStatus.error);
153-
},
123+
final startDate = event.startDate.isBefore(currentStartDate)
124+
? event.startDate
125+
: currentStartDate;
126+
final endDate = event.endDate.isAfter(currentEndDate)
127+
? event.endDate
128+
: currentEndDate;
129+
130+
await _loadAndWatchCalendarMonthRange(
131+
loadDate: event.date,
132+
startDate: startDate,
133+
endDate: endDate,
134+
emit: emit,
135+
exposeLoadingRange: true,
154136
);
155137
}
156138

@@ -328,6 +310,97 @@ class MonthlySchedulesBloc
328310
emit(state.copyWith(preparationDurationByScheduleId: () => nextDurations));
329311
}
330312

313+
void _onScheduleStreamChanged(
314+
_MonthlySchedulesScheduleStreamChanged event,
315+
Emitter<MonthlySchedulesState> emit,
316+
) {
317+
if (event.requestId != _scheduleRangeRequestId) {
318+
return;
319+
}
320+
321+
final groupedSchedules = _groupSchedulesByDate(event.schedules);
322+
final nextState = state.copyWith(
323+
status: () => MonthlySchedulesStatus.success,
324+
schedules: () => groupedSchedules,
325+
startDate: () => event.startDate,
326+
endDate: () => event.endDate,
327+
);
328+
emit(nextState);
329+
_requestVisibleDatePreparationPrefetch(nextState);
330+
}
331+
332+
void _onScheduleStreamFailed(
333+
_MonthlySchedulesScheduleStreamFailed event,
334+
Emitter<MonthlySchedulesState> emit,
335+
) {
336+
if (event.requestId != _scheduleRangeRequestId) {
337+
return;
338+
}
339+
340+
emit(state.copyWith(status: () => MonthlySchedulesStatus.error));
341+
}
342+
343+
Future<void> _loadAndWatchCalendarMonthRange({
344+
required DateTime loadDate,
345+
required DateTime startDate,
346+
required DateTime endDate,
347+
required Emitter<MonthlySchedulesState> emit,
348+
bool exposeLoadingRange = false,
349+
}) async {
350+
final requestId = ++_scheduleRangeRequestId;
351+
await _cancelScheduleSubscription();
352+
353+
var loadingState = state.copyWith(
354+
status: () => MonthlySchedulesStatus.loading,
355+
);
356+
if (exposeLoadingRange) {
357+
loadingState = loadingState.copyWith(
358+
startDate: () => startDate,
359+
endDate: () => endDate,
360+
);
361+
}
362+
emit(loadingState);
363+
364+
try {
365+
await _loadSchedulesForMonthUseCase(loadDate);
366+
} catch (_) {
367+
if (requestId == _scheduleRangeRequestId) {
368+
emit(state.copyWith(status: () => MonthlySchedulesStatus.error));
369+
}
370+
return;
371+
}
372+
373+
if (requestId != _scheduleRangeRequestId) {
374+
return;
375+
}
376+
377+
_scheduleSubscription = _getSchedulesByDateUseCase(startDate, endDate)
378+
.listen(
379+
(schedules) {
380+
if (!isClosed && requestId == _scheduleRangeRequestId) {
381+
add(
382+
_MonthlySchedulesScheduleStreamChanged(
383+
requestId: requestId,
384+
startDate: startDate,
385+
endDate: endDate,
386+
schedules: schedules,
387+
),
388+
);
389+
}
390+
},
391+
onError: (Object error, StackTrace stackTrace) {
392+
if (!isClosed && requestId == _scheduleRangeRequestId) {
393+
add(_MonthlySchedulesScheduleStreamFailed(requestId: requestId));
394+
}
395+
},
396+
);
397+
}
398+
399+
Future<void> _cancelScheduleSubscription() async {
400+
await _scheduleSubscription?.cancel();
401+
_scheduleSubscription = null;
402+
}
403+
331404
void _requestVisibleDatePreparationPrefetch(
332405
MonthlySchedulesState sourceState,
333406
) {
@@ -393,6 +466,7 @@ class MonthlySchedulesBloc
393466

394467
@override
395468
Future<void> close() async {
469+
await _cancelScheduleSubscription();
396470
await _preparationSubscription?.cancel();
397471
return super.close();
398472
}

lib/presentation/calendar/bloc/monthly_schedules_event.dart

Lines changed: 29 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -75,10 +75,36 @@ final class MonthlySchedulesPreparationsStreamChanged
7575
extends MonthlySchedulesEvent {
7676
final Map<String, PreparationEntity> preparations;
7777

78-
const MonthlySchedulesPreparationsStreamChanged({
79-
required this.preparations,
80-
});
78+
const MonthlySchedulesPreparationsStreamChanged({required this.preparations});
8179

8280
@override
8381
List<Object> get props => [preparations];
8482
}
83+
84+
final class _MonthlySchedulesScheduleStreamChanged
85+
extends MonthlySchedulesEvent {
86+
const _MonthlySchedulesScheduleStreamChanged({
87+
required this.requestId,
88+
required this.startDate,
89+
required this.endDate,
90+
required this.schedules,
91+
});
92+
93+
final int requestId;
94+
final DateTime startDate;
95+
final DateTime endDate;
96+
final List<ScheduleEntity> schedules;
97+
98+
@override
99+
List<Object> get props => [requestId, startDate, endDate, schedules];
100+
}
101+
102+
final class _MonthlySchedulesScheduleStreamFailed
103+
extends MonthlySchedulesEvent {
104+
const _MonthlySchedulesScheduleStreamFailed({required this.requestId});
105+
106+
final int requestId;
107+
108+
@override
109+
List<Object> get props => [requestId];
110+
}

0 commit comments

Comments
 (0)