Skip to content

Commit 86a8964

Browse files
authored
Merge pull request #515 from DevKor-github/feature/flutter-coverage-gate
[codex] Add Flutter coverage gate
2 parents a523044 + 8f290e0 commit 86a8964

96 files changed

Lines changed: 16836 additions & 2765 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.github/workflows/flutter_test.yml

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -31,5 +31,14 @@ jobs:
3131
run: dart run build_runner build --delete-conflicting-outputs
3232
- name: Analyze
3333
run: flutter analyze
34-
- name: Run test
35-
run: flutter test
34+
- name: Run test with coverage
35+
run: flutter test --coverage
36+
- name: Check coverage
37+
run: dart run tool/check_coverage.dart --min=80
38+
- name: Upload coverage report
39+
if: always()
40+
uses: actions/upload-artifact@v4
41+
with:
42+
name: flutter-coverage-lcov
43+
path: coverage/lcov.info
44+
if-no-files-found: warn

README.md

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -166,6 +166,18 @@ Run tests with coverage:
166166
flutter test --coverage
167167
```
168168

169+
Check the app-owned line coverage gate:
170+
171+
```sh
172+
dart run tool/check_coverage.dart --min=80
173+
```
174+
175+
The Flutter Testing GitHub Actions workflow runs tests with coverage, enforces
176+
an 80% line coverage gate for app-owned Dart files, and uploads
177+
`coverage/lcov.info` as the `flutter-coverage-lcov` artifact. The gate filters
178+
generated files, localization output, FlutterFire options, and Drift schema
179+
bootstrap definitions so the percentage reflects tested application behavior.
180+
169181
Run the web app locally:
170182

171183
```sh

lib/core/services/alarm_scheduler_service.dart

