diff --git a/lib/features/record/components/button/midnight_taken_warning_dialog.dart b/lib/features/record/components/button/midnight_taken_warning_dialog.dart new file mode 100644 index 0000000000..1aad0630f9 --- /dev/null +++ b/lib/features/record/components/button/midnight_taken_warning_dialog.dart @@ -0,0 +1,240 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_svg/flutter_svg.dart'; +import 'package:pilll/components/atoms/button.dart'; +import 'package:pilll/components/atoms/font.dart'; +import 'package:pilll/components/atoms/text_color.dart'; +import 'package:pilll/entity/pill_sheet.codegen.dart'; +import 'package:pilll/entity/pill_sheet_type.dart'; +import 'package:pilll/entity/setting.codegen.dart'; +import 'package:pilll/features/localizations/l.dart'; +import 'package:pilll/utils/analytics.dart'; +import 'package:pilll/utils/datetime/day.dart'; +import 'package:pilll/utils/formatter/date_time_formatter.dart'; +import 'package:pilll/utils/shared_preference/keys.dart'; +import 'package:shared_preferences/shared_preferences.dart'; +import 'package:url_launcher/url_launcher.dart'; + +/// 記録操作時点でまだ届いていない当日の通知時刻を返す +/// +/// 通知時刻が0:00-2:00内(例: 1:00)に設定されているユーザーがその時刻より後に記録した場合、 +/// 通知は既に届いているため「届かない」対象から除外する +List midnightTakenWarningRemainingReminderTimes({ + required List reminderTimes, + required DateTime recordedAt, +}) { + return reminderTimes + .where( + (reminderTime) => reminderTime.hour > recordedAt.hour || (reminderTime.hour == recordedAt.hour && reminderTime.minute > recordedAt.minute)) + .toList(); +} + +/// 深夜(0:00-2:00)服用記録の注意ダイアログを表示すべきかどうかを判定する +/// +/// 日を跨いだことに気づかず服用記録をすると当日分まで記録され、 +/// 当日の通知が届かなくなる問い合わせへの対策として、30日に1回まで注意ダイアログを表示する +bool shouldShowMidnightTakenWarningDialog({ + required SharedPreferences sharedPreferences, + required DateTime takenDate, + required DateTime recordedAt, + required Setting setting, + required PillSheet activePillSheet, +}) { + // 0:00〜1:59の記録操作のみ対象 + if (recordedAt.hour >= 2) { + return false; + } + // 当日分まで服用記録された場合のみ対象。過去のピル番号タップ等、当日分が記録されない場合は当日の通知は届く + if (takenDate.date() != recordedAt.date()) { + return false; + } + // リマインダー通知がOFFの場合は当日の通知が元々送信されない(LocalNotificationService.runが早期return)ため、 + // 「通知が届かない」という注意自体が成立しない + if (!setting.isOnReminder) { + return false; + } + // 偽薬/休薬期間の通知がOFFで当日が偽薬/休薬期間の場合、当日の通知は元々登録されない + // (RegisterReminderLocalNotification.runがdosingPeriod超のピル番号をスキップ)ため、注意自体が成立しない + if (!setting.isOnNotifyInNotTakenDuration && activePillSheet.pillSheetType.dosingPeriod < activePillSheet.pillNumberFor(targetDate: recordedAt)) { + return false; + } + // 記録時点でこれから届く予定の当日通知がなければ「通知が届かない」という注意自体が成立しない + if (midnightTakenWarningRemainingReminderTimes(reminderTimes: setting.reminderTimes, recordedAt: recordedAt).isEmpty) { + return false; + } + // キーは「二度と表示しない」押下時にのみ保存されるため、未保存(null)は未押下として扱う + if (sharedPreferences.getBool(BoolKey.midnightTakenWarningDialogNeverShowAgain) ?? false) { + return false; + } + final lastShownMilliseconds = sharedPreferences.getDouble(DoubleKey.midnightTakenWarningDialogLastShownDateTime); + // 30日に1回まで: 前回表示から30日未満の場合は表示しない。未表示(null)なら無条件で表示する + if (lastShownMilliseconds != null && daysBetween(DateTime.fromMillisecondsSinceEpoch(lastShownMilliseconds.toInt()), recordedAt) < 30) { + return false; + } + return true; +} + +/// 深夜(0:00-2:00)服用記録の注意ダイアログが表示中かどうか +/// +/// 「飲んだ」ボタンとピルシートの番号タップの複数経路から短時間に呼ばれた場合に、 +/// 表示記録(SharedPreferences)の保存前に両方が表示可能と判定して二重表示されるのを防ぐ +bool _midnightTakenWarningDialogIsShowing = false; + +/// 深夜(0:00-2:00)にアプリ上で服用記録をした場合に、当日分まで服用記録されたことを知らせる注意ダイアログを表示する +/// +/// [takenDate] は服用記録された最後の日、[recordedAt] は記録操作を行った日時。 +/// 服用記録(takePill)の完了を待たずに呼び出してよい +void showMidnightTakenWarningDialogIfNeeded({ + required BuildContext context, + required DateTime takenDate, + required DateTime recordedAt, + required Setting setting, + required PillSheet activePillSheet, +}) async { + if (_midnightTakenWarningDialogIsShowing) { + return; + } + _midnightTakenWarningDialogIsShowing = true; + try { + final sharedPreferences = await SharedPreferences.getInstance(); + if (!shouldShowMidnightTakenWarningDialog( + sharedPreferences: sharedPreferences, + takenDate: takenDate, + recordedAt: recordedAt, + setting: setting, + activePillSheet: activePillSheet, + )) { + return; + } + if (!context.mounted) { + return; + } + + analytics.logScreenView(screenName: 'MidnightTakenWarningDialog'); + await showDialog( + context: context, + // 「閉じる」でSharedPreferencesに表示記録を残すため、ボタン以外で閉じられないようにする + barrierDismissible: false, + builder: (context) => MidnightTakenWarningDialog( + takenDate: takenDate, + reminderTimes: midnightTakenWarningRemainingReminderTimes(reminderTimes: setting.reminderTimes, recordedAt: recordedAt), + // 2回目以降(過去に「閉じる」で表示日時が保存済み)の表示でのみ「二度と表示しない」を出す + showsNeverShowAgainButton: sharedPreferences.containsKey(DoubleKey.midnightTakenWarningDialogLastShownDateTime), + ), + ); + } finally { + // ダイアログが閉じた時点で表示記録は保存済みのため、フラグ解除後の再表示は30日条件で防がれる + _midnightTakenWarningDialogIsShowing = false; + } +} + +/// 深夜(0:00-2:00)の服用記録で当日分まで記録されたことを知らせる注意ダイアログ +class MidnightTakenWarningDialog extends StatelessWidget { + /// 服用記録をした日時。本文の日付(例: 7/11)の表示に使う + final DateTime takenDate; + + /// 記録時点でまだ届いていない当日の通知時刻。本文の通知時刻(例: 19:00)の表示に使う + final List reminderTimes; + + /// 「二度と表示しない」ボタンを表示するかどうか。2回目以降の表示でtrue + final bool showsNeverShowAgainButton; + + const MidnightTakenWarningDialog({ + super.key, + required this.takenDate, + required this.reminderTimes, + required this.showsNeverShowAgainButton, + }); + + @override + Widget build(BuildContext context) { + return AlertDialog( + contentPadding: const EdgeInsets.only( + left: 24, + right: 24, + top: 4, + bottom: 24, + ), + actionsPadding: const EdgeInsets.only(left: 24, right: 24, bottom: 32), + titlePadding: const EdgeInsets.only(top: 32), + title: SvgPicture.asset('images/alert_24.svg', width: 24, height: 24), + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.all(Radius.circular(20)), + ), + content: Column( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + mainAxisSize: MainAxisSize.min, + children: [ + Text( + L.midnightTakenWarningDialogTitle, + style: const TextStyle( + fontFamily: FontFamily.japanese, + fontWeight: FontWeight.w700, + fontSize: 16, + color: TextColor.main, + ), + textAlign: TextAlign.center, + ), + const SizedBox(height: 16), + Text( + L.midnightTakenWarningDialogBody( + '${takenDate.month}/${takenDate.day}', + reminderTimes.map((reminderTime) => DateTimeFormatter.militaryTime(reminderTime.dateTime())).join(', '), + ), + style: const TextStyle( + fontFamily: FontFamily.japanese, + fontWeight: FontWeight.w300, + fontSize: 14, + height: 1.7, + color: TextColor.main, + ), + textAlign: TextAlign.center, + ), + ], + ), + actions: [ + AppOutlinedButton( + onPressed: () async { + analytics.logEvent(name: 'midnight_taken_warning_faq'); + launchUrl( + Uri.parse( + 'https://pilll.notion.site/63c3436e63ea4ba8a1407be40cb5f0e5', + ), + ); + }, + text: L.midnightTakenWarningDialogSeeHowToRevert, + ), + Center( + child: AlertButton( + text: L.close, + onPressed: () async { + analytics.logEvent(name: 'midnight_taken_warning_close'); + await (await SharedPreferences.getInstance()).setDouble( + DoubleKey.midnightTakenWarningDialogLastShownDateTime, + now().millisecondsSinceEpoch.toDouble(), + ); + if (context.mounted) { + Navigator.of(context).pop(); + } + }, + ), + ), + if (showsNeverShowAgainButton) + Center( + child: AlertButton( + text: L.midnightTakenWarningDialogNeverShowAgain, + onPressed: () async { + analytics.logEvent(name: 'midnight_taken_warning_never_show_again'); + await (await SharedPreferences.getInstance()).setBool( + BoolKey.midnightTakenWarningDialogNeverShowAgain, + true, + ); + if (context.mounted) { + Navigator.of(context).pop(); + } + }, + ), + ), + ], + ); + } +} diff --git a/lib/features/record/components/button/record_page_button.dart b/lib/features/record/components/button/record_page_button.dart index 2abe49c2ec..90c2d6784f 100644 --- a/lib/features/record/components/button/record_page_button.dart +++ b/lib/features/record/components/button/record_page_button.dart @@ -6,20 +6,25 @@ import 'package:pilll/features/record/components/button/rest_duration_button.dar import 'package:pilll/features/record/components/button/taken_button.dart'; import 'package:pilll/entity/pill_sheet.codegen.dart'; import 'package:pilll/entity/pill_sheet_group.codegen.dart'; +import 'package:pilll/entity/setting.codegen.dart'; import 'package:pilll/utils/local_notification.dart'; -class RecordPageButton extends HookConsumerWidget { +class RecordPageButton extends ConsumerWidget { final PillSheetGroup pillSheetGroup; final PillSheet currentPillSheet; final bool userIsPremiumOtTrial; final User user; + /// TakenButtonの深夜(0:00-2:00)服用記録の注意ダイアログの表示判定・文言に使う + final Setting setting; + const RecordPageButton({ super.key, required this.pillSheetGroup, required this.currentPillSheet, required this.userIsPremiumOtTrial, required this.user, + required this.setting, }); @override @@ -48,6 +53,7 @@ class RecordPageButton extends HookConsumerWidget { pillSheetGroup: pillSheetGroup, activePillSheet: currentPillSheet, userIsPremiumOtTrial: userIsPremiumOtTrial, + setting: setting, registerReminderLocalNotification: registerReminderLocalNotification, ); } diff --git a/lib/features/record/components/button/taken_button.dart b/lib/features/record/components/button/taken_button.dart index 4319d0313b..2fc2f1ba73 100644 --- a/lib/features/record/components/button/taken_button.dart +++ b/lib/features/record/components/button/taken_button.dart @@ -8,18 +8,23 @@ import 'package:pilll/features/release_note/release_note.dart'; import 'package:pilll/features/record/util/request_in_app_review.dart'; import 'package:pilll/entity/pill_sheet.codegen.dart'; import 'package:pilll/entity/pill_sheet_group.codegen.dart'; +import 'package:pilll/entity/setting.codegen.dart'; import 'package:pilll/features/error/error_alert.dart'; +import 'package:pilll/features/record/components/button/midnight_taken_warning_dialog.dart'; import 'package:pilll/utils/error_log.dart'; import 'package:pilll/native/widget.dart'; import 'package:pilll/provider/take_pill.dart'; import 'package:pilll/utils/datetime/day.dart'; import 'package:pilll/utils/local_notification.dart'; -class TakenButton extends HookConsumerWidget { +class TakenButton extends ConsumerWidget { final BuildContext parentContext; final PillSheetGroup pillSheetGroup; final PillSheet activePillSheet; final bool userIsPremiumOtTrial; + + /// 深夜(0:00-2:00)服用記録の注意ダイアログの表示判定・文言に使う + final Setting setting; final RegisterReminderLocalNotification registerReminderLocalNotification; const TakenButton({ @@ -28,6 +33,7 @@ class TakenButton extends HookConsumerWidget { required this.pillSheetGroup, required this.activePillSheet, required this.userIsPremiumOtTrial, + required this.setting, required this.registerReminderLocalNotification, }); @override @@ -49,12 +55,22 @@ class TakenButton extends HookConsumerWidget { ); requestInAppReview(); showReleaseNotePreDialog(context); - final updatedPillSheetGroup = await takePill( - takenDate: now(), + final takenDate = now(); + // 深夜服用の注意ダイアログは服用記録の完了(await)を待たずに表示するため、awaitの前に服用記録を開始する + final updatedPillSheetGroupFuture = takePill( + takenDate: takenDate, pillSheetGroup: pillSheetGroup, activePillSheet: activePillSheet, isQuickRecord: false, ); + showMidnightTakenWarningDialogIfNeeded( + context: context, + takenDate: takenDate, + recordedAt: takenDate, + setting: setting, + activePillSheet: activePillSheet, + ); + final updatedPillSheetGroup = await updatedPillSheetGroupFuture; syncActivePillSheetValue(pillSheetGroup: updatedPillSheetGroup); await registerReminderLocalNotification(); } catch (exception, stack) { diff --git a/lib/features/record/components/pill_sheet/record_page_pill_sheet.dart b/lib/features/record/components/pill_sheet/record_page_pill_sheet.dart index bf933ff2e0..b5bc54300a 100644 --- a/lib/features/record/components/pill_sheet/record_page_pill_sheet.dart +++ b/lib/features/record/components/pill_sheet/record_page_pill_sheet.dart @@ -12,6 +12,7 @@ import 'package:pilll/components/organisms/pill_mark/pill_mark_with_number_layou import 'package:pilll/components/organisms/pill_sheet/pill_sheet_view_layout.dart'; import 'package:pilll/components/organisms/pill_sheet/pill_sheet_view_weekday_line.dart'; import 'package:pilll/features/release_note/release_note.dart'; +import 'package:pilll/features/record/components/button/midnight_taken_warning_dialog.dart'; import 'package:pilll/features/record/components/pill_sheet/components/pill_number.dart'; import 'package:pilll/features/record/util/request_in_app_review.dart'; import 'package:pilll/provider/revert_take_pill.dart'; @@ -29,7 +30,7 @@ import 'package:pilll/utils/datetime/day.dart'; import 'package:pilll/utils/local_notification.dart'; // ここを編集する時は historical_pill_sheet_group/component/pill_sheet.dart も編集する -class RecordPagePillSheet extends HookConsumerWidget { +class RecordPagePillSheet extends ConsumerWidget { final PillSheetGroup pillSheetGroup; final PillSheet pillSheet; final Setting setting; @@ -175,13 +176,26 @@ class RecordPagePillSheet extends HookConsumerWidget { requestInAppReview(); showReleaseNotePreDialog(context); - await _takeWithPillNumber( - takePill, - registerReminderLocalNotification, - pillSheetGroup: pillSheetGroup, - pillNumberInPillSheet: pillNumberInPillSheet, - pillSheet: pillSheet, - ); + // _takeWithPillNumberは服用記録されないケースでnullを返す + final isRecorded = await _takeWithPillNumber( + takePill, + registerReminderLocalNotification, + pillSheetGroup: pillSheetGroup, + pillNumberInPillSheet: pillNumberInPillSheet, + pillSheet: pillSheet, + ) != + null; + // 実際に服用記録された場合のみダイアログの表示判定をする。記録された場合activePillSheetは必ず存在する + final activePillSheet = pillSheetGroup.activePillSheet; + if (isRecorded && activePillSheet != null && context.mounted) { + showMidnightTakenWarningDialogIfNeeded( + context: context, + takenDate: pillSheet.displayPillTakeDate(pillNumberInPillSheet), + recordedAt: now(), + setting: setting, + activePillSheet: activePillSheet, + ); + } } } catch (exception, stack) { errorLogger.recordError(exception, stack); diff --git a/lib/features/record/page.dart b/lib/features/record/page.dart index 2bc73b8a3b..a017ed9ceb 100644 --- a/lib/features/record/page.dart +++ b/lib/features/record/page.dart @@ -60,7 +60,7 @@ class RecordPage extends HookConsumerWidget { } } -class RecordPageBody extends HookConsumerWidget { +class RecordPageBody extends ConsumerWidget { final PillSheetGroup? pillSheetGroup; final Setting setting; final User user; @@ -155,6 +155,7 @@ class RecordPageBody extends HookConsumerWidget { currentPillSheet: activePillSheet, userIsPremiumOtTrial: user.premiumOrTrial, user: user, + setting: setting, ), const SizedBox(height: 40), ], diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index c8860d2f52..2c63ebb87c 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -2800,5 +2800,29 @@ "restDurationFeatureAppealPoint3": "Easy to edit or resume your cycle", "@restDurationFeatureAppealPoint3": { "description": "" + }, + "midnightTakenWarningDialogTitle": "Please note", + "@midnightTakenWarningDialogTitle": { + "description": "" + }, + "midnightTakenWarningDialogBody": "Your pills have been recorded as taken through {takenDate}. Today's ({takenDate}) reminder notification at {reminderTimes} will not be delivered.\nIf this was a mistake, please revert the taken record.", + "@midnightTakenWarningDialogBody": { + "description": "", + "placeholders": { + "takenDate": { + "type": "String" + }, + "reminderTimes": { + "type": "String" + } + } + }, + "midnightTakenWarningDialogSeeHowToRevert": "See how to revert a taken record", + "@midnightTakenWarningDialogSeeHowToRevert": { + "description": "" + }, + "midnightTakenWarningDialogNeverShowAgain": "Don't show this again", + "@midnightTakenWarningDialogNeverShowAgain": { + "description": "" } } \ No newline at end of file diff --git a/lib/l10n/app_ja.arb b/lib/l10n/app_ja.arb index 0ecb2c26d0..2c6df2a92d 100644 --- a/lib/l10n/app_ja.arb +++ b/lib/l10n/app_ja.arb @@ -2699,5 +2699,30 @@ "restDurationFeatureAppealPoint1": "無料で使える休薬記録", "restDurationFeatureAppealPoint2": "ピルシート右上の歯車から開始", - "restDurationFeatureAppealPoint3": "期間の編集・再開もかんたん" + "restDurationFeatureAppealPoint3": "期間の編集・再開もかんたん", + + "midnightTakenWarningDialogTitle": "ご注意ください", + "@midnightTakenWarningDialogTitle": { + "description": "深夜0時〜2時にアプリで服用記録をした際に表示される注意ダイアログのタイトルです。日を跨いだことに気づかず当日分まで服用記録されたことをユーザーに知らせます。" + }, + "midnightTakenWarningDialogBody": "{takenDate}分の服用記録までされました。{takenDate}である本日{reminderTimes}の通知はお手元に届きません。\n間違いの場合は、服用を取り消してください", + "@midnightTakenWarningDialogBody": { + "description": "深夜0時〜2時にアプリで服用記録をした際に表示される注意ダイアログの本文です。{takenDate}には服用記録された日付(例:7/11)、{reminderTimes}にはユーザーが設定している通知時刻(例:19:00)が表示されます。", + "placeholders": { + "takenDate": { + "type": "String" + }, + "reminderTimes": { + "type": "String" + } + } + }, + "midnightTakenWarningDialogSeeHowToRevert": "服用の取り消し方法を見る", + "@midnightTakenWarningDialogSeeHowToRevert": { + "description": "深夜服用記録の注意ダイアログに表示される、服用記録の取り消し方法を説明するFAQページを開くボタンの文言です。" + }, + "midnightTakenWarningDialogNeverShowAgain": "二度と表示しない", + "@midnightTakenWarningDialogNeverShowAgain": { + "description": "深夜服用記録の注意ダイアログを今後表示しないようにするボタンの文言です。2回目以降の表示でのみ現れます。" + } } diff --git a/lib/utils/shared_preference/keys.dart b/lib/utils/shared_preference/keys.dart index 96b2404ce1..81a99c72e0 100644 --- a/lib/utils/shared_preference/keys.dart +++ b/lib/utils/shared_preference/keys.dart @@ -53,6 +53,16 @@ extension BoolKey on String { /// 服用おやすみ (無料機能) のアピール Bar を × で閉じたかどうか。 static const restDurationFeatureAppealIsClosed = 'restDurationFeatureAppealIsClosed'; + + /// 深夜(0:00-2:00)服用記録の注意ダイアログで「二度と表示しない」が押されたかどうか。 + /// trueの場合、以降このダイアログは表示しない。 + static const midnightTakenWarningDialogNeverShowAgain = 'midnightTakenWarningDialogNeverShowAgain'; +} + +extension DoubleKey on String { + /// 深夜(0:00-2:00)服用記録の注意ダイアログを「閉じる」で閉じた日時 (millisecondsSinceEpoch)。 + /// 30日に1回まで表示する判定と、2回目以降の表示判定(「二度と表示しない」ボタンの出し分け)に使う。 + static const midnightTakenWarningDialogLastShownDateTime = 'midnightTakenWarningDialogLastShownDateTime'; } extension StringKey on String { diff --git a/test/features/record/components/button/midnight_taken_warning_dialog_test.dart b/test/features/record/components/button/midnight_taken_warning_dialog_test.dart new file mode 100644 index 0000000000..a22f65359e --- /dev/null +++ b/test/features/record/components/button/midnight_taken_warning_dialog_test.dart @@ -0,0 +1,473 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:pilll/components/atoms/button.dart'; +import 'package:pilll/entity/pill_sheet.codegen.dart'; +import 'package:pilll/entity/pill_sheet_type.dart'; +import 'package:pilll/entity/setting.codegen.dart'; +import 'package:pilll/features/record/components/button/midnight_taken_warning_dialog.dart'; +import 'package:pilll/utils/shared_preference/keys.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +/// 表示条件を満たす基本形のSettingを生成する +/// +/// デフォルト値は「19:00のリマインダー通知がONで設定されている」状態。 +/// 深夜0:00-1:59の記録操作に対して通知が未到達になる、ダイアログ表示対象の典型ケースを表す +Setting buildSetting({ + List reminderTimes = const [ReminderTime(hour: 19, minute: 0)], + bool isOnReminder = true, + bool isOnNotifyInNotTakenDuration = true, +}) { + return Setting( + pillNumberForFromMenstruation: 1, + durationMenstruation: 4, + reminderTimes: reminderTimes, + timezoneDatabaseName: null, + isOnReminder: isOnReminder, + isOnNotifyInNotTakenDuration: isOnNotifyInNotTakenDuration, + ); +} + +/// 表示条件を満たす基本形のactivePillSheetを生成する +/// +/// 28錠タイプ(21錠実薬+7錠偽薬)で、[recordedAt] がシートの [pillNumberForRecordedAt] 番目にあたるピルシートを返す。 +/// デフォルトの10番目は実薬期間の日を表し、ダイアログ表示対象の典型ケース +PillSheet buildActivePillSheet( + {required DateTime recordedAt, int pillNumberForRecordedAt = 10}) { + return PillSheet.v1( + id: 'pill_sheet_id', + typeInfo: PillSheetType.pillsheet_28_7.typeInfo, + beginDate: recordedAt.subtract(Duration(days: pillNumberForRecordedAt - 1)), + lastTakenDate: null, + createdAt: recordedAt, + ); +} + +void main() { + setUp(() { + TestWidgetsFlutterBinding.ensureInitialized(); + }); + + group("#midnightTakenWarningRemainingReminderTimes", () { + test("記録時刻より後の通知時刻のみ残る", () { + expect( + midnightTakenWarningRemainingReminderTimes( + reminderTimes: const [ + ReminderTime(hour: 1, minute: 0), + ReminderTime(hour: 19, minute: 0), + ], + recordedAt: DateTime(2026, 7, 11, 1, 30), + ), + const [ReminderTime(hour: 19, minute: 0)], + ); + }); + + test("記録時刻と同時刻の通知時刻は残らない", () { + expect( + midnightTakenWarningRemainingReminderTimes( + reminderTimes: const [ReminderTime(hour: 0, minute: 30)], + recordedAt: DateTime(2026, 7, 11, 0, 30), + ), + isEmpty, + ); + }); + + test("記録時刻より1分後の通知時刻は残る", () { + expect( + midnightTakenWarningRemainingReminderTimes( + reminderTimes: const [ReminderTime(hour: 0, minute: 31)], + recordedAt: DateTime(2026, 7, 11, 0, 30), + ), + const [ReminderTime(hour: 0, minute: 31)], + ); + }); + }); + + group("#shouldShowMidnightTakenWarningDialog", () { + group("記録操作時刻の境界値", () { + test("23:59の記録操作の場合は表示しない", () async { + SharedPreferences.setMockInitialValues({}); + expect( + shouldShowMidnightTakenWarningDialog( + sharedPreferences: await SharedPreferences.getInstance(), + takenDate: DateTime(2026, 7, 10, 23, 59), + recordedAt: DateTime(2026, 7, 10, 23, 59), + setting: buildSetting(), + activePillSheet: + buildActivePillSheet(recordedAt: DateTime(2026, 7, 10, 23, 59)), + ), + false, + ); + }); + + test("0:00の記録操作の場合は表示する", () async { + SharedPreferences.setMockInitialValues({}); + expect( + shouldShowMidnightTakenWarningDialog( + sharedPreferences: await SharedPreferences.getInstance(), + takenDate: DateTime(2026, 7, 11, 0, 0), + recordedAt: DateTime(2026, 7, 11, 0, 0), + setting: buildSetting(), + activePillSheet: + buildActivePillSheet(recordedAt: DateTime(2026, 7, 11, 0, 0)), + ), + true, + ); + }); + + test("1:59の記録操作の場合は表示する", () async { + SharedPreferences.setMockInitialValues({}); + expect( + shouldShowMidnightTakenWarningDialog( + sharedPreferences: await SharedPreferences.getInstance(), + takenDate: DateTime(2026, 7, 11, 1, 59), + recordedAt: DateTime(2026, 7, 11, 1, 59), + setting: buildSetting(), + activePillSheet: + buildActivePillSheet(recordedAt: DateTime(2026, 7, 11, 1, 59)), + ), + true, + ); + }); + + test("2:00の記録操作の場合は表示しない", () async { + SharedPreferences.setMockInitialValues({}); + expect( + shouldShowMidnightTakenWarningDialog( + sharedPreferences: await SharedPreferences.getInstance(), + takenDate: DateTime(2026, 7, 11, 2, 0), + recordedAt: DateTime(2026, 7, 11, 2, 0), + setting: buildSetting(), + activePillSheet: + buildActivePillSheet(recordedAt: DateTime(2026, 7, 11, 2, 0)), + ), + false, + ); + }); + }); + + group("服用記録された日", () { + test("過去のピル番号タップ等、当日分まで記録されない場合は表示しない", () async { + SharedPreferences.setMockInitialValues({}); + expect( + shouldShowMidnightTakenWarningDialog( + sharedPreferences: await SharedPreferences.getInstance(), + takenDate: DateTime(2026, 7, 10), + recordedAt: DateTime(2026, 7, 11, 0, 30), + setting: buildSetting(), + activePillSheet: + buildActivePillSheet(recordedAt: DateTime(2026, 7, 11, 0, 30)), + ), + false, + ); + }); + + test("当日のピル番号タップで当日分まで記録された場合は表示する", () async { + SharedPreferences.setMockInitialValues({}); + expect( + shouldShowMidnightTakenWarningDialog( + sharedPreferences: await SharedPreferences.getInstance(), + takenDate: DateTime(2026, 7, 11), + recordedAt: DateTime(2026, 7, 11, 0, 30), + setting: buildSetting(), + activePillSheet: + buildActivePillSheet(recordedAt: DateTime(2026, 7, 11, 0, 30)), + ), + true, + ); + }); + }); + + group("通知時刻の設定", () { + test("通知時刻が未設定の場合は表示しない", () async { + SharedPreferences.setMockInitialValues({}); + expect( + shouldShowMidnightTakenWarningDialog( + sharedPreferences: await SharedPreferences.getInstance(), + takenDate: DateTime(2026, 7, 11, 0, 10), + recordedAt: DateTime(2026, 7, 11, 0, 10), + setting: buildSetting(reminderTimes: const []), + activePillSheet: + buildActivePillSheet(recordedAt: DateTime(2026, 7, 11, 0, 10)), + ), + false, + ); + }); + + test("通知時刻1:00で0:30に記録した場合は表示する(1:00の通知はまだ届いていない)", () async { + SharedPreferences.setMockInitialValues({}); + expect( + shouldShowMidnightTakenWarningDialog( + sharedPreferences: await SharedPreferences.getInstance(), + takenDate: DateTime(2026, 7, 11, 0, 30), + recordedAt: DateTime(2026, 7, 11, 0, 30), + setting: buildSetting( + reminderTimes: const [ReminderTime(hour: 1, minute: 0)]), + activePillSheet: + buildActivePillSheet(recordedAt: DateTime(2026, 7, 11, 0, 30)), + ), + true, + ); + }); + + test("リマインダー通知がOFFの場合は表示しない", () async { + SharedPreferences.setMockInitialValues({}); + expect( + shouldShowMidnightTakenWarningDialog( + sharedPreferences: await SharedPreferences.getInstance(), + takenDate: DateTime(2026, 7, 11, 0, 10), + recordedAt: DateTime(2026, 7, 11, 0, 10), + setting: buildSetting(isOnReminder: false), + activePillSheet: + buildActivePillSheet(recordedAt: DateTime(2026, 7, 11, 0, 10)), + ), + false, + ); + }); + + test("通知時刻1:00で1:30に記録した場合は表示しない(通知は既に届いている)", () async { + SharedPreferences.setMockInitialValues({}); + expect( + shouldShowMidnightTakenWarningDialog( + sharedPreferences: await SharedPreferences.getInstance(), + takenDate: DateTime(2026, 7, 11, 1, 30), + recordedAt: DateTime(2026, 7, 11, 1, 30), + setting: buildSetting( + reminderTimes: const [ReminderTime(hour: 1, minute: 0)]), + activePillSheet: + buildActivePillSheet(recordedAt: DateTime(2026, 7, 11, 1, 30)), + ), + false, + ); + }); + }); + + group("休薬/偽薬期間の通知設定", () { + test("偽薬期間の通知がOFFで当日が偽薬期間(22番目)の場合は表示しない", () async { + SharedPreferences.setMockInitialValues({}); + expect( + shouldShowMidnightTakenWarningDialog( + sharedPreferences: await SharedPreferences.getInstance(), + takenDate: DateTime(2026, 7, 11, 0, 10), + recordedAt: DateTime(2026, 7, 11, 0, 10), + setting: buildSetting(isOnNotifyInNotTakenDuration: false), + activePillSheet: buildActivePillSheet( + recordedAt: DateTime(2026, 7, 11, 0, 10), + pillNumberForRecordedAt: 22), + ), + false, + ); + }); + + test("偽薬期間の通知がOFFでも当日が実薬期間の最終日(21番目)の場合は表示する", () async { + SharedPreferences.setMockInitialValues({}); + expect( + shouldShowMidnightTakenWarningDialog( + sharedPreferences: await SharedPreferences.getInstance(), + takenDate: DateTime(2026, 7, 11, 0, 10), + recordedAt: DateTime(2026, 7, 11, 0, 10), + setting: buildSetting(isOnNotifyInNotTakenDuration: false), + activePillSheet: buildActivePillSheet( + recordedAt: DateTime(2026, 7, 11, 0, 10), + pillNumberForRecordedAt: 21), + ), + true, + ); + }); + + test("偽薬期間の通知がONの場合は当日が偽薬期間(22番目)でも表示する", () async { + SharedPreferences.setMockInitialValues({}); + expect( + shouldShowMidnightTakenWarningDialog( + sharedPreferences: await SharedPreferences.getInstance(), + takenDate: DateTime(2026, 7, 11, 0, 10), + recordedAt: DateTime(2026, 7, 11, 0, 10), + setting: buildSetting(), + activePillSheet: buildActivePillSheet( + recordedAt: DateTime(2026, 7, 11, 0, 10), + pillNumberForRecordedAt: 22), + ), + true, + ); + }); + }); + + group("SharedPreferencesの保存状態", () { + test("「二度と表示しない」が保存済みの場合は表示しない", () async { + SharedPreferences.setMockInitialValues({ + BoolKey.midnightTakenWarningDialogNeverShowAgain: true, + }); + expect( + shouldShowMidnightTakenWarningDialog( + sharedPreferences: await SharedPreferences.getInstance(), + takenDate: DateTime(2026, 7, 11, 0, 10), + recordedAt: DateTime(2026, 7, 11, 0, 10), + setting: buildSetting(), + activePillSheet: + buildActivePillSheet(recordedAt: DateTime(2026, 7, 11, 0, 10)), + ), + false, + ); + }); + + test("前日に表示済みの場合は表示しない(2日連続で表示されない)", () async { + SharedPreferences.setMockInitialValues({ + DoubleKey.midnightTakenWarningDialogLastShownDateTime: + DateTime(2026, 6, 30, 0, 30).millisecondsSinceEpoch.toDouble(), + }); + expect( + shouldShowMidnightTakenWarningDialog( + sharedPreferences: await SharedPreferences.getInstance(), + takenDate: DateTime(2026, 7, 1, 0, 10), + recordedAt: DateTime(2026, 7, 1, 0, 10), + setting: buildSetting(), + activePillSheet: + buildActivePillSheet(recordedAt: DateTime(2026, 7, 1, 0, 10)), + ), + false, + ); + }); + + test("前回表示から29日後の場合は表示しない", () async { + SharedPreferences.setMockInitialValues({ + DoubleKey.midnightTakenWarningDialogLastShownDateTime: + DateTime(2026, 6, 12, 0, 30).millisecondsSinceEpoch.toDouble(), + }); + expect( + shouldShowMidnightTakenWarningDialog( + sharedPreferences: await SharedPreferences.getInstance(), + takenDate: DateTime(2026, 7, 11, 0, 10), + recordedAt: DateTime(2026, 7, 11, 0, 10), + setting: buildSetting(), + activePillSheet: + buildActivePillSheet(recordedAt: DateTime(2026, 7, 11, 0, 10)), + ), + false, + ); + }); + + test("前回表示から30日後の場合は表示する", () async { + SharedPreferences.setMockInitialValues({ + DoubleKey.midnightTakenWarningDialogLastShownDateTime: + DateTime(2026, 6, 11, 0, 30).millisecondsSinceEpoch.toDouble(), + }); + expect( + shouldShowMidnightTakenWarningDialog( + sharedPreferences: await SharedPreferences.getInstance(), + takenDate: DateTime(2026, 7, 11, 0, 10), + recordedAt: DateTime(2026, 7, 11, 0, 10), + setting: buildSetting(), + activePillSheet: + buildActivePillSheet(recordedAt: DateTime(2026, 7, 11, 0, 10)), + ), + true, + ); + }); + + test("年をまたいで前回表示から30日以上経過している場合は表示する", () async { + SharedPreferences.setMockInitialValues({ + DoubleKey.midnightTakenWarningDialogLastShownDateTime: + DateTime(2026, 12, 10, 0, 30).millisecondsSinceEpoch.toDouble(), + }); + expect( + shouldShowMidnightTakenWarningDialog( + sharedPreferences: await SharedPreferences.getInstance(), + takenDate: DateTime(2027, 1, 9, 0, 10), + recordedAt: DateTime(2027, 1, 9, 0, 10), + setting: buildSetting(), + activePillSheet: + buildActivePillSheet(recordedAt: DateTime(2027, 1, 9, 0, 10)), + ), + true, + ); + }); + + test("年をまたいでも前回表示から30日未満の場合は表示しない", () async { + SharedPreferences.setMockInitialValues({ + DoubleKey.midnightTakenWarningDialogLastShownDateTime: + DateTime(2026, 12, 31, 0, 30).millisecondsSinceEpoch.toDouble(), + }); + expect( + shouldShowMidnightTakenWarningDialog( + sharedPreferences: await SharedPreferences.getInstance(), + takenDate: DateTime(2027, 1, 1, 0, 10), + recordedAt: DateTime(2027, 1, 1, 0, 10), + setting: buildSetting(), + activePillSheet: + buildActivePillSheet(recordedAt: DateTime(2027, 1, 1, 0, 10)), + ), + false, + ); + }); + }); + }); + + group("MidnightTakenWarningDialog", () { + testWidgets("初回表示の場合は「二度と表示しない」ボタンが表示されない", (WidgetTester tester) async { + await tester.pumpWidget( + MaterialApp( + home: MidnightTakenWarningDialog( + takenDate: DateTime(2026, 7, 11, 0, 10), + reminderTimes: const [ReminderTime(hour: 19, minute: 0)], + showsNeverShowAgainButton: false, + ), + ), + ); + await tester.pump(); + + // FAQリンクボタンと「閉じる」ボタンのみ表示される + expect(find.byType(AppOutlinedButton), findsOneWidget); + expect(find.byType(AlertButton), findsOneWidget); + }); + + testWidgets("2回目以降の表示の場合は「二度と表示しない」ボタンが表示される", (WidgetTester tester) async { + await tester.pumpWidget( + MaterialApp( + home: MidnightTakenWarningDialog( + takenDate: DateTime(2026, 7, 11, 0, 10), + reminderTimes: const [ReminderTime(hour: 19, minute: 0)], + showsNeverShowAgainButton: true, + ), + ), + ); + await tester.pump(); + + // 「閉じる」に加えて「二度と表示しない」のAlertButtonが表示される + expect(find.byType(AppOutlinedButton), findsOneWidget); + expect(find.byType(AlertButton), findsNWidgets(2)); + }); + + testWidgets("本文に服用記録された日付と通知時刻が表示される", (WidgetTester tester) async { + await tester.pumpWidget( + MaterialApp( + home: MidnightTakenWarningDialog( + takenDate: DateTime(2026, 7, 11, 0, 10), + reminderTimes: const [ReminderTime(hour: 19, minute: 0)], + showsNeverShowAgainButton: false, + ), + ), + ); + await tester.pump(); + + expect(find.textContaining('7/11'), findsOneWidget); + expect(find.textContaining('19:00'), findsOneWidget); + }); + + testWidgets("通知時刻が複数ある場合は全ての時刻が表示される", (WidgetTester tester) async { + await tester.pumpWidget( + MaterialApp( + home: MidnightTakenWarningDialog( + takenDate: DateTime(2026, 7, 11, 0, 10), + reminderTimes: const [ + ReminderTime(hour: 19, minute: 0), + ReminderTime(hour: 22, minute: 30), + ], + showsNeverShowAgainButton: false, + ), + ), + ); + await tester.pump(); + + expect(find.textContaining('19:00, 22:30'), findsOneWidget); + }); + }); +} diff --git a/test/features/record/components/button/record_page_button_test.dart b/test/features/record/components/button/record_page_button_test.dart index 490e945c86..b2fc57e9eb 100644 --- a/test/features/record/components/button/record_page_button_test.dart +++ b/test/features/record/components/button/record_page_button_test.dart @@ -2,6 +2,7 @@ import 'package:flutter/rendering.dart'; import 'package:pilll/entity/pill_sheet.codegen.dart'; import 'package:pilll/entity/pill_sheet_group.codegen.dart'; import 'package:pilll/entity/pill_sheet_type.dart'; +import 'package:pilll/entity/setting.codegen.dart'; import 'package:pilll/features/record/components/button/cancel_button.dart'; import 'package:pilll/features/record/components/button/record_page_button.dart'; import 'package:pilll/features/record/components/button/rest_duration_button.dart'; @@ -93,6 +94,13 @@ void main() { currentPillSheet: activePillSheet, userIsPremiumOtTrial: false, user: FakeUser(), + setting: const Setting( + pillNumberForFromMenstruation: 1, + durationMenstruation: 4, + reminderTimes: [], + timezoneDatabaseName: null, + isOnReminder: true, + ), ), ), ), @@ -172,6 +180,13 @@ void main() { currentPillSheet: activePillSheet, userIsPremiumOtTrial: false, user: FakeUser(), + setting: const Setting( + pillNumberForFromMenstruation: 1, + durationMenstruation: 4, + reminderTimes: [], + timezoneDatabaseName: null, + isOnReminder: true, + ), ), ), ), @@ -247,6 +262,13 @@ void main() { currentPillSheet: activePillSheet, userIsPremiumOtTrial: false, user: FakeUser(), + setting: const Setting( + pillNumberForFromMenstruation: 1, + durationMenstruation: 4, + reminderTimes: [], + timezoneDatabaseName: null, + isOnReminder: true, + ), ), ), ), diff --git a/test/features/record/record_page_button_state_test.dart b/test/features/record/record_page_button_state_test.dart index f20d351f4b..e584f75c50 100644 --- a/test/features/record/record_page_button_state_test.dart +++ b/test/features/record/record_page_button_state_test.dart @@ -7,6 +7,7 @@ import 'package:pilll/features/record/components/button/taken_button.dart'; import 'package:pilll/entity/pill_sheet.codegen.dart'; import 'package:pilll/entity/pill_sheet_group.codegen.dart'; import 'package:pilll/entity/pill_sheet_type.dart'; +import 'package:pilll/entity/setting.codegen.dart'; import 'package:pilll/utils/datetime/day.dart'; import 'package:pilll/utils/environment.dart'; import 'package:flutter/material.dart'; @@ -55,6 +56,13 @@ void main() { currentPillSheet: pillSheet, userIsPremiumOtTrial: false, user: FakeUser(), + setting: const Setting( + pillNumberForFromMenstruation: 1, + durationMenstruation: 4, + reminderTimes: [], + timezoneDatabaseName: null, + isOnReminder: true, + ), ), ), ), @@ -91,6 +99,13 @@ void main() { currentPillSheet: pillSheet, userIsPremiumOtTrial: false, user: FakeUser(), + setting: const Setting( + pillNumberForFromMenstruation: 1, + durationMenstruation: 4, + reminderTimes: [], + timezoneDatabaseName: null, + isOnReminder: true, + ), ), ), ),