Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 11 additions & 2 deletions .github/workflows/flutter_test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -31,5 +31,14 @@ jobs:
run: dart run build_runner build --delete-conflicting-outputs
- name: Analyze
run: flutter analyze
- name: Run test
run: flutter test
- name: Run test with coverage
run: flutter test --coverage
- name: Check coverage
run: dart run tool/check_coverage.dart --min=80
- name: Upload coverage report
if: always()
uses: actions/upload-artifact@v4
with:
name: flutter-coverage-lcov
path: coverage/lcov.info
if-no-files-found: warn
12 changes: 12 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -166,6 +166,18 @@ Run tests with coverage:
flutter test --coverage
```

Check the app-owned line coverage gate:

```sh
dart run tool/check_coverage.dart --min=80
```

The Flutter Testing GitHub Actions workflow runs tests with coverage, enforces
an 80% line coverage gate for app-owned Dart files, and uploads
`coverage/lcov.info` as the `flutter-coverage-lcov` artifact. The gate filters
generated files, localization output, FlutterFire options, and Drift schema
bootstrap definitions so the percentage reflects tested application behavior.

Run the web app locally:

```sh
Expand Down
23 changes: 13 additions & 10 deletions lib/core/services/alarm_scheduler_service.dart
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,8 @@ class AlarmSchedulerService {
return capabilities;
} on MissingPluginException {
AppLogger.debug(
'$_logTag getCapabilities -> unsupported: missing plugin');
'$_logTag getCapabilities -> unsupported: missing plugin',
);
return AlarmSchedulerCapabilities.unsupported;
} on PlatformException catch (error) {
AppLogger.debug(
Expand All @@ -50,7 +51,8 @@ class AlarmSchedulerService {
return state;
} on MissingPluginException {
AppLogger.debug(
'$_logTag checkPermission -> unsupported: missing plugin');
'$_logTag checkPermission -> unsupported: missing plugin',
);
return AlarmPermissionState.unsupported;
} on PlatformException catch (error) {
AppLogger.debug(
Expand All @@ -73,7 +75,8 @@ class AlarmSchedulerService {
return state;
} on MissingPluginException {
AppLogger.debug(
'$_logTag requestPermission -> unsupported: missing plugin');
'$_logTag requestPermission -> unsupported: missing plugin',
);
return AlarmPermissionState.unsupported;
} on PlatformException catch (error) {
AppLogger.debug(
Expand Down Expand Up @@ -149,9 +152,7 @@ class AlarmSchedulerService {
}
}

Future<void> cancelAllNativeAlarms(
List<ScheduledAlarmRecord> records,
) async {
Future<void> cancelAllNativeAlarms(List<ScheduledAlarmRecord> records) async {
for (final record in records) {
await cancelNativeAlarm(record);
}
Expand Down Expand Up @@ -182,7 +183,8 @@ class AlarmSchedulerService {
final launchPayloadHandler = _launchPayloadHandler;
if (launchPayloadHandler == null) {
AppLogger.debug(
'$_logTag dispatchPendingLaunchPayload skipped: no handler');
'$_logTag dispatchPendingLaunchPayload skipped: no handler',
);
return;
}
if (kIsWeb) {
Expand All @@ -209,6 +211,9 @@ class AlarmSchedulerService {
'${error.code} ${error.message}',
);
return;
} catch (error) {
AppLogger.debug('$_logTag getLaunchPayload invalid response: $error');
return;
}
}

Expand Down Expand Up @@ -266,8 +271,6 @@ class AlarmSchedulerService {

Map<String, String>? _payloadFromObject(Object? raw) {
if (raw is! Map) return null;
return raw.map(
(key, value) => MapEntry(key.toString(), value.toString()),
);
return raw.map((key, value) => MapEntry(key.toString(), value.toString()));
}
}
17 changes: 11 additions & 6 deletions lib/core/services/fallback_alarm_notification_service.dart
Original file line number Diff line number Diff line change
Expand Up @@ -16,32 +16,37 @@ abstract interface class FallbackAlarmNotificationService {
@Singleton(as: FallbackAlarmNotificationService)
class FallbackAlarmNotificationServiceImpl
implements FallbackAlarmNotificationService {
FallbackAlarmNotificationServiceImpl({
NotificationService? notificationService,
}) : _notificationService =
notificationService ?? NotificationService.instance;

final NotificationService _notificationService;

@override
Future<AlarmPermissionState> checkPermission() async {
return _fromAuthorizationStatus(
await NotificationService.instance.checkNotificationPermission(),
await _notificationService.checkNotificationPermission(),
);
}

@override
Future<AlarmPermissionState> requestPermission() async {
return _fromAuthorizationStatus(
await NotificationService.instance.requestPermission(),
await _notificationService.requestPermission(),
);
}

@override
Future<void> scheduleFallbackAlarm(ScheduledAlarmRecord record) {
return NotificationService.instance.scheduleFallbackAlarm(record);
return _notificationService.scheduleFallbackAlarm(record);
}

@override
Future<void> cancelFallbackAlarm(ScheduledAlarmRecord record) async {
final notificationId =
record.fallbackNotificationId ?? stableAlarmId(record.scheduleId);
await NotificationService.instance.cancelFallbackNotification(
notificationId,
);
await _notificationService.cancelFallbackNotification(notificationId);
}

AlarmPermissionState _fromAuthorizationStatus(AuthorizationStatus status) {
Expand Down
82 changes: 82 additions & 0 deletions lib/core/services/notification_content.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
import 'dart:convert';

import 'package:on_time_front/core/services/notification_routing.dart';
import 'package:on_time_front/domain/entities/alarm_entities.dart';

class NotificationDisplayContent {
const NotificationDisplayContent({
required this.title,
required this.body,
required this.payload,
});

final String title;
final String body;
final String payload;
}

NotificationDisplayContent? remoteNotificationDisplayContent({
required Map<String, dynamic> data,
String? notificationTitle,
String? notificationBody,
}) {
final title = notificationTitle ?? data['title'] ?? data['Title'];
final body =
notificationBody ??
data['content'] ??
data['body'] ??
data['Content'] ??
data['Body'];

if (title == null && body == null) {
return null;
}

return NotificationDisplayContent(
title: title?.toString() ?? '알림',
body: body?.toString() ?? '',
payload: jsonEncode(data),
);
}

String? encodeLocalNotificationPayload(Map<String, dynamic>? payload) {
return payload == null ? null : jsonEncode(payload);
}

String preparationStepNotificationTitle({
required String scheduleName,
required String preparationName,
}) {
return '[$scheduleName] $preparationName';
}

String preparationStepNotificationBody({required String languageCode}) {
return localizedNotificationText(
languageCode: languageCode,
ko: '이어서 준비하세요.',
en: 'Continue preparing',
);
}

Map<String, String> preparationStepNotificationPayload({
required String scheduleId,
required String stepId,
}) {
return {
'type': 'preparation_step',
'scheduleId': scheduleId,
'stepId': stepId,
};
}

int fallbackNotificationIdForRecord(ScheduledAlarmRecord record) {
return record.fallbackNotificationId ?? stableAlarmId(record.scheduleId);
}

String fallbackAlarmNotificationBody({required String languageCode}) {
return localizedNotificationText(
languageCode: languageCode,
ko: '준비를 시작할 시간입니다.',
en: 'It is time to get ready.',
);
}
80 changes: 80 additions & 0 deletions lib/core/services/notification_routing.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
import 'dart:convert';

import 'package:equatable/equatable.dart';

class NotificationRouteTarget extends Equatable {
const NotificationRouteTarget(this.path, {this.extra});

final String path;
final Object? extra;

@override
List<Object?> get props => [path, extra];
}

String localizedNotificationText({
required String languageCode,
required String ko,
required String en,
}) {
return languageCode == 'ko' ? ko : en;
}

bool isScheduleAlarmPayload(Map<dynamic, dynamic>? payload) {
if (payload == null) return false;
final type = payload['type']?.toString();
final promptVariant = payload['promptVariant']?.toString();
return type == 'schedule_alarm' ||
payload['alarmLaunchPayloadVersion'] != null ||
(promptVariant == 'alarm' && payload['scheduleId'] != null);
}

bool isScheduleAlarmMessagePayload({
required Map<dynamic, dynamic> data,
String? title,
}) {
return isScheduleAlarmPayload(data) ||
title == '약속 알림' ||
title == 'Schedule alarm';
}

NotificationRouteTarget? notificationRouteForPayloadString(String? payload) {
if (payload == null) return null;

try {
final decoded = jsonDecode(payload);
if (decoded is! Map<String, dynamic>) {
return null;
}
return notificationRouteForData(decoded);
} on FormatException {
return null;
}
}

NotificationRouteTarget? notificationRouteForData(Map<dynamic, dynamic> data) {
final type = data['type']?.toString();
final scheduleId = data['scheduleId']?.toString();

if (type == 'schedule_alarm' && scheduleId != null) {
return NotificationRouteTarget(
'/scheduleStart',
extra: Map<String, dynamic>.from(data),
);
}

if (type != null && type.contains('5min')) {
return const NotificationRouteTarget(
'/scheduleStart',
extra: {'promptVariant': 'earlyStart'},
);
}

if ((type != null &&
(type.startsWith('schedule_') || type.startsWith('preparation_'))) ||
scheduleId != null) {
return const NotificationRouteTarget('/alarmScreen');
}

return null;
}
Loading
Loading