From b60763c730fe0c616bf5fa6d7091c7fcd9312ebf Mon Sep 17 00:00:00 2001 From: bannzai Date: Thu, 25 Jun 2026 18:14:40 +0900 Subject: [PATCH 01/22] =?UTF-8?q?feat:=20PaywallSource=20=E3=81=AB?= =?UTF-8?q?=E3=83=94=E3=83=AB=E3=82=B7=E3=83=BC=E3=83=88=E7=B5=82=E4=BA=86?= =?UTF-8?q?=E3=83=80=E3=82=A4=E3=82=A2=E3=83=AD=E3=82=B0=E7=94=A8=E3=81=AE?= =?UTF-8?q?2=20source=20=E3=82=92=E8=BF=BD=E5=8A=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ended_pill_sheet_dialog_history / ended_pill_sheet_dialog_summary を追加し、A/B 各バリアントの paywall 経路を計測できるようにする --- lib/features/premium_introduction/paywall_source.dart | 6 ++++++ test/features/premium_introduction/paywall_source_test.dart | 2 ++ 2 files changed, 8 insertions(+) diff --git a/lib/features/premium_introduction/paywall_source.dart b/lib/features/premium_introduction/paywall_source.dart index 33ec45a160..36504950ee 100644 --- a/lib/features/premium_introduction/paywall_source.dart +++ b/lib/features/premium_introduction/paywall_source.dart @@ -32,6 +32,8 @@ enum PaywallSource { featureAppealReminderCustomize, specialOfferingBar, specialOfferingBar2, + endedPillSheetDialogHistory, + endedPillSheetDialogSummary, } extension PaywallSourceFunction on PaywallSource { @@ -89,6 +91,10 @@ extension PaywallSourceFunction on PaywallSource { return 'special_offering_bar'; case PaywallSource.specialOfferingBar2: return 'special_offering_bar2'; + case PaywallSource.endedPillSheetDialogHistory: + return 'ended_pill_sheet_dialog_history'; + case PaywallSource.endedPillSheetDialogSummary: + return 'ended_pill_sheet_dialog_summary'; } } } diff --git a/test/features/premium_introduction/paywall_source_test.dart b/test/features/premium_introduction/paywall_source_test.dart index eaba2325d3..0b854cf0ae 100644 --- a/test/features/premium_introduction/paywall_source_test.dart +++ b/test/features/premium_introduction/paywall_source_test.dart @@ -17,6 +17,8 @@ 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'); }); }); } From 60cab835e2bba55403210331f9cefa6b890f5b5b Mon Sep 17 00:00:00 2001 From: bannzai Date: Thu, 25 Jun 2026 18:14:55 +0900 Subject: [PATCH 02/22] =?UTF-8?q?feat:=20Remote=20Config=20=E3=81=AB=20end?= =?UTF-8?q?edPillSheetDialogVariant=20=E3=82=92=E8=BF=BD=E5=8A=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A/B バリアント割り当てを Firebase Remote Config / A/B Testing で行う。 default は空文字(実験未参加=非表示)で、history_blur / summary_stats を配信する。 String 取得用の getStringOrDefault 拡張も追加 --- .../remote_config_parameter.codegen.dart | 10 ++++ ...mote_config_parameter.codegen.freezed.dart | 46 ++++++++++++++++--- .../remote_config_parameter.codegen.g.dart | 2 + lib/provider/remote_config_parameter.dart | 12 +++++ lib/provider/remote_config_parameter.g.dart | 2 +- lib/utils/remote_config.dart | 1 + 6 files changed, 65 insertions(+), 8 deletions(-) diff --git a/lib/entity/remote_config_parameter.codegen.dart b/lib/entity/remote_config_parameter.codegen.dart index 5478e586d4..6efb6190e6 100644 --- a/lib/entity/remote_config_parameter.codegen.dart +++ b/lib/entity/remote_config_parameter.codegen.dart @@ -40,6 +40,9 @@ abstract class RemoteConfigKeys { /// 特別オファー2で代替テキストを使用するかのフラグキー static const specialOffering2UseAlternativeText = 'specialOffering2UseAlternativeText'; + + /// ピルシート終了ダイアログの A/B バリアント識別子キー + static const endedPillSheetDialogVariant = 'endedPillSheetDialogVariant'; } /// Remote Configパラメータのデフォルト値定義 @@ -81,6 +84,9 @@ abstract class RemoteConfigParameterDefaultValues { /// 特別オファー2で代替テキストを使用する(デフォルト) static const specialOffering2UseAlternativeText = true; + + /// ピルシート終了ダイアログのバリアント(空文字 = 実験未参加 / 非表示) + static const endedPillSheetDialogVariant = ''; } // [RemoteConfigDefaultValues] でgrepした場所に全て設定する @@ -150,6 +156,10 @@ class RemoteConfigParameter with _$RemoteConfigParameter { RemoteConfigParameterDefaultValues.specialOffering2UseAlternativeText, ) bool specialOffering2UseAlternativeText, + + /// ピルシート終了ダイアログの 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 00aca3b23c..d77b55368a 100644 --- a/lib/entity/remote_config_parameter.codegen.freezed.dart +++ b/lib/entity/remote_config_parameter.codegen.freezed.dart @@ -64,6 +64,10 @@ mixin _$RemoteConfigParameter { /// trueの場合、特別オファー2画面で異なるテキスト表現を使用する bool get specialOffering2UseAlternativeText => 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; @@ -85,7 +89,8 @@ abstract class $RemoteConfigParameterCopyWith<$Res> { int specialOfferingUserCreationDateTimeOffset, int specialOfferingUserCreationDateTimeOffsetSince, int specialOfferingUserCreationDateTimeOffsetUntil, - bool specialOffering2UseAlternativeText}); + bool specialOffering2UseAlternativeText, + String endedPillSheetDialogVariant}); } /// @nodoc @@ -111,6 +116,7 @@ class _$RemoteConfigParameterCopyWithImpl<$Res, $Val extends RemoteConfigParamet Object? specialOfferingUserCreationDateTimeOffsetSince = null, Object? specialOfferingUserCreationDateTimeOffsetUntil = null, Object? specialOffering2UseAlternativeText = null, + Object? endedPillSheetDialogVariant = null, }) { return _then(_value.copyWith( isPaywallFirst: null == isPaywallFirst @@ -157,6 +163,10 @@ class _$RemoteConfigParameterCopyWithImpl<$Res, $Val extends RemoteConfigParamet ? _value.specialOffering2UseAlternativeText : specialOffering2UseAlternativeText // ignore: cast_nullable_to_non_nullable as bool, + endedPillSheetDialogVariant: null == endedPillSheetDialogVariant + ? _value.endedPillSheetDialogVariant + : endedPillSheetDialogVariant // ignore: cast_nullable_to_non_nullable + as String, ) as $Val); } } @@ -178,7 +188,8 @@ abstract class _$$RemoteConfigParameterImplCopyWith<$Res> implements $RemoteConf int specialOfferingUserCreationDateTimeOffset, int specialOfferingUserCreationDateTimeOffsetSince, int specialOfferingUserCreationDateTimeOffsetUntil, - bool specialOffering2UseAlternativeText}); + bool specialOffering2UseAlternativeText, + String endedPillSheetDialogVariant}); } /// @nodoc @@ -201,6 +212,7 @@ class __$$RemoteConfigParameterImplCopyWithImpl<$Res> extends _$RemoteConfigPara Object? specialOfferingUserCreationDateTimeOffsetSince = null, Object? specialOfferingUserCreationDateTimeOffsetUntil = null, Object? specialOffering2UseAlternativeText = null, + Object? endedPillSheetDialogVariant = null, }) { return _then(_$RemoteConfigParameterImpl( isPaywallFirst: null == isPaywallFirst @@ -247,6 +259,10 @@ class __$$RemoteConfigParameterImplCopyWithImpl<$Res> extends _$RemoteConfigPara ? _value.specialOffering2UseAlternativeText : specialOffering2UseAlternativeText // ignore: cast_nullable_to_non_nullable as bool, + endedPillSheetDialogVariant: null == endedPillSheetDialogVariant + ? _value.endedPillSheetDialogVariant + : endedPillSheetDialogVariant // ignore: cast_nullable_to_non_nullable + as String, )); } } @@ -265,7 +281,8 @@ class _$RemoteConfigParameterImpl extends _RemoteConfigParameter { this.specialOfferingUserCreationDateTimeOffset = RemoteConfigParameterDefaultValues.specialOfferingUserCreationDateTimeOffset, this.specialOfferingUserCreationDateTimeOffsetSince = RemoteConfigParameterDefaultValues.specialOfferingUserCreationDateTimeOffsetSince, this.specialOfferingUserCreationDateTimeOffsetUntil = RemoteConfigParameterDefaultValues.specialOfferingUserCreationDateTimeOffsetUntil, - this.specialOffering2UseAlternativeText = RemoteConfigParameterDefaultValues.specialOffering2UseAlternativeText}) + this.specialOffering2UseAlternativeText = RemoteConfigParameterDefaultValues.specialOffering2UseAlternativeText, + this.endedPillSheetDialogVariant = RemoteConfigParameterDefaultValues.endedPillSheetDialogVariant}) : super._(); factory _$RemoteConfigParameterImpl.fromJson(Map json) => _$$RemoteConfigParameterImplFromJson(json); @@ -336,9 +353,15 @@ class _$RemoteConfigParameterImpl extends _RemoteConfigParameter { @JsonKey() final bool specialOffering2UseAlternativeText; + /// ピルシート終了ダイアログの 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)'; + return 'RemoteConfigParameter(isPaywallFirst: $isPaywallFirst, skipInitialSetting: $skipInitialSetting, trialDeadlineDateOffsetDay: $trialDeadlineDateOffsetDay, discountEntitlementOffsetDay: $discountEntitlementOffsetDay, discountCountdownBoundaryHour: $discountCountdownBoundaryHour, premiumIntroductionPattern: $premiumIntroductionPattern, premiumIntroductionShowsAppStoreReviewCard: $premiumIntroductionShowsAppStoreReviewCard, specialOfferingUserCreationDateTimeOffset: $specialOfferingUserCreationDateTimeOffset, specialOfferingUserCreationDateTimeOffsetSince: $specialOfferingUserCreationDateTimeOffsetSince, specialOfferingUserCreationDateTimeOffsetUntil: $specialOfferingUserCreationDateTimeOffsetUntil, specialOffering2UseAlternativeText: $specialOffering2UseAlternativeText, endedPillSheetDialogVariant: $endedPillSheetDialogVariant)'; } @override @@ -365,7 +388,9 @@ class _$RemoteConfigParameterImpl extends _RemoteConfigParameter { (identical(other.specialOfferingUserCreationDateTimeOffsetUntil, specialOfferingUserCreationDateTimeOffsetUntil) || other.specialOfferingUserCreationDateTimeOffsetUntil == specialOfferingUserCreationDateTimeOffsetUntil) && (identical(other.specialOffering2UseAlternativeText, specialOffering2UseAlternativeText) || - other.specialOffering2UseAlternativeText == specialOffering2UseAlternativeText)); + other.specialOffering2UseAlternativeText == specialOffering2UseAlternativeText) && + (identical(other.endedPillSheetDialogVariant, endedPillSheetDialogVariant) || + other.endedPillSheetDialogVariant == endedPillSheetDialogVariant)); } @JsonKey(ignore: true) @@ -382,7 +407,8 @@ class _$RemoteConfigParameterImpl extends _RemoteConfigParameter { specialOfferingUserCreationDateTimeOffset, specialOfferingUserCreationDateTimeOffsetSince, specialOfferingUserCreationDateTimeOffsetUntil, - specialOffering2UseAlternativeText); + specialOffering2UseAlternativeText, + endedPillSheetDialogVariant); @JsonKey(ignore: true) @override @@ -410,7 +436,8 @@ abstract class _RemoteConfigParameter extends RemoteConfigParameter { final int specialOfferingUserCreationDateTimeOffset, final int specialOfferingUserCreationDateTimeOffsetSince, final int specialOfferingUserCreationDateTimeOffsetUntil, - final bool specialOffering2UseAlternativeText}) = _$RemoteConfigParameterImpl; + final bool specialOffering2UseAlternativeText, + final String endedPillSheetDialogVariant}) = _$RemoteConfigParameterImpl; _RemoteConfigParameter._() : super._(); factory _RemoteConfigParameter.fromJson(Map json) = _$RemoteConfigParameterImpl.fromJson; @@ -471,6 +498,11 @@ abstract class _RemoteConfigParameter extends RemoteConfigParameter { /// trueの場合、特別オファー2画面で異なるテキスト表現を使用する bool get specialOffering2UseAlternativeText; @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 8bdb7d0846..1a4693d851 100644 --- a/lib/entity/remote_config_parameter.codegen.g.dart +++ b/lib/entity/remote_config_parameter.codegen.g.dart @@ -26,6 +26,7 @@ _$RemoteConfigParameterImpl _$$RemoteConfigParameterImplFromJson(Map _$$RemoteConfigParameterImplToJson(_$RemoteConfigParameterImpl instance) => { @@ -40,4 +41,5 @@ Map _$$RemoteConfigParameterImplToJson(_$RemoteConfigParameterI 'specialOfferingUserCreationDateTimeOffsetSince': instance.specialOfferingUserCreationDateTimeOffsetSince, 'specialOfferingUserCreationDateTimeOffsetUntil': instance.specialOfferingUserCreationDateTimeOffsetUntil, 'specialOffering2UseAlternativeText': instance.specialOffering2UseAlternativeText, + 'endedPillSheetDialogVariant': instance.endedPillSheetDialogVariant, }; diff --git a/lib/provider/remote_config_parameter.dart b/lib/provider/remote_config_parameter.dart index 0f402a7fac..9ea085b787 100644 --- a/lib/provider/remote_config_parameter.dart +++ b/lib/provider/remote_config_parameter.dart @@ -50,6 +50,10 @@ RemoteConfigParameter remoteConfigParameter(RemoteConfigParameterRef ref) { RemoteConfigKeys.specialOffering2UseAlternativeText, RemoteConfigParameterDefaultValues.specialOffering2UseAlternativeText, ), + endedPillSheetDialogVariant: remoteConfig.getStringOrDefault( + RemoteConfigKeys.endedPillSheetDialogVariant, + RemoteConfigParameterDefaultValues.endedPillSheetDialogVariant, + ), ); } @@ -69,4 +73,12 @@ extension RemoteConfigExt on FirebaseRemoteConfig { return defaultValue; } } + + String getStringOrDefault(String key, String defaultValue) { + try { + return getAll().containsKey(key) ? getString(key) : defaultValue; + } catch (error) { + return defaultValue; + } + } } diff --git a/lib/provider/remote_config_parameter.g.dart b/lib/provider/remote_config_parameter.g.dart index 74200dab89..28fbbff89f 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'3ef3e7f4d1f4a3d91fb0fd372c52c9926c34c9c1'; +String _$remoteConfigParameterHash() => r'8c97c74f5d28e39c6c079ae97a92e6a017c9a106'; /// See also [remoteConfigParameter]. @ProviderFor(remoteConfigParameter) diff --git a/lib/utils/remote_config.dart b/lib/utils/remote_config.dart index 6df9af0ff0..a48b68244d 100644 --- a/lib/utils/remote_config.dart +++ b/lib/utils/remote_config.dart @@ -31,6 +31,7 @@ Future setupRemoteConfig() async { RemoteConfigKeys.specialOfferingUserCreationDateTimeOffsetUntil: RemoteConfigParameterDefaultValues.specialOfferingUserCreationDateTimeOffsetUntil, RemoteConfigKeys.specialOffering2UseAlternativeText: RemoteConfigParameterDefaultValues.specialOffering2UseAlternativeText, + RemoteConfigKeys.endedPillSheetDialogVariant: RemoteConfigParameterDefaultValues.endedPillSheetDialogVariant, }), ).wait; // 項目が増えて来てfetchが重たくなっていてアプリが開かない説があるので非同期にする。計測はしてない。since: 2025-06-25 From 9fa8b7e94647d537bf99295f907dc63749264818 Mon Sep 17 00:00:00 2001 From: bannzai Date: Thu, 25 Jun 2026 18:15:04 +0900 Subject: [PATCH 03/22] =?UTF-8?q?refactor:=20missedPillDays=20=E3=81=AE?= =?UTF-8?q?=E9=9B=86=E5=90=88=E6=A7=8B=E7=AF=89=E3=82=92=E5=88=86=E8=A7=A3?= =?UTF-8?q?=E3=81=97=20scheduledPillDays=20=E3=82=92=E8=BF=BD=E5=8A=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 服用予定日と服用記録日の集合構築を内部ヘルパーに切り出し(missedPillDays の戻り値は不変)、 服用予定日数を返す scheduledPillDays を追加する。 ダイアログ Variant B の x(服用記録日数) = scheduledPillDays - missedPillDays に利用する --- .../pill_sheet_modified_history.codegen.dart | 37 ++++++++++-- .../pill_sheet_modified_history_test.dart | 59 +++++++++++++++++++ 2 files changed, 90 insertions(+), 6 deletions(-) diff --git a/lib/entity/pill_sheet_modified_history.codegen.dart b/lib/entity/pill_sheet_modified_history.codegen.dart index 1392be3151..d7e39da067 100644 --- a/lib/entity/pill_sheet_modified_history.codegen.dart +++ b/lib/entity/pill_sheet_modified_history.codegen.dart @@ -303,13 +303,15 @@ abstract class PillSheetModifiedHistoryServiceActionFactory { } } -/// 渡されたPillSheetModifiedHistory配列から飲み忘れ日数を計算する -int missedPillDays({ +/// 渡されたPillSheetModifiedHistory配列から、服用予定日(履歴期間の全日 − 服用お休み日)と +/// 服用記録のあった日の集合を構築する。履歴が空の場合は null を返す。 +/// missedPillDays / scheduledPillDays が共通で利用する。 +({Set scheduledDates, Set takenDates})? _buildPillTakenDateSets({ required List histories, required DateTime maxDate, }) { if (histories.isEmpty) { - return 0; + return null; } // 昇順に並べ替える。服用お休み期間の集計時に、服用お休みが開始された後の差分の日付を集計するために順番を整える必要がある @@ -371,7 +373,30 @@ int missedPillDays({ } } - // 服用記録がない日数を計算 - 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 = _buildPillTakenDateSets(histories: histories, maxDate: maxDate); + if (sets == null) { + return 0; + } + // 服用予定日のうち服用記録がない日数を計算 + return sets.scheduledDates.difference(sets.takenDates).length; +} + +/// 渡されたPillSheetModifiedHistory配列から服用予定日数(履歴期間の全日 − 服用お休み日)を計算する +int scheduledPillDays({ + required List histories, + required DateTime maxDate, +}) { + final sets = _buildPillTakenDateSets(histories: histories, maxDate: maxDate); + if (sets == null) { + return 0; + } + return sets.scheduledDates.length; } diff --git a/test/entity/pill_sheet_modified_history_test.dart b/test/entity/pill_sheet_modified_history_test.dart index a54cce1822..150ace10a2 100644 --- a/test/entity/pill_sheet_modified_history_test.dart +++ b/test/entity/pill_sheet_modified_history_test.dart @@ -748,6 +748,65 @@ void main() { }); }); + group('#scheduledPillDays', () { + test('履歴が空の場合は0を返す', () { + expect(scheduledPillDays(histories: [], maxDate: DateTime.parse('2020-09-28')), 0); + }); + + test('15日前に最初の記録がある場合、服用予定日数は15日', () { + final today = DateTime.parse('2020-09-28'); + final baseDate = DateTime(today.year, today.month, today.day); + final histories = [ + PillSheetModifiedHistory( + id: 'history_1', + actionType: PillSheetModifiedActionType.takenPill.name, + estimatedEventCausingDate: baseDate.subtract(const Duration(days: 15)), + createdAt: baseDate.subtract(const Duration(days: 15)), + value: const PillSheetModifiedHistoryValue(), + beforePillSheetGroup: null, + afterPillSheetGroup: null, + ), + ]; + expect(scheduledPillDays(histories: histories, maxDate: today), 15); + }); + + test('服用お休み期間は服用予定日数から除外される', () { + final today = DateTime.parse('2020-09-28'); + final baseDate = DateTime(today.year, today.month, today.day); + final histories = [ + PillSheetModifiedHistory( + id: 'began', + actionType: PillSheetModifiedActionType.beganRestDuration.name, + estimatedEventCausingDate: baseDate.subtract(const Duration(days: 4)), + createdAt: baseDate.subtract(const Duration(days: 4)), + value: const PillSheetModifiedHistoryValue(), + beforePillSheetGroup: null, + afterPillSheetGroup: null, + ), + PillSheetModifiedHistory( + id: 'ended', + actionType: PillSheetModifiedActionType.endedRestDuration.name, + estimatedEventCausingDate: baseDate.subtract(const Duration(days: 2)), + createdAt: baseDate.subtract(const Duration(days: 2)), + value: const PillSheetModifiedHistoryValue(), + beforePillSheetGroup: null, + afterPillSheetGroup: null, + ), + PillSheetModifiedHistory( + id: 'taken', + actionType: PillSheetModifiedActionType.takenPill.name, + estimatedEventCausingDate: baseDate, + createdAt: baseDate, + value: const PillSheetModifiedHistoryValue(), + beforePillSheetGroup: null, + afterPillSheetGroup: null, + ), + ]; + // allDates: 4日前〜1日前の4日間、休薬: 4日前・3日前の2日間 → 服用予定日数 = 4 - 2 = 2 + expect(scheduledPillDays(histories: histories, maxDate: today), 2); + }); + }); + group('#createTakenPillAction', () { // テスト用のPillSheetGroupを作成するヘルパー関数 PillSheetGroup createPillSheetGroup({required List pillSheets}) { From dc8ad8f38d309419d0017d87b7c1f4a5c3b580ea Mon Sep 17 00:00:00 2001 From: bannzai Date: Thu, 25 Jun 2026 18:15:12 +0900 Subject: [PATCH 04/22] =?UTF-8?q?feat:=20=E3=83=94=E3=83=AB=E3=82=B7?= =?UTF-8?q?=E3=83=BC=E3=83=88=E7=B5=82=E4=BA=86=E3=83=80=E3=82=A4=E3=82=A2?= =?UTF-8?q?=E3=83=AD=E3=82=B0=E3=81=AE=E8=A1=A8=E7=A4=BA=E6=B8=88=E3=81=BF?= =?UTF-8?q?=E3=83=95=E3=83=A9=E3=82=B0=E7=94=A8=20SharedPreferences=20?= =?UTF-8?q?=E3=82=AD=E3=83=BC=E3=82=92=E8=BF=BD=E5=8A=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit endedPillSheetDialogShown(pillSheetGroupID) で終了グループごとに表示済みを保持し、 1つの終了ピルシートグループにつき1回だけダイアログを出す --- lib/utils/shared_preference/keys.dart | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/lib/utils/shared_preference/keys.dart b/lib/utils/shared_preference/keys.dart index b3e9c60f4d..d9069f6937 100644 --- a/lib/utils/shared_preference/keys.dart +++ b/lib/utils/shared_preference/keys.dart @@ -48,6 +48,10 @@ extension BoolKey on String { /// 服用おやすみ (無料機能) のアピール Bar を × で閉じたかどうか。 static const restDurationFeatureAppealIsClosed = 'restDurationFeatureAppealIsClosed'; + + /// ピルシート終了ダイアログを当該グループで表示済みかどうか。 + /// pillSheetGroupID をサフィックスに付与し、終了グループごとに1回だけ表示する。 + static String endedPillSheetDialogShown(String pillSheetGroupID) => 'endedPillSheetDialogShown_$pillSheetGroupID'; } extension StringKey on String { From 6007ce29e98335c7660eb016a762abd4c2aed519 Mon Sep 17 00:00:00 2001 From: bannzai Date: Thu, 25 Jun 2026 18:15:25 +0900 Subject: [PATCH 05/22] =?UTF-8?q?feat:=20=E3=83=94=E3=83=AB=E3=82=B7?= =?UTF-8?q?=E3=83=BC=E3=83=88=E7=B5=82=E4=BA=86=E6=99=82=E3=81=AE=E8=AA=B2?= =?UTF-8?q?=E9=87=91=E8=BB=A2=E6=8F=9B=E3=83=80=E3=82=A4=E3=82=A2=E3=83=AD?= =?UTF-8?q?=E3=82=B0(A/B)=E3=82=92=E8=BF=BD=E5=8A=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ピルシート終了時に free ユーザーへ価値ティーザー型ダイアログを表示する。 - Variant A(history_blur): 最新の服用履歴3件を先頭1件は鮮明・残り2件は blur で見せる - Variant B(summary_stats): 服用記録できた日数・記録漏れ日数の集計メッセージを表示 - shown / cta_tapped / dismissed を variant 付きで送出し、CTA から各 source で paywall を開く - 集計の境界値・variant 解決・ダイアログの分岐描画をテスト --- .../components/history_blur_teaser.dart | 58 ++++++++++ .../components/summary_stats_teaser.dart | 55 +++++++++ .../ended_pill_sheet_dialog.dart | 108 ++++++++++++++++++ .../ended_pill_sheet_dialog_variant.dart | 45 ++++++++ .../ended_pill_sheet_taken_summary.dart | 15 +++ lib/l10n/app_en.arb | 15 +++ lib/l10n/app_ja.arb | 17 ++- .../ended_pill_sheet_dialog_test.dart | 75 ++++++++++++ .../ended_pill_sheet_dialog_variant_test.dart | 31 +++++ .../ended_pill_sheet_taken_summary_test.dart | 69 +++++++++++ 10 files changed, 487 insertions(+), 1 deletion(-) create mode 100644 lib/features/ended_pill_sheet_dialog/components/history_blur_teaser.dart create mode 100644 lib/features/ended_pill_sheet_dialog/components/summary_stats_teaser.dart create mode 100644 lib/features/ended_pill_sheet_dialog/ended_pill_sheet_dialog.dart create mode 100644 lib/features/ended_pill_sheet_dialog/ended_pill_sheet_dialog_variant.dart create mode 100644 lib/features/ended_pill_sheet_dialog/ended_pill_sheet_taken_summary.dart create mode 100644 test/features/ended_pill_sheet_dialog/ended_pill_sheet_dialog_test.dart create mode 100644 test/features/ended_pill_sheet_dialog/ended_pill_sheet_dialog_variant_test.dart create mode 100644 test/features/ended_pill_sheet_dialog/ended_pill_sheet_taken_summary_test.dart 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..1ee57d2ad5 --- /dev/null +++ b/lib/features/ended_pill_sheet_dialog/components/history_blur_teaser.dart @@ -0,0 +1,58 @@ +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/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'; + +/// Variant A: ピルシート履歴のぼかしティーザー。 +/// 最新の服用記録3件を表示し、先頭1件は鮮明・残り2件は blur で見せて「続きはプレミアム」を訴求する。 +class HistoryBlurTeaser extends ConsumerWidget { + const HistoryBlurTeaser({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final takenHistories = (ref.watch(pillSheetModifiedHistoriesWithLimitProvider(limit: 30)).valueOrNull ?? []) + .where((history) => history.enumActionType == PillSheetModifiedActionType.takenPill) + .take(3) + .toList(); + + 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), + ...takenHistories.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), + child: ImageFiltered( + imageFilter: ImageFilter.blur(sigmaX: 6, sigmaY: 6), + child: row, + ), + ); + }), + ], + ); + } +} 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..c1ddc6d42e --- /dev/null +++ b/lib/features/ended_pill_sheet_dialog/components/summary_stats_teaser.dart @@ -0,0 +1,55 @@ +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/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) { + final maxDate = pillSheetGroup.pillSheets.last.estimatedEndTakenDate; + final summary = endedPillSheetTakenSummary( + histories: ref + .watch(pillSheetModifiedHistoriesWithRangeProvider( + begin: pillSheetGroup.pillSheets.first.beginDate, + end: maxDate, + )) + .valueOrNull ?? + [], + maxDate: maxDate, + ); + + 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), + Text( + L.endedPillSheetDialogSummaryMessage(summary.recordedDays, summary.missedDays), + style: const TextStyle( + fontFamily: FontFamily.japanese, + fontWeight: FontWeight.w400, + fontSize: 16, + color: TextColor.main, + ), + ), + ], + ); + } +} 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..2c2b329f1b --- /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 => const HistoryBlurTeaser(), + 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..3b9eec576c --- /dev/null +++ b/lib/features/ended_pill_sheet_dialog/ended_pill_sheet_taken_summary.dart @@ -0,0 +1,15 @@ +import 'package:pilll/entity/pill_sheet_modified_history.codegen.dart'; + +/// 終了したピルシートグループの服用記録サマリ。 +/// recordedDays = 服用記録できた日数(x)、missedDays = 記録漏れ日数(y)。 +/// y は既存の服用遵守ロジック [missedPillDays] に委譲し、x は (服用予定日数 − y) で求める。 +({int recordedDays, int missedDays}) endedPillSheetTakenSummary({ + required List histories, + required DateTime maxDate, +}) { + final missedDays = missedPillDays(histories: histories, maxDate: maxDate); + return ( + recordedDays: scheduledPillDays(histories: histories, maxDate: maxDate) - missedDays, + missedDays: missedDays, + ); +} 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/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..00a9604d1d --- /dev/null +++ b/test/features/ended_pill_sheet_dialog/ended_pill_sheet_dialog_test.dart @@ -0,0 +1,75 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:hooks_riverpod/hooks_riverpod.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_type.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/pill_sheet_modified_history.dart'; + +PillSheetGroup _dummyPillSheetGroup() => 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(2020, 9, 1), + lastTakenDate: DateTime(2020, 9, 28), + createdAt: DateTime(2020, 9, 1), + ), + ], + createdAt: DateTime(2020, 9, 1), + ); + +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: _dummyPillSheetGroup(), + ), + ), + ), + ); + await tester.pump(); + + expect(find.byType(HistoryBlurTeaser), findsOneWidget); + expect(find.byType(SummaryStatsTeaser), findsNothing); + }); + + testWidgets('variant=summaryStats のとき SummaryStatsTeaser を表示し HistoryBlurTeaser は表示しない', (tester) async { + final pillSheetGroup = _dummyPillSheetGroup(); + 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); + }); + }); +} 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..d4d2b83848 --- /dev/null +++ b/test/features/ended_pill_sheet_dialog/ended_pill_sheet_dialog_variant_test.dart @@ -0,0 +1,31 @@ +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..5588004a27 --- /dev/null +++ b/test/features/ended_pill_sheet_dialog/ended_pill_sheet_taken_summary_test.dart @@ -0,0 +1,69 @@ +import 'package:flutter_test/flutter_test.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/features/ended_pill_sheet_dialog/ended_pill_sheet_taken_summary.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, + ); +} + +void main() { + group('#endedPillSheetTakenSummary', () { + test('履歴が空の場合は recordedDays/missedDays ともに0', () { + final result = endedPillSheetTakenSummary(histories: [], maxDate: DateTime.parse('2020-09-28')); + expect(result.recordedDays, 0); + expect(result.missedDays, 0); + }); + + test('履歴期間すべてに服用記録がある場合、recordedDays=服用予定日数 / missedDays=0', () { + final today = DateTime.parse('2020-09-28'); + final baseDate = DateTime(today.year, today.month, today.day); + final histories = [ + for (int i = 0; i < 30; i++) + _history( + actionType: PillSheetModifiedActionType.takenPill.name, + date: baseDate.subtract(Duration(days: i + 1)), + ), + ]; + final result = endedPillSheetTakenSummary(histories: histories, maxDate: today); + expect(result.recordedDays, 30); + expect(result.missedDays, 0); + }); + + test('15日前に1日だけ服用記録がある場合、recordedDays=1 / missedDays=14', () { + final today = DateTime.parse('2020-09-28'); + final baseDate = DateTime(today.year, today.month, today.day); + final histories = [ + _history( + actionType: PillSheetModifiedActionType.takenPill.name, + date: baseDate.subtract(const Duration(days: 15)), + ), + ]; + final result = endedPillSheetTakenSummary(histories: histories, maxDate: today); + expect(result.recordedDays, 1); + expect(result.missedDays, 14); + }); + + test('服用お休み期間中の日数は recordedDays にも missedDays にも含まれない', () { + // 4日前に休薬開始・2日前に休薬終了、今日服用記録。休薬2日分(4日前,3日前)は服用予定から除外される + final today = DateTime.parse('2020-09-28'); + final baseDate = DateTime(today.year, today.month, today.day); + final histories = [ + _history(actionType: PillSheetModifiedActionType.beganRestDuration.name, date: baseDate.subtract(const Duration(days: 4))), + _history(actionType: PillSheetModifiedActionType.endedRestDuration.name, date: baseDate.subtract(const Duration(days: 2))), + _history(actionType: PillSheetModifiedActionType.takenPill.name, date: baseDate), + ]; + final result = endedPillSheetTakenSummary(histories: histories, maxDate: today); + // recordedDays + missedDays は服用予定日数(休薬日を除いた日数)に一致する + expect(result.recordedDays + result.missedDays, scheduledPillDays(histories: histories, maxDate: today)); + }); + }); +} From 9ab08dc4d347122cbbb0dc8869057151e8ec13a7 Mon Sep 17 00:00:00 2001 From: bannzai Date: Thu, 25 Jun 2026 18:15:33 +0900 Subject: [PATCH 06/22] =?UTF-8?q?feat:=20=E7=B5=82=E4=BA=86=E3=83=94?= =?UTF-8?q?=E3=83=AB=E3=82=B7=E3=83=BC=E3=83=88=E3=81=AE=20free=20?= =?UTF-8?q?=E3=83=A6=E3=83=BC=E3=82=B6=E3=83=BC=E3=81=AB=E8=AA=B2=E9=87=91?= =?UTF-8?q?=E8=BB=A2=E6=8F=9B=E3=83=80=E3=82=A4=E3=82=A2=E3=83=AD=E3=82=B0?= =?UTF-8?q?=E3=82=92=E8=A1=A8=E7=A4=BA=E3=81=99=E3=82=8B=E3=83=88=E3=83=AA?= =?UTF-8?q?=E3=82=AC=E3=83=BC=E3=82=92=E8=BF=BD=E5=8A=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit HomePageBody の起動時 useEffect に「終了グループ + free + 未表示」分岐を追加し、 Remote Config の variant に応じてダイアログを表示する。既存の月次 paywall とは排他。 バーは残し、ダイアログを追加する(置換しない) --- lib/features/home/page.dart | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/lib/features/home/page.dart b/lib/features/home/page.dart index e620ba74b8..c6d8c14719 100644 --- a/lib/features/home/page.dart +++ b/lib/features/home/page.dart @@ -16,6 +16,9 @@ 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/ended_pill_sheet_dialog.dart'; +import 'package:pilll/features/ended_pill_sheet_dialog/ended_pill_sheet_dialog_variant.dart'; import 'package:pilll/utils/analytics.dart'; import 'package:pilll/features/calendar/page.dart'; import 'package:pilll/features/menstruation/page.dart'; @@ -124,6 +127,9 @@ class HomePageBody extends HookConsumerWidget { isOneMonthPassedTrialDeadline = false; } 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 { @@ -158,6 +164,16 @@ class HomePageBody extends HookConsumerWidget { now().millisecondsSinceEpoch, ); } + } else if (pillSheetGroup != null && pillSheetGroup.activePillSheet == null && !user.premiumOrTrial) { + // ピルシートが終了した free ユーザーに、課金転換ダイアログ(A/B)を終了グループにつき1回だけ表示する + final variant = endedPillSheetDialogVariantFromRemoteConfig(remoteConfigParameter.endedPillSheetDialogVariant); + final pillSheetGroupID = pillSheetGroup.id; + if (variant != null && + pillSheetGroupID != null && + !(sharedPreferences.getBool(BoolKey.endedPillSheetDialogShown(pillSheetGroupID)) ?? false)) { + await showEndedPillSheetDialog(context, variant: variant, pillSheetGroup: pillSheetGroup); + sharedPreferences.setBool(BoolKey.endedPillSheetDialogShown(pillSheetGroupID), true); + } } }); From d615da4874db3265ebd7ceb9f0ddbcba1a6ef395 Mon Sep 17 00:00:00 2001 From: bannzai Date: Thu, 2 Jul 2026 10:45:03 +0900 Subject: [PATCH 07/22] =?UTF-8?q?fix:=20=E3=83=94=E3=83=AB=E3=82=B7?= =?UTF-8?q?=E3=83=BC=E3=83=88=E7=B5=82=E4=BA=86=E3=83=80=E3=82=A4=E3=82=A2?= =?UTF-8?q?=E3=83=AD=E3=82=B0=E3=81=AE=E6=9C=8D=E7=94=A8=E8=A8=98=E9=8C=B2?= =?UTF-8?q?=E9=9B=86=E8=A8=88=E3=82=92=E4=BF=AE=E6=AD=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 日付集合をすべて日付のみに正規化(時刻付き履歴で服用日が一致せず recordedDays=0 になる不具合を修正) - 集計期間をシート開始日(beginDate)〜終了日(endDate・両端含む)にし、開始日〜初回記録前の未記録日も記録漏れに含める - endedPillSheetTakenSummary は pillTakenDateSets を1回だけ呼び出す(服用予定日数・記録漏れ日数の二重計算を解消) - scheduledPillDays を削除し、_buildPillTakenDateSets を pillTakenDateSets として公開 --- .../pill_sheet_modified_history.codegen.dart | 45 +++++----- .../components/summary_stats_teaser.dart | 10 ++- .../ended_pill_sheet_taken_summary.dart | 19 +++- .../pill_sheet_modified_history_test.dart | 64 +------------- .../ended_pill_sheet_taken_summary_test.dart | 87 ++++++++++++------- 5 files changed, 102 insertions(+), 123 deletions(-) diff --git a/lib/entity/pill_sheet_modified_history.codegen.dart b/lib/entity/pill_sheet_modified_history.codegen.dart index d7e39da067..0e90772c37 100644 --- a/lib/entity/pill_sheet_modified_history.codegen.dart +++ b/lib/entity/pill_sheet_modified_history.codegen.dart @@ -303,26 +303,35 @@ abstract class PillSheetModifiedHistoryServiceActionFactory { } } -/// 渡されたPillSheetModifiedHistory配列から、服用予定日(履歴期間の全日 − 服用お休み日)と -/// 服用記録のあった日の集合を構築する。履歴が空の場合は null を返す。 -/// missedPillDays / scheduledPillDays が共通で利用する。 -({Set scheduledDates, Set takenDates})? _buildPillTakenDateSets({ +/// 渡されたPillSheetModifiedHistory配列から、服用予定日(集計期間の全日 − 服用お休み日)と +/// 服用記録のあった日の集合を構築する。集計対象が無い場合は null を返す。 +/// 日付はすべて日付のみ(午前0時)に正規化して集合化する。時刻付きのまま差分すると服用日が一致せず記録が漏れるため。 +/// [beginDate] を渡すと集計開始日として使い(シート開始日など)、省略時は最古の履歴日を開始日とする。 +({Set scheduledDates, Set takenDates})? pillTakenDateSets({ required List histories, required DateTime maxDate, + DateTime? beginDate, }) { - if (histories.isEmpty) { - return null; - } - // 昇順に並べ替える。服用お休み期間の集計時に、服用お休みが開始された後の差分の日付を集計するために順番を整える必要がある 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 = DateTime(beginDate.year, beginDate.month, beginDate.day); + } else { + if (orderedHistories.isEmpty) { + return null; + } + final oldestDate = orderedHistories.first.estimatedEventCausingDate; + minDate = DateTime(oldestDate.year, oldestDate.month, oldestDate.day); + } + final normalizedMaxDate = DateTime(maxDate.year, maxDate.month, maxDate.day); 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))); } @@ -366,7 +375,7 @@ abstract class PillSheetModifiedHistoryServiceActionFactory { // 現在まで服用お休み中の場合には、差分の日付を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)), ); @@ -381,22 +390,10 @@ int missedPillDays({ required List histories, required DateTime maxDate, }) { - final sets = _buildPillTakenDateSets(histories: histories, maxDate: maxDate); + final sets = pillTakenDateSets(histories: histories, maxDate: maxDate); if (sets == null) { return 0; } // 服用予定日のうち服用記録がない日数を計算 return sets.scheduledDates.difference(sets.takenDates).length; } - -/// 渡されたPillSheetModifiedHistory配列から服用予定日数(履歴期間の全日 − 服用お休み日)を計算する -int scheduledPillDays({ - required List histories, - required DateTime maxDate, -}) { - final sets = _buildPillTakenDateSets(histories: histories, maxDate: maxDate); - if (sets == null) { - return 0; - } - return sets.scheduledDates.length; -} 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 index c1ddc6d42e..97b05cbd10 100644 --- a/lib/features/ended_pill_sheet_dialog/components/summary_stats_teaser.dart +++ b/lib/features/ended_pill_sheet_dialog/components/summary_stats_teaser.dart @@ -15,16 +15,18 @@ class SummaryStatsTeaser extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { - final maxDate = pillSheetGroup.pillSheets.last.estimatedEndTakenDate; + final beginDate = pillSheetGroup.pillSheets.first.beginDate; + final endDate = pillSheetGroup.pillSheets.last.estimatedEndTakenDate; final summary = endedPillSheetTakenSummary( histories: ref .watch(pillSheetModifiedHistoriesWithRangeProvider( - begin: pillSheetGroup.pillSheets.first.beginDate, - end: maxDate, + begin: beginDate, + end: endDate, )) .valueOrNull ?? [], - maxDate: maxDate, + beginDate: beginDate, + endDate: endDate, ); return Column( 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 index 3b9eec576c..5d7e158207 100644 --- 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 @@ -2,14 +2,25 @@ import 'package:pilll/entity/pill_sheet_modified_history.codegen.dart'; /// 終了したピルシートグループの服用記録サマリ。 /// recordedDays = 服用記録できた日数(x)、missedDays = 記録漏れ日数(y)。 -/// y は既存の服用遵守ロジック [missedPillDays] に委譲し、x は (服用予定日数 − y) で求める。 +/// [beginDate](シート開始日)〜[endDate](最終服用予定日、両端含む)を集計期間とする。 +/// 開始日から数日後に初回記録した場合や一度も記録しなかった場合でも、未記録日を missedDays に含める。 ({int recordedDays, int missedDays}) endedPillSheetTakenSummary({ required List histories, - required DateTime maxDate, + required DateTime beginDate, + required DateTime endDate, }) { - final missedDays = missedPillDays(histories: histories, maxDate: maxDate); + // pillTakenDateSets の上限は排他的(daysBetween で上限日を含めない)なので、endDate を含めるため翌日を渡す + final sets = pillTakenDateSets( + histories: histories, + beginDate: beginDate, + maxDate: endDate.add(const Duration(days: 1)), + ); + if (sets == null) { + return (recordedDays: 0, missedDays: 0); + } + final missedDays = sets.scheduledDates.difference(sets.takenDates).length; return ( - recordedDays: scheduledPillDays(histories: histories, maxDate: maxDate) - missedDays, + recordedDays: sets.scheduledDates.length - missedDays, missedDays: missedDays, ); } diff --git a/test/entity/pill_sheet_modified_history_test.dart b/test/entity/pill_sheet_modified_history_test.dart index 150ace10a2..dcc3781c31 100644 --- a/test/entity/pill_sheet_modified_history_test.dart +++ b/test/entity/pill_sheet_modified_history_test.dart @@ -301,8 +301,9 @@ 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("今日服用した場合は飲み忘れが0日", () { @@ -748,65 +749,6 @@ void main() { }); }); - group('#scheduledPillDays', () { - test('履歴が空の場合は0を返す', () { - expect(scheduledPillDays(histories: [], maxDate: DateTime.parse('2020-09-28')), 0); - }); - - test('15日前に最初の記録がある場合、服用予定日数は15日', () { - final today = DateTime.parse('2020-09-28'); - final baseDate = DateTime(today.year, today.month, today.day); - final histories = [ - PillSheetModifiedHistory( - id: 'history_1', - actionType: PillSheetModifiedActionType.takenPill.name, - estimatedEventCausingDate: baseDate.subtract(const Duration(days: 15)), - createdAt: baseDate.subtract(const Duration(days: 15)), - value: const PillSheetModifiedHistoryValue(), - beforePillSheetGroup: null, - afterPillSheetGroup: null, - ), - ]; - expect(scheduledPillDays(histories: histories, maxDate: today), 15); - }); - - test('服用お休み期間は服用予定日数から除外される', () { - final today = DateTime.parse('2020-09-28'); - final baseDate = DateTime(today.year, today.month, today.day); - final histories = [ - PillSheetModifiedHistory( - id: 'began', - actionType: PillSheetModifiedActionType.beganRestDuration.name, - estimatedEventCausingDate: baseDate.subtract(const Duration(days: 4)), - createdAt: baseDate.subtract(const Duration(days: 4)), - value: const PillSheetModifiedHistoryValue(), - beforePillSheetGroup: null, - afterPillSheetGroup: null, - ), - PillSheetModifiedHistory( - id: 'ended', - actionType: PillSheetModifiedActionType.endedRestDuration.name, - estimatedEventCausingDate: baseDate.subtract(const Duration(days: 2)), - createdAt: baseDate.subtract(const Duration(days: 2)), - value: const PillSheetModifiedHistoryValue(), - beforePillSheetGroup: null, - afterPillSheetGroup: null, - ), - PillSheetModifiedHistory( - id: 'taken', - actionType: PillSheetModifiedActionType.takenPill.name, - estimatedEventCausingDate: baseDate, - createdAt: baseDate, - value: const PillSheetModifiedHistoryValue(), - beforePillSheetGroup: null, - afterPillSheetGroup: null, - ), - ]; - // allDates: 4日前〜1日前の4日間、休薬: 4日前・3日前の2日間 → 服用予定日数 = 4 - 2 = 2 - expect(scheduledPillDays(histories: histories, maxDate: today), 2); - }); - }); - group('#createTakenPillAction', () { // テスト用のPillSheetGroupを作成するヘルパー関数 PillSheetGroup createPillSheetGroup({required List pillSheets}) { 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 index 5588004a27..ee57b18656 100644 --- 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 @@ -3,7 +3,8 @@ import 'package:pilll/entity/pill_sheet_modified_history.codegen.dart'; import 'package:pilll/entity/pill_sheet_modified_history_value.codegen.dart'; import 'package:pilll/features/ended_pill_sheet_dialog/ended_pill_sheet_taken_summary.dart'; -PillSheetModifiedHistory _history({required String actionType, required DateTime date}) { +PillSheetModifiedHistory _history( + {required String actionType, required DateTime date}) { return PillSheetModifiedHistory( id: 'test_id', actionType: actionType, @@ -17,53 +18,79 @@ PillSheetModifiedHistory _history({required String actionType, required DateTime void main() { group('#endedPillSheetTakenSummary', () { - test('履歴が空の場合は recordedDays/missedDays ともに0', () { - final result = endedPillSheetTakenSummary(histories: [], maxDate: DateTime.parse('2020-09-28')); + test('履歴が空でも beginDate〜endDate の全日が記録漏れになる', () { + final result = endedPillSheetTakenSummary( + histories: [], + beginDate: DateTime(2020, 9, 1), + endDate: DateTime(2020, 9, 28), + ); + // 9/1〜9/28 の28日すべてが記録漏れ expect(result.recordedDays, 0); - expect(result.missedDays, 0); + expect(result.missedDays, 28); }); - test('履歴期間すべてに服用記録がある場合、recordedDays=服用予定日数 / missedDays=0', () { - final today = DateTime.parse('2020-09-28'); - final baseDate = DateTime(today.year, today.month, today.day); + test('開始日〜終了日を全日服用した場合、recordedDays=全日数 / missedDays=0(時刻付き履歴でも日付正規化される)', + () { + final begin = DateTime(2020, 9, 1); + final end = DateTime(2020, 9, 28); final histories = [ - for (int i = 0; i < 30; i++) + for (int i = 0; i < 28; i++) + // 実データと同様に時刻付きの estimatedEventCausingDate を持たせる _history( actionType: PillSheetModifiedActionType.takenPill.name, - date: baseDate.subtract(Duration(days: i + 1)), + date: begin.add(Duration(days: i, hours: 18, minutes: 56)), ), ]; - final result = endedPillSheetTakenSummary(histories: histories, maxDate: today); - expect(result.recordedDays, 30); + final result = endedPillSheetTakenSummary( + histories: histories, beginDate: begin, endDate: end); + expect(result.recordedDays, 28); expect(result.missedDays, 0); }); - test('15日前に1日だけ服用記録がある場合、recordedDays=1 / missedDays=14', () { - final today = DateTime.parse('2020-09-28'); - final baseDate = DateTime(today.year, today.month, today.day); + test('開始日から数日後に初回記録した場合、開始日〜初回記録前の未記録日が記録漏れに含まれる', () { + final begin = DateTime(2020, 9, 1); + final end = DateTime(2020, 9, 28); + // 9/5〜9/28 の24日を服用(9/1〜9/4 の4日は未記録) final histories = [ - _history( - actionType: PillSheetModifiedActionType.takenPill.name, - date: baseDate.subtract(const Duration(days: 15)), - ), + for (int i = 4; i < 28; i++) + _history( + actionType: PillSheetModifiedActionType.takenPill.name, + date: begin.add(Duration(days: i)), + ), ]; - final result = endedPillSheetTakenSummary(histories: histories, maxDate: today); - expect(result.recordedDays, 1); - expect(result.missedDays, 14); + final result = endedPillSheetTakenSummary( + histories: histories, beginDate: begin, endDate: end); + expect(result.recordedDays, 24); + expect(result.missedDays, 4); }); test('服用お休み期間中の日数は recordedDays にも missedDays にも含まれない', () { - // 4日前に休薬開始・2日前に休薬終了、今日服用記録。休薬2日分(4日前,3日前)は服用予定から除外される - final today = DateTime.parse('2020-09-28'); - final baseDate = DateTime(today.year, today.month, today.day); + final begin = DateTime(2020, 9, 1); + final end = DateTime(2020, 9, 28); final histories = [ - _history(actionType: PillSheetModifiedActionType.beganRestDuration.name, date: baseDate.subtract(const Duration(days: 4))), - _history(actionType: PillSheetModifiedActionType.endedRestDuration.name, date: baseDate.subtract(const Duration(days: 2))), - _history(actionType: PillSheetModifiedActionType.takenPill.name, date: baseDate), + // 9/1〜9/4 服用 + for (int i = 0; i < 4; i++) + _history( + actionType: PillSheetModifiedActionType.takenPill.name, + date: begin.add(Duration(days: i))), + // 9/5 休薬開始、9/8 休薬終了(9/5・9/6・9/7 の3日が休薬) + _history( + actionType: PillSheetModifiedActionType.beganRestDuration.name, + date: DateTime(2020, 9, 5)), + _history( + actionType: PillSheetModifiedActionType.endedRestDuration.name, + date: DateTime(2020, 9, 8)), + // 9/8〜9/28 服用 + for (int i = 7; i < 28; i++) + _history( + actionType: PillSheetModifiedActionType.takenPill.name, + date: begin.add(Duration(days: i))), ]; - final result = endedPillSheetTakenSummary(histories: histories, maxDate: today); - // recordedDays + missedDays は服用予定日数(休薬日を除いた日数)に一致する - expect(result.recordedDays + result.missedDays, scheduledPillDays(histories: histories, maxDate: today)); + final result = endedPillSheetTakenSummary( + histories: histories, beginDate: begin, endDate: end); + // 28日 − 休薬3日 = 服用予定25日。全て服用済み → recorded=25, missed=0 + expect(result.recordedDays, 25); + expect(result.missedDays, 0); }); }); } From 72c0c2e733efca4047a4243440fff5846cf4a9d7 Mon Sep 17 00:00:00 2001 From: bannzai Date: Thu, 2 Jul 2026 10:49:46 +0900 Subject: [PATCH 08/22] =?UTF-8?q?fix:=20=E7=B5=82=E4=BA=86=E3=83=80?= =?UTF-8?q?=E3=82=A4=E3=82=A2=E3=83=AD=E3=82=B0=E3=81=AE=E8=A1=A8=E7=A4=BA?= =?UTF-8?q?=E6=B8=88=E3=81=BF=E3=83=95=E3=83=A9=E3=82=B0=E4=BF=9D=E5=AD=98?= =?UTF-8?q?=E3=82=92=20await=20=E3=81=97=E5=A4=B1=E6=95=97=E3=82=92?= =?UTF-8?q?=E3=83=AD=E3=82=B0=E3=81=99=E3=82=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CodeRabbit レビュー対応: setBool の結果を待たずにいたため、保存失敗時に同じ終了グループでダイアログが再表示される可能性があった。await して失敗時は debugPrint する。 --- lib/features/home/page.dart | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/lib/features/home/page.dart b/lib/features/home/page.dart index c6d8c14719..f823a22480 100644 --- a/lib/features/home/page.dart +++ b/lib/features/home/page.dart @@ -172,7 +172,11 @@ class HomePageBody extends HookConsumerWidget { pillSheetGroupID != null && !(sharedPreferences.getBool(BoolKey.endedPillSheetDialogShown(pillSheetGroupID)) ?? false)) { await showEndedPillSheetDialog(context, variant: variant, pillSheetGroup: pillSheetGroup); - sharedPreferences.setBool(BoolKey.endedPillSheetDialogShown(pillSheetGroupID), true); + final saved = await sharedPreferences.setBool(BoolKey.endedPillSheetDialogShown(pillSheetGroupID), true); + if (!saved) { + // 保存に失敗すると同じ終了グループで再表示される可能性があるため、失敗をログに残す + debugPrint('Failed to persist endedPillSheetDialogShown for $pillSheetGroupID'); + } } } }); From 723748ec862932f5c1e38a13f4a4cdfaa6221ffe Mon Sep 17 00:00:00 2001 From: bannzai Date: Thu, 2 Jul 2026 10:50:51 +0900 Subject: [PATCH 09/22] =?UTF-8?q?style:=20Widget=20Test=20=E3=81=AE?= =?UTF-8?q?=E3=82=B0=E3=83=AB=E3=83=BC=E3=83=97=E5=90=8D=E3=81=AB=20#=20?= =?UTF-8?q?=E3=83=97=E3=83=AC=E3=83=95=E3=82=A3=E3=83=83=E3=82=AF=E3=82=B9?= =?UTF-8?q?=E3=82=92=E4=BB=98=E3=81=91=E3=82=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CodeRabbit レビュー対応: Widget Test のグループは Widget クラス名を # 始まりで命名する規約に合わせる。 --- .../ended_pill_sheet_dialog_test.dart | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) 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 index 00a9604d1d..e6e085e45d 100644 --- 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 @@ -27,12 +27,15 @@ PillSheetGroup _dummyPillSheetGroup() => PillSheetGroup( ); void main() { - group('EndedPillSheetDialog', () { - testWidgets('variant=historyBlur のとき HistoryBlurTeaser を表示し SummaryStatsTeaser は表示しない', (tester) async { + group('#EndedPillSheetDialog', () { + testWidgets( + 'variant=historyBlur のとき HistoryBlurTeaser を表示し SummaryStatsTeaser は表示しない', + (tester) async { await tester.pumpWidget( ProviderScope( overrides: [ - pillSheetModifiedHistoriesWithLimitProvider(limit: 30).overrideWith((ref) => Stream.value([])), + pillSheetModifiedHistoriesWithLimitProvider(limit: 30).overrideWith( + (ref) => Stream.value([])), ], child: MaterialApp( home: EndedPillSheetDialog( @@ -48,7 +51,9 @@ void main() { expect(find.byType(SummaryStatsTeaser), findsNothing); }); - testWidgets('variant=summaryStats のとき SummaryStatsTeaser を表示し HistoryBlurTeaser は表示しない', (tester) async { + testWidgets( + 'variant=summaryStats のとき SummaryStatsTeaser を表示し HistoryBlurTeaser は表示しない', + (tester) async { final pillSheetGroup = _dummyPillSheetGroup(); await tester.pumpWidget( ProviderScope( From 871b3196151712c91fb791965c165c698ff29d98 Mon Sep 17 00:00:00 2001 From: bannzai Date: Fri, 10 Jul 2026 05:21:03 +0900 Subject: [PATCH 10/22] :shell: dart format ./ --- .../ended_pill_sheet_dialog_variant_test.dart | 12 ++++++++---- .../premium_introduction/paywall_source_test.dart | 6 ++++-- 2 files changed, 12 insertions(+), 6 deletions(-) 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 index d4d2b83848..1a690fd4e3 100644 --- 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 @@ -5,10 +5,12 @@ import 'package:pilll/features/premium_introduction/paywall_source.dart'; void main() { group('#endedPillSheetDialogVariantFromRemoteConfig', () { test('history_blur は historyBlur に解決される', () { - expect(endedPillSheetDialogVariantFromRemoteConfig('history_blur'), EndedPillSheetDialogVariant.historyBlur); + expect(endedPillSheetDialogVariantFromRemoteConfig('history_blur'), + EndedPillSheetDialogVariant.historyBlur); }); test('summary_stats は summaryStats に解決される', () { - expect(endedPillSheetDialogVariantFromRemoteConfig('summary_stats'), EndedPillSheetDialogVariant.summaryStats); + expect(endedPillSheetDialogVariantFromRemoteConfig('summary_stats'), + EndedPillSheetDialogVariant.summaryStats); }); test('空文字は null(実験未参加=非表示)', () { expect(endedPillSheetDialogVariantFromRemoteConfig(''), isNull); @@ -24,8 +26,10 @@ void main() { expect(EndedPillSheetDialogVariant.summaryStats.value, 'summary_stats'); }); test('paywallSource が各バリアントの source と一致', () { - expect(EndedPillSheetDialogVariant.historyBlur.paywallSource, PaywallSource.endedPillSheetDialogHistory); - expect(EndedPillSheetDialogVariant.summaryStats.paywallSource, PaywallSource.endedPillSheetDialogSummary); + expect(EndedPillSheetDialogVariant.historyBlur.paywallSource, + PaywallSource.endedPillSheetDialogHistory); + expect(EndedPillSheetDialogVariant.summaryStats.paywallSource, + PaywallSource.endedPillSheetDialogSummary); }); }); } diff --git a/test/features/premium_introduction/paywall_source_test.dart b/test/features/premium_introduction/paywall_source_test.dart index 0b854cf0ae..82fabceab8 100644 --- a/test/features/premium_introduction/paywall_source_test.dart +++ b/test/features/premium_introduction/paywall_source_test.dart @@ -17,8 +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'); + expect(PaywallSource.endedPillSheetDialogHistory.value, + 'ended_pill_sheet_dialog_history'); + expect(PaywallSource.endedPillSheetDialogSummary.value, + 'ended_pill_sheet_dialog_summary'); }); }); } From 2fa54c4e830a04866f51df139012bdcfa7204cfd Mon Sep 17 00:00:00 2001 From: bannzai Date: Fri, 10 Jul 2026 06:19:52 +0900 Subject: [PATCH 11/22] =?UTF-8?q?fix:=20=E6=9C=8D=E7=94=A8=E8=A8=98?= =?UTF-8?q?=E9=8C=B2=E9=9B=86=E8=A8=88=E3=81=A7=20.date()=20=E3=82=92?= =?UTF-8?q?=E4=BD=BF=E3=81=84=E3=80=81=E5=8F=96=E3=82=8A=E6=B6=88=E3=81=97?= =?UTF-8?q?(revertTakenPill)=E3=82=92=E5=8F=8D=E6=98=A0=E3=81=99=E3=82=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 日付正規化を DateTime(y,m,d) の手書きから .date() extension に置き換え - revertTakenPill 履歴の before/after グループの lastTakenDate 差分から取り消された日を takenDates から除外する。取り消し後に再記録された日は時系列処理で再度加算される --- .../pill_sheet_modified_history.codegen.dart | 26 ++++++++++++------- 1 file changed, 17 insertions(+), 9 deletions(-) diff --git a/lib/entity/pill_sheet_modified_history.codegen.dart b/lib/entity/pill_sheet_modified_history.codegen.dart index 0e90772c37..b5f6350df2 100644 --- a/lib/entity/pill_sheet_modified_history.codegen.dart +++ b/lib/entity/pill_sheet_modified_history.codegen.dart @@ -320,15 +320,14 @@ abstract class PillSheetModifiedHistoryServiceActionFactory { // 集計開始日: beginDate 指定があればそれを、なければ最古の履歴日を使う。時刻を持つと日付集合の差分がずれるため日付のみに正規化する final DateTime minDate; if (beginDate != null) { - minDate = DateTime(beginDate.year, beginDate.month, beginDate.day); + minDate = beginDate.date(); } else { if (orderedHistories.isEmpty) { return null; } - final oldestDate = orderedHistories.first.estimatedEventCausingDate; - minDate = DateTime(oldestDate.year, oldestDate.month, oldestDate.day); + minDate = orderedHistories.first.estimatedEventCausingDate.date(); } - final normalizedMaxDate = DateTime(maxDate.year, maxDate.month, maxDate.day); + final normalizedMaxDate = maxDate.date(); final allDates = {}; final days = daysBetween(minDate, normalizedMaxDate); @@ -344,16 +343,25 @@ abstract class PillSheetModifiedHistoryServiceActionFactory { 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); } + // 服用記録の取り消しを反映する。取り消された日 = (取り消し後の最終服用日, 取り消し前の最終服用日] の範囲。 + // 時系列順に処理しているため、取り消し後に再記録された日は後続の takenPill で再度追加される + if (history.actionType == PillSheetModifiedActionType.revertTakenPill.name) { + final beforeLastTakenDate = history.beforePillSheetGroup?.lastTakenPillSheetOrFirstPillSheet.lastTakenDate; + if (beforeLastTakenDate != null) { + final afterLastTakenDate = history.afterPillSheetGroup?.lastTakenPillSheetOrFirstPillSheet.lastTakenDate; + takenDates.removeWhere( + (takenDate) => + (afterLastTakenDate == null || takenDate.isAfter(afterLastTakenDate.date())) && !takenDate.isAfter(beforeLastTakenDate.date()), + ); + } + } + // 服用お休み中は記録されないので集計から除外 // 先にhistoryBeginRestDurationDate != null チェックする必要がある。なぜなら、PillSheetModifiedActionType.beganRestDuration の時に値が入るから if (historyBeginRestDurationDate != null) { From 9521678195a1679a2ba51ee2186c9246206d81d6 Mon Sep 17 00:00:00 2001 From: bannzai Date: Fri, 10 Jul 2026 06:20:10 +0900 Subject: [PATCH 12/22] =?UTF-8?q?fix:=20=E7=B5=82=E4=BA=86=E3=83=80?= =?UTF-8?q?=E3=82=A4=E3=82=A2=E3=83=AD=E3=82=B0=E3=81=AE=E9=9B=86=E8=A8=88?= =?UTF-8?q?=E3=82=92=E5=AE=9F=E8=96=AC=E3=81=AE=E6=9C=8D=E7=94=A8=E4=BA=88?= =?UTF-8?q?=E5=AE=9A=E6=97=A5=E3=83=99=E3=83=BC=E3=82=B9=E3=81=AB=E3=81=99?= =?UTF-8?q?=E3=82=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 服用予定日を begin〜end の全日から「各シートの錠剤日付のうち実薬分(dosingPeriod)」に変更。 pillsheet_21 等の休薬・偽薬期間が記録漏れにカウントされる問題を修正する。 服用お休みによる日付シフトは PillSheet.buildDates が織り込む --- .../ended_pill_sheet_taken_summary.dart | 34 ++-- .../ended_pill_sheet_taken_summary_test.dart | 162 ++++++++++++------ 2 files changed, 131 insertions(+), 65 deletions(-) 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 index 5d7e158207..88e5e33af5 100644 --- 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 @@ -1,26 +1,30 @@ +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'; /// 終了したピルシートグループの服用記録サマリ。 /// recordedDays = 服用記録できた日数(x)、missedDays = 記録漏れ日数(y)。 -/// [beginDate](シート開始日)〜[endDate](最終服用予定日、両端含む)を集計期間とする。 -/// 開始日から数日後に初回記録した場合や一度も記録しなかった場合でも、未記録日を missedDays に含める。 +/// 服用予定日は各シートの錠剤日付のうち実薬分(dosingPeriod)のみを対象とし、 +/// 偽薬・休薬期間は記録漏れに含めない。服用お休みによる日付シフトは PillSheet.buildDates が織り込む。 ({int recordedDays, int missedDays}) endedPillSheetTakenSummary({ + required PillSheetGroup pillSheetGroup, required List histories, - required DateTime beginDate, - required DateTime endDate, }) { - // pillTakenDateSets の上限は排他的(daysBetween で上限日を含めない)なので、endDate を含めるため翌日を渡す - final sets = pillTakenDateSets( - histories: histories, - beginDate: beginDate, - maxDate: endDate.add(const Duration(days: 1)), - ); - if (sets == null) { - return (recordedDays: 0, missedDays: 0); - } - final missedDays = sets.scheduledDates.difference(sets.takenDates).length; + 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: sets.scheduledDates.length - missedDays, + recordedDays: scheduledDates.length - missedDays, missedDays: missedDays, ); } 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 index ee57b18656..24a4b314b4 100644 --- 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 @@ -1,6 +1,9 @@ import 'package:flutter_test/flutter_test.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'; PillSheetModifiedHistory _history( @@ -16,81 +19,140 @@ PillSheetModifiedHistory _history( ); } +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('履歴が空でも beginDate〜endDate の全日が記録漏れになる', () { + test('履歴が空の場合、実薬の全日が記録漏れになる(28錠すべて実薬)', () { final result = endedPillSheetTakenSummary( + pillSheetGroup: _pillSheetGroup( + pillSheetType: PillSheetType.pillsheet_28_0, + beginDate: DateTime(2020, 9, 1), + lastTakenDate: null, + ), histories: [], - beginDate: DateTime(2020, 9, 1), - endDate: DateTime(2020, 9, 28), ); - // 9/1〜9/28 の28日すべてが記録漏れ expect(result.recordedDays, 0); expect(result.missedDays, 28); }); - test('開始日〜終了日を全日服用した場合、recordedDays=全日数 / missedDays=0(時刻付き履歴でも日付正規化される)', + test('実薬期間を全日服用した場合、recordedDays=実薬日数 / missedDays=0(時刻付き履歴でも日付正規化される)', () { final begin = DateTime(2020, 9, 1); - final end = DateTime(2020, 9, 28); - final histories = [ - for (int i = 0; i < 28; i++) - // 実データと同様に時刻付きの estimatedEventCausingDate を持たせる - _history( - actionType: PillSheetModifiedActionType.takenPill.name, - date: begin.add(Duration(days: i, hours: 18, minutes: 56)), - ), - ]; final result = endedPillSheetTakenSummary( - histories: histories, beginDate: begin, endDate: end); + 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 end = DateTime(2020, 9, 28); - // 9/5〜9/28 の24日を服用(9/1〜9/4 の4日は未記録) - final histories = [ - for (int i = 4; i < 28; i++) - _history( - actionType: PillSheetModifiedActionType.takenPill.name, - date: begin.add(Duration(days: i)), - ), - ]; final result = endedPillSheetTakenSummary( - histories: histories, beginDate: begin, endDate: end); + 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('服用お休み期間中の日数は recordedDays にも missedDays にも含まれない', () { + test('服用記録を取り消してそのままの日は記録漏れに含まれる', () { final begin = DateTime(2020, 9, 1); - final end = DateTime(2020, 9, 28); - final histories = [ - // 9/1〜9/4 服用 - for (int i = 0; i < 4; i++) - _history( - actionType: PillSheetModifiedActionType.takenPill.name, - date: begin.add(Duration(days: i))), - // 9/5 休薬開始、9/8 休薬終了(9/5・9/6・9/7 の3日が休薬) - _history( - actionType: PillSheetModifiedActionType.beganRestDuration.name, - date: DateTime(2020, 9, 5)), - _history( - actionType: PillSheetModifiedActionType.endedRestDuration.name, - date: DateTime(2020, 9, 8)), - // 9/8〜9/28 服用 - for (int i = 7; i < 28; i++) - _history( - actionType: PillSheetModifiedActionType.takenPill.name, - date: begin.add(Duration(days: i))), - ]; + // 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( - histories: histories, beginDate: begin, endDate: end); - // 28日 − 休薬3日 = 服用予定25日。全て服用済み → recorded=25, missed=0 - expect(result.recordedDays, 25); - expect(result.missedDays, 0); + 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); }); }); } From 69b7e37099e7726c0fbcc6b4becdf1ad0c13386d Mon Sep 17 00:00:00 2001 From: bannzai Date: Fri, 10 Jul 2026 06:20:11 +0900 Subject: [PATCH 13/22] =?UTF-8?q?fix:=20=E9=9B=86=E8=A8=88=E3=83=86?= =?UTF-8?q?=E3=82=A3=E3=83=BC=E3=82=B6=E3=83=BC=E3=81=A7=E5=B1=A5=E6=AD=B4?= =?UTF-8?q?=E3=83=AD=E3=83=BC=E3=83=89=E5=AE=8C=E4=BA=86=E3=82=92=E5=BE=85?= =?UTF-8?q?=E3=81=A1=E3=80=81=E5=AF=BE=E8=B1=A1=E3=82=B0=E3=83=AB=E3=83=BC?= =?UTF-8?q?=E3=83=97=E3=81=AE=E5=B1=A5=E6=AD=B4=E3=81=AB=E7=B5=9E=E3=82=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - AsyncValue を when で分岐し、ロード中は Indicator・エラーはメッセージをそのまま表示(空履歴での誤集計を防ぐ) - afterPillSheetGroup.id でフィルタし、同じ日付範囲を含む別グループの履歴が混ざらないようにする --- .../components/summary_stats_teaser.dart | 57 +++++++++++-------- 1 file changed, 34 insertions(+), 23 deletions(-) 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 index 97b05cbd10..9d3b45eb18 100644 --- a/lib/features/ended_pill_sheet_dialog/components/summary_stats_teaser.dart +++ b/lib/features/ended_pill_sheet_dialog/components/summary_stats_teaser.dart @@ -2,6 +2,7 @@ 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'; @@ -15,20 +16,6 @@ class SummaryStatsTeaser extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { - final beginDate = pillSheetGroup.pillSheets.first.beginDate; - final endDate = pillSheetGroup.pillSheets.last.estimatedEndTakenDate; - final summary = endedPillSheetTakenSummary( - histories: ref - .watch(pillSheetModifiedHistoriesWithRangeProvider( - begin: beginDate, - end: endDate, - )) - .valueOrNull ?? - [], - beginDate: beginDate, - endDate: endDate, - ); - return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ @@ -42,15 +29,39 @@ class SummaryStatsTeaser extends ConsumerWidget { ), ), const SizedBox(height: 16), - Text( - L.endedPillSheetDialogSummaryMessage(summary.recordedDays, summary.missedDays), - style: const TextStyle( - fontFamily: FontFamily.japanese, - fontWeight: FontWeight.w400, - fontSize: 16, - color: TextColor.main, - ), - ), + ref + .watch(pillSheetModifiedHistoriesWithRangeProvider( + begin: pillSheetGroup.pillSheets.first.beginDate, + end: pillSheetGroup.pillSheets.last.estimatedEndTakenDate, + )) + .when( + data: (histories) { + // 日付範囲だけでは別グループ(削除して作り直した場合など)の履歴が混ざるため、対象グループの履歴に絞る + 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()), + ), ], ); } From 3edc953186274a95f5810c9a563eb1130bd79f0f Mon Sep 17 00:00:00 2001 From: bannzai Date: Fri, 10 Jul 2026 06:20:11 +0900 Subject: [PATCH 14/22] =?UTF-8?q?fix:=20blur=20=E3=81=97=E3=81=9F=E5=B1=A5?= =?UTF-8?q?=E6=AD=B4=E8=A1=8C=E3=82=92=20ExcludeSemantics=20=E3=81=A7?= =?UTF-8?q?=E8=A6=86=E3=81=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 視覚的に隠している履歴がスクリーンリーダーで読み上げられないようにする --- .../components/history_blur_teaser.dart | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) 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 index 1ee57d2ad5..4fbae28fb8 100644 --- a/lib/features/ended_pill_sheet_dialog/components/history_blur_teaser.dart +++ b/lib/features/ended_pill_sheet_dialog/components/history_blur_teaser.dart @@ -46,9 +46,12 @@ class HistoryBlurTeaser extends ConsumerWidget { } return Padding( padding: const EdgeInsets.only(bottom: 12), - child: ImageFiltered( - imageFilter: ImageFilter.blur(sigmaX: 6, sigmaY: 6), - child: row, + // blur で視覚的に隠した内容がスクリーンリーダーで読み上げられないよう、semantics ごと除外する + child: ExcludeSemantics( + child: ImageFiltered( + imageFilter: ImageFilter.blur(sigmaX: 6, sigmaY: 6), + child: row, + ), ), ); }), From da16439345a4c1dd9fdc30359a0ffb1601741f17 Mon Sep 17 00:00:00 2001 From: bannzai Date: Fri, 10 Jul 2026 06:39:27 +0900 Subject: [PATCH 15/22] =?UTF-8?q?fix:=20=E5=B1=A5=E6=AD=B4=E3=81=8CTTL?= =?UTF-8?q?=E5=88=87=E3=82=8C=E3=81=A7=E7=A9=BA=E3=81=AE=E5=A0=B4=E5=90=88?= =?UTF-8?q?=E3=81=AF=E9=9B=86=E8=A8=88=E3=83=A1=E3=83=83=E3=82=BB=E3=83=BC?= =?UTF-8?q?=E3=82=B8=E3=82=92=E8=A1=A8=E7=A4=BA=E3=81=97=E3=81=AA=E3=81=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 履歴は180日のTTLで削除されるため、終了から長期間経過したグループでは取得結果が空になり 「服用記録0件・記録漏れ全日」の誤った集計が表示されていた。対象グループの履歴が空の場合は 集計メッセージを出さず、見出し・プレミアム訴求・CTAのみ表示する --- .../components/summary_stats_teaser.dart | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) 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 index 9d3b45eb18..1718b11768 100644 --- a/lib/features/ended_pill_sheet_dialog/components/summary_stats_teaser.dart +++ b/lib/features/ended_pill_sheet_dialog/components/summary_stats_teaser.dart @@ -37,9 +37,15 @@ class SummaryStatsTeaser extends ConsumerWidget { .when( data: (histories) { // 日付範囲だけでは別グループ(削除して作り直した場合など)の履歴が混ざるため、対象グループの履歴に絞る + final groupHistories = histories.where((history) => history.afterPillSheetGroup?.id == pillSheetGroup.id).toList(); + // 履歴は TTL(PillSheetModifiedHistoryServiceActionFactory.limitDays = 180日)で削除されるため、 + // 終了から長期間経過したグループでは空になる。誤った「全日記録漏れ」を表示しないため集計メッセージを出さない + if (groupHistories.isEmpty) { + return const SizedBox.shrink(); + } final summary = endedPillSheetTakenSummary( pillSheetGroup: pillSheetGroup, - histories: histories.where((history) => history.afterPillSheetGroup?.id == pillSheetGroup.id).toList(), + histories: groupHistories, ); return Text( L.endedPillSheetDialogSummaryMessage(summary.recordedDays, summary.missedDays), From 07fd6408d06fde28b6c79c78e0519d1390676b1e Mon Sep 17 00:00:00 2001 From: bannzai Date: Fri, 10 Jul 2026 07:06:29 +0900 Subject: [PATCH 16/22] =?UTF-8?q?test:=20missedPillDays=20=E3=81=AE=20reve?= =?UTF-8?q?rtTakenPill=20=E5=8F=8D=E6=98=A0=E3=82=92=E7=9B=B4=E6=8E=A5?= =?UTF-8?q?=E6=A4=9C=E8=A8=BC=E3=81=99=E3=82=8B=E3=83=86=E3=82=B9=E3=83=88?= =?UTF-8?q?=E3=82=92=E8=BF=BD=E5=8A=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 取り消してそのままの日は飲み忘れとしてカウントされる - 取り消して同じ日に再記録した場合は飲み忘れにならない - 複数日分まとめて取り消した場合は全日が飲み忘れになる --- .../pill_sheet_modified_history_test.dart | 135 ++++++++++++++++++ 1 file changed, 135 insertions(+) diff --git a/test/entity/pill_sheet_modified_history_test.dart b/test/entity/pill_sheet_modified_history_test.dart index dcc3781c31..54285fcb78 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,103 @@ 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("30日間すべて服用記録がある場合は0を返す", () { final today = DateTime.parse("2020-09-28"); final baseDate = DateTime(today.year, today.month, today.day); From 07a7cec6d4c8c3ad210b9f53b4976d9841776a30 Mon Sep 17 00:00:00 2001 From: bannzai Date: Fri, 10 Jul 2026 07:35:58 +0900 Subject: [PATCH 17/22] =?UTF-8?q?test:=20missedPillDays=20=E3=81=AE?= =?UTF-8?q?=E6=8C=99=E5=8B=95=E5=A4=89=E6=9B=B4=E3=81=AB=E5=AF=BE=E3=81=99?= =?UTF-8?q?=E3=82=8B=E5=A2=83=E7=95=8C=E5=80=A4=E3=83=86=E3=82=B9=E3=83=88?= =?UTF-8?q?=E3=82=92=E8=BF=BD=E5=8A=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PR #1809 の既存挙動変更の監査で不足していた直接テストを追加: - 全件取り消し(取り消し後の最終服用日が null)で全日が飲み忘れになる - before/after グループが null の revert は従来どおり無視される - 最古履歴が深夜(23:59)の時刻付きでも日付単位で集計される - 時刻付き最古履歴 + 服用お休み継続中で休薬期間が除外される --- .../pill_sheet_modified_history_test.dart | 126 ++++++++++++++++++ 1 file changed, 126 insertions(+) diff --git a/test/entity/pill_sheet_modified_history_test.dart b/test/entity/pill_sheet_modified_history_test.dart index 54285fcb78..d200cdcd7a 100644 --- a/test/entity/pill_sheet_modified_history_test.dart +++ b/test/entity/pill_sheet_modified_history_test.dart @@ -195,6 +195,69 @@ void main() { 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("30日間すべて服用記録がある場合は0を返す", () { final today = DateTime.parse("2020-09-28"); final baseDate = DateTime(today.year, today.month, today.day); @@ -441,6 +504,69 @@ void main() { 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日", () { final today = DateTime.parse("2020-09-28"); final baseDate = DateTime(today.year, today.month, today.day); From 5ab0993388a52267b7d5b0a0243e3e20b352e010 Mon Sep 17 00:00:00 2001 From: bannzai Date: Sat, 11 Jul 2026 23:04:07 +0900 Subject: [PATCH 18/22] =?UTF-8?q?fix:=20=E3=83=AC=E3=83=93=E3=83=A5?= =?UTF-8?q?=E3=83=BC=E3=82=B3=E3=83=A1=E3=83=B3=E3=83=88=E5=AF=BE=E5=BF=9C?= =?UTF-8?q?=20-=20=E7=B5=82=E4=BA=86=E3=83=80=E3=82=A4=E3=82=A2=E3=83=AD?= =?UTF-8?q?=E3=82=B0=E3=81=AE=E5=AF=BE=E8=B1=A1=E7=B5=9E=E3=82=8A=E8=BE=BC?= =?UTF-8?q?=E3=81=BF=E3=81=A8=E9=9B=86=E8=A8=88=E6=8A=91=E6=AD=A2=E3=82=92?= =?UTF-8?q?=E5=BC=B7=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - HistoryBlurTeaser を対象グループの履歴に絞り、取り消し(revertTakenPill)されたままの服用記録を表示しない - SummaryStatsTeaser は集計開始日が履歴TTL(180日)の窓外の場合、部分的な履歴欠落による誤集計を防ぐため集計メッセージを表示しない - 削除済み(deletedAt != null)グループを終了ダイアログの表示対象から除外 - 上記の widget テストを追加 --- .../components/history_blur_teaser.dart | 19 +- .../components/summary_stats_teaser.dart | 9 + .../ended_pill_sheet_dialog.dart | 2 +- lib/features/home/page.dart | 5 +- .../ended_pill_sheet_dialog_test.dart | 193 ++++++++++++++++-- 5 files changed, 208 insertions(+), 20 deletions(-) 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 index 4fbae28fb8..fb65ff1d63 100644 --- a/lib/features/ended_pill_sheet_dialog/components/history_blur_teaser.dart +++ b/lib/features/ended_pill_sheet_dialog/components/history_blur_teaser.dart @@ -4,20 +4,31 @@ 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/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'; /// Variant A: ピルシート履歴のぼかしティーザー。 -/// 最新の服用記録3件を表示し、先頭1件は鮮明・残り2件は blur で見せて「続きはプレミアム」を訴求する。 +/// 対象グループの最新の服用記録3件を表示し、先頭1件は鮮明・残り2件は blur で見せて「続きはプレミアム」を訴求する。 class HistoryBlurTeaser extends ConsumerWidget { - const HistoryBlurTeaser({super.key}); + final PillSheetGroup pillSheetGroup; + const HistoryBlurTeaser({super.key, required this.pillSheetGroup}); @override Widget build(BuildContext context, WidgetRef ref) { - final takenHistories = (ref.watch(pillSheetModifiedHistoriesWithLimitProvider(limit: 30)).valueOrNull ?? []) - .where((history) => history.enumActionType == PillSheetModifiedActionType.takenPill) + // 直近の取得件数だけでは別グループ(削除して作り直した場合など)の履歴が混ざるため、対象グループの履歴に絞る + final groupHistories = (ref.watch(pillSheetModifiedHistoriesWithLimitProvider(limit: 30)).valueOrNull ?? []) + .where((history) => history.afterPillSheetGroup?.id == pillSheetGroup.id) + .toList(); + // 取り消し(revertTakenPill)を反映した最終的な服用記録日の集合。取り消されたままの服用記録をティーザーに表示しないために使う。 + // maxDate は服用予定日(scheduledDates)の構築にのみ影響し takenDates には影響しない + final takenDates = pillTakenDateSets(histories: groupHistories, maxDate: today())?.takenDates ?? {}; + final takenHistories = groupHistories + .where((history) => + history.enumActionType == PillSheetModifiedActionType.takenPill && takenDates.contains(history.estimatedEventCausingDate.date())) .take(3) .toList(); 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 index 1718b11768..af89e0ba54 100644 --- a/lib/features/ended_pill_sheet_dialog/components/summary_stats_teaser.dart +++ b/lib/features/ended_pill_sheet_dialog/components/summary_stats_teaser.dart @@ -4,9 +4,11 @@ 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/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'; +import 'package:pilll/utils/datetime/day.dart'; /// Variant B: 服用記録の集計メッセージティーザー。 /// 終了したピルシートグループの服用記録できた日数・記録漏れ日数を集計して表示する。 @@ -36,6 +38,13 @@ class SummaryStatsTeaser extends ConsumerWidget { )) .when( data: (histories) { + // 履歴は作成から limitDays(180日) の TTL で削除される。集計開始日が TTL 窓の外にある場合、 + // 期間前半の履歴だけが削除されている可能性があり、消えた服用日を記録漏れとして誤集計するため集計メッセージを出さない + if (!pillSheetGroup.pillSheets.first.beginDate + .date() + .isAfter(today().subtract(const Duration(days: PillSheetModifiedHistoryServiceActionFactory.limitDays)))) { + return const SizedBox.shrink(); + } // 日付範囲だけでは別グループ(削除して作り直した場合など)の履歴が混ざるため、対象グループの履歴に絞る final groupHistories = histories.where((history) => history.afterPillSheetGroup?.id == pillSheetGroup.id).toList(); // 履歴は TTL(PillSheetModifiedHistoryServiceActionFactory.limitDays = 180日)で削除されるため、 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 index 2c2b329f1b..62a6b10360 100644 --- a/lib/features/ended_pill_sheet_dialog/ended_pill_sheet_dialog.dart +++ b/lib/features/ended_pill_sheet_dialog/ended_pill_sheet_dialog.dart @@ -75,7 +75,7 @@ class EndedPillSheetDialog extends StatelessWidget { ), ), switch (variant) { - EndedPillSheetDialogVariant.historyBlur => const HistoryBlurTeaser(), + EndedPillSheetDialogVariant.historyBlur => HistoryBlurTeaser(pillSheetGroup: pillSheetGroup), EndedPillSheetDialogVariant.summaryStats => SummaryStatsTeaser(pillSheetGroup: pillSheetGroup), }, const SizedBox(height: 20), diff --git a/lib/features/home/page.dart b/lib/features/home/page.dart index 962584d2bd..e857bb4fe1 100644 --- a/lib/features/home/page.dart +++ b/lib/features/home/page.dart @@ -140,8 +140,9 @@ class HomePageBody extends HookConsumerWidget { BoolKey.isAlreadyAnsweredPreStoreReviewModal, true, ); - } else if (pillSheetGroup != null && pillSheetGroup.activePillSheet == null && !user.premiumOrTrial) { - // ピルシートが終了した free ユーザーに、課金転換ダイアログ(A/B)を終了グループにつき1回だけ表示する + } 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 && 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 index e6e085e45d..f4ed4cef5a 100644 --- 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 @@ -1,46 +1,76 @@ 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/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'; -PillSheetGroup _dummyPillSheetGroup() => PillSheetGroup( - id: 'group_id', +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: DateTime(2020, 9, 28), + 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 { + testWidgets('variant=historyBlur のとき HistoryBlurTeaser を表示し SummaryStatsTeaser は表示しない', (tester) async { await tester.pumpWidget( ProviderScope( overrides: [ - pillSheetModifiedHistoriesWithLimitProvider(limit: 30).overrideWith( - (ref) => Stream.value([])), + pillSheetModifiedHistoriesWithLimitProvider(limit: 30).overrideWith((ref) => Stream.value([])), ], child: MaterialApp( home: EndedPillSheetDialog( variant: EndedPillSheetDialogVariant.historyBlur, - pillSheetGroup: _dummyPillSheetGroup(), + pillSheetGroup: _pillSheetGroup(), ), ), ), @@ -51,10 +81,8 @@ void main() { expect(find.byType(SummaryStatsTeaser), findsNothing); }); - testWidgets( - 'variant=summaryStats のとき SummaryStatsTeaser を表示し HistoryBlurTeaser は表示しない', - (tester) async { - final pillSheetGroup = _dummyPillSheetGroup(); + testWidgets('variant=summaryStats のとき SummaryStatsTeaser を表示し HistoryBlurTeaser は表示しない', (tester) async { + final pillSheetGroup = _pillSheetGroup(); await tester.pumpWidget( ProviderScope( overrides: [ @@ -77,4 +105,143 @@ void main() { 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)); + }); + }); + + 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); + }); + }); } From feee1cacaeb064a703450186f4d4a0faf2a8aea8 Mon Sep 17 00:00:00 2001 From: bannzai Date: Sat, 11 Jul 2026 23:24:55 +0900 Subject: [PATCH 19/22] =?UTF-8?q?fix:=20=E9=9B=86=E8=A8=88=E3=82=92?= =?UTF-8?q?=E6=8F=90=E7=A4=BA=E3=81=A7=E3=81=8D=E3=81=AA=E3=81=84=E5=A0=B4?= =?UTF-8?q?=E5=90=88=E3=81=AF=E7=B5=82=E4=BA=86=E3=83=80=E3=82=A4=E3=82=A2?= =?UTF-8?q?=E3=83=AD=E3=82=B0(summary=5Fstats)=E3=81=AE=E8=A1=A8=E7=A4=BA?= =?UTF-8?q?=E8=87=AA=E4=BD=93=E3=82=92=E6=8A=91=E6=AD=A2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 集計メッセージの無いダイアログで impression と表示済みフラグを消費しないよう、 表示トリガー側で endedPillSheetTakenSummaryAvailable(履歴TTL窓内かつ対象グループの 履歴あり)を事前判定する。ティーザー側の同条件チェックは表示中のデータ更新に対する 防御として共通関数で残す --- .../components/summary_stats_teaser.dart | 20 +-- .../ended_pill_sheet_taken_summary.dart | 17 +++ lib/features/home/page.dart | 22 +++ .../ended_pill_sheet_taken_summary_test.dart | 129 ++++++++++++++++++ 4 files changed, 173 insertions(+), 15 deletions(-) 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 index af89e0ba54..5b2d49a77e 100644 --- a/lib/features/ended_pill_sheet_dialog/components/summary_stats_teaser.dart +++ b/lib/features/ended_pill_sheet_dialog/components/summary_stats_teaser.dart @@ -4,11 +4,9 @@ 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/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'; -import 'package:pilll/utils/datetime/day.dart'; /// Variant B: 服用記録の集計メッセージティーザー。 /// 終了したピルシートグループの服用記録できた日数・記録漏れ日数を集計して表示する。 @@ -38,23 +36,15 @@ class SummaryStatsTeaser extends ConsumerWidget { )) .when( data: (histories) { - // 履歴は作成から limitDays(180日) の TTL で削除される。集計開始日が TTL 窓の外にある場合、 - // 期間前半の履歴だけが削除されている可能性があり、消えた服用日を記録漏れとして誤集計するため集計メッセージを出さない - if (!pillSheetGroup.pillSheets.first.beginDate - .date() - .isAfter(today().subtract(const Duration(days: PillSheetModifiedHistoryServiceActionFactory.limitDays)))) { - return const SizedBox.shrink(); - } - // 日付範囲だけでは別グループ(削除して作り直した場合など)の履歴が混ざるため、対象グループの履歴に絞る - final groupHistories = histories.where((history) => history.afterPillSheetGroup?.id == pillSheetGroup.id).toList(); - // 履歴は TTL(PillSheetModifiedHistoryServiceActionFactory.limitDays = 180日)で削除されるため、 - // 終了から長期間経過したグループでは空になる。誤った「全日記録漏れ」を表示しないため集計メッセージを出さない - if (groupHistories.isEmpty) { + // 集計を提示できない場合(履歴TTL切れ・対象グループの履歴なし)は集計メッセージを出さない。 + // 表示トリガー側(home/page.dart)でも同条件で表示自体を抑止しており、こちらは表示中のデータ更新に対する防御 + if (!endedPillSheetTakenSummaryAvailable(pillSheetGroup: pillSheetGroup, histories: histories)) { return const SizedBox.shrink(); } final summary = endedPillSheetTakenSummary( pillSheetGroup: pillSheetGroup, - histories: groupHistories, + // 日付範囲だけでは別グループ(削除して作り直した場合など)の履歴が混ざるため、対象グループの履歴に絞る + histories: histories.where((history) => history.afterPillSheetGroup?.id == pillSheetGroup.id).toList(), ); return Text( L.endedPillSheetDialogSummaryMessage(summary.recordedDays, summary.missedDays), 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 index 88e5e33af5..443a56423c 100644 --- 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 @@ -3,6 +3,23 @@ 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)のみを対象とし、 diff --git a/lib/features/home/page.dart b/lib/features/home/page.dart index e857bb4fe1..80123a3a11 100644 --- a/lib/features/home/page.dart +++ b/lib/features/home/page.dart @@ -17,6 +17,8 @@ import 'package:pilll/provider/shared_preferences.dart'; import 'package:pilll/provider/remote_config_parameter.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/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'; @@ -148,6 +150,26 @@ class HomePageBody extends HookConsumerWidget { if (variant != null && pillSheetGroupID != null && !(sharedPreferences.getBool(BoolKey.endedPillSheetDialogShown(pillSheetGroupID)) ?? false)) { + if (variant == EndedPillSheetDialogVariant.summaryStats) { + // 集計を提示できない場合(履歴TTL切れ・対象グループの履歴なし)、集計メッセージの無いダイアログで + // impression と表示済みフラグを消費しないよう表示自体を抑止する + try { + final histories = await ref.read(pillSheetModifiedHistoriesWithRangeProvider( + begin: pillSheetGroup.pillSheets.first.beginDate, + end: pillSheetGroup.pillSheets.last.estimatedEndTakenDate, + ).future); + if (!endedPillSheetTakenSummaryAvailable(pillSheetGroup: pillSheetGroup, histories: histories)) { + return; + } + } catch (exception) { + // 集計可否を判定できないため表示しない。フラグ未保存のため次回起動時に再判定される + debugPrint('Failed to load histories for ended pill sheet dialog: $exception'); + return; + } + if (!context.mounted) { + return; + } + } await showEndedPillSheetDialog(context, variant: variant, pillSheetGroup: pillSheetGroup); final saved = await sharedPreferences.setBool(BoolKey.endedPillSheetDialogShown(pillSheetGroupID), true); if (!saved) { 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 index 24a4b314b4..b523297289 100644 --- 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 @@ -1,10 +1,14 @@ 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}) { @@ -155,4 +159,129 @@ void main() { expect(result.missedDays, 26); }); }); + + 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); + }); + }); } From 5dc4a449bd5a00b13b5b8d560004f3d010e75391 Mon Sep 17 00:00:00 2001 From: bannzai Date: Sat, 11 Jul 2026 23:52:10 +0900 Subject: [PATCH 20/22] =?UTF-8?q?fix:=20=E3=83=AC=E3=83=93=E3=83=A5?= =?UTF-8?q?=E3=83=BC=E3=82=B3=E3=83=A1=E3=83=B3=E3=83=88=E5=AF=BE=E5=BF=9C?= =?UTF-8?q?=20-=20=E3=81=BE=E3=81=A8=E3=82=81=E8=A8=98=E9=8C=B2=E3=81=AE?= =?UTF-8?q?=E9=9B=86=E8=A8=88=E5=8F=8D=E6=98=A0=E3=83=BB=E8=B5=B7=E5=8B=95?= =?UTF-8?q?=E6=99=82=E3=83=A2=E3=83=BC=E3=83=80=E3=83=AB=E6=8E=92=E4=BB=96?= =?UTF-8?q?=E3=83=BB=E5=B1=A5=E6=AD=B4=E3=83=AD=E3=83=BC=E3=83=89=E4=B8=AD?= =?UTF-8?q?=E3=81=AE=E7=A9=BA=E3=83=86=E3=82=A3=E3=83=BC=E3=82=B6=E3=83=BC?= =?UTF-8?q?=E6=8A=91=E6=AD=A2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - pillTakenDateSets: まとめ記録では操作日ではなく記録対象日 (記録前の最終服用日, 記録後の最終服用日] を展開して集計する。グループ情報の無い旧履歴は従来どおり - 起動時自動モーダルの排他: shownPaywallOnThisAppLaunchProvider を共有フラグとして、終了ダイアログと買い切りオファーが同一起動で重ならないようにする - HistoryBlurTeaser: AsyncValue.when でローディング中は Indicator を表示し、表示トリガー側でも履歴取得後に表示可否を判定する(内容の無いダイアログで impression と表示済みフラグを消費しない) --- .../pill_sheet_modified_history.codegen.dart | 17 +++- .../components/history_blur_teaser.dart | 90 ++++++++++++------- lib/features/home/page.dart | 51 ++++++----- .../show_lifetime_offer_on_app_launch.dart | 2 + .../resolver/show_paywall_on_app_launch.dart | 7 +- .../pill_sheet_modified_history_test.dart | 64 +++++++++++++ .../ended_pill_sheet_dialog_test.dart | 21 +++++ .../ended_pill_sheet_taken_summary_test.dart | 35 ++++++++ ...how_lifetime_offer_on_app_launch_test.dart | 10 +++ 9 files changed, 241 insertions(+), 56 deletions(-) diff --git a/lib/entity/pill_sheet_modified_history.codegen.dart b/lib/entity/pill_sheet_modified_history.codegen.dart index b5f6350df2..cc50b220b7 100644 --- a/lib/entity/pill_sheet_modified_history.codegen.dart +++ b/lib/entity/pill_sheet_modified_history.codegen.dart @@ -346,7 +346,22 @@ abstract class PillSheetModifiedHistoryServiceActionFactory { final date = history.estimatedEventCausingDate.date(); if (history.actionType == PillSheetModifiedActionType.takenPill.name || history.actionType == PillSheetModifiedActionType.automaticallyRecordedLastTakenDate.name) { - takenDates.add(date); + final afterPillSheet = history.afterPillSheetGroup?.lastTakenPillSheetOrFirstPillSheet; + final afterLastTakenDate = afterPillSheet?.lastTakenDate; + if (afterPillSheet != null && afterLastTakenDate != null) { + // まとめ記録では1回の操作で複数日分のピルが記録され、履歴の日時は操作時刻になるため、 + // 操作日ではなく記録対象日 = (記録前の最終服用日, 記録後の最終服用日] を展開して集計する。 + // 記録前の最終服用日が無い(初回記録)場合はシート開始日から展開する + final exclusiveFloorDate = (history.beforePillSheetGroup?.lastTakenPillSheetOrFirstPillSheet.lastTakenDate ?? + afterPillSheet.beginDate.subtract(const Duration(days: 1))) + .date(); + for (var takenDate = afterLastTakenDate.date(); takenDate.isAfter(exclusiveFloorDate); takenDate = takenDate.subtract(const Duration(days: 1))) { + takenDates.add(takenDate); + } + } else { + // グループ情報の無い過去の履歴では記録対象日を特定できないため、従来どおり履歴の日付を記録日とみなす + takenDates.add(date); + } } // 服用記録の取り消しを反映する。取り消された日 = (取り消し後の最終服用日, 取り消し前の最終服用日] の範囲。 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 index fb65ff1d63..2a328abc9c 100644 --- a/lib/features/ended_pill_sheet_dialog/components/history_blur_teaser.dart +++ b/lib/features/ended_pill_sheet_dialog/components/history_blur_teaser.dart @@ -4,6 +4,7 @@ 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'; @@ -11,6 +12,28 @@ 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 ?? {}; + return groupHistories + .where((history) => + history.enumActionType == PillSheetModifiedActionType.takenPill && takenDates.contains(history.estimatedEventCausingDate.date())) + .take(3) + .toList(); +} + /// Variant A: ピルシート履歴のぼかしティーザー。 /// 対象グループの最新の服用記録3件を表示し、先頭1件は鮮明・残り2件は blur で見せて「続きはプレミアム」を訴求する。 class HistoryBlurTeaser extends ConsumerWidget { @@ -19,19 +42,6 @@ class HistoryBlurTeaser extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { - // 直近の取得件数だけでは別グループ(削除して作り直した場合など)の履歴が混ざるため、対象グループの履歴に絞る - final groupHistories = (ref.watch(pillSheetModifiedHistoriesWithLimitProvider(limit: 30)).valueOrNull ?? []) - .where((history) => history.afterPillSheetGroup?.id == pillSheetGroup.id) - .toList(); - // 取り消し(revertTakenPill)を反映した最終的な服用記録日の集合。取り消されたままの服用記録をティーザーに表示しないために使う。 - // maxDate は服用予定日(scheduledDates)の構築にのみ影響し takenDates には影響しない - final takenDates = pillTakenDateSets(histories: groupHistories, maxDate: today())?.takenDates ?? {}; - final takenHistories = groupHistories - .where((history) => - history.enumActionType == PillSheetModifiedActionType.takenPill && takenDates.contains(history.estimatedEventCausingDate.date())) - .take(3) - .toList(); - return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ @@ -45,27 +55,43 @@ class HistoryBlurTeaser extends ConsumerWidget { ), ), const SizedBox(height: 16), - ...takenHistories.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, + 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/home/page.dart b/lib/features/home/page.dart index 80123a3a11..e564f3dcf1 100644 --- a/lib/features/home/page.dart +++ b/lib/features/home/page.dart @@ -15,9 +15,11 @@ 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'; @@ -149,27 +151,36 @@ class HomePageBody extends HookConsumerWidget { final pillSheetGroupID = pillSheetGroup.id; if (variant != null && pillSheetGroupID != null && - !(sharedPreferences.getBool(BoolKey.endedPillSheetDialogShown(pillSheetGroupID)) ?? false)) { - if (variant == EndedPillSheetDialogVariant.summaryStats) { - // 集計を提示できない場合(履歴TTL切れ・対象グループの履歴なし)、集計メッセージの無いダイアログで - // impression と表示済みフラグを消費しないよう表示自体を抑止する - try { - final histories = await ref.read(pillSheetModifiedHistoriesWithRangeProvider( - begin: pillSheetGroup.pillSheets.first.beginDate, - end: pillSheetGroup.pillSheets.last.estimatedEndTakenDate, - ).future); - if (!endedPillSheetTakenSummaryAvailable(pillSheetGroup: pillSheetGroup, histories: histories)) { - return; - } - } catch (exception) { - // 集計可否を判定できないため表示しない。フラグ未保存のため次回起動時に再判定される - debugPrint('Failed to load histories for ended pill sheet dialog: $exception'); - return; - } - if (!context.mounted) { - return; - } + !(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; } + // 同一起動で後続の起動時自動モーダル(買い切りオファー等)が重ねて表示されないよう共有フラグを立てる + ref.read(shownPaywallOnThisAppLaunchProvider.notifier).state = true; await showEndedPillSheetDialog(context, variant: variant, pillSheetGroup: pillSheetGroup); final saved = await sharedPreferences.setBool(BoolKey.endedPillSheetDialogShown(pillSheetGroupID), true); if (!saved) { 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 5e5ab94f51..a8ae9e85fd 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 @@ -41,6 +41,8 @@ class ShowLifetimeOfferOnAppLaunch extends HookConsumerWidget { if (!context.mounted) { 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/test/entity/pill_sheet_modified_history_test.dart b/test/entity/pill_sheet_modified_history_test.dart index d200cdcd7a..f5f12aed92 100644 --- a/test/entity/pill_sheet_modified_history_test.dart +++ b/test/entity/pill_sheet_modified_history_test.dart @@ -258,6 +258,70 @@ 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 に通常記録(グループ情報なし = 履歴の日付がそのまま記録日) + 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("30日間すべて服用記録がある場合は0を返す", () { final today = DateTime.parse("2020-09-28"); final baseDate = DateTime(today.year, today.month, today.day); 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 index f4ed4cef5a..e53d5319f5 100644 --- 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 @@ -2,6 +2,7 @@ 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'; @@ -172,6 +173,26 @@ void main() { // 取り消された 9/28 は表示されず、9/27・9/26 の2件のみ表示される expect(find.byType(PillSheetModifiedHistoryTakenPillAction), findsNWidgets(2)); }); + + 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', () { 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 index b523297289..19b8465f3c 100644 --- 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 @@ -158,6 +158,41 @@ void main() { 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', () { 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( From 97cceb77f476935c68dde71d7d717896dc8343e1 Mon Sep 17 00:00:00 2001 From: bannzai Date: Sun, 12 Jul 2026 00:10:59 +0900 Subject: [PATCH 21/22] =?UTF-8?q?fix:=20=E3=83=AC=E3=83=93=E3=83=A5?= =?UTF-8?q?=E3=83=BC=E3=82=B3=E3=83=A1=E3=83=B3=E3=83=88=E5=AF=BE=E5=BF=9C?= =?UTF-8?q?=20-=20=E8=A8=98=E9=8C=B2=E5=AF=BE=E8=B1=A1=E6=97=A5=E3=81=AE?= =?UTF-8?q?=E5=B0=8E=E5=87=BA=E3=82=92=E5=85=B1=E9=80=9A=E5=8C=96=E3=81=97?= =?UTF-8?q?=E7=B7=A8=E9=9B=86=E6=B8=88=E3=81=BF=E5=B1=A5=E6=AD=B4=E3=83=BB?= =?UTF-8?q?=E9=81=8E=E5=8E=BB=E6=97=A5=E8=A8=98=E9=8C=B2=E3=82=92=E5=8F=8D?= =?UTF-8?q?=E6=98=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - takenPillHistoryTargetDates を切り出し、服用日時が編集された履歴は編集後の日時を記録日とする(before/after の lastTakenDate は編集前のままのため) - 履歴ティーザーの照合を操作日ではなく記録対象日で行い、過去日のピルを後から記録した履歴が落ちないようにする - 終了ダイアログ表示直前に起動時モーダルの共有排他フラグを再確認する(履歴取得 await 中の競合対策) --- .../pill_sheet_modified_history.codegen.dart | 38 +++++++++-------- .../components/history_blur_teaser.dart | 3 +- lib/features/home/page.dart | 4 ++ .../pill_sheet_modified_history_test.dart | 41 +++++++++++++++++++ .../ended_pill_sheet_dialog_test.dart | 24 +++++++++++ 5 files changed, 93 insertions(+), 17 deletions(-) diff --git a/lib/entity/pill_sheet_modified_history.codegen.dart b/lib/entity/pill_sheet_modified_history.codegen.dart index cc50b220b7..2420c5ed1d 100644 --- a/lib/entity/pill_sheet_modified_history.codegen.dart +++ b/lib/entity/pill_sheet_modified_history.codegen.dart @@ -303,6 +303,27 @@ abstract class PillSheetModifiedHistoryServiceActionFactory { } } +/// takenPill / automaticallyRecordedLastTakenDate の履歴が記録対象とした日付(日付のみに正規化)を返す。 +/// まとめ記録では1回の操作で複数日分のピルが記録され、履歴の日時は操作時刻になるため、 +/// 操作日ではなく記録対象日 = (記録前の最終服用日, 記録後の最終服用日] を展開する。 +/// 記録前の最終服用日が無い(初回記録)場合はシート開始日から展開する。 +/// 服用日時が編集された履歴は before/after の lastTakenDate が編集前のままのため、編集後の履歴日時を記録日とみなす。 +/// グループ情報の無い過去の履歴も記録対象日を特定できないため履歴日時を記録日とみなす。 +List takenPillHistoryTargetDates(PillSheetModifiedHistory history) { + final afterPillSheet = history.afterPillSheetGroup?.lastTakenPillSheetOrFirstPillSheet; + final afterLastTakenDate = afterPillSheet?.lastTakenDate; + if (history.value.takenPill?.edited != null || afterPillSheet == null || afterLastTakenDate == null) { + return [history.estimatedEventCausingDate.date()]; + } + final exclusiveFloorDate = (history.beforePillSheetGroup?.lastTakenPillSheetOrFirstPillSheet.lastTakenDate ?? + afterPillSheet.beginDate.subtract(const Duration(days: 1))) + .date(); + return [ + for (var takenDate = afterLastTakenDate.date(); takenDate.isAfter(exclusiveFloorDate); takenDate = takenDate.subtract(const Duration(days: 1))) + takenDate, + ]; +} + /// 渡されたPillSheetModifiedHistory配列から、服用予定日(集計期間の全日 − 服用お休み日)と /// 服用記録のあった日の集合を構築する。集計対象が無い場合は null を返す。 /// 日付はすべて日付のみ(午前0時)に正規化して集合化する。時刻付きのまま差分すると服用日が一致せず記録が漏れるため。 @@ -346,22 +367,7 @@ abstract class PillSheetModifiedHistoryServiceActionFactory { final date = history.estimatedEventCausingDate.date(); if (history.actionType == PillSheetModifiedActionType.takenPill.name || history.actionType == PillSheetModifiedActionType.automaticallyRecordedLastTakenDate.name) { - final afterPillSheet = history.afterPillSheetGroup?.lastTakenPillSheetOrFirstPillSheet; - final afterLastTakenDate = afterPillSheet?.lastTakenDate; - if (afterPillSheet != null && afterLastTakenDate != null) { - // まとめ記録では1回の操作で複数日分のピルが記録され、履歴の日時は操作時刻になるため、 - // 操作日ではなく記録対象日 = (記録前の最終服用日, 記録後の最終服用日] を展開して集計する。 - // 記録前の最終服用日が無い(初回記録)場合はシート開始日から展開する - final exclusiveFloorDate = (history.beforePillSheetGroup?.lastTakenPillSheetOrFirstPillSheet.lastTakenDate ?? - afterPillSheet.beginDate.subtract(const Duration(days: 1))) - .date(); - for (var takenDate = afterLastTakenDate.date(); takenDate.isAfter(exclusiveFloorDate); takenDate = takenDate.subtract(const Duration(days: 1))) { - takenDates.add(takenDate); - } - } else { - // グループ情報の無い過去の履歴では記録対象日を特定できないため、従来どおり履歴の日付を記録日とみなす - takenDates.add(date); - } + takenDates.addAll(takenPillHistoryTargetDates(history)); } // 服用記録の取り消しを反映する。取り消された日 = (取り消し後の最終服用日, 取り消し前の最終服用日] の範囲。 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 index 2a328abc9c..3be662b1de 100644 --- a/lib/features/ended_pill_sheet_dialog/components/history_blur_teaser.dart +++ b/lib/features/ended_pill_sheet_dialog/components/history_blur_teaser.dart @@ -27,9 +27,10 @@ List historyBlurTeaserHistories({ // 取り消し(revertTakenPill)を反映した最終的な服用記録日の集合。取り消されたままの服用記録をティーザーに表示しないために使う。 // maxDate は服用予定日(scheduledDates)の構築にのみ影響し takenDates には影響しない final takenDates = pillTakenDateSets(histories: groupHistories, maxDate: today())?.takenDates ?? {}; + // 過去日のピルを後から記録した履歴は操作日と記録対象日が異なるため、記録対象日で照合する return groupHistories .where((history) => - history.enumActionType == PillSheetModifiedActionType.takenPill && takenDates.contains(history.estimatedEventCausingDate.date())) + history.enumActionType == PillSheetModifiedActionType.takenPill && takenPillHistoryTargetDates(history).any(takenDates.contains)) .take(3) .toList(); } diff --git a/lib/features/home/page.dart b/lib/features/home/page.dart index e564f3dcf1..61401461fc 100644 --- a/lib/features/home/page.dart +++ b/lib/features/home/page.dart @@ -179,6 +179,10 @@ class HomePageBody extends HookConsumerWidget { if (!teaserAvailable || !context.mounted) { return; } + // 履歴取得の await 中に別の起動時自動モーダルが表示された場合に重ならないよう、表示直前に共有フラグを再確認する + if (ref.read(shownPaywallOnThisAppLaunchProvider)) { + return; + } // 同一起動で後続の起動時自動モーダル(買い切りオファー等)が重ねて表示されないよう共有フラグを立てる ref.read(shownPaywallOnThisAppLaunchProvider.notifier).state = true; await showEndedPillSheetDialog(context, variant: variant, pillSheetGroup: pillSheetGroup); diff --git a/test/entity/pill_sheet_modified_history_test.dart b/test/entity/pill_sheet_modified_history_test.dart index f5f12aed92..885d4f3c78 100644 --- a/test/entity/pill_sheet_modified_history_test.dart +++ b/test/entity/pill_sheet_modified_history_test.dart @@ -322,6 +322,47 @@ void main() { 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("30日間すべて服用記録がある場合は0を返す", () { final today = DateTime.parse("2020-09-28"); final baseDate = DateTime(today.year, today.month, today.day); 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 index e53d5319f5..e1e7ddc225 100644 --- 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 @@ -174,6 +174,30 @@ void main() { 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 { await tester.pumpWidget( ProviderScope( From a2369566d383730b507262ddc9edf9709e8d9e43 Mon Sep 17 00:00:00 2001 From: bannzai Date: Sun, 12 Jul 2026 06:46:21 +0900 Subject: [PATCH 22/22] =?UTF-8?q?fix:=20=E3=83=AC=E3=83=93=E3=83=A5?= =?UTF-8?q?=E3=83=BC=E3=82=B3=E3=83=A1=E3=83=B3=E3=83=88=E5=AF=BE=E5=BF=9C?= =?UTF-8?q?=20-=20=E5=8F=96=E3=82=8A=E6=B6=88=E3=81=97=E3=83=BB=E7=B7=A8?= =?UTF-8?q?=E9=9B=86=E3=83=BB=E5=90=8C=E6=97=A5=E8=A4=87=E6=95=B0=E5=9B=9E?= =?UTF-8?q?=E8=A8=98=E9=8C=B2=E3=81=AE=E9=9B=86=E8=A8=88=E3=81=A8=E3=83=86?= =?UTF-8?q?=E3=82=A3=E3=83=BC=E3=82=B6=E3=83=BC=E8=A1=A8=E7=A4=BA=E3=82=92?= =?UTF-8?q?=E7=B2=BE=E7=B7=BB=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 同じピル日に2回目を記録した履歴(lastTakenDate 不変)も記録対象日としてその日を返す - 服用日時を編集した記録が取り消された場合、編集後の日付も服用済みから除外する(元のピル日付→編集後日付の対応を保持) - 一部だけ取り消されたまとめ記録の行はティーザーに表示しない(行表示が afterPillSheetGroup ベースで実態とずれるため) - 買い切りオファー側も表示直前に起動時モーダルの共有排他フラグを再確認する --- .../pill_sheet_modified_history.codegen.dart | 51 +++++-- .../components/history_blur_teaser.dart | 16 +- .../show_lifetime_offer_on_app_launch.dart | 5 + .../pill_sheet_modified_history_test.dart | 138 ++++++++++++++++++ .../ended_pill_sheet_dialog_test.dart | 33 +++++ 5 files changed, 226 insertions(+), 17 deletions(-) diff --git a/lib/entity/pill_sheet_modified_history.codegen.dart b/lib/entity/pill_sheet_modified_history.codegen.dart index 2420c5ed1d..446fac562d 100644 --- a/lib/entity/pill_sheet_modified_history.codegen.dart +++ b/lib/entity/pill_sheet_modified_history.codegen.dart @@ -303,25 +303,40 @@ abstract class PillSheetModifiedHistoryServiceActionFactory { } } -/// takenPill / automaticallyRecordedLastTakenDate の履歴が記録対象とした日付(日付のみに正規化)を返す。 +/// takenPill / automaticallyRecordedLastTakenDate の履歴が記録対象としたピル日付 +/// (before/after の lastTakenDate 差分。日付のみに正規化)を返す。 /// まとめ記録では1回の操作で複数日分のピルが記録され、履歴の日時は操作時刻になるため、 /// 操作日ではなく記録対象日 = (記録前の最終服用日, 記録後の最終服用日] を展開する。 /// 記録前の最終服用日が無い(初回記録)場合はシート開始日から展開する。 -/// 服用日時が編集された履歴は before/after の lastTakenDate が編集前のままのため、編集後の履歴日時を記録日とみなす。 -/// グループ情報の無い過去の履歴も記録対象日を特定できないため履歴日時を記録日とみなす。 -List takenPillHistoryTargetDates(PillSheetModifiedHistory history) { +/// 2錠飲み等で同じピル日に2回目を記録した履歴は lastTakenDate が変わらないため、その日を返す。 +/// グループ情報の無い過去の履歴は記録対象日を特定できないため履歴日時を記録日とみなす。 +List _takenPillLastTakenRangeDates(PillSheetModifiedHistory history) { final afterPillSheet = history.afterPillSheetGroup?.lastTakenPillSheetOrFirstPillSheet; final afterLastTakenDate = afterPillSheet?.lastTakenDate; - if (history.value.takenPill?.edited != null || afterPillSheet == null || afterLastTakenDate == null) { + if (afterPillSheet == null || afterLastTakenDate == null) { return [history.estimatedEventCausingDate.date()]; } final exclusiveFloorDate = (history.beforePillSheetGroup?.lastTakenPillSheetOrFirstPillSheet.lastTakenDate ?? afterPillSheet.beginDate.subtract(const Duration(days: 1))) .date(); - return [ + 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配列から、服用予定日(集計期間の全日 − 服用お休み日)と @@ -358,6 +373,9 @@ List takenPillHistoryTargetDates(PillSheetModifiedHistory history) { // takenPill || automaticallyRecordedLastTakenDate アクションの日付を収集 final takenDates = {}; + // 服用日時が編集された履歴は編集後の日付で takenDates に入る一方、取り消しは元のピル日付を対象とするため、 + // 元のピル日付 → 編集後の記録日の対応を保持して取り消し時に編集後の日付も除外できるようにする + final editedTakenDatesByOriginalDate = >{}; // beganRestDuration から endedRestDuration の間の日付を収集 final restDurationDates = {}; @@ -367,7 +385,13 @@ List takenPillHistoryTargetDates(PillSheetModifiedHistory history) { final date = history.estimatedEventCausingDate.date(); if (history.actionType == PillSheetModifiedActionType.takenPill.name || history.actionType == PillSheetModifiedActionType.automaticallyRecordedLastTakenDate.name) { - takenDates.addAll(takenPillHistoryTargetDates(history)); + final targetDates = takenPillHistoryTargetDates(history); + takenDates.addAll(targetDates); + if (history.value.takenPill?.edited != null) { + for (final originalDate in _takenPillLastTakenRangeDates(history)) { + editedTakenDatesByOriginalDate.putIfAbsent(originalDate, () => []).addAll(targetDates); + } + } } // 服用記録の取り消しを反映する。取り消された日 = (取り消し後の最終服用日, 取り消し前の最終服用日] の範囲。 @@ -376,10 +400,15 @@ List takenPillHistoryTargetDates(PillSheetModifiedHistory history) { final beforeLastTakenDate = history.beforePillSheetGroup?.lastTakenPillSheetOrFirstPillSheet.lastTakenDate; if (beforeLastTakenDate != null) { final afterLastTakenDate = history.afterPillSheetGroup?.lastTakenPillSheetOrFirstPillSheet.lastTakenDate; - takenDates.removeWhere( - (takenDate) => - (afterLastTakenDate == null || takenDate.isAfter(afterLastTakenDate.date())) && !takenDate.isAfter(beforeLastTakenDate.date()), - ); + 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); + } + } } } 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 index 3be662b1de..8dd3ef7ecb 100644 --- a/lib/features/ended_pill_sheet_dialog/components/history_blur_teaser.dart +++ b/lib/features/ended_pill_sheet_dialog/components/history_blur_teaser.dart @@ -27,12 +27,16 @@ List historyBlurTeaserHistories({ // 取り消し(revertTakenPill)を反映した最終的な服用記録日の集合。取り消されたままの服用記録をティーザーに表示しないために使う。 // maxDate は服用予定日(scheduledDates)の構築にのみ影響し takenDates には影響しない final takenDates = pillTakenDateSets(histories: groupHistories, maxDate: today())?.takenDates ?? {}; - // 過去日のピルを後から記録した履歴は操作日と記録対象日が異なるため、記録対象日で照合する - return groupHistories - .where((history) => - history.enumActionType == PillSheetModifiedActionType.takenPill && takenPillHistoryTargetDates(history).any(takenDates.contains)) - .take(3) - .toList(); + // 過去日のピルを後から記録した履歴は操作日と記録対象日が異なるため、記録対象日で照合する。 + // 一部だけ取り消されたまとめ記録は行表示(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: ピルシート履歴のぼかしティーザー。 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 a8ae9e85fd..ecb29d983a 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 @@ -41,6 +41,11 @@ class ShowLifetimeOfferOnAppLaunch extends HookConsumerWidget { if (!context.mounted) { return; } + // callback 予約後に別の起動時自動モーダル(ピルシート終了ダイアログ等)が表示された場合に + // 重ならないよう、表示直前に共有フラグを再確認する + if (ref.read(shownPaywallOnThisAppLaunchProvider)) { + return; + } // 同一起動で後続の起動時自動モーダル(ピルシート終了ダイアログ等)が重ねて表示されないよう共有フラグを立てる ref.read(shownPaywallOnThisAppLaunchProvider.notifier).state = true; // 表示期限の起点となる初回表示時刻を記録する diff --git a/test/entity/pill_sheet_modified_history_test.dart b/test/entity/pill_sheet_modified_history_test.dart index 885d4f3c78..ad06815ade 100644 --- a/test/entity/pill_sheet_modified_history_test.dart +++ b/test/entity/pill_sheet_modified_history_test.dart @@ -363,6 +363,54 @@ void main() { 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); @@ -1115,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 index e1e7ddc225..02a1fdb858 100644 --- 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 @@ -198,6 +198,39 @@ void main() { 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(