Skip to content

Commit a4a86ad

Browse files
authored
Merge pull request #563 from DevKor-github/feature/issue-533-schedule-mutation-alarm-effects
Centralize schedule mutation alarm effects
2 parents 825db07 + 28fc1e2 commit a4a86ad

7 files changed

Lines changed: 267 additions & 88 deletions
Lines changed: 7 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,22 +1,23 @@
1-
import 'dart:async';
2-
31
import 'package:injectable/injectable.dart';
42
import 'package:on_time_front/domain/entities/schedule_entity.dart';
53
import 'package:on_time_front/domain/repositories/schedule_repository.dart';
6-
import 'package:on_time_front/domain/use-cases/reconcile_alarms_use_case.dart';
4+
import 'package:on_time_front/domain/use-cases/schedule_mutation_alarm_effects_coordinator.dart';
75

86
@Injectable()
97
class CreateScheduleWithPlaceUseCase {
108
final ScheduleRepository _scheduleRepository;
11-
final ReconcileAlarmsUseCase _reconcileAlarmsUseCase;
9+
final ScheduleMutationAlarmEffectsCoordinator _alarmEffectsCoordinator;
1210

1311
CreateScheduleWithPlaceUseCase(
1412
this._scheduleRepository,
15-
this._reconcileAlarmsUseCase,
13+
this._alarmEffectsCoordinator,
1614
);
1715

1816
Future<void> call(ScheduleEntity schedule) async {
1917
await _scheduleRepository.createSchedule(schedule);
20-
unawaited(_reconcileAlarmsUseCase());
18+
await _alarmEffectsCoordinator(
19+
operation: ScheduleMutationAlarmOperation.created,
20+
scheduleId: schedule.id,
21+
);
2122
}
2223
}
Lines changed: 7 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,26 +1,23 @@
1-
import 'dart:async';
2-
31
import 'package:injectable/injectable.dart';
42
import 'package:on_time_front/domain/entities/schedule_entity.dart';
53
import 'package:on_time_front/domain/repositories/schedule_repository.dart';
6-
import 'package:on_time_front/domain/use-cases/cancel_schedule_alarm_use_case.dart';
7-
import 'package:on_time_front/domain/use-cases/reconcile_alarms_use_case.dart';
4+
import 'package:on_time_front/domain/use-cases/schedule_mutation_alarm_effects_coordinator.dart';
85

96
@Injectable()
107
class DeleteScheduleUseCase {
118
final ScheduleRepository _scheduleRepository;
12-
final CancelScheduleAlarmUseCase _cancelScheduleAlarmUseCase;
13-
final ReconcileAlarmsUseCase _reconcileAlarmsUseCase;
9+
final ScheduleMutationAlarmEffectsCoordinator _alarmEffectsCoordinator;
1410

1511
DeleteScheduleUseCase(
1612
this._scheduleRepository,
17-
this._cancelScheduleAlarmUseCase,
18-
this._reconcileAlarmsUseCase,
13+
this._alarmEffectsCoordinator,
1914
);
2015

2116
Future<void> call(ScheduleEntity schedule) async {
2217
await _scheduleRepository.deleteSchedule(schedule);
23-
await _cancelScheduleAlarmUseCase(schedule.id);
24-
unawaited(_reconcileAlarmsUseCase());
18+
await _alarmEffectsCoordinator(
19+
operation: ScheduleMutationAlarmOperation.deleted,
20+
scheduleId: schedule.id,
21+
);
2522
}
2623
}
Lines changed: 7 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,26 +1,23 @@
1-
import 'dart:async';
2-
31
import 'package:injectable/injectable.dart';
42
import 'package:on_time_front/domain/repositories/schedule_repository.dart';
5-
import 'package:on_time_front/domain/use-cases/cancel_schedule_alarm_use_case.dart';
6-
import 'package:on_time_front/domain/use-cases/reconcile_alarms_use_case.dart';
3+
import 'package:on_time_front/domain/use-cases/schedule_mutation_alarm_effects_coordinator.dart';
74

