diff --git a/lib/entity/pill_sheet_modified_history.codegen.dart b/lib/entity/pill_sheet_modified_history.codegen.dart index 1392be3151..0b635cb827 100644 --- a/lib/entity/pill_sheet_modified_history.codegen.dart +++ b/lib/entity/pill_sheet_modified_history.codegen.dart @@ -303,44 +303,113 @@ abstract class PillSheetModifiedHistoryServiceActionFactory { } } -/// 渡されたPillSheetModifiedHistory配列から飲み忘れ日数を計算する -int missedPillDays({ +/// takenPill / automaticallyRecordedLastTakenDate の履歴が記録対象としたピル日付 +/// (before/after の lastTakenDate 差分。日付のみに正規化)を返す。 +/// まとめ記録では1回の操作で複数日分のピルが記録され、履歴の日時は操作時刻になるため、 +/// 操作日ではなく記録対象日 = (記録前の最終服用日, 記録後の最終服用日] を展開する。 +/// 記録前の最終服用日が無い(初回記録)場合はシート開始日から展開する。 +/// 2錠飲み等で同じピル日に2回目を記録した履歴は lastTakenDate が変わらないため、その日を返す。 +/// グループ情報の無い過去の履歴は記録対象日を特定できないため履歴日時を記録日とみなす。 +List _takenPillLastTakenRangeDates(PillSheetModifiedHistory history) { + final afterPillSheet = history.afterPillSheetGroup?.lastTakenPillSheetOrFirstPillSheet; + final afterLastTakenDate = afterPillSheet?.lastTakenDate; + if (afterPillSheet == null || afterLastTakenDate == null) { + return [history.estimatedEventCausingDate.date()]; + } + final exclusiveFloorDate = + (history.beforePillSheetGroup?.lastTakenPillSheetOrFirstPillSheet.lastTakenDate ?? afterPillSheet.beginDate.subtract(const Duration(days: 1))) + .date(); + final rangeDates = [ + for (var takenDate = afterLastTakenDate.date(); takenDate.isAfter(exclusiveFloorDate); takenDate = takenDate.subtract(const Duration(days: 1))) + takenDate, + ]; + if (rangeDates.isEmpty) { + return [afterLastTakenDate.date()]; + } + return rangeDates; +} + +/// takenPill / automaticallyRecordedLastTakenDate の履歴が服用記録日として集計される日付(日付のみに正規化)を返す。 +/// 基本は記録対象のピル日付([_takenPillLastTakenRangeDates])。 +/// 服用日時が編集された履歴は before/after の lastTakenDate が編集前のままのため、編集後の履歴日時を記録日とみなす。 +List takenPillHistoryTargetDates(PillSheetModifiedHistory history) { + if (history.value.takenPill?.edited != null) { + return [history.estimatedEventCausingDate.date()]; + } + return _takenPillLastTakenRangeDates(history); +} + +/// 渡されたPillSheetModifiedHistory配列から、服用予定日(集計期間の全日 − 服用お休み日)と +/// 服用記録のあった日の集合を構築する。集計対象が無い場合は null を返す。 +/// 日付はすべて日付のみ(午前0時)に正規化して集合化する。時刻付きのまま差分すると服用日が一致せず記録が漏れるため。 +/// [beginDate] を渡すと集計開始日として使い(シート開始日など)、省略時は最古の履歴日を開始日とする。 +({Set scheduledDates, Set takenDates})? pillTakenDateSets({ required List histories, required DateTime maxDate, + DateTime? beginDate, }) { - if (histories.isEmpty) { - return 0; - } - // 昇順に並べ替える。服用お休み期間の集計時に、服用お休みが開始された後の差分の日付を集計するために順番を整える必要がある final orderedHistories = histories.sortedBy( (history) => history.estimatedEventCausingDate, ); - final minDate = orderedHistories.map((history) => history.estimatedEventCausingDate).reduce((a, b) => a.isBefore(b) ? a : b); + // 集計開始日: beginDate 指定があればそれを、なければ最古の履歴日を使う。時刻を持つと日付集合の差分がずれるため日付のみに正規化する + final DateTime minDate; + if (beginDate != null) { + minDate = beginDate.date(); + } else { + if (orderedHistories.isEmpty) { + return null; + } + minDate = orderedHistories.first.estimatedEventCausingDate.date(); + } + final normalizedMaxDate = maxDate.date(); final allDates = {}; - final days = daysBetween(minDate, maxDate); + final days = daysBetween(minDate, normalizedMaxDate); for (var i = 0; i < days; i++) { allDates.add(minDate.add(Duration(days: i))); } // takenPill || automaticallyRecordedLastTakenDate アクションの日付を収集 final takenDates = {}; + // 服用日時が編集された履歴は編集後の日付で takenDates に入る一方、取り消しは元のピル日付を対象とするため、 + // 元のピル日付 → 編集後の記録日の対応を保持して取り消し時に編集後の日付も除外できるようにする + final editedTakenDatesByOriginalDate = >{}; // beganRestDuration から endedRestDuration の間の日付を収集 final restDurationDates = {}; DateTime? historyBeginRestDurationDate; for (final history in orderedHistories) { // estimatedEventCausingDateの日付部分のみを使用 - final date = DateTime( - history.estimatedEventCausingDate.year, - history.estimatedEventCausingDate.month, - history.estimatedEventCausingDate.day, - ); + final date = history.estimatedEventCausingDate.date(); if (history.actionType == PillSheetModifiedActionType.takenPill.name || history.actionType == PillSheetModifiedActionType.automaticallyRecordedLastTakenDate.name) { - takenDates.add(date); + final targetDates = takenPillHistoryTargetDates(history); + takenDates.addAll(targetDates); + if (history.value.takenPill?.edited != null) { + for (final originalDate in _takenPillLastTakenRangeDates(history)) { + editedTakenDatesByOriginalDate.putIfAbsent(originalDate, () => []).addAll(targetDates); + } + } + } + + // 服用記録の取り消しを反映する。取り消された日 = (取り消し後の最終服用日, 取り消し前の最終服用日] の範囲。 + // 時系列順に処理しているため、取り消し後に再記録された日は後続の takenPill で再度追加される + if (history.actionType == PillSheetModifiedActionType.revertTakenPill.name) { + final beforeLastTakenDate = history.beforePillSheetGroup?.lastTakenPillSheetOrFirstPillSheet.lastTakenDate; + if (beforeLastTakenDate != null) { + final afterLastTakenDate = history.afterPillSheetGroup?.lastTakenPillSheetOrFirstPillSheet.lastTakenDate; + bool isRevertedDate(DateTime takenDate) => + (afterLastTakenDate == null || takenDate.isAfter(afterLastTakenDate.date())) && !takenDate.isAfter(beforeLastTakenDate.date()); + takenDates.removeWhere(isRevertedDate); + // 編集済み履歴の記録は編集後の日付で takenDates に入っているため、元のピル日付が取り消し範囲なら編集後の日付も除外する + for (final entry in editedTakenDatesByOriginalDate.entries) { + if (isRevertedDate(entry.key)) { + entry.value.forEach(takenDates.remove); + } + } + } } // 服用お休み中は記録されないので集計から除外 @@ -364,14 +433,25 @@ int missedPillDays({ // 現在まで服用お休み中の場合には、差分の日付をrestDurationDatesに追加する if (historyBeginRestDurationDate != null) { - for (var i = 0; i < daysBetween(historyBeginRestDurationDate, maxDate); i++) { + for (var i = 0; i < daysBetween(historyBeginRestDurationDate, normalizedMaxDate); i++) { restDurationDates.add( historyBeginRestDurationDate.add(Duration(days: i)), ); } } - // 服用記録がない日数を計算 - final missedDays = allDates.difference(takenDates).difference(restDurationDates).length; - return missedDays; + return (scheduledDates: allDates.difference(restDurationDates), takenDates: takenDates); +} + +/// 渡されたPillSheetModifiedHistory配列から飲み忘れ日数を計算する +int missedPillDays({ + required List histories, + required DateTime maxDate, +}) { + final sets = pillTakenDateSets(histories: histories, maxDate: maxDate); + if (sets == null) { + return 0; + } + // 服用予定日のうち服用記録がない日数を計算 + return sets.scheduledDates.difference(sets.takenDates).length; } diff --git a/lib/entity/remote_config_parameter.codegen.dart b/lib/entity/remote_config_parameter.codegen.dart index 4e42e36a2a..1360ffa222 100644 --- a/lib/entity/remote_config_parameter.codegen.dart +++ b/lib/entity/remote_config_parameter.codegen.dart @@ -55,6 +55,9 @@ abstract class RemoteConfigKeys { /// 買い切りオファーの訴求コピーのA/Bテストバリアント識別子キー static const lifetimeOfferCopyVariant = 'lifetimeOfferCopyVariant'; + + /// ピルシート終了ダイアログの A/B バリアント識別子キー + static const endedPillSheetDialogVariant = 'endedPillSheetDialogVariant'; } /// Remote Configパラメータのデフォルト値定義 @@ -112,6 +115,9 @@ abstract class RemoteConfigParameterDefaultValues { // default(A) or ownership(B) ... /// 買い切りオファーの訴求コピーバリアントのデフォルト値(A/Bテスト用) static const lifetimeOfferCopyVariant = 'default'; + + /// ピルシート終了ダイアログのバリアント(空文字 = 実験未参加 / 非表示) + static const endedPillSheetDialogVariant = ''; } // [RemoteConfigDefaultValues] でgrepした場所に全て設定する @@ -210,6 +216,10 @@ class RemoteConfigParameter with _$RemoteConfigParameter { /// 買い切りオファーの訴求コピーのA/Bテストバリアント識別子 /// バー・オファー画面の文言を切り替える('default', 'ownership'等) @Default(RemoteConfigParameterDefaultValues.lifetimeOfferCopyVariant) String lifetimeOfferCopyVariant, + + /// ピルシート終了ダイアログの A/B バリアント識別子 + /// 'history_blur' / 'summary_stats' / '' (非表示)。Firebase A/B Testing で配信する + @Default(RemoteConfigParameterDefaultValues.endedPillSheetDialogVariant) String endedPillSheetDialogVariant, }) = _RemoteConfigParameter; RemoteConfigParameter._(); factory RemoteConfigParameter.fromJson(Map json) => _$RemoteConfigParameterFromJson(json); diff --git a/lib/entity/remote_config_parameter.codegen.freezed.dart b/lib/entity/remote_config_parameter.codegen.freezed.dart index 684ed72c35..eb3fc92422 100644 --- a/lib/entity/remote_config_parameter.codegen.freezed.dart +++ b/lib/entity/remote_config_parameter.codegen.freezed.dart @@ -84,6 +84,10 @@ mixin _$RemoteConfigParameter { /// バー・オファー画面の文言を切り替える('default', 'ownership'等) String get lifetimeOfferCopyVariant => throw _privateConstructorUsedError; + /// ピルシート終了ダイアログの A/B バリアント識別子 + /// 'history_blur' / 'summary_stats' / '' (非表示)。Firebase A/B Testing で配信する + String get endedPillSheetDialogVariant => throw _privateConstructorUsedError; + Map toJson() => throw _privateConstructorUsedError; @JsonKey(ignore: true) $RemoteConfigParameterCopyWith get copyWith => throw _privateConstructorUsedError; @@ -110,7 +114,8 @@ abstract class $RemoteConfigParameterCopyWith<$Res> { int lifetimeOfferUserCreationDaysSince, int lifetimeOfferUserCreationDaysUntil, int lifetimeOfferDurationHours, - String lifetimeOfferCopyVariant}); + String lifetimeOfferCopyVariant, + String endedPillSheetDialogVariant}); } /// @nodoc @@ -141,6 +146,7 @@ class _$RemoteConfigParameterCopyWithImpl<$Res, $Val extends RemoteConfigParamet Object? lifetimeOfferUserCreationDaysUntil = null, Object? lifetimeOfferDurationHours = null, Object? lifetimeOfferCopyVariant = null, + Object? endedPillSheetDialogVariant = null, }) { return _then(_value.copyWith( isPaywallFirst: null == isPaywallFirst @@ -207,6 +213,10 @@ class _$RemoteConfigParameterCopyWithImpl<$Res, $Val extends RemoteConfigParamet ? _value.lifetimeOfferCopyVariant : lifetimeOfferCopyVariant // ignore: cast_nullable_to_non_nullable as String, + endedPillSheetDialogVariant: null == endedPillSheetDialogVariant + ? _value.endedPillSheetDialogVariant + : endedPillSheetDialogVariant // ignore: cast_nullable_to_non_nullable + as String, ) as $Val); } } @@ -233,7 +243,8 @@ abstract class _$$RemoteConfigParameterImplCopyWith<$Res> implements $RemoteConf int lifetimeOfferUserCreationDaysSince, int lifetimeOfferUserCreationDaysUntil, int lifetimeOfferDurationHours, - String lifetimeOfferCopyVariant}); + String lifetimeOfferCopyVariant, + String endedPillSheetDialogVariant}); } /// @nodoc @@ -261,6 +272,7 @@ class __$$RemoteConfigParameterImplCopyWithImpl<$Res> extends _$RemoteConfigPara Object? lifetimeOfferUserCreationDaysUntil = null, Object? lifetimeOfferDurationHours = null, Object? lifetimeOfferCopyVariant = null, + Object? endedPillSheetDialogVariant = null, }) { return _then(_$RemoteConfigParameterImpl( isPaywallFirst: null == isPaywallFirst @@ -327,6 +339,10 @@ class __$$RemoteConfigParameterImplCopyWithImpl<$Res> extends _$RemoteConfigPara ? _value.lifetimeOfferCopyVariant : lifetimeOfferCopyVariant // ignore: cast_nullable_to_non_nullable as String, + endedPillSheetDialogVariant: null == endedPillSheetDialogVariant + ? _value.endedPillSheetDialogVariant + : endedPillSheetDialogVariant // ignore: cast_nullable_to_non_nullable + as String, )); } } @@ -350,7 +366,8 @@ class _$RemoteConfigParameterImpl extends _RemoteConfigParameter { this.lifetimeOfferUserCreationDaysSince = RemoteConfigParameterDefaultValues.lifetimeOfferUserCreationDaysSince, this.lifetimeOfferUserCreationDaysUntil = RemoteConfigParameterDefaultValues.lifetimeOfferUserCreationDaysUntil, this.lifetimeOfferDurationHours = RemoteConfigParameterDefaultValues.lifetimeOfferDurationHours, - this.lifetimeOfferCopyVariant = RemoteConfigParameterDefaultValues.lifetimeOfferCopyVariant}) + this.lifetimeOfferCopyVariant = RemoteConfigParameterDefaultValues.lifetimeOfferCopyVariant, + this.endedPillSheetDialogVariant = RemoteConfigParameterDefaultValues.endedPillSheetDialogVariant}) : super._(); factory _$RemoteConfigParameterImpl.fromJson(Map json) => _$$RemoteConfigParameterImplFromJson(json); @@ -451,9 +468,15 @@ class _$RemoteConfigParameterImpl extends _RemoteConfigParameter { @JsonKey() final String lifetimeOfferCopyVariant; + /// ピルシート終了ダイアログの A/B バリアント識別子 + /// 'history_blur' / 'summary_stats' / '' (非表示)。Firebase A/B Testing で配信する + @override + @JsonKey() + final String endedPillSheetDialogVariant; + @override String toString() { - return 'RemoteConfigParameter(isPaywallFirst: $isPaywallFirst, skipInitialSetting: $skipInitialSetting, trialDeadlineDateOffsetDay: $trialDeadlineDateOffsetDay, discountEntitlementOffsetDay: $discountEntitlementOffsetDay, discountCountdownBoundaryHour: $discountCountdownBoundaryHour, premiumIntroductionPattern: $premiumIntroductionPattern, premiumIntroductionShowsAppStoreReviewCard: $premiumIntroductionShowsAppStoreReviewCard, specialOfferingUserCreationDateTimeOffset: $specialOfferingUserCreationDateTimeOffset, specialOfferingUserCreationDateTimeOffsetSince: $specialOfferingUserCreationDateTimeOffsetSince, specialOfferingUserCreationDateTimeOffsetUntil: $specialOfferingUserCreationDateTimeOffsetUntil, specialOffering2UseAlternativeText: $specialOffering2UseAlternativeText, lifetimeOfferEnabled: $lifetimeOfferEnabled, lifetimeOfferUserCreationDaysSince: $lifetimeOfferUserCreationDaysSince, lifetimeOfferUserCreationDaysUntil: $lifetimeOfferUserCreationDaysUntil, lifetimeOfferDurationHours: $lifetimeOfferDurationHours, lifetimeOfferCopyVariant: $lifetimeOfferCopyVariant)'; + return 'RemoteConfigParameter(isPaywallFirst: $isPaywallFirst, skipInitialSetting: $skipInitialSetting, trialDeadlineDateOffsetDay: $trialDeadlineDateOffsetDay, discountEntitlementOffsetDay: $discountEntitlementOffsetDay, discountCountdownBoundaryHour: $discountCountdownBoundaryHour, premiumIntroductionPattern: $premiumIntroductionPattern, premiumIntroductionShowsAppStoreReviewCard: $premiumIntroductionShowsAppStoreReviewCard, specialOfferingUserCreationDateTimeOffset: $specialOfferingUserCreationDateTimeOffset, specialOfferingUserCreationDateTimeOffsetSince: $specialOfferingUserCreationDateTimeOffsetSince, specialOfferingUserCreationDateTimeOffsetUntil: $specialOfferingUserCreationDateTimeOffsetUntil, specialOffering2UseAlternativeText: $specialOffering2UseAlternativeText, lifetimeOfferEnabled: $lifetimeOfferEnabled, lifetimeOfferUserCreationDaysSince: $lifetimeOfferUserCreationDaysSince, lifetimeOfferUserCreationDaysUntil: $lifetimeOfferUserCreationDaysUntil, lifetimeOfferDurationHours: $lifetimeOfferDurationHours, lifetimeOfferCopyVariant: $lifetimeOfferCopyVariant, endedPillSheetDialogVariant: $endedPillSheetDialogVariant)'; } @override @@ -488,7 +511,9 @@ class _$RemoteConfigParameterImpl extends _RemoteConfigParameter { other.lifetimeOfferUserCreationDaysUntil == lifetimeOfferUserCreationDaysUntil) && (identical(other.lifetimeOfferDurationHours, lifetimeOfferDurationHours) || other.lifetimeOfferDurationHours == lifetimeOfferDurationHours) && - (identical(other.lifetimeOfferCopyVariant, lifetimeOfferCopyVariant) || other.lifetimeOfferCopyVariant == lifetimeOfferCopyVariant)); + (identical(other.lifetimeOfferCopyVariant, lifetimeOfferCopyVariant) || other.lifetimeOfferCopyVariant == lifetimeOfferCopyVariant) && + (identical(other.endedPillSheetDialogVariant, endedPillSheetDialogVariant) || + other.endedPillSheetDialogVariant == endedPillSheetDialogVariant)); } @JsonKey(ignore: true) @@ -510,7 +535,8 @@ class _$RemoteConfigParameterImpl extends _RemoteConfigParameter { lifetimeOfferUserCreationDaysSince, lifetimeOfferUserCreationDaysUntil, lifetimeOfferDurationHours, - lifetimeOfferCopyVariant); + lifetimeOfferCopyVariant, + endedPillSheetDialogVariant); @JsonKey(ignore: true) @override @@ -543,7 +569,8 @@ abstract class _RemoteConfigParameter extends RemoteConfigParameter { final int lifetimeOfferUserCreationDaysSince, final int lifetimeOfferUserCreationDaysUntil, final int lifetimeOfferDurationHours, - final String lifetimeOfferCopyVariant}) = _$RemoteConfigParameterImpl; + final String lifetimeOfferCopyVariant, + final String endedPillSheetDialogVariant}) = _$RemoteConfigParameterImpl; _RemoteConfigParameter._() : super._(); factory _RemoteConfigParameter.fromJson(Map json) = _$RemoteConfigParameterImpl.fromJson; @@ -629,6 +656,11 @@ abstract class _RemoteConfigParameter extends RemoteConfigParameter { /// バー・オファー画面の文言を切り替える('default', 'ownership'等) String get lifetimeOfferCopyVariant; @override + + /// ピルシート終了ダイアログの A/B バリアント識別子 + /// 'history_blur' / 'summary_stats' / '' (非表示)。Firebase A/B Testing で配信する + String get endedPillSheetDialogVariant; + @override @JsonKey(ignore: true) _$$RemoteConfigParameterImplCopyWith<_$RemoteConfigParameterImpl> get copyWith => throw _privateConstructorUsedError; } diff --git a/lib/entity/remote_config_parameter.codegen.g.dart b/lib/entity/remote_config_parameter.codegen.g.dart index 0be8400304..7f97bc2105 100644 --- a/lib/entity/remote_config_parameter.codegen.g.dart +++ b/lib/entity/remote_config_parameter.codegen.g.dart @@ -34,6 +34,7 @@ _$RemoteConfigParameterImpl _$$RemoteConfigParameterImplFromJson(Map _$$RemoteConfigParameterImplToJson(_$RemoteConfigParameterImpl instance) => { @@ -53,4 +54,5 @@ Map _$$RemoteConfigParameterImplToJson(_$RemoteConfigParameterI 'lifetimeOfferUserCreationDaysUntil': instance.lifetimeOfferUserCreationDaysUntil, 'lifetimeOfferDurationHours': instance.lifetimeOfferDurationHours, 'lifetimeOfferCopyVariant': instance.lifetimeOfferCopyVariant, + 'endedPillSheetDialogVariant': instance.endedPillSheetDialogVariant, }; diff --git a/lib/features/ended_pill_sheet_dialog/components/history_blur_teaser.dart b/lib/features/ended_pill_sheet_dialog/components/history_blur_teaser.dart new file mode 100644 index 0000000000..1746b22bae --- /dev/null +++ b/lib/features/ended_pill_sheet_dialog/components/history_blur_teaser.dart @@ -0,0 +1,106 @@ +import 'dart:ui'; + +import 'package:flutter/material.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:pilll/components/atoms/font.dart'; +import 'package:pilll/components/atoms/text_color.dart'; +import 'package:pilll/components/molecules/indicator.dart'; +import 'package:pilll/entity/pill_sheet_group.codegen.dart'; +import 'package:pilll/entity/pill_sheet_modified_history.codegen.dart'; +import 'package:pilll/features/calendar/components/pill_sheet_modified_history/components/rows/pill_sheet_modified_history_taken_pill_action.dart'; +import 'package:pilll/features/localizations/l.dart'; +import 'package:pilll/provider/pill_sheet_modified_history.dart'; +import 'package:pilll/utils/datetime/day.dart'; + +/// HistoryBlurTeaser が参照する履歴の取得上限。 +/// 表示前判定(home/page.dart)とティーザー本体で同じ provider 引数を使うための定数 +const historyBlurTeaserHistoriesLimit = 30; + +/// 対象グループの履歴から、ティーザーに表示する服用記録(取り消し反映済み・最新3件)を返す。 +/// 表示前判定(home/page.dart)とティーザー本体の双方で同じ選定ロジックを使うために切り出している +List historyBlurTeaserHistories({ + required PillSheetGroup pillSheetGroup, + required List histories, +}) { + // 直近の取得件数だけでは別グループ(削除して作り直した場合など)の履歴が混ざるため、対象グループの履歴に絞る + final groupHistories = histories.where((history) => history.afterPillSheetGroup?.id == pillSheetGroup.id).toList(); + // 取り消し(revertTakenPill)を反映した最終的な服用記録日の集合。取り消されたままの服用記録をティーザーに表示しないために使う。 + // maxDate は服用予定日(scheduledDates)の構築にのみ影響し takenDates には影響しない + final takenDates = pillTakenDateSets(histories: groupHistories, maxDate: today())?.takenDates ?? {}; + // 過去日のピルを後から記録した履歴は操作日と記録対象日が異なるため、記録対象日で照合する。 + // 一部だけ取り消されたまとめ記録は行表示(afterPillSheetGroup ベース)が実態とずれるため、 + // 記録対象日の全てが取り消されず残っている履歴だけを表示する + return groupHistories + .where((history) { + if (history.enumActionType != PillSheetModifiedActionType.takenPill) { + return false; + } + final targetDates = takenPillHistoryTargetDates(history); + return targetDates.isNotEmpty && targetDates.every(takenDates.contains); + }) + .take(3) + .toList(); +} + +/// Variant A: ピルシート履歴のぼかしティーザー。 +/// 対象グループの最新の服用記録3件を表示し、先頭1件は鮮明・残り2件は blur で見せて「続きはプレミアム」を訴求する。 +class HistoryBlurTeaser extends ConsumerWidget { + final PillSheetGroup pillSheetGroup; + const HistoryBlurTeaser({super.key, required this.pillSheetGroup}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + L.endedPillSheetDialogHistoryTitle, + style: const TextStyle( + fontFamily: FontFamily.japanese, + fontWeight: FontWeight.w600, + fontSize: 18, + color: TextColor.main, + ), + ), + const SizedBox(height: 16), + ref.watch(pillSheetModifiedHistoriesWithLimitProvider(limit: historyBlurTeaserHistoriesLimit)).when( + data: (histories) => Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: historyBlurTeaserHistories(pillSheetGroup: pillSheetGroup, histories: histories).indexed.map((entry) { + final row = PillSheetModifiedHistoryTakenPillAction( + premiumOrTrial: false, + estimatedEventCausingDate: entry.$2.estimatedEventCausingDate, + history: entry.$2, + value: entry.$2.value.takenPill, + ); + if (entry.$1 == 0) { + return Padding(padding: const EdgeInsets.only(bottom: 12), child: row); + } + return Padding( + padding: const EdgeInsets.only(bottom: 12), + // blur で視覚的に隠した内容がスクリーンリーダーで読み上げられないよう、semantics ごと除外する + child: ExcludeSemantics( + child: ImageFiltered( + imageFilter: ImageFilter.blur(sigmaX: 6, sigmaY: 6), + child: row, + ), + ), + ); + }).toList(), + ), + error: (error, _) => Text( + error.toString(), + style: const TextStyle( + fontFamily: FontFamily.japanese, + fontWeight: FontWeight.w400, + fontSize: 16, + color: TextColor.main, + ), + ), + // 読み込み完了前に履歴0件の空ティーザーを描画しない + loading: () => const Center(child: Indicator()), + ), + ], + ); + } +} diff --git a/lib/features/ended_pill_sheet_dialog/components/summary_stats_teaser.dart b/lib/features/ended_pill_sheet_dialog/components/summary_stats_teaser.dart new file mode 100644 index 0000000000..5b2d49a77e --- /dev/null +++ b/lib/features/ended_pill_sheet_dialog/components/summary_stats_teaser.dart @@ -0,0 +1,73 @@ +import 'package:flutter/material.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:pilll/components/atoms/font.dart'; +import 'package:pilll/components/atoms/text_color.dart'; +import 'package:pilll/components/molecules/indicator.dart'; +import 'package:pilll/entity/pill_sheet_group.codegen.dart'; +import 'package:pilll/features/ended_pill_sheet_dialog/ended_pill_sheet_taken_summary.dart'; +import 'package:pilll/features/localizations/l.dart'; +import 'package:pilll/provider/pill_sheet_modified_history.dart'; + +/// Variant B: 服用記録の集計メッセージティーザー。 +/// 終了したピルシートグループの服用記録できた日数・記録漏れ日数を集計して表示する。 +class SummaryStatsTeaser extends ConsumerWidget { + final PillSheetGroup pillSheetGroup; + const SummaryStatsTeaser({super.key, required this.pillSheetGroup}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + L.endedPillSheetDialogSummaryTitle, + style: const TextStyle( + fontFamily: FontFamily.japanese, + fontWeight: FontWeight.w600, + fontSize: 18, + color: TextColor.main, + ), + ), + const SizedBox(height: 16), + ref + .watch(pillSheetModifiedHistoriesWithRangeProvider( + begin: pillSheetGroup.pillSheets.first.beginDate, + end: pillSheetGroup.pillSheets.last.estimatedEndTakenDate, + )) + .when( + data: (histories) { + // 集計を提示できない場合(履歴TTL切れ・対象グループの履歴なし)は集計メッセージを出さない。 + // 表示トリガー側(home/page.dart)でも同条件で表示自体を抑止しており、こちらは表示中のデータ更新に対する防御 + if (!endedPillSheetTakenSummaryAvailable(pillSheetGroup: pillSheetGroup, histories: histories)) { + return const SizedBox.shrink(); + } + final summary = endedPillSheetTakenSummary( + pillSheetGroup: pillSheetGroup, + // 日付範囲だけでは別グループ(削除して作り直した場合など)の履歴が混ざるため、対象グループの履歴に絞る + histories: histories.where((history) => history.afterPillSheetGroup?.id == pillSheetGroup.id).toList(), + ); + return Text( + L.endedPillSheetDialogSummaryMessage(summary.recordedDays, summary.missedDays), + style: const TextStyle( + fontFamily: FontFamily.japanese, + fontWeight: FontWeight.w400, + fontSize: 16, + color: TextColor.main, + ), + ); + }, + error: (error, _) => Text( + error.toString(), + style: const TextStyle( + fontFamily: FontFamily.japanese, + fontWeight: FontWeight.w400, + fontSize: 16, + color: TextColor.main, + ), + ), + loading: () => const Center(child: Indicator()), + ), + ], + ); + } +} diff --git a/lib/features/ended_pill_sheet_dialog/ended_pill_sheet_dialog.dart b/lib/features/ended_pill_sheet_dialog/ended_pill_sheet_dialog.dart new file mode 100644 index 0000000000..62a6b10360 --- /dev/null +++ b/lib/features/ended_pill_sheet_dialog/ended_pill_sheet_dialog.dart @@ -0,0 +1,108 @@ +import 'package:flutter/material.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_group.codegen.dart'; +import 'package:pilll/features/ended_pill_sheet_dialog/components/history_blur_teaser.dart'; +import 'package:pilll/features/ended_pill_sheet_dialog/components/summary_stats_teaser.dart'; +import 'package:pilll/features/ended_pill_sheet_dialog/ended_pill_sheet_dialog_variant.dart'; +import 'package:pilll/features/localizations/l.dart'; +import 'package:pilll/features/premium_introduction/premium_introduction_sheet.dart'; +import 'package:pilll/utils/analytics.dart'; +import 'package:pilll/utils/emoji/emoji.dart'; + +/// ピルシート終了時に free ユーザー向けに表示する課金転換ダイアログ。 +/// variant に応じて履歴ぼかし(A) / 集計メッセージ(B) のティーザーを出し分け、CTA から paywall を開く。 +/// 表示・CTA・dismiss を analytics に variant 付きで記録する。 +Future showEndedPillSheetDialog( + BuildContext context, { + required EndedPillSheetDialogVariant variant, + required PillSheetGroup pillSheetGroup, +}) async { + analytics.logEvent( + name: 'ended_sheet_dialog_shown', + parameters: {'variant': variant.value}, + ); + + final isCTATapped = await showDialog( + context: context, + builder: (_) => EndedPillSheetDialog(variant: variant, pillSheetGroup: pillSheetGroup), + ) ?? + false; + + if (isCTATapped) { + analytics.logEvent( + name: 'ended_sheet_dialog_cta_tapped', + parameters: {'variant': variant.value}, + ); + if (context.mounted) { + showPremiumIntroductionSheet(context, source: variant.paywallSource); + } + } else { + analytics.logEvent( + name: 'ended_sheet_dialog_dismissed', + parameters: {'variant': variant.value}, + ); + } +} + +/// [showEndedPillSheetDialog] が表示するダイアログ本体。 +/// CTA 押下時は pop(true)、閉じる/外タップ時は pop(false) を返し、呼び出し側で paywall 表示と計測を分岐する。 +class EndedPillSheetDialog extends StatelessWidget { + final EndedPillSheetDialogVariant variant; + final PillSheetGroup pillSheetGroup; + + const EndedPillSheetDialog({ + super.key, + required this.variant, + required this.pillSheetGroup, + }); + + @override + Widget build(BuildContext context) { + return Dialog( + child: Padding( + padding: const EdgeInsets.fromLTRB(20, 8, 20, 20), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Align( + alignment: Alignment.centerRight, + child: IconButton( + icon: const Icon(Icons.close, color: TextColor.gray), + onPressed: () => Navigator.of(context).pop(false), + ), + ), + switch (variant) { + EndedPillSheetDialogVariant.historyBlur => HistoryBlurTeaser(pillSheetGroup: pillSheetGroup), + EndedPillSheetDialogVariant.summaryStats => SummaryStatsTeaser(pillSheetGroup: pillSheetGroup), + }, + const SizedBox(height: 20), + Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const Text(lockEmoji, style: TextStyle(fontSize: 16)), + const SizedBox(width: 6), + Text( + L.takingHistoryIsPremiumFeature, + style: const TextStyle( + fontFamily: FontFamily.japanese, + fontWeight: FontWeight.w400, + fontSize: 14, + color: TextColor.main, + ), + ), + ], + ), + const SizedBox(height: 12), + AppOutlinedButton( + text: L.viewMoreDetails, + onPressed: () async => Navigator.of(context).pop(true), + ), + ], + ), + ), + ); + } +} diff --git a/lib/features/ended_pill_sheet_dialog/ended_pill_sheet_dialog_variant.dart b/lib/features/ended_pill_sheet_dialog/ended_pill_sheet_dialog_variant.dart new file mode 100644 index 0000000000..a9f083bb1f --- /dev/null +++ b/lib/features/ended_pill_sheet_dialog/ended_pill_sheet_dialog_variant.dart @@ -0,0 +1,45 @@ +import 'package:pilll/features/premium_introduction/paywall_source.dart'; + +/// ピルシート終了ダイアログの A/B バリアント。 +/// Firebase Remote Config の `endedPillSheetDialogVariant` で配信される文字列に対応する。 +enum EndedPillSheetDialogVariant { + /// 履歴ぼかしティーザー(先頭1件表示 + 残り blur) + historyBlur, + + /// 服用記録の集計メッセージティーザー + summaryStats, +} + +extension EndedPillSheetDialogVariantFunction on EndedPillSheetDialogVariant { + /// Firebase Analytics の variant パラメータ / Remote Config に対応する snake_case 文字列。 + String get value { + switch (this) { + case EndedPillSheetDialogVariant.historyBlur: + return 'history_blur'; + case EndedPillSheetDialogVariant.summaryStats: + return 'summary_stats'; + } + } + + /// CTA から開く paywall の source。 + PaywallSource get paywallSource { + switch (this) { + case EndedPillSheetDialogVariant.historyBlur: + return PaywallSource.endedPillSheetDialogHistory; + case EndedPillSheetDialogVariant.summaryStats: + return PaywallSource.endedPillSheetDialogSummary; + } + } +} + +/// Remote Config の文字列値から variant を解決する。空文字・未知値は null(ダイアログ非表示)。 +EndedPillSheetDialogVariant? endedPillSheetDialogVariantFromRemoteConfig(String value) { + switch (value) { + case 'history_blur': + return EndedPillSheetDialogVariant.historyBlur; + case 'summary_stats': + return EndedPillSheetDialogVariant.summaryStats; + default: + return null; + } +} diff --git a/lib/features/ended_pill_sheet_dialog/ended_pill_sheet_taken_summary.dart b/lib/features/ended_pill_sheet_dialog/ended_pill_sheet_taken_summary.dart new file mode 100644 index 0000000000..443a56423c --- /dev/null +++ b/lib/features/ended_pill_sheet_dialog/ended_pill_sheet_taken_summary.dart @@ -0,0 +1,47 @@ +import 'package:pilll/entity/pill_sheet_group.codegen.dart'; +import 'package:pilll/entity/pill_sheet_modified_history.codegen.dart'; +import 'package:pilll/entity/pill_sheet_type.dart'; +import 'package:pilll/utils/datetime/day.dart'; + +/// 終了ダイアログ(Variant B)の集計メッセージが提示可能かどうか。 +/// 履歴は作成から PillSheetModifiedHistoryServiceActionFactory.limitDays(180日) の TTL で削除されるため、 +/// 集計開始日が TTL 窓の外にある場合は期間前半の履歴だけが削除されている可能性があり集計できない。 +/// また対象グループの履歴が1件も無い場合も集計できない。 +/// [histories] は集計期間で取得した履歴(グループ絞り込み前)。 +bool endedPillSheetTakenSummaryAvailable({ + required PillSheetGroup pillSheetGroup, + required List histories, +}) { + if (!pillSheetGroup.pillSheets.first.beginDate + .date() + .isAfter(today().subtract(const Duration(days: PillSheetModifiedHistoryServiceActionFactory.limitDays)))) { + return false; + } + return histories.any((history) => history.afterPillSheetGroup?.id == pillSheetGroup.id); +} + +/// 終了したピルシートグループの服用記録サマリ。 +/// recordedDays = 服用記録できた日数(x)、missedDays = 記録漏れ日数(y)。 +/// 服用予定日は各シートの錠剤日付のうち実薬分(dosingPeriod)のみを対象とし、 +/// 偽薬・休薬期間は記録漏れに含めない。服用お休みによる日付シフトは PillSheet.buildDates が織り込む。 +({int recordedDays, int missedDays}) endedPillSheetTakenSummary({ + required PillSheetGroup pillSheetGroup, + required List histories, +}) { + final scheduledDates = pillSheetGroup.pillSheets + .expand((pillSheet) => pillSheet.buildDates().take(pillSheet.pillSheetType.dosingPeriod)) + .map((pillTakeDate) => pillTakeDate.date()) + .toSet(); + final takenDates = pillTakenDateSets( + histories: histories, + beginDate: pillSheetGroup.pillSheets.first.beginDate, + // pillTakenDateSets の上限は排他的なので、最終服用予定日を含めるため翌日を渡す + maxDate: pillSheetGroup.pillSheets.last.estimatedEndTakenDate.add(const Duration(days: 1)), + )?.takenDates ?? + {}; + final missedDays = scheduledDates.difference(takenDates).length; + return ( + recordedDays: scheduledDates.length - missedDays, + missedDays: missedDays, + ); +} diff --git a/lib/features/home/page.dart b/lib/features/home/page.dart index d60d68c178..61401461fc 100644 --- a/lib/features/home/page.dart +++ b/lib/features/home/page.dart @@ -14,6 +14,13 @@ import 'package:pilll/provider/locale.dart'; import 'package:pilll/provider/pill_sheet_group.dart'; import 'package:pilll/provider/user.dart'; import 'package:pilll/provider/shared_preferences.dart'; +import 'package:pilll/provider/remote_config_parameter.dart'; +import 'package:pilll/features/ended_pill_sheet_dialog/components/history_blur_teaser.dart'; +import 'package:pilll/features/ended_pill_sheet_dialog/ended_pill_sheet_dialog.dart'; +import 'package:pilll/features/ended_pill_sheet_dialog/ended_pill_sheet_dialog_variant.dart'; +import 'package:pilll/features/ended_pill_sheet_dialog/ended_pill_sheet_taken_summary.dart'; +import 'package:pilll/features/root/resolver/show_paywall_on_app_launch.dart'; +import 'package:pilll/provider/pill_sheet_modified_history.dart'; import 'package:pilll/utils/analytics.dart'; import 'package:pilll/features/calendar/page.dart'; import 'package:pilll/features/menstruation/page.dart'; @@ -108,6 +115,9 @@ class HomePageBody extends HookConsumerWidget { ); final shouldAskCancelReason = user.shouldAskCancelReason; final error = useState(null); + // this.pillSheetGroup は field のため null promotion が効かない。useEffect 内で参照するためローカル変数化する + final pillSheetGroup = this.pillSheetGroup; + final remoteConfigParameter = ref.watch(remoteConfigParameterProvider); useEffect(() { WidgetsBinding.instance.addPostFrameCallback((timestamp) async { @@ -134,6 +144,54 @@ class HomePageBody extends HookConsumerWidget { BoolKey.isAlreadyAnsweredPreStoreReviewModal, true, ); + } else if (pillSheetGroup != null && pillSheetGroup.deletedAt == null && pillSheetGroup.activePillSheet == null && !user.premiumOrTrial) { + // ピルシートが終了した free ユーザーに、課金転換ダイアログ(A/B)を終了グループにつき1回だけ表示する。 + // 削除済みグループ(deletedAt != null)は activePillSheet == null でも「終了」ではないため対象外 + final variant = endedPillSheetDialogVariantFromRemoteConfig(remoteConfigParameter.endedPillSheetDialogVariant); + final pillSheetGroupID = pillSheetGroup.id; + if (variant != null && + pillSheetGroupID != null && + !(sharedPreferences.getBool(BoolKey.endedPillSheetDialogShown(pillSheetGroupID)) ?? false) && + // 買い切りオファー等の起動時自動モーダルと同一起動で重ねて表示しない + !ref.read(shownPaywallOnThisAppLaunchProvider)) { + // ティーザー内容を提示できない場合、内容の無いダイアログで impression と表示済みフラグを消費しないよう表示自体を抑止する。 + // historyBlur: 表示できる服用記録なし / summaryStats: 履歴TTL切れ・対象グループの履歴なし + final bool teaserAvailable; + try { + teaserAvailable = switch (variant) { + EndedPillSheetDialogVariant.historyBlur => historyBlurTeaserHistories( + pillSheetGroup: pillSheetGroup, + histories: await ref.read(pillSheetModifiedHistoriesWithLimitProvider(limit: historyBlurTeaserHistoriesLimit).future), + ).isNotEmpty, + EndedPillSheetDialogVariant.summaryStats => endedPillSheetTakenSummaryAvailable( + pillSheetGroup: pillSheetGroup, + histories: await ref.read(pillSheetModifiedHistoriesWithRangeProvider( + begin: pillSheetGroup.pillSheets.first.beginDate, + end: pillSheetGroup.pillSheets.last.estimatedEndTakenDate, + ).future), + ), + }; + } catch (exception) { + // ティーザー内容の有無を判定できないため表示しない。フラグ未保存のため次回起動時に再判定される + debugPrint('Failed to load histories for ended pill sheet dialog: $exception'); + return; + } + if (!teaserAvailable || !context.mounted) { + return; + } + // 履歴取得の await 中に別の起動時自動モーダルが表示された場合に重ならないよう、表示直前に共有フラグを再確認する + if (ref.read(shownPaywallOnThisAppLaunchProvider)) { + return; + } + // 同一起動で後続の起動時自動モーダル(買い切りオファー等)が重ねて表示されないよう共有フラグを立てる + ref.read(shownPaywallOnThisAppLaunchProvider.notifier).state = true; + await showEndedPillSheetDialog(context, variant: variant, pillSheetGroup: pillSheetGroup); + final saved = await sharedPreferences.setBool(BoolKey.endedPillSheetDialogShown(pillSheetGroupID), true); + if (!saved) { + // 保存に失敗すると同じ終了グループで再表示される可能性があるため、失敗をログに残す + debugPrint('Failed to persist endedPillSheetDialogShown for $pillSheetGroupID'); + } + } } }); diff --git a/lib/features/premium_introduction/paywall_source.dart b/lib/features/premium_introduction/paywall_source.dart index b3a5b12a43..415f78b29c 100644 --- a/lib/features/premium_introduction/paywall_source.dart +++ b/lib/features/premium_introduction/paywall_source.dart @@ -34,6 +34,8 @@ enum PaywallSource { specialOfferingBar2, lifetimeOfferBar, lifetimeOfferAppLaunch, + endedPillSheetDialogHistory, + endedPillSheetDialogSummary, } extension PaywallSourceFunction on PaywallSource { @@ -95,6 +97,10 @@ extension PaywallSourceFunction on PaywallSource { return 'lifetime_offer_bar'; case PaywallSource.lifetimeOfferAppLaunch: return 'lifetime_offer_app_launch'; + case PaywallSource.endedPillSheetDialogHistory: + return 'ended_pill_sheet_dialog_history'; + case PaywallSource.endedPillSheetDialogSummary: + return 'ended_pill_sheet_dialog_summary'; } } } diff --git a/lib/features/root/resolver/show_lifetime_offer_on_app_launch.dart b/lib/features/root/resolver/show_lifetime_offer_on_app_launch.dart index 005ed1e954..eaf84bd879 100644 --- a/lib/features/root/resolver/show_lifetime_offer_on_app_launch.dart +++ b/lib/features/root/resolver/show_lifetime_offer_on_app_launch.dart @@ -44,6 +44,13 @@ class ShowLifetimeOfferOnAppLaunch extends HookConsumerWidget { if (!context.mounted) { return; } + // callback 予約後に別の起動時自動モーダル(ピルシート終了ダイアログ等)が表示された場合に + // 重ならないよう、表示直前に共有フラグを再確認する + if (ref.read(shownPaywallOnThisAppLaunchProvider)) { + return; + } + // 同一起動で後続の起動時自動モーダル(ピルシート終了ダイアログ等)が重ねて表示されないよう共有フラグを立てる + ref.read(shownPaywallOnThisAppLaunchProvider.notifier).state = true; // 表示期限の起点となる初回表示時刻を記録する await setLifetimeOfferFirstDisplayedDateTimeIfAbsent(ref); // 表示条件の再評価等でcallbackが複数回走っても二重表示しないよう、表示前にフラグを立てる diff --git a/lib/features/root/resolver/show_paywall_on_app_launch.dart b/lib/features/root/resolver/show_paywall_on_app_launch.dart index 96538ec1c3..9747f2efe3 100644 --- a/lib/features/root/resolver/show_paywall_on_app_launch.dart +++ b/lib/features/root/resolver/show_paywall_on_app_launch.dart @@ -11,10 +11,11 @@ import 'package:pilll/provider/remote_config_parameter.dart'; import 'package:pilll/provider/typed_shared_preferences.dart'; import 'package:pilll/utils/shared_preference/keys.dart'; -/// 同一起動内で起動時ペイウォールを表示済みかどうか +/// 同一起動内で起動時の自動モーダル(起動時ペイウォール・買い切りオファー・ピルシート終了ダイアログ)を +/// いずれか表示済みかどうか /// -/// 他の起動時自動モーダル(例: ShowLifetimeOfferOnAppLaunch)が同一起動で重ねて表示されるのを避けるための、 -/// アプリプロセス内でのみ保持するフラグ。永続化はしない。 +/// 起動時自動モーダル同士が同一起動で重ねて表示されるのを避けるための、 +/// アプリプロセス内でのみ保持するフラグ。表示する側が表示直前に立てる。永続化はしない。 final shownPaywallOnThisAppLaunchProvider = StateProvider((ref) => false); class ShowPaywallOnAppLaunch extends HookConsumerWidget { diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index c8860d2f52..30715b7b7b 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -2800,5 +2800,20 @@ "restDurationFeatureAppealPoint3": "Easy to edit or resume your cycle", "@restDurationFeatureAppealPoint3": { "description": "" + }, + + "endedPillSheetDialogHistoryTitle": "Want to see your pill-taking history?", + "endedPillSheetDialogSummaryTitle": "Let's look back on this pill sheet", + "endedPillSheetDialogSummaryMessage": "{recordedDays} days recorded, {missedDays} days missed", + "@endedPillSheetDialogSummaryMessage": { + "description": "", + "placeholders": { + "recordedDays": { + "type": "int" + }, + "missedDays": { + "type": "int" + } + } } } \ No newline at end of file diff --git a/lib/l10n/app_ja.arb b/lib/l10n/app_ja.arb index 0ecb2c26d0..0484b4bf21 100644 --- a/lib/l10n/app_ja.arb +++ b/lib/l10n/app_ja.arb @@ -2699,5 +2699,20 @@ "restDurationFeatureAppealPoint1": "無料で使える休薬記録", "restDurationFeatureAppealPoint2": "ピルシート右上の歯車から開始", - "restDurationFeatureAppealPoint3": "期間の編集・再開もかんたん" + "restDurationFeatureAppealPoint3": "期間の編集・再開もかんたん", + + "endedPillSheetDialogHistoryTitle": "ピルシートの履歴を見てみませんか?", + "endedPillSheetDialogSummaryTitle": "今回のピルシートをふりかえりましょう", + "endedPillSheetDialogSummaryMessage": "服用記録 {recordedDays} 件・記録漏れ {missedDays} 件でした", + "@endedPillSheetDialogSummaryMessage": { + "description": "ピルシート終了ダイアログ(Variant B)で服用記録の集計を表示するメッセージ。recordedDaysは服用記録できた日数、missedDaysは記録漏れ日数。", + "placeholders": { + "recordedDays": { + "type": "int" + }, + "missedDays": { + "type": "int" + } + } + } } diff --git a/lib/provider/remote_config_parameter.dart b/lib/provider/remote_config_parameter.dart index 788345d9e0..2701df1fa3 100644 --- a/lib/provider/remote_config_parameter.dart +++ b/lib/provider/remote_config_parameter.dart @@ -70,6 +70,10 @@ RemoteConfigParameter remoteConfigParameter(RemoteConfigParameterRef ref) { RemoteConfigKeys.lifetimeOfferCopyVariant, RemoteConfigParameterDefaultValues.lifetimeOfferCopyVariant, ), + endedPillSheetDialogVariant: remoteConfig.getStringOrDefault( + RemoteConfigKeys.endedPillSheetDialogVariant, + RemoteConfigParameterDefaultValues.endedPillSheetDialogVariant, + ), ); } diff --git a/lib/provider/remote_config_parameter.g.dart b/lib/provider/remote_config_parameter.g.dart index a6cd8efac2..b3e0d49739 100644 --- a/lib/provider/remote_config_parameter.g.dart +++ b/lib/provider/remote_config_parameter.g.dart @@ -6,7 +6,7 @@ part of 'remote_config_parameter.dart'; // RiverpodGenerator // ************************************************************************** -String _$remoteConfigParameterHash() => r'0e0e28c8f1d77f201fea001a8935755b80555ed0'; +String _$remoteConfigParameterHash() => r'b1c92c35885cfa20dd9d093446a3d25a1ecd93b4'; /// See also [remoteConfigParameter]. @ProviderFor(remoteConfigParameter) diff --git a/lib/utils/remote_config.dart b/lib/utils/remote_config.dart index bfd83b142d..386b63deb1 100644 --- a/lib/utils/remote_config.dart +++ b/lib/utils/remote_config.dart @@ -36,6 +36,7 @@ Future setupRemoteConfig() async { RemoteConfigKeys.lifetimeOfferUserCreationDaysUntil: RemoteConfigParameterDefaultValues.lifetimeOfferUserCreationDaysUntil, RemoteConfigKeys.lifetimeOfferDurationHours: RemoteConfigParameterDefaultValues.lifetimeOfferDurationHours, RemoteConfigKeys.lifetimeOfferCopyVariant: RemoteConfigParameterDefaultValues.lifetimeOfferCopyVariant, + RemoteConfigKeys.endedPillSheetDialogVariant: RemoteConfigParameterDefaultValues.endedPillSheetDialogVariant, }), ).wait; // 項目が増えて来てfetchが重たくなっていてアプリが開かない説があるので非同期にする。計測はしてない。since: 2025-06-25 diff --git a/lib/utils/shared_preference/keys.dart b/lib/utils/shared_preference/keys.dart index 96b2404ce1..6a727bc0b2 100644 --- a/lib/utils/shared_preference/keys.dart +++ b/lib/utils/shared_preference/keys.dart @@ -53,6 +53,10 @@ extension BoolKey on String { /// 服用おやすみ (無料機能) のアピール Bar を × で閉じたかどうか。 static const restDurationFeatureAppealIsClosed = 'restDurationFeatureAppealIsClosed'; + + /// ピルシート終了ダイアログを当該グループで表示済みかどうか。 + /// pillSheetGroupID をサフィックスに付与し、終了グループごとに1回だけ表示する。 + static String endedPillSheetDialogShown(String pillSheetGroupID) => 'endedPillSheetDialogShown_$pillSheetGroupID'; } extension StringKey on String { diff --git a/test/entity/pill_sheet_modified_history_test.dart b/test/entity/pill_sheet_modified_history_test.dart index a54cce1822..ad06815ade 100644 --- a/test/entity/pill_sheet_modified_history_test.dart +++ b/test/entity/pill_sheet_modified_history_test.dart @@ -52,6 +52,44 @@ void main() { }); group("#missedPillDays", () { + // revertTakenPill の before/after 用に lastTakenDate だけ異なる PillSheetGroup を作るヘルパー + PillSheetGroup createPillSheetGroupWithLastTakenDate( + DateTime? lastTakenDate) { + return PillSheetGroup( + id: 'group_id', + pillSheetIDs: ['pill_sheet_id_1'], + pillSheets: [ + PillSheet.v1( + id: 'pill_sheet_id_1', + typeInfo: PillSheetType.pillsheet_28_0.typeInfo, + beginDate: DateTime.parse("2020-09-01"), + lastTakenDate: lastTakenDate, + createdAt: DateTime.parse("2020-09-01"), + ), + ], + createdAt: DateTime.parse("2020-09-01"), + ); + } + + PillSheetModifiedHistory createRevertHistory({ + required DateTime estimatedEventCausingDate, + required DateTime beforeLastTakenDate, + required DateTime? afterLastTakenDate, + }) { + return PillSheetModifiedHistory( + id: 'revert', + actionType: PillSheetModifiedActionType.revertTakenPill.name, + estimatedEventCausingDate: estimatedEventCausingDate, + createdAt: estimatedEventCausingDate, + value: const PillSheetModifiedHistoryValue( + revertTakenPill: RevertTakenPillValue()), + beforePillSheetGroup: + createPillSheetGroupWithLastTakenDate(beforeLastTakenDate), + afterPillSheetGroup: + createPillSheetGroupWithLastTakenDate(afterLastTakenDate), + ); + } + test("履歴が空の場合は0を返す", () { final result = missedPillDays( histories: [], @@ -60,6 +98,319 @@ void main() { expect(result, 0); }); + test("服用記録を取り消してそのままの場合、取り消した日は飲み忘れとしてカウントされる", () { + final today = DateTime.parse("2020-09-05"); + final baseDate = DateTime(today.year, today.month, today.day); + final histories = [ + // 9/1〜9/4 の4日間服用 + for (int i = 1; i <= 4; i++) + PillSheetModifiedHistory( + id: 'taken_$i', + actionType: PillSheetModifiedActionType.takenPill.name, + estimatedEventCausingDate: DateTime(2020, 9, i), + createdAt: DateTime(2020, 9, i), + value: const PillSheetModifiedHistoryValue(), + beforePillSheetGroup: null, + afterPillSheetGroup: null, + ), + // 9/4 の服用記録を取り消してそのまま(lastTakenDate: 9/4 → 9/3) + createRevertHistory( + estimatedEventCausingDate: DateTime(2020, 9, 4, 20), + beforeLastTakenDate: DateTime(2020, 9, 4), + afterLastTakenDate: DateTime(2020, 9, 3), + ), + ]; + + final result = missedPillDays(histories: histories, maxDate: baseDate); + + // 集計期間は 9/1〜9/4 の4日間。9/4 は取り消されたので1日の飲み忘れ + expect(result, 1); + }); + + test("服用記録を取り消して同じ日に再記録した場合、飲み忘れにならない", () { + final today = DateTime.parse("2020-09-05"); + final baseDate = DateTime(today.year, today.month, today.day); + final histories = [ + // 9/1〜9/4 の4日間服用 + for (int i = 1; i <= 4; i++) + PillSheetModifiedHistory( + id: 'taken_$i', + actionType: PillSheetModifiedActionType.takenPill.name, + estimatedEventCausingDate: DateTime(2020, 9, i), + createdAt: DateTime(2020, 9, i), + value: const PillSheetModifiedHistoryValue(), + beforePillSheetGroup: null, + afterPillSheetGroup: null, + ), + // 9/4 の服用記録を一度取り消す + createRevertHistory( + estimatedEventCausingDate: DateTime(2020, 9, 4, 20), + beforeLastTakenDate: DateTime(2020, 9, 4), + afterLastTakenDate: DateTime(2020, 9, 3), + ), + // 同じ日に再記録(取り消しより後の時刻) + PillSheetModifiedHistory( + id: 'retaken', + actionType: PillSheetModifiedActionType.takenPill.name, + estimatedEventCausingDate: DateTime(2020, 9, 4, 21), + createdAt: DateTime(2020, 9, 4, 21), + value: const PillSheetModifiedHistoryValue(), + beforePillSheetGroup: null, + afterPillSheetGroup: null, + ), + ]; + + final result = missedPillDays(histories: histories, maxDate: baseDate); + + // 取り消し後に再記録しているので飲み忘れは0日 + expect(result, 0); + }); + + test("複数日分の服用記録をまとめて取り消した場合、その全日が飲み忘れとしてカウントされる", () { + final today = DateTime.parse("2020-09-05"); + final baseDate = DateTime(today.year, today.month, today.day); + final histories = [ + // 9/1〜9/4 の4日間服用 + for (int i = 1; i <= 4; i++) + PillSheetModifiedHistory( + id: 'taken_$i', + actionType: PillSheetModifiedActionType.takenPill.name, + estimatedEventCausingDate: DateTime(2020, 9, i), + createdAt: DateTime(2020, 9, i), + value: const PillSheetModifiedHistoryValue(), + beforePillSheetGroup: null, + afterPillSheetGroup: null, + ), + // 9/2〜9/4 の3日分をまとめて取り消し(lastTakenDate: 9/4 → 9/1) + createRevertHistory( + estimatedEventCausingDate: DateTime(2020, 9, 4, 20), + beforeLastTakenDate: DateTime(2020, 9, 4), + afterLastTakenDate: DateTime(2020, 9, 1), + ), + ]; + + final result = missedPillDays(histories: histories, maxDate: baseDate); + + // 9/1 のみ服用記録が残り、9/2〜9/4 の3日が飲み忘れ + expect(result, 3); + }); + + test("服用記録を全件取り消した場合(取り消し後の最終服用日がnull)、全日が飲み忘れとしてカウントされる", () { + final today = DateTime.parse("2020-09-05"); + final baseDate = DateTime(today.year, today.month, today.day); + final histories = [ + // 9/1〜9/4 の4日間服用 + for (int i = 1; i <= 4; i++) + PillSheetModifiedHistory( + id: 'taken_$i', + actionType: PillSheetModifiedActionType.takenPill.name, + estimatedEventCausingDate: DateTime(2020, 9, i), + createdAt: DateTime(2020, 9, i), + value: const PillSheetModifiedHistoryValue(), + beforePillSheetGroup: null, + afterPillSheetGroup: null, + ), + // 9/1〜9/4 の全件を取り消し(lastTakenDate: 9/4 → null) + createRevertHistory( + estimatedEventCausingDate: DateTime(2020, 9, 4, 20), + beforeLastTakenDate: DateTime(2020, 9, 4), + afterLastTakenDate: null, + ), + ]; + + final result = missedPillDays(histories: histories, maxDate: baseDate); + + // 集計期間 9/1〜9/4 の4日全ての服用記録が取り消されるので4日の飲み忘れ + expect(result, 4); + }); + + test("revertTakenPill の before/after グループが null の場合、取り消しは集計に反映されない(従来どおり無視される)", () { + final today = DateTime.parse("2020-09-05"); + final baseDate = DateTime(today.year, today.month, today.day); + final histories = [ + // 9/1〜9/4 の4日間服用 + for (int i = 1; i <= 4; i++) + PillSheetModifiedHistory( + id: 'taken_$i', + actionType: PillSheetModifiedActionType.takenPill.name, + estimatedEventCausingDate: DateTime(2020, 9, i), + createdAt: DateTime(2020, 9, i), + value: const PillSheetModifiedHistoryValue(), + beforePillSheetGroup: null, + afterPillSheetGroup: null, + ), + // グループ情報の無い取り消し履歴。取り消し日を特定できないためスキップされる + PillSheetModifiedHistory( + id: 'revert_without_group', + actionType: PillSheetModifiedActionType.revertTakenPill.name, + estimatedEventCausingDate: DateTime(2020, 9, 4, 20), + createdAt: DateTime(2020, 9, 4, 20), + value: const PillSheetModifiedHistoryValue( + revertTakenPill: RevertTakenPillValue()), + beforePillSheetGroup: null, + afterPillSheetGroup: null, + ), + ]; + + final result = missedPillDays(histories: histories, maxDate: baseDate); + + // 9/1〜9/4 の服用記録はそのまま残るので飲み忘れは0日 + expect(result, 0); + }); + + test("複数日分をまとめて記録した場合、記録対象の全日が服用済みとして扱われる", () { + final today = DateTime.parse("2020-09-05"); + final baseDate = DateTime(today.year, today.month, today.day); + final histories = [ + // 9/1 に通常記録(グループ情報なし = 履歴の日付がそのまま記録日) + PillSheetModifiedHistory( + id: 'taken_1', + actionType: PillSheetModifiedActionType.takenPill.name, + estimatedEventCausingDate: DateTime(2020, 9, 1), + createdAt: DateTime(2020, 9, 1), + value: const PillSheetModifiedHistoryValue(), + beforePillSheetGroup: null, + afterPillSheetGroup: null, + ), + // 9/4 の操作で 9/2〜9/4 の3日分をまとめて記録(lastTakenDate: 9/1 → 9/4) + PillSheetModifiedHistory( + id: 'taken_bulk', + actionType: PillSheetModifiedActionType.takenPill.name, + estimatedEventCausingDate: DateTime(2020, 9, 4, 20), + createdAt: DateTime(2020, 9, 4, 20), + value: const PillSheetModifiedHistoryValue(takenPill: TakenPillValue()), + beforePillSheetGroup: createPillSheetGroupWithLastTakenDate(DateTime(2020, 9, 1)), + afterPillSheetGroup: createPillSheetGroupWithLastTakenDate(DateTime(2020, 9, 4)), + ), + ]; + + final result = missedPillDays(histories: histories, maxDate: baseDate); + + // 集計期間 9/1〜9/4 のうち、9/1 は通常記録・9/2〜9/4 はまとめ記録の対象なので飲み忘れは0日 + expect(result, 0); + }); + + test("初回からまとめて記録した場合(記録前の最終服用日がnull)、シート開始日から記録対象日が展開される", () { + final today = DateTime.parse("2020-09-04"); + final baseDate = DateTime(today.year, today.month, today.day); + final histories = [ + // 集計期間の起点となる履歴(シート作成。服用記録ではない) + PillSheetModifiedHistory( + id: 'created', + actionType: PillSheetModifiedActionType.createdPillSheet.name, + estimatedEventCausingDate: DateTime(2020, 9, 1), + createdAt: DateTime(2020, 9, 1), + value: const PillSheetModifiedHistoryValue(), + beforePillSheetGroup: null, + afterPillSheetGroup: null, + ), + // 9/3 の操作で 9/1〜9/3 の3日分を初回まとめ記録(lastTakenDate: null → 9/3) + PillSheetModifiedHistory( + id: 'taken_bulk', + actionType: PillSheetModifiedActionType.takenPill.name, + estimatedEventCausingDate: DateTime(2020, 9, 3, 21), + createdAt: DateTime(2020, 9, 3, 21), + value: const PillSheetModifiedHistoryValue(takenPill: TakenPillValue()), + beforePillSheetGroup: createPillSheetGroupWithLastTakenDate(null), + afterPillSheetGroup: createPillSheetGroupWithLastTakenDate(DateTime(2020, 9, 3)), + ), + ]; + + final result = missedPillDays(histories: histories, maxDate: baseDate); + + // 集計期間 9/1〜9/3 の全日がまとめ記録の対象(シート開始日 9/1 から展開)なので飲み忘れは0日 + expect(result, 0); + }); + + test("服用日時が編集された履歴は、編集後の日付が服用記録日として扱われる", () { + final today = DateTime.parse("2020-09-04"); + final baseDate = DateTime(today.year, today.month, today.day); + final histories = [ + // 9/1 に通常記録(グループ情報なし) + PillSheetModifiedHistory( + id: 'taken_1', + actionType: PillSheetModifiedActionType.takenPill.name, + estimatedEventCausingDate: DateTime(2020, 9, 1), + createdAt: DateTime(2020, 9, 1), + value: const PillSheetModifiedHistoryValue(), + beforePillSheetGroup: null, + afterPillSheetGroup: null, + ), + // 9/3 の記録を 9/2 23:00 に編集済み。before/after の lastTakenDate は編集前のまま(9/1 → 9/3) + PillSheetModifiedHistory( + id: 'taken_edited', + actionType: PillSheetModifiedActionType.takenPill.name, + estimatedEventCausingDate: DateTime(2020, 9, 2, 23), + createdAt: DateTime(2020, 9, 3, 21), + value: PillSheetModifiedHistoryValue( + takenPill: TakenPillValue( + edited: TakenPillEditedValue( + createdDate: DateTime(2020, 9, 3, 22), + actualTakenDate: DateTime(2020, 9, 2, 23), + historyRecordedDate: DateTime(2020, 9, 3, 21), + ), + ), + ), + beforePillSheetGroup: createPillSheetGroupWithLastTakenDate(DateTime(2020, 9, 1)), + afterPillSheetGroup: createPillSheetGroupWithLastTakenDate(DateTime(2020, 9, 3)), + ), + ]; + + final result = missedPillDays(histories: histories, maxDate: baseDate); + + // 集計期間 9/1〜9/3 のうち、9/1(通常記録)と 9/2(編集後の記録日)が服用済み。 + // 編集前の lastTakenDate 差分(9/3)は記録日として使われないため飲み忘れは1日 + expect(result, 1); + }); + + test("服用日時を編集した記録を取り消した場合、編集後の日付も服用済みから除外される", () { + final today = DateTime.parse("2020-09-05"); + final baseDate = DateTime(today.year, today.month, today.day); + final histories = [ + // 9/1・9/2 に通常記録(グループ情報なし) + for (int i = 1; i <= 2; i++) + PillSheetModifiedHistory( + id: 'taken_$i', + actionType: PillSheetModifiedActionType.takenPill.name, + estimatedEventCausingDate: DateTime(2020, 9, i), + createdAt: DateTime(2020, 9, i), + value: const PillSheetModifiedHistoryValue(), + beforePillSheetGroup: null, + afterPillSheetGroup: null, + ), + // 9/3 のピルの記録を 9/4 0:30 に編集済み(before/after の lastTakenDate は元のピル日付 9/2 → 9/3 のまま) + PillSheetModifiedHistory( + id: 'taken_edited', + actionType: PillSheetModifiedActionType.takenPill.name, + estimatedEventCausingDate: DateTime(2020, 9, 4, 0, 30), + createdAt: DateTime(2020, 9, 3, 21), + value: PillSheetModifiedHistoryValue( + takenPill: TakenPillValue( + edited: TakenPillEditedValue( + createdDate: DateTime(2020, 9, 4, 1), + actualTakenDate: DateTime(2020, 9, 4, 0, 30), + historyRecordedDate: DateTime(2020, 9, 3, 21), + ), + ), + ), + beforePillSheetGroup: createPillSheetGroupWithLastTakenDate(DateTime(2020, 9, 2)), + afterPillSheetGroup: createPillSheetGroupWithLastTakenDate(DateTime(2020, 9, 3)), + ), + // 9/3 のピルの記録を取り消し(lastTakenDate: 9/3 → 9/2) + createRevertHistory( + estimatedEventCausingDate: DateTime(2020, 9, 4, 10), + beforeLastTakenDate: DateTime(2020, 9, 3), + afterLastTakenDate: DateTime(2020, 9, 2), + ), + ]; + + final result = missedPillDays(histories: histories, maxDate: baseDate); + + // 集計期間 9/1〜9/4 のうち、9/1・9/2 が服用済み。 + // 取り消されたピル(元の日付 9/3)の編集後の記録日 9/4 も除外されるため飲み忘れは2日(9/3・9/4) + expect(result, 2); + }); + test("30日間すべて服用記録がある場合は0を返す", () { final today = DateTime.parse("2020-09-28"); final baseDate = DateTime(today.year, today.month, today.day); @@ -301,8 +652,72 @@ void main() { final result = missedPillDays(histories: histories, maxDate: today); - // 5日前から今日までの6日間のうち、1日だけ服用記録があるので5日の飲み忘れ - expect(result, 5); + // 5日前(9/23)〜昨日(9/27)の5日間のうち、9/23 に服用記録があるので4日の飲み忘れ。 + // 同じ日に時刻違いの履歴が複数あっても、日付単位に正規化して集計される + expect(result, 4); + }); + + test("最古の履歴が深夜(23:59)の時刻付きでも日付単位で集計される", () { + final today = DateTime.parse("2020-09-05"); + final baseDate = DateTime(today.year, today.month, today.day); + final histories = [ + // 集計開始日となる最古の履歴が時刻付き(深夜の境界値) + PillSheetModifiedHistory( + id: 'taken_1', + actionType: PillSheetModifiedActionType.takenPill.name, + estimatedEventCausingDate: DateTime(2020, 9, 1, 23, 59), + createdAt: DateTime(2020, 9, 1, 23, 59), + value: const PillSheetModifiedHistoryValue(), + beforePillSheetGroup: null, + afterPillSheetGroup: null, + ), + PillSheetModifiedHistory( + id: 'taken_2', + actionType: PillSheetModifiedActionType.takenPill.name, + estimatedEventCausingDate: DateTime(2020, 9, 2), + createdAt: DateTime(2020, 9, 2), + value: const PillSheetModifiedHistoryValue(), + beforePillSheetGroup: null, + afterPillSheetGroup: null, + ), + ]; + + final result = missedPillDays(histories: histories, maxDate: baseDate); + + // 集計期間は 9/1〜9/4 の4日間。9/1(23:59)と9/2 に服用記録があるので 9/3・9/4 の2日が飲み忘れ + expect(result, 2); + }); + + test("最古の履歴が時刻付きで服用お休みが継続中の場合も、服用お休み期間は飲み忘れから除外される", () { + final today = DateTime.parse("2020-09-05"); + final baseDate = DateTime(today.year, today.month, today.day); + final histories = [ + // 集計開始日となる最古の履歴が時刻付きの服用記録 + PillSheetModifiedHistory( + id: 'taken_1', + actionType: PillSheetModifiedActionType.takenPill.name, + estimatedEventCausingDate: DateTime(2020, 9, 1, 8), + createdAt: DateTime(2020, 9, 1, 8), + value: const PillSheetModifiedHistoryValue(), + beforePillSheetGroup: null, + afterPillSheetGroup: null, + ), + // 9/2 から服用お休み開始(終了していない) + PillSheetModifiedHistory( + id: 'began_rest_duration', + actionType: PillSheetModifiedActionType.beganRestDuration.name, + estimatedEventCausingDate: DateTime(2020, 9, 2, 9), + createdAt: DateTime(2020, 9, 2, 9), + value: const PillSheetModifiedHistoryValue(), + beforePillSheetGroup: null, + afterPillSheetGroup: null, + ), + ]; + + final result = missedPillDays(histories: histories, maxDate: baseDate); + + // 集計期間 9/1〜9/4 のうち、9/1 は服用済み・9/2〜9/4 は服用お休み中なので飲み忘れは0日 + expect(result, 0); }); test("今日服用した場合は飲み忘れが0日", () { @@ -748,6 +1163,96 @@ void main() { }); }); + group('#takenPillHistoryTargetDates', () { + PillSheetGroup pillSheetGroupWithLastTakenDate(DateTime? lastTakenDate) { + return PillSheetGroup( + id: 'group_id', + pillSheetIDs: ['pill_sheet_id_1'], + pillSheets: [ + PillSheet.v1( + id: 'pill_sheet_id_1', + typeInfo: PillSheetType.pillsheet_28_0.typeInfo, + beginDate: DateTime.parse("2020-09-01"), + lastTakenDate: lastTakenDate, + createdAt: DateTime.parse("2020-09-01"), + ), + ], + createdAt: DateTime.parse("2020-09-01"), + ); + } + + PillSheetModifiedHistory takenHistory({ + required DateTime estimatedEventCausingDate, + required PillSheetModifiedHistoryValue value, + required DateTime? beforeLastTakenDate, + required DateTime? afterLastTakenDate, + }) { + return PillSheetModifiedHistory( + id: 'taken', + actionType: PillSheetModifiedActionType.takenPill.name, + estimatedEventCausingDate: estimatedEventCausingDate, + createdAt: estimatedEventCausingDate, + value: value, + beforePillSheetGroup: pillSheetGroupWithLastTakenDate(beforeLastTakenDate), + afterPillSheetGroup: pillSheetGroupWithLastTakenDate(afterLastTakenDate), + ); + } + + test("まとめ記録は (記録前の最終服用日, 記録後の最終服用日] のピル日付を返す", () { + final result = takenPillHistoryTargetDates(takenHistory( + estimatedEventCausingDate: DateTime(2020, 9, 4, 20), + value: const PillSheetModifiedHistoryValue(takenPill: TakenPillValue()), + beforeLastTakenDate: DateTime(2020, 9, 1), + afterLastTakenDate: DateTime(2020, 9, 4), + )); + expect( + result, + unorderedEquals([DateTime(2020, 9, 2), DateTime(2020, 9, 3), DateTime(2020, 9, 4)]), + ); + }); + + test("同じピル日に2回目を記録した履歴(lastTakenDateが変わらない)はその日を返す", () { + final result = takenPillHistoryTargetDates(takenHistory( + estimatedEventCausingDate: DateTime(2020, 9, 3, 21), + value: const PillSheetModifiedHistoryValue(takenPill: TakenPillValue()), + beforeLastTakenDate: DateTime(2020, 9, 3, 8), + afterLastTakenDate: DateTime(2020, 9, 3, 21), + )); + expect(result, [DateTime(2020, 9, 3)]); + }); + + test("服用日時が編集された履歴は編集後の履歴日時の日付を返す", () { + final result = takenPillHistoryTargetDates(takenHistory( + estimatedEventCausingDate: DateTime(2020, 9, 2, 23), + value: PillSheetModifiedHistoryValue( + takenPill: TakenPillValue( + edited: TakenPillEditedValue( + createdDate: DateTime(2020, 9, 3, 22), + actualTakenDate: DateTime(2020, 9, 2, 23), + historyRecordedDate: DateTime(2020, 9, 3, 21), + ), + ), + ), + beforeLastTakenDate: DateTime(2020, 9, 2), + afterLastTakenDate: DateTime(2020, 9, 3), + )); + expect(result, [DateTime(2020, 9, 2)]); + }); + + test("グループ情報の無い履歴は履歴日時の日付を返す", () { + final result = takenPillHistoryTargetDates(PillSheetModifiedHistory( + id: 'taken', + actionType: PillSheetModifiedActionType.takenPill.name, + estimatedEventCausingDate: DateTime(2020, 9, 3, 21), + createdAt: DateTime(2020, 9, 3, 21), + value: const PillSheetModifiedHistoryValue(), + beforePillSheetGroup: null, + afterPillSheetGroup: null, + )); + expect(result, [DateTime(2020, 9, 3)]); + }); + }); + group('#createTakenPillAction', () { // テスト用のPillSheetGroupを作成するヘルパー関数 PillSheetGroup createPillSheetGroup({required List pillSheets}) { diff --git a/test/features/ended_pill_sheet_dialog/ended_pill_sheet_dialog_test.dart b/test/features/ended_pill_sheet_dialog/ended_pill_sheet_dialog_test.dart new file mode 100644 index 0000000000..02a1fdb858 --- /dev/null +++ b/test/features/ended_pill_sheet_dialog/ended_pill_sheet_dialog_test.dart @@ -0,0 +1,325 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:mockito/mockito.dart'; +import 'package:pilll/components/molecules/indicator.dart'; +import 'package:pilll/entity/pill_sheet.codegen.dart'; +import 'package:pilll/entity/pill_sheet_group.codegen.dart'; +import 'package:pilll/entity/pill_sheet_modified_history.codegen.dart'; +import 'package:pilll/entity/pill_sheet_modified_history_value.codegen.dart'; +import 'package:pilll/entity/pill_sheet_type.dart'; +import 'package:pilll/features/calendar/components/pill_sheet_modified_history/components/rows/pill_sheet_modified_history_taken_pill_action.dart'; +import 'package:pilll/features/ended_pill_sheet_dialog/components/history_blur_teaser.dart'; +import 'package:pilll/features/ended_pill_sheet_dialog/components/summary_stats_teaser.dart'; +import 'package:pilll/features/ended_pill_sheet_dialog/ended_pill_sheet_dialog.dart'; +import 'package:pilll/features/ended_pill_sheet_dialog/ended_pill_sheet_dialog_variant.dart'; +import 'package:pilll/provider/database.dart'; +import 'package:pilll/provider/pill_sheet_modified_history.dart'; +import 'package:pilll/utils/datetime/day.dart'; + +import '../../helper/mock.mocks.dart'; + +PillSheetGroup _pillSheetGroup({String id = 'group_id', DateTime? lastTakenDate}) => PillSheetGroup( + id: id, + pillSheetIDs: ['pill_sheet_id_1'], + pillSheets: [ + PillSheet.v1( + id: 'pill_sheet_id_1', + typeInfo: PillSheetType.pillsheet_28_0.typeInfo, + beginDate: DateTime(2020, 9, 1), + lastTakenDate: lastTakenDate ?? DateTime(2020, 9, 28), + createdAt: DateTime(2020, 9, 1), + ), + ], + createdAt: DateTime(2020, 9, 1), + ); + +PillSheetModifiedHistory _takenHistory({required String groupID, required DateTime date}) => PillSheetModifiedHistory( + id: 'taken_$date', + actionType: PillSheetModifiedActionType.takenPill.name, + estimatedEventCausingDate: date, + createdAt: date, + value: const PillSheetModifiedHistoryValue(takenPill: TakenPillValue()), + beforePillSheetGroup: _pillSheetGroup(id: groupID, lastTakenDate: date.subtract(const Duration(days: 1))), + afterPillSheetGroup: _pillSheetGroup(id: groupID, lastTakenDate: date), + ); + +PillSheetModifiedHistory _revertHistory({ + required String groupID, + required DateTime date, + required DateTime beforeLastTakenDate, + required DateTime? afterLastTakenDate, +}) => + PillSheetModifiedHistory( + id: 'revert_$date', + actionType: PillSheetModifiedActionType.revertTakenPill.name, + estimatedEventCausingDate: date, + createdAt: date, + value: const PillSheetModifiedHistoryValue(revertTakenPill: RevertTakenPillValue()), + beforePillSheetGroup: _pillSheetGroup(id: groupID, lastTakenDate: beforeLastTakenDate), + afterPillSheetGroup: _pillSheetGroup(id: groupID, lastTakenDate: afterLastTakenDate), + ); + +void main() { + group('#EndedPillSheetDialog', () { + testWidgets('variant=historyBlur のとき HistoryBlurTeaser を表示し SummaryStatsTeaser は表示しない', (tester) async { + await tester.pumpWidget( + ProviderScope( + overrides: [ + pillSheetModifiedHistoriesWithLimitProvider(limit: 30).overrideWith((ref) => Stream.value([])), + ], + child: MaterialApp( + home: EndedPillSheetDialog( + variant: EndedPillSheetDialogVariant.historyBlur, + pillSheetGroup: _pillSheetGroup(), + ), + ), + ), + ); + await tester.pump(); + + expect(find.byType(HistoryBlurTeaser), findsOneWidget); + expect(find.byType(SummaryStatsTeaser), findsNothing); + }); + + testWidgets('variant=summaryStats のとき SummaryStatsTeaser を表示し HistoryBlurTeaser は表示しない', (tester) async { + final pillSheetGroup = _pillSheetGroup(); + await tester.pumpWidget( + ProviderScope( + overrides: [ + pillSheetModifiedHistoriesWithRangeProvider( + begin: pillSheetGroup.pillSheets.first.beginDate, + end: pillSheetGroup.pillSheets.last.estimatedEndTakenDate, + ).overrideWith((ref) => Stream.value([])), + ], + child: MaterialApp( + home: EndedPillSheetDialog( + variant: EndedPillSheetDialogVariant.summaryStats, + pillSheetGroup: pillSheetGroup, + ), + ), + ), + ); + await tester.pump(); + + expect(find.byType(SummaryStatsTeaser), findsOneWidget); + expect(find.byType(HistoryBlurTeaser), findsNothing); + }); + }); + + group('#HistoryBlurTeaser', () { + Widget buildTeaser({required List histories, required PillSheetGroup pillSheetGroup}) { + return ProviderScope( + overrides: [ + databaseProvider.overrideWith((ref) => MockDatabaseConnection()), + pillSheetModifiedHistoriesWithLimitProvider(limit: 30).overrideWith((ref) => Stream.value(histories)), + ], + child: MaterialApp( + home: Scaffold(body: HistoryBlurTeaser(pillSheetGroup: pillSheetGroup)), + ), + ); + } + + testWidgets('対象グループの服用記録が最新3件まで表示される', (tester) async { + final mockTodayRepository = MockTodayService(); + todayRepository = mockTodayRepository; + when(mockTodayRepository.now()).thenReturn(DateTime.parse('2020-09-29')); + + // 提供順は provider と同じ降順(新しい順) + final histories = [ + for (int day = 28; day >= 25; day--) _takenHistory(groupID: 'group_id', date: DateTime(2020, 9, day, 10)), + ]; + + await tester.pumpWidget(buildTeaser(histories: histories, pillSheetGroup: _pillSheetGroup())); + await tester.pump(); + + expect(find.byType(PillSheetModifiedHistoryTakenPillAction), findsNWidgets(3)); + }); + + testWidgets('別グループの服用記録は表示されない', (tester) async { + final mockTodayRepository = MockTodayService(); + todayRepository = mockTodayRepository; + when(mockTodayRepository.now()).thenReturn(DateTime.parse('2020-09-29')); + + final histories = [ + for (int day = 28; day >= 26; day--) _takenHistory(groupID: 'other_group_id', date: DateTime(2020, 9, day, 10)), + ]; + + await tester.pumpWidget(buildTeaser(histories: histories, pillSheetGroup: _pillSheetGroup())); + await tester.pump(); + + expect(find.byType(PillSheetModifiedHistoryTakenPillAction), findsNothing); + }); + + testWidgets('取り消し(revertTakenPill)されたままの服用記録は表示されない', (tester) async { + final mockTodayRepository = MockTodayService(); + todayRepository = mockTodayRepository; + when(mockTodayRepository.now()).thenReturn(DateTime.parse('2020-09-29')); + + // 9/26〜9/28 に服用後、9/28 の記録を取り消してそのまま(lastTakenDate: 9/28 → 9/27) + final histories = [ + _revertHistory( + groupID: 'group_id', + date: DateTime(2020, 9, 28, 20), + beforeLastTakenDate: DateTime(2020, 9, 28), + afterLastTakenDate: DateTime(2020, 9, 27), + ), + for (int day = 28; day >= 26; day--) _takenHistory(groupID: 'group_id', date: DateTime(2020, 9, day, 10)), + ]; + + await tester.pumpWidget(buildTeaser(histories: histories, pillSheetGroup: _pillSheetGroup())); + await tester.pump(); + + // 取り消された 9/28 は表示されず、9/27・9/26 の2件のみ表示される + expect(find.byType(PillSheetModifiedHistoryTakenPillAction), findsNWidgets(2)); + }); + + testWidgets('過去日のピルを後から記録した履歴(操作日と記録対象日が異なる)も表示される', (tester) async { + final mockTodayRepository = MockTodayService(); + todayRepository = mockTodayRepository; + when(mockTodayRepository.now()).thenReturn(DateTime.parse('2020-09-29')); + + final histories = [ + // 9/28 の操作で 9/27 分のピルを記録(lastTakenDate: 9/26 → 9/27) + PillSheetModifiedHistory( + id: 'taken_past_day', + actionType: PillSheetModifiedActionType.takenPill.name, + estimatedEventCausingDate: DateTime(2020, 9, 28, 10), + createdAt: DateTime(2020, 9, 28, 10), + value: const PillSheetModifiedHistoryValue(takenPill: TakenPillValue()), + beforePillSheetGroup: _pillSheetGroup(id: 'group_id', lastTakenDate: DateTime(2020, 9, 26)), + afterPillSheetGroup: _pillSheetGroup(id: 'group_id', lastTakenDate: DateTime(2020, 9, 27)), + ), + ]; + + await tester.pumpWidget(buildTeaser(histories: histories, pillSheetGroup: _pillSheetGroup())); + await tester.pump(); + + expect(find.byType(PillSheetModifiedHistoryTakenPillAction), findsOneWidget); + }); + + testWidgets('一部だけ取り消されたまとめ記録の行は表示されない', (tester) async { + final mockTodayRepository = MockTodayService(); + todayRepository = mockTodayRepository; + when(mockTodayRepository.now()).thenReturn(DateTime.parse('2020-09-29')); + + final histories = [ + // 9/28 の記録だけを取り消し(lastTakenDate: 9/28 → 9/27) + _revertHistory( + groupID: 'group_id', + date: DateTime(2020, 9, 28, 20), + beforeLastTakenDate: DateTime(2020, 9, 28), + afterLastTakenDate: DateTime(2020, 9, 27), + ), + // 9/28 の操作で 9/26〜9/28 の3日分をまとめて記録(lastTakenDate: 9/25 → 9/28) + PillSheetModifiedHistory( + id: 'taken_bulk', + actionType: PillSheetModifiedActionType.takenPill.name, + estimatedEventCausingDate: DateTime(2020, 9, 28, 10), + createdAt: DateTime(2020, 9, 28, 10), + value: const PillSheetModifiedHistoryValue(takenPill: TakenPillValue()), + beforePillSheetGroup: _pillSheetGroup(id: 'group_id', lastTakenDate: DateTime(2020, 9, 25)), + afterPillSheetGroup: _pillSheetGroup(id: 'group_id', lastTakenDate: DateTime(2020, 9, 28)), + ), + ]; + + await tester.pumpWidget(buildTeaser(histories: histories, pillSheetGroup: _pillSheetGroup())); + await tester.pump(); + + // まとめ記録の行表示は afterPillSheetGroup(9/28 まで記録済み)ベースのため、 + // 一部が取り消された状態では実態とずれる。記録対象日が全て残っている履歴のみ表示する + expect(find.byType(PillSheetModifiedHistoryTakenPillAction), findsNothing); + }); + + testWidgets('履歴の読み込み中は服用記録行を表示せずインジケータを表示する', (tester) async { + await tester.pumpWidget( + ProviderScope( + overrides: [ + databaseProvider.overrideWith((ref) => MockDatabaseConnection()), + // 値を流さない Stream でローディング状態を維持する + pillSheetModifiedHistoriesWithLimitProvider(limit: historyBlurTeaserHistoriesLimit) + .overrideWith((ref) => const Stream>.empty()), + ], + child: MaterialApp( + home: Scaffold(body: HistoryBlurTeaser(pillSheetGroup: _pillSheetGroup())), + ), + ), + ); + await tester.pump(); + + expect(find.byType(Indicator), findsOneWidget); + expect(find.byType(PillSheetModifiedHistoryTakenPillAction), findsNothing); + }); + }); + + group('#SummaryStatsTeaser', () { + Widget buildTeaser({required List histories, required PillSheetGroup pillSheetGroup}) { + return ProviderScope( + overrides: [ + pillSheetModifiedHistoriesWithRangeProvider( + begin: pillSheetGroup.pillSheets.first.beginDate, + end: pillSheetGroup.pillSheets.last.estimatedEndTakenDate, + ).overrideWith((ref) => Stream.value(histories)), + ], + child: MaterialApp( + home: Scaffold(body: SummaryStatsTeaser(pillSheetGroup: pillSheetGroup)), + ), + ); + } + + testWidgets('集計開始日が履歴TTL(180日)の窓内なら集計メッセージが表示される', (tester) async { + final mockTodayRepository = MockTodayService(); + todayRepository = mockTodayRepository; + when(mockTodayRepository.now()).thenReturn(DateTime.parse('2020-10-01')); + + final pillSheetGroup = _pillSheetGroup(); + await tester.pumpWidget( + buildTeaser( + histories: [_takenHistory(groupID: 'group_id', date: DateTime(2020, 9, 1, 10))], + pillSheetGroup: pillSheetGroup, + ), + ); + await tester.pump(); + + // タイトルと集計メッセージの2つの Text が表示される + expect(find.descendant(of: find.byType(SummaryStatsTeaser), matching: find.byType(Text)), findsNWidgets(2)); + }); + + testWidgets('集計開始日が履歴TTL(180日)の窓外なら、履歴が部分的に削除されている可能性があるため集計メッセージを表示しない', (tester) async { + final mockTodayRepository = MockTodayService(); + todayRepository = mockTodayRepository; + // beginDate(2020-09-01) の履歴は TTL 切れの時期(273日後) + when(mockTodayRepository.now()).thenReturn(DateTime.parse('2021-06-01')); + + final pillSheetGroup = _pillSheetGroup(); + await tester.pumpWidget( + buildTeaser( + histories: [_takenHistory(groupID: 'group_id', date: DateTime(2020, 9, 20, 10))], + pillSheetGroup: pillSheetGroup, + ), + ); + await tester.pump(); + + // 履歴が残っていても集計メッセージは出さず、タイトルの Text のみ表示される + expect(find.descendant(of: find.byType(SummaryStatsTeaser), matching: find.byType(Text)), findsOneWidget); + }); + + testWidgets('対象グループの履歴が無い場合は集計メッセージを表示しない', (tester) async { + final mockTodayRepository = MockTodayService(); + todayRepository = mockTodayRepository; + when(mockTodayRepository.now()).thenReturn(DateTime.parse('2020-10-01')); + + final pillSheetGroup = _pillSheetGroup(); + await tester.pumpWidget( + buildTeaser( + histories: [_takenHistory(groupID: 'other_group_id', date: DateTime(2020, 9, 1, 10))], + pillSheetGroup: pillSheetGroup, + ), + ); + await tester.pump(); + + // 別グループの履歴しか無い場合はタイトルの Text のみ表示される + expect(find.descendant(of: find.byType(SummaryStatsTeaser), matching: find.byType(Text)), findsOneWidget); + }); + }); +} diff --git a/test/features/ended_pill_sheet_dialog/ended_pill_sheet_dialog_variant_test.dart b/test/features/ended_pill_sheet_dialog/ended_pill_sheet_dialog_variant_test.dart new file mode 100644 index 0000000000..1a690fd4e3 --- /dev/null +++ b/test/features/ended_pill_sheet_dialog/ended_pill_sheet_dialog_variant_test.dart @@ -0,0 +1,35 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:pilll/features/ended_pill_sheet_dialog/ended_pill_sheet_dialog_variant.dart'; +import 'package:pilll/features/premium_introduction/paywall_source.dart'; + +void main() { + group('#endedPillSheetDialogVariantFromRemoteConfig', () { + test('history_blur は historyBlur に解決される', () { + expect(endedPillSheetDialogVariantFromRemoteConfig('history_blur'), + EndedPillSheetDialogVariant.historyBlur); + }); + test('summary_stats は summaryStats に解決される', () { + expect(endedPillSheetDialogVariantFromRemoteConfig('summary_stats'), + EndedPillSheetDialogVariant.summaryStats); + }); + test('空文字は null(実験未参加=非表示)', () { + expect(endedPillSheetDialogVariantFromRemoteConfig(''), isNull); + }); + test('未知の値は null(非表示)', () { + expect(endedPillSheetDialogVariantFromRemoteConfig('unknown'), isNull); + }); + }); + + group('#EndedPillSheetDialogVariantFunction', () { + test('value が Remote Config / analytics の文字列と一致', () { + expect(EndedPillSheetDialogVariant.historyBlur.value, 'history_blur'); + expect(EndedPillSheetDialogVariant.summaryStats.value, 'summary_stats'); + }); + test('paywallSource が各バリアントの source と一致', () { + expect(EndedPillSheetDialogVariant.historyBlur.paywallSource, + PaywallSource.endedPillSheetDialogHistory); + expect(EndedPillSheetDialogVariant.summaryStats.paywallSource, + PaywallSource.endedPillSheetDialogSummary); + }); + }); +} diff --git a/test/features/ended_pill_sheet_dialog/ended_pill_sheet_taken_summary_test.dart b/test/features/ended_pill_sheet_dialog/ended_pill_sheet_taken_summary_test.dart new file mode 100644 index 0000000000..19b8465f3c --- /dev/null +++ b/test/features/ended_pill_sheet_dialog/ended_pill_sheet_taken_summary_test.dart @@ -0,0 +1,322 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:mockito/mockito.dart'; +import 'package:pilll/entity/pill_sheet.codegen.dart'; +import 'package:pilll/entity/pill_sheet_group.codegen.dart'; +import 'package:pilll/entity/pill_sheet_modified_history.codegen.dart'; +import 'package:pilll/entity/pill_sheet_modified_history_value.codegen.dart'; +import 'package:pilll/entity/pill_sheet_type.dart'; +import 'package:pilll/features/ended_pill_sheet_dialog/ended_pill_sheet_taken_summary.dart'; +import 'package:pilll/utils/datetime/day.dart'; + +import '../../helper/mock.mocks.dart'; + +PillSheetModifiedHistory _history( + {required String actionType, required DateTime date}) { + return PillSheetModifiedHistory( + id: 'test_id', + actionType: actionType, + estimatedEventCausingDate: date, + createdAt: date, + value: const PillSheetModifiedHistoryValue(), + beforePillSheetGroup: null, + afterPillSheetGroup: null, + ); +} + +PillSheetGroup _pillSheetGroup( + {required PillSheetType pillSheetType, + required DateTime beginDate, + required DateTime? lastTakenDate}) { + return PillSheetGroup( + id: 'group_id', + pillSheetIDs: ['pill_sheet_id_1'], + pillSheets: [ + PillSheet.v1( + id: 'pill_sheet_id_1', + typeInfo: pillSheetType.typeInfo, + beginDate: beginDate, + lastTakenDate: lastTakenDate, + createdAt: beginDate, + ), + ], + createdAt: beginDate, + ); +} + +void main() { + group('#endedPillSheetTakenSummary', () { + test('履歴が空の場合、実薬の全日が記録漏れになる(28錠すべて実薬)', () { + final result = endedPillSheetTakenSummary( + pillSheetGroup: _pillSheetGroup( + pillSheetType: PillSheetType.pillsheet_28_0, + beginDate: DateTime(2020, 9, 1), + lastTakenDate: null, + ), + histories: [], + ); + expect(result.recordedDays, 0); + expect(result.missedDays, 28); + }); + + test('実薬期間を全日服用した場合、recordedDays=実薬日数 / missedDays=0(時刻付き履歴でも日付正規化される)', + () { + final begin = DateTime(2020, 9, 1); + final result = endedPillSheetTakenSummary( + pillSheetGroup: _pillSheetGroup( + pillSheetType: PillSheetType.pillsheet_28_0, + beginDate: begin, + lastTakenDate: DateTime(2020, 9, 28), + ), + histories: [ + for (int i = 0; i < 28; i++) + // 実データと同様に時刻付きの estimatedEventCausingDate を持たせる + _history( + actionType: PillSheetModifiedActionType.takenPill.name, + date: begin.add(Duration(days: i, hours: 18, minutes: 56)), + ), + ], + ); + expect(result.recordedDays, 28); + expect(result.missedDays, 0); + }); + + test('pillsheet_21(21実薬+7休薬)で実薬21日を全て記録した場合、休薬7日は記録漏れに含まれない', () { + final begin = DateTime(2020, 9, 1); + final result = endedPillSheetTakenSummary( + pillSheetGroup: _pillSheetGroup( + pillSheetType: PillSheetType.pillsheet_21, + beginDate: begin, + lastTakenDate: DateTime(2020, 9, 21), + ), + histories: [ + for (int i = 0; i < 21; i++) + _history( + actionType: PillSheetModifiedActionType.takenPill.name, + date: begin.add(Duration(days: i)), + ), + ], + ); + expect(result.recordedDays, 21); + expect(result.missedDays, 0); + }); + + test('開始日から数日後に初回記録した場合、開始日〜初回記録前の未記録日が記録漏れに含まれる', () { + final begin = DateTime(2020, 9, 1); + final result = endedPillSheetTakenSummary( + pillSheetGroup: _pillSheetGroup( + pillSheetType: PillSheetType.pillsheet_28_0, + beginDate: begin, + lastTakenDate: DateTime(2020, 9, 28), + ), + // 9/5〜9/28 の24日を服用(9/1〜9/4 の4日は未記録) + histories: [ + for (int i = 4; i < 28; i++) + _history( + actionType: PillSheetModifiedActionType.takenPill.name, + date: begin.add(Duration(days: i)), + ), + ], + ); + expect(result.recordedDays, 24); + expect(result.missedDays, 4); + }); + + test('服用記録を取り消してそのままの日は記録漏れに含まれる', () { + final begin = DateTime(2020, 9, 1); + // 9/1〜9/3 を服用後、9/3 の記録を取り消してそのまま + final beforeRevertPillSheetGroup = _pillSheetGroup( + pillSheetType: PillSheetType.pillsheet_28_0, + beginDate: begin, + lastTakenDate: DateTime(2020, 9, 3), + ); + final afterRevertPillSheetGroup = _pillSheetGroup( + pillSheetType: PillSheetType.pillsheet_28_0, + beginDate: begin, + lastTakenDate: DateTime(2020, 9, 2), + ); + final result = endedPillSheetTakenSummary( + pillSheetGroup: beforeRevertPillSheetGroup, + histories: [ + for (int i = 0; i < 3; i++) + _history( + actionType: PillSheetModifiedActionType.takenPill.name, + date: begin.add(Duration(days: i)), + ), + PillSheetModifiedHistory( + id: 'revert', + actionType: PillSheetModifiedActionType.revertTakenPill.name, + estimatedEventCausingDate: DateTime(2020, 9, 3, 20), + createdAt: DateTime(2020, 9, 3, 20), + value: const PillSheetModifiedHistoryValue( + revertTakenPill: RevertTakenPillValue()), + beforePillSheetGroup: beforeRevertPillSheetGroup, + afterPillSheetGroup: afterRevertPillSheetGroup, + ), + ], + ); + // 9/1・9/2 の2日が記録済み。9/3 は取り消されたので記録漏れ側に入る + expect(result.recordedDays, 2); + expect(result.missedDays, 26); + }); + + test('複数日分をまとめて記録した場合、記録対象の全日が recordedDays に含まれる', () { + final begin = DateTime(2020, 9, 1); + final result = endedPillSheetTakenSummary( + pillSheetGroup: _pillSheetGroup( + pillSheetType: PillSheetType.pillsheet_28_0, + beginDate: begin, + lastTakenDate: DateTime(2020, 9, 3), + ), + histories: [ + // 9/3 の操作で 9/1〜9/3 の3日分を初回まとめ記録(lastTakenDate: null → 9/3)。 + // 履歴の日時は操作時刻なので、記録対象日は before/after の lastTakenDate 差分から展開される + PillSheetModifiedHistory( + id: 'taken_bulk', + actionType: PillSheetModifiedActionType.takenPill.name, + estimatedEventCausingDate: DateTime(2020, 9, 3, 21), + createdAt: DateTime(2020, 9, 3, 21), + value: const PillSheetModifiedHistoryValue(takenPill: TakenPillValue()), + beforePillSheetGroup: _pillSheetGroup( + pillSheetType: PillSheetType.pillsheet_28_0, + beginDate: begin, + lastTakenDate: null, + ), + afterPillSheetGroup: _pillSheetGroup( + pillSheetType: PillSheetType.pillsheet_28_0, + beginDate: begin, + lastTakenDate: DateTime(2020, 9, 3), + ), + ), + ], + ); + // 9/1〜9/3 の3日が記録済み、残り25日が記録漏れ + expect(result.recordedDays, 3); + expect(result.missedDays, 25); + }); + }); + + group('#endedPillSheetTakenSummaryAvailable', () { + PillSheetModifiedHistory historyWithGroup({required String groupID, required DateTime date}) { + return PillSheetModifiedHistory( + id: 'taken_$date', + actionType: PillSheetModifiedActionType.takenPill.name, + estimatedEventCausingDate: date, + createdAt: date, + value: const PillSheetModifiedHistoryValue(takenPill: TakenPillValue()), + beforePillSheetGroup: null, + afterPillSheetGroup: PillSheetGroup( + id: groupID, + pillSheetIDs: ['pill_sheet_id_1'], + pillSheets: [ + PillSheet.v1( + id: 'pill_sheet_id_1', + typeInfo: PillSheetType.pillsheet_28_0.typeInfo, + beginDate: DateTime(2020, 9, 1), + lastTakenDate: date, + createdAt: DateTime(2020, 9, 1), + ), + ], + createdAt: DateTime(2020, 9, 1), + ), + ); + } + + test('集計開始日が履歴TTL(180日)の窓内で対象グループの履歴がある場合はtrue', () { + final mockTodayRepository = MockTodayService(); + todayRepository = mockTodayRepository; + when(mockTodayRepository.now()).thenReturn(DateTime.parse('2020-10-01')); + + final result = endedPillSheetTakenSummaryAvailable( + pillSheetGroup: _pillSheetGroup( + pillSheetType: PillSheetType.pillsheet_28_0, + beginDate: DateTime(2020, 9, 1), + lastTakenDate: DateTime(2020, 9, 28), + ), + histories: [historyWithGroup(groupID: 'group_id', date: DateTime(2020, 9, 1, 10))], + ); + expect(result, true); + }); + + test('集計開始日が履歴TTL(180日)の窓外の場合、履歴があってもfalse', () { + final mockTodayRepository = MockTodayService(); + todayRepository = mockTodayRepository; + when(mockTodayRepository.now()).thenReturn(DateTime.parse('2021-06-01')); + + final result = endedPillSheetTakenSummaryAvailable( + pillSheetGroup: _pillSheetGroup( + pillSheetType: PillSheetType.pillsheet_28_0, + beginDate: DateTime(2020, 9, 1), + lastTakenDate: DateTime(2020, 9, 28), + ), + histories: [historyWithGroup(groupID: 'group_id', date: DateTime(2020, 9, 20, 10))], + ); + expect(result, false); + }); + + test('境界値: 集計開始日がちょうど180日前の場合、初日の履歴のTTL切れを保証できないためfalse', () { + final mockTodayRepository = MockTodayService(); + todayRepository = mockTodayRepository; + // 2020-09-01 + 180日 = 2021-02-28 + when(mockTodayRepository.now()).thenReturn(DateTime.parse('2021-02-28')); + + final result = endedPillSheetTakenSummaryAvailable( + pillSheetGroup: _pillSheetGroup( + pillSheetType: PillSheetType.pillsheet_28_0, + beginDate: DateTime(2020, 9, 1), + lastTakenDate: DateTime(2020, 9, 28), + ), + histories: [historyWithGroup(groupID: 'group_id', date: DateTime(2020, 9, 20, 10))], + ); + expect(result, false); + }); + + test('境界値: 集計開始日が179日前の場合はtrue', () { + final mockTodayRepository = MockTodayService(); + todayRepository = mockTodayRepository; + // 2021-02-27 - 180日 = 2020-08-31 < beginDate(2020-09-01) + when(mockTodayRepository.now()).thenReturn(DateTime.parse('2021-02-27')); + + final result = endedPillSheetTakenSummaryAvailable( + pillSheetGroup: _pillSheetGroup( + pillSheetType: PillSheetType.pillsheet_28_0, + beginDate: DateTime(2020, 9, 1), + lastTakenDate: DateTime(2020, 9, 28), + ), + histories: [historyWithGroup(groupID: 'group_id', date: DateTime(2020, 9, 20, 10))], + ); + expect(result, true); + }); + + test('対象グループの履歴が無い場合(別グループの履歴のみ)はfalse', () { + final mockTodayRepository = MockTodayService(); + todayRepository = mockTodayRepository; + when(mockTodayRepository.now()).thenReturn(DateTime.parse('2020-10-01')); + + final result = endedPillSheetTakenSummaryAvailable( + pillSheetGroup: _pillSheetGroup( + pillSheetType: PillSheetType.pillsheet_28_0, + beginDate: DateTime(2020, 9, 1), + lastTakenDate: DateTime(2020, 9, 28), + ), + histories: [historyWithGroup(groupID: 'other_group_id', date: DateTime(2020, 9, 1, 10))], + ); + expect(result, false); + }); + + test('履歴が空の場合はfalse', () { + final mockTodayRepository = MockTodayService(); + todayRepository = mockTodayRepository; + when(mockTodayRepository.now()).thenReturn(DateTime.parse('2020-10-01')); + + final result = endedPillSheetTakenSummaryAvailable( + pillSheetGroup: _pillSheetGroup( + pillSheetType: PillSheetType.pillsheet_28_0, + beginDate: DateTime(2020, 9, 1), + lastTakenDate: DateTime(2020, 9, 28), + ), + histories: [], + ); + expect(result, false); + }); + }); +} diff --git a/test/features/premium_introduction/paywall_source_test.dart b/test/features/premium_introduction/paywall_source_test.dart index eaba2325d3..82fabceab8 100644 --- a/test/features/premium_introduction/paywall_source_test.dart +++ b/test/features/premium_introduction/paywall_source_test.dart @@ -17,6 +17,10 @@ void main() { expect(PaywallSource.featureAppealAlarmKit.value, 'feature_appeal_alarm_kit'); expect(PaywallSource.specialOfferingBar2.value, 'special_offering_bar2'); + expect(PaywallSource.endedPillSheetDialogHistory.value, + 'ended_pill_sheet_dialog_history'); + expect(PaywallSource.endedPillSheetDialogSummary.value, + 'ended_pill_sheet_dialog_summary'); }); }); } diff --git a/test/features/root/resolver/show_lifetime_offer_on_app_launch_test.dart b/test/features/root/resolver/show_lifetime_offer_on_app_launch_test.dart index c3b65bad9f..0127c3ca1b 100644 --- a/test/features/root/resolver/show_lifetime_offer_on_app_launch_test.dart +++ b/test/features/root/resolver/show_lifetime_offer_on_app_launch_test.dart @@ -207,6 +207,16 @@ void main() { ); }); + testWidgets('モーダルを表示した起動では起動時自動モーダルの共有フラグ(shownPaywallOnThisAppLaunch)が立つ', + (WidgetTester tester) async { + await pumpShowLifetimeOfferOnAppLaunch(tester); + + final container = ProviderScope.containerOf( + tester.element(find.byType(MaterialApp)), + ); + expect(container.read(shownPaywallOnThisAppLaunchProvider), isTrue); + }); + testWidgets('同一起動で起動時ペイウォールが表示済みの場合はモーダルが表示されず、フラグも初回表示時刻も記録されない', (WidgetTester tester) async { final sharedPreferences = await pumpShowLifetimeOfferOnAppLaunch(