Lines changed: 13 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,8 @@ class AlarmSchedulerService {
2727
return capabilities;
2828
} on MissingPluginException {
2929
AppLogger.debug(
30-
'$_logTag getCapabilities -> unsupported: missing plugin');
30+
'$_logTag getCapabilities -> unsupported: missing plugin',
31+
);
3132
return AlarmSchedulerCapabilities.unsupported;
3233
} on PlatformException catch (error) {
3334
AppLogger.debug(
@@ -50,7 +51,8 @@ class AlarmSchedulerService {
5051
return state;
5152
} on MissingPluginException {
5253
AppLogger.debug(
53-
'$_logTag checkPermission -> unsupported: missing plugin');
54+
'$_logTag checkPermission -> unsupported: missing plugin',
55+
);
5456
return AlarmPermissionState.unsupported;
5557
} on PlatformException catch (error) {
5658
AppLogger.debug(
@@ -73,7 +75,8 @@ class AlarmSchedulerService {
7375
return state;
7476
} on MissingPluginException {
7577
AppLogger.debug(
76-
'$_logTag requestPermission -> unsupported: missing plugin');
78+
'$_logTag requestPermission -> unsupported: missing plugin',
79+
);
7780
return AlarmPermissionState.unsupported;
7881
} on PlatformException catch (error) {
7982
AppLogger.debug(
@@ -149,9 +152,7 @@ class AlarmSchedulerService {
149152
}
150153
}
151154

152-
Future<void> cancelAllNativeAlarms(
153-
List<ScheduledAlarmRecord> records,
154-
) async {
155+
Future<void> cancelAllNativeAlarms(List<ScheduledAlarmRecord> records) async {
155156
for (final record in records) {
156157
await cancelNativeAlarm(record);
157158
}
@@ -182,7 +183,8 @@ class AlarmSchedulerService {
182183
final launchPayloadHandler = _launchPayloadHandler;
183184
if (launchPayloadHandler == null) {
184185
AppLogger.debug(
185-
'$_logTag dispatchPendingLaunchPayload skipped: no handler');
186+
'$_logTag dispatchPendingLaunchPayload skipped: no handler',
187+
);
186188
return;
187189
}
188190
if (kIsWeb) {
@@ -209,6 +211,9 @@ class AlarmSchedulerService {
209211
'${error.code} ${error.message}',
210212
);
211213
return;
214+
} catch (error) {
215+
AppLogger.debug('$_logTag getLaunchPayload invalid response: $error');
216+
return;
212217
}
213218
}
214219

@@ -266,8 +271,6 @@ class AlarmSchedulerService {
266271

267272
Map<String, String>? _payloadFromObject(Object? raw) {
268273
if (raw is! Map) return null;
269-
return raw.map(
270-
(key, value) => MapEntry(key.toString(), value.toString()),
271-
);
274+
return raw.map((key, value) => MapEntry(key.toString(), value.toString()));
272275
}
273276
}

lib/core/services/fallback_alarm_notification_service.dart

Lines changed: 11 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -16,32 +16,37 @@ abstract interface class FallbackAlarmNotificationService {
1616
@Singleton(as: FallbackAlarmNotificationService)
1717
class FallbackAlarmNotificationServiceImpl
1818
implements FallbackAlarmNotificationService {
19+
FallbackAlarmNotificationServiceImpl({
20+
NotificationService? notificationService,
21+
}) : _notificationService =
22+
notificationService ?? NotificationService.instance;
23+
24+
final NotificationService _notificationService;
25+
1926
@override
2027
Future<AlarmPermissionState> checkPermission() async {
2128
return _fromAuthorizationStatus(
22-
await NotificationService.instance.checkNotificationPermission(),
29+
await _notificationService.checkNotificationPermission(),
2330
);
2431
}
2532

2633
@override
2734
Future<AlarmPermissionState> requestPermission() async {
2835
return _fromAuthorizationStatus(
29-
await NotificationService.instance.requestPermission(),
36+
await _notificationService.requestPermission(),
3037
);
3138
}
3239

3340
@override
3441
Future<void> scheduleFallbackAlarm(ScheduledAlarmRecord record) {
35-
return NotificationService.instance.scheduleFallbackAlarm(record);
42+
return _notificationService.scheduleFallbackAlarm(record);
3643
}
3744

3845
@override
3946
Future<void> cancelFallbackAlarm(ScheduledAlarmRecord record) async {
4047
final notificationId =
4148
record.fallbackNotificationId ?? stableAlarmId(record.scheduleId);
42-
await NotificationService.instance.cancelFallbackNotification(
43-
notificationId,
44-
);
49+
await _notificationService.cancelFallbackNotification(notificationId);
4550
}
4651

4752
AlarmPermissionState _fromAuthorizationStatus(AuthorizationStatus status) {
Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
import 'dart:convert';
2+
3+
import 'package:on_time_front/core/services/notification_routing.dart';
4+
import 'package:on_time_front/domain/entities/alarm_entities.dart';
5+
6+
class NotificationDisplayContent {
7+
const NotificationDisplayContent({
8+
required this.title,
9+
required this.body,
10+
required this.payload,
11+
});
12+
13+
final String title;
14+
final String body;
15+
final String payload;
16+
}
17+
18+
NotificationDisplayContent? remoteNotificationDisplayContent({
19+
required Map<String, dynamic> data,
20+
String? notificationTitle,
21+
String? notificationBody,
22+
}) {
23+
final title = notificationTitle ?? data['title'] ?? data['Title'];
24+
final body =
25+
notificationBody ??
26+
data['content'] ??
27+
data['body'] ??
28+
data['Content'] ??
29+
data['Body'];
30+
31+
if (title == null && body == null) {
32+
return null;
33+
}
34+
35+
return NotificationDisplayContent(
36+
title: title?.toString() ?? '알림',
37+
body: body?.toString() ?? '',
38+
payload: jsonEncode(data),
39+
);
40+
}
41+
42+
String? encodeLocalNotificationPayload(Map<String, dynamic>? payload) {
43+
return payload == null ? null : jsonEncode(payload);
44+
}
45+
46+
String preparationStepNotificationTitle({
47+
required String scheduleName,
48+
required String preparationName,
49+
}) {
50+
return '[$scheduleName] $preparationName';
51+
}
52+
53+
String preparationStepNotificationBody({required String languageCode}) {
54+
return localizedNotificationText(
55+
languageCode: languageCode,
56+
ko: '이어서 준비하세요.',
57+
en: 'Continue preparing',
58+
);
59+
}
60+
61+
Map<String, String> preparationStepNotificationPayload({
62+
required String scheduleId,
63+
required String stepId,
64+
}) {
65+
return {
66+
'type': 'preparation_step',
67+
'scheduleId': scheduleId,
68+
'stepId': stepId,
69+
};
70+
}
71+
72+
int fallbackNotificationIdForRecord(ScheduledAlarmRecord record) {
73+
return record.fallbackNotificationId ?? stableAlarmId(record.scheduleId);
74+
}
75+
76+
String fallbackAlarmNotificationBody({required String languageCode}) {
77+
return localizedNotificationText(
78+
languageCode: languageCode,
79+
ko: '준비를 시작할 시간입니다.',
80+
en: 'It is time to get ready.',
81+
);
82+
}
Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
import 'dart:convert';
2+
3+
import 'package:equatable/equatable.dart';
4+
5+
class NotificationRouteTarget extends Equatable {
6+
const NotificationRouteTarget(this.path, {this.extra});
7+
8+
final String path;
9+
final Object? extra;
10+
11+
@override
12+
List<Object?> get props => [path, extra];
13+
}
14+
15+
String localizedNotificationText({
16+
required String languageCode,
17+
required String ko,
18+
required String en,
19+
}) {
20+
return languageCode == 'ko' ? ko : en;
21+
}
22+
23+
bool isScheduleAlarmPayload(Map<dynamic, dynamic>? payload) {
24+
if (payload == null) return false;
25+
final type = payload['type']?.toString();
26+
final promptVariant = payload['promptVariant']?.toString();
27+
return type == 'schedule_alarm' ||
28+
payload['alarmLaunchPayloadVersion'] != null ||
29+
(promptVariant == 'alarm' && payload['scheduleId'] != null);
30+
}
31+
32+
bool isScheduleAlarmMessagePayload({
33+
required Map<dynamic, dynamic> data,
34+
String? title,
35+
}) {
36+
return isScheduleAlarmPayload(data) ||
37+
title == '약속 알림' ||
38+
title == 'Schedule alarm';
39+
}
40+
41+
NotificationRouteTarget? notificationRouteForPayloadString(String? payload) {
42+
if (payload == null) return null;
43+
44+
try {
45+
final decoded = jsonDecode(payload);
46+
if (decoded is! Map<String, dynamic>) {
47+
return null;
48+
}
49+
return notificationRouteForData(decoded);
50+
} on FormatException {
51+
return null;
52+
}
53+
}
54+
55+
NotificationRouteTarget? notificationRouteForData(Map<dynamic, dynamic> data) {
56+
final type = data['type']?.toString();
57+
final scheduleId = data['scheduleId']?.toString();
58+
59+
if (type == 'schedule_alarm' && scheduleId != null) {
60+
return NotificationRouteTarget(
61+
'/scheduleStart',
62+
extra: Map<String, dynamic>.from(data),
63+
);
64+
}
65+
66+
if (type != null && type.contains('5min')) {
67+
return const NotificationRouteTarget(
68+
'/scheduleStart',
69+
extra: {'promptVariant': 'earlyStart'},
70+
);
71+
}
72+
73+
if ((type != null &&
74+
(type.startsWith('schedule_') || type.startsWith('preparation_'))) ||
75+
scheduleId != null) {
76+
return const NotificationRouteTarget('/alarmScreen');
77+
}
78+
79+
return null;
80+
}

0 commit comments

Comments
 (0)