85
@Injectable()
96
class FinishScheduleUseCase {
107
final ScheduleRepository _scheduleRepository;
11-
final CancelScheduleAlarmUseCase _cancelScheduleAlarmUseCase;
12-
final ReconcileAlarmsUseCase _reconcileAlarmsUseCase;
8+
final ScheduleMutationAlarmEffectsCoordinator _alarmEffectsCoordinator;
139

1410
FinishScheduleUseCase(
1511
this._scheduleRepository,
16-
this._cancelScheduleAlarmUseCase,
17-
this._reconcileAlarmsUseCase,
12+
this._alarmEffectsCoordinator,
1813
);
1914

2015
Future<void> call(String scheduleId, int latenessTime) async {
2116
await _scheduleRepository.startSchedule(scheduleId);
2217
await _scheduleRepository.finishSchedule(scheduleId, latenessTime);
23-
await _cancelScheduleAlarmUseCase(scheduleId);
24-
unawaited(_reconcileAlarmsUseCase());
18+
await _alarmEffectsCoordinator(
19+
operation: ScheduleMutationAlarmOperation.finished,
20+
scheduleId: scheduleId,
21+
);
2522
}
2623
}
Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
import 'dart:async';
2+
3+
import 'package:injectable/injectable.dart';
4+
import 'package:on_time_front/core/logging/app_logger.dart';
5+
import 'package:on_time_front/domain/use-cases/cancel_schedule_alarm_use_case.dart';
6+
import 'package:on_time_front/domain/use-cases/reconcile_alarms_use_case.dart';
7+
8+
enum ScheduleMutationAlarmOperation { created, updated, deleted, finished }
9+
10+
@Injectable()
11+
class ScheduleMutationAlarmEffectsCoordinator {
12+
static const _logTag = '[ScheduleMutationAlarmEffects]';
13+
14+
final CancelScheduleAlarmUseCase _cancelScheduleAlarmUseCase;
15+
final ReconcileAlarmsUseCase _reconcileAlarmsUseCase;
16+
17+
ScheduleMutationAlarmEffectsCoordinator(
18+
this._cancelScheduleAlarmUseCase,
19+
this._reconcileAlarmsUseCase,
20+
);
21+
22+
Future<void> call({
23+
required ScheduleMutationAlarmOperation operation,
24+
required String scheduleId,
25+
}) async {
26+
if (_requiresTargetedCancellation(operation)) {
27+
await _cancelScheduleAlarmUseCase(scheduleId);
28+
}
29+
_reconcileBestEffort(operation, scheduleId);
30+
}
31+
32+
bool _requiresTargetedCancellation(ScheduleMutationAlarmOperation operation) {
33+
return operation == ScheduleMutationAlarmOperation.deleted ||
34+
operation == ScheduleMutationAlarmOperation.finished;
35+
}
36+
37+
void _reconcileBestEffort(
38+
ScheduleMutationAlarmOperation operation,
39+
String scheduleId,
40+
) {
41+
unawaited(
42+
_reconcileAlarmsUseCase().then<void>((_) {}).catchError((
43+
Object error,
44+
StackTrace _,
45+
) {
46+
AppLogger.debug(
47+
'$_logTag reconcile failed '
48+
'operation=${operation.name} '
49+
'scheduleId=$scheduleId '
50+
'errorType=${error.runtimeType}',
51+
);
52+
}),
53+
);
54+
}
55+
}
Lines changed: 10 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,17 @@
1-
import 'dart:async';
2-
31
import 'package:injectable/injectable.dart';
42
import 'package:on_time_front/domain/entities/schedule_entity.dart';
53
import 'package:on_time_front/domain/repositories/schedule_repository.dart';
6-
import 'package:on_time_front/domain/use-cases/reconcile_alarms_use_case.dart';
4+
import 'package:on_time_front/domain/use-cases/schedule_mutation_alarm_effects_coordinator.dart';
75

86
@Injectable()
97
class UpdateScheduleUseCase {
108
final ScheduleRepository _scheduleRepository;
11-
final ReconcileAlarmsUseCase _reconcileAlarmsUseCase;
9+
final ScheduleMutationAlarmEffectsCoordinator _alarmEffectsCoordinator;
1210

13-
UpdateScheduleUseCase(this._scheduleRepository, this._reconcileAlarmsUseCase);
11+
UpdateScheduleUseCase(
12+
this._scheduleRepository,
13+
this._alarmEffectsCoordinator,
14+
);
1415

1516
Future<void> call(
1617
ScheduleEntity schedule, {
@@ -20,6 +21,9 @@ class UpdateScheduleUseCase {
2021
schedule,
2122
includePreparationSource: includePreparationSource,
2223
);
23-
unawaited(_reconcileAlarmsUseCase());
24+
await _alarmEffectsCoordinator(
25+
operation: ScheduleMutationAlarmOperation.updated,
26+
scheduleId: schedule.id,
27+
);
2428
}
2529
}
Lines changed: 127 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,127 @@
1+
import 'package:flutter_test/flutter_test.dart';
2+
import 'package:on_time_front/domain/entities/alarm_entities.dart';
3+
import 'package:on_time_front/domain/use-cases/cancel_schedule_alarm_use_case.dart';
4+
import 'package:on_time_front/domain/use-cases/reconcile_alarms_use_case.dart';
5+
import 'package:on_time_front/domain/use-cases/schedule_mutation_alarm_effects_coordinator.dart';
6+
7+
void main() {
8+
test(
9+
'created and updated schedules reconcile without targeted cancellation',
10+
() async {
11+
final cancel = _FakeCancelScheduleAlarmUseCase();
12+
final reconcile = _FakeReconcileAlarmsUseCase();
13+
final coordinator = ScheduleMutationAlarmEffectsCoordinator(
14+
cancel,
15+
reconcile,
16+
);
17+
18+
await coordinator(
19+
operation: ScheduleMutationAlarmOperation.created,
20+
scheduleId: 'schedule-1',
21+
);
22+
await coordinator(
23+
operation: ScheduleMutationAlarmOperation.updated,
24+
scheduleId: 'schedule-1',
25+
);
26+
await pumpEventQueue();
27+
28+
expect(cancel.cancelledScheduleIds, isEmpty);
29+
expect(reconcile.callCount, 2);
30+
},
31+
);
32+
33+
test(
34+
'deleted and finished schedules cancel targeted alarm before reconciling',
35+
() async {
36+
final events = <String>[];
37+
final cancel = _FakeCancelScheduleAlarmUseCase(events: events);
38+
final reconcile = _FakeReconcileAlarmsUseCase(events: events);
39+
final coordinator = ScheduleMutationAlarmEffectsCoordinator(
40+
cancel,
41+
reconcile,
42+
);
43+
44+
await coordinator(
45+
operation: ScheduleMutationAlarmOperation.deleted,
46+
scheduleId: 'schedule-1',
47+
);
48+
await coordinator(
49+
operation: ScheduleMutationAlarmOperation.finished,
50+
scheduleId: 'schedule-2',
51+
);
52+
await pumpEventQueue();
53+
54+
expect(events, [
55+
'cancel:schedule-1',
56+
'reconcile',
57+
'cancel:schedule-2',
58+
'reconcile',
59+
]);
60+
},
61+
);
62+
63+
test('reconciliation failures remain best-effort', () async {
64+
final cancel = _FakeCancelScheduleAlarmUseCase();
65+
final reconcile = _FakeReconcileAlarmsUseCase()..throwOnCall = true;
66+
final coordinator = ScheduleMutationAlarmEffectsCoordinator(
67+
cancel,
68+
reconcile,
69+
);
70+
71+
await coordinator(
72+
operation: ScheduleMutationAlarmOperation.created,
73+
scheduleId: 'schedule-1',
74+
);
75+
await pumpEventQueue();
76+
77+
expect(reconcile.callCount, 1);
78+
});
79+
}
80+
81+
class _FakeCancelScheduleAlarmUseCase implements CancelScheduleAlarmUseCase {
82+
_FakeCancelScheduleAlarmUseCase({List<String>? events}) : _events = events;
83+
84+
final cancelledScheduleIds = <String>[];
85+
final List<String>? _events;
86+
87+
@override
88+
Future<void> call(String scheduleId) async {
89+
cancelledScheduleIds.add(scheduleId);
90+
_events?.add('cancel:$scheduleId');
91+
}
92+
93+
@override
94+
dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation);
95+
}
96+
97+
class _FakeReconcileAlarmsUseCase implements ReconcileAlarmsUseCase {
98+
_FakeReconcileAlarmsUseCase({List<String>? events}) : _events = events;
99+
100+
int callCount = 0;
101+
bool throwOnCall = false;
102+
final List<String>? _events;
103+
104+
@override
105+
Future<AlarmReconciliationResult> call() async {
106+
callCount += 1;
107+
_events?.add('reconcile');
108+
if (throwOnCall) {
109+
throw Exception('reconcile failed');
110+
}
111+
return AlarmReconciliationResult(
112+
status: AlarmReconciliationStatus.armed,
113+
nativeAlarmProvider: AlarmProvider.none,
114+
fallbackProvider: AlarmProvider.localNotification,
115+
armedScheduleIds: const [],
116+
skippedScheduleCount: 0,
117+
failures: const [],
118+
scheduleWindowStart: DateTime.utc(2026, 5, 15),
119+
scheduleWindowEnd: DateTime.utc(2026, 5, 23),
120+
alarmCoverageStart: DateTime.utc(2026, 5, 15),
121+
alarmCoverageEnd: DateTime.utc(2026, 5, 22),
122+
);
123+
}
124+
125+
@override
126+
dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation);
127+
}

0 commit comments

Comments
 (0)