Showing
5 changed files
with
97 additions
and
8 deletions
| @@ -10,11 +10,14 @@ import '../widgets/app_review_prompt_dialog.dart'; | @@ -10,11 +10,14 @@ import '../widgets/app_review_prompt_dialog.dart'; | ||
| 10 | class AppReviewPromptLogic { | 10 | class AppReviewPromptLogic { |
| 11 | AppReviewPromptLogic({ | 11 | AppReviewPromptLogic({ |
| 12 | required LocalStorage storage, | 12 | required LocalStorage storage, |
| 13 | + required int Function() userIdProvider, | ||
| 13 | PlatformHostApi? platformHostApi, | 14 | PlatformHostApi? platformHostApi, |
| 14 | }) : _storage = storage, | 15 | }) : _storage = storage, |
| 16 | + _userIdProvider = userIdProvider, | ||
| 15 | _platformHostApi = platformHostApi ?? PlatformHostApi(); | 17 | _platformHostApi = platformHostApi ?? PlatformHostApi(); |
| 16 | 18 | ||
| 17 | final LocalStorage _storage; | 19 | final LocalStorage _storage; |
| 20 | + final int Function() _userIdProvider; | ||
| 18 | final PlatformHostApi _platformHostApi; | 21 | final PlatformHostApi _platformHostApi; |
| 19 | 22 | ||
| 20 | bool _isShowing = false; | 23 | bool _isShowing = false; |
| @@ -25,9 +28,10 @@ class AppReviewPromptLogic { | @@ -25,9 +28,10 @@ class AppReviewPromptLogic { | ||
| 25 | bool canShowPrompt(BuildContext context) { | 28 | bool canShowPrompt(BuildContext context) { |
| 26 | if (_isShowing || !context.mounted) return false; | 29 | if (_isShowing || !context.mounted) return false; |
| 27 | 30 | ||
| 31 | + final userId = _currentUserId; | ||
| 32 | + if (userId == null) return false; | ||
| 28 | final now = DateTime.now(); | 33 | final now = DateTime.now(); |
| 29 | - final nextShowAt = _storage.appReviewPromptNextShowAt; | ||
| 30 | - return nextShowAt == null || !now.isBefore(nextShowAt); | 34 | + return _canShowAt(userId, now); |
| 31 | } | 35 | } |
| 32 | 36 | ||
| 33 | Future<void> maybeShowPrompt(BuildContext context) async { | 37 | Future<void> maybeShowPrompt(BuildContext context) async { |
| @@ -59,6 +63,42 @@ class AppReviewPromptLogic { | @@ -59,6 +63,42 @@ class AppReviewPromptLogic { | ||
| 59 | } | 63 | } |
| 60 | } | 64 | } |
| 61 | 65 | ||
| 66 | + /// Records a qualified sleep-report view. The prompt is deliberately shown | ||
| 67 | + /// only on the next app launch after the user reaches the home page. | ||
| 68 | + Future<void> recordPromptEligibility() async { | ||
| 69 | + final userId = _currentUserId; | ||
| 70 | + if (userId == null) return; | ||
| 71 | + | ||
| 72 | + final now = DateTime.now(); | ||
| 73 | + if (!_canShowAt(userId, now)) return; | ||
| 74 | + | ||
| 75 | + if (_storage.hasAppReviewPromptPending(userId)) return; | ||
| 76 | + | ||
| 77 | + await _storage.setAppReviewPromptPending(userId); | ||
| 78 | + } | ||
| 79 | + | ||
| 80 | + /// Consumes a pending qualified view when the app next enters its home page. | ||
| 81 | + Future<void> maybeShowPendingPrompt(BuildContext context) async { | ||
| 82 | + if (_isShowing || !context.mounted) return; | ||
| 83 | + | ||
| 84 | + final userId = _currentUserId; | ||
| 85 | + if (userId == null) return; | ||
| 86 | + | ||
| 87 | + if (!_storage.hasAppReviewPromptPending(userId)) return; | ||
| 88 | + | ||
| 89 | + final now = DateTime.now(); | ||
| 90 | + if (!_canShowAt(userId, now)) { | ||
| 91 | + await _storage.clearAppReviewPromptPending(userId); | ||
| 92 | + return; | ||
| 93 | + } | ||
| 94 | + | ||
| 95 | + // Clear before presenting so a recreated home page cannot show it twice. | ||
| 96 | + await _storage.clearAppReviewPromptPending(userId); | ||
| 97 | + if (context.mounted) { | ||
| 98 | + await maybeShowPrompt(context); | ||
| 99 | + } | ||
| 100 | + } | ||
| 101 | + | ||
| 62 | Future<void> _requestAppReview() async { | 102 | Future<void> _requestAppReview() async { |
| 63 | if (kIsWeb || defaultTargetPlatform != TargetPlatform.iOS) return; | 103 | if (kIsWeb || defaultTargetPlatform != TargetPlatform.iOS) return; |
| 64 | 104 | ||
| @@ -83,6 +123,21 @@ class AppReviewPromptLogic { | @@ -83,6 +123,21 @@ class AppReviewPromptLogic { | ||
| 83 | } | 123 | } |
| 84 | 124 | ||
| 85 | Future<void> _coolDown(Duration duration) { | 125 | Future<void> _coolDown(Duration duration) { |
| 86 | - return _storage.setAppReviewPromptNextShowAt(DateTime.now().add(duration)); | 126 | + final userId = _currentUserId; |
| 127 | + if (userId == null) return Future.value(); | ||
| 128 | + return _storage.setAppReviewPromptNextShowAt( | ||
| 129 | + userId, | ||
| 130 | + DateTime.now().add(duration), | ||
| 131 | + ); | ||
| 132 | + } | ||
| 133 | + | ||
| 134 | + int? get _currentUserId { | ||
| 135 | + final userId = _userIdProvider(); | ||
| 136 | + return userId > 0 ? userId : null; | ||
| 137 | + } | ||
| 138 | + | ||
| 139 | + bool _canShowAt(int userId, DateTime now) { | ||
| 140 | + final nextShowAt = _storage.appReviewPromptNextShowAt(userId); | ||
| 141 | + return nextShowAt == null || !now.isBefore(nextShowAt); | ||
| 87 | } | 142 | } |
| 88 | } | 143 | } |
| @@ -3,6 +3,7 @@ import 'package:get/get.dart'; | @@ -3,6 +3,7 @@ import 'package:get/get.dart'; | ||
| 3 | import 'package:lottie/lottie.dart'; | 3 | import 'package:lottie/lottie.dart'; |
| 4 | 4 | ||
| 5 | import '../../../../core/constants/intent_keys.dart'; | 5 | import '../../../../core/constants/intent_keys.dart'; |
| 6 | +import '../../../../core/services/user_state_service.dart'; | ||
| 6 | import '../../../../data/local/local_storage.dart'; | 7 | import '../../../../data/local/local_storage.dart'; |
| 7 | import '../../../../data/local/user_preferences_storage.dart'; | 8 | import '../../../../data/local/user_preferences_storage.dart'; |
| 8 | import '../../../routes/app_pages.dart'; | 9 | import '../../../routes/app_pages.dart'; |
| @@ -33,6 +34,7 @@ class HealthTrendContent extends StatefulWidget { | @@ -33,6 +34,7 @@ class HealthTrendContent extends StatefulWidget { | ||
| 33 | final HealthReportQuery query; | 34 | final HealthReportQuery query; |
| 34 | final int selectedTypeIndex; | 35 | final int selectedTypeIndex; |
| 35 | final int refreshToken; | 36 | final int refreshToken; |
| 37 | + | ||
| 36 | /// Whether users can switch trend types by horizontally swiping the content. | 38 | /// Whether users can switch trend types by horizontally swiping the content. |
| 37 | /// Tapping a tab remains available when this is false. | 39 | /// Tapping a tab remains available when this is false. |
| 38 | final bool allowTabSwipe; | 40 | final bool allowTabSwipe; |
| @@ -441,6 +443,7 @@ class _SleepTrendSectionState extends State<_SleepTrendSection> { | @@ -441,6 +443,7 @@ class _SleepTrendSectionState extends State<_SleepTrendSection> { | ||
| 441 | ); | 443 | ); |
| 442 | _appReviewPromptLogic = AppReviewPromptLogic( | 444 | _appReviewPromptLogic = AppReviewPromptLogic( |
| 443 | storage: Get.find<LocalStorage>(), | 445 | storage: Get.find<LocalStorage>(), |
| 446 | + userIdProvider: () => Get.find<UserStateService>().userId, | ||
| 444 | ); | 447 | ); |
| 445 | _logic.initializeQuery(widget.query.period, widget.query.date); | 448 | _logic.initializeQuery(widget.query.period, widget.query.date); |
| 446 | _periodWorker = ever(_logic.selectedPeriod, (period) { | 449 | _periodWorker = ever(_logic.selectedPeriod, (period) { |
| @@ -504,7 +507,7 @@ class _SleepTrendSectionState extends State<_SleepTrendSection> { | @@ -504,7 +507,7 @@ class _SleepTrendSectionState extends State<_SleepTrendSection> { | ||
| 504 | isVip: widget.isVip, | 507 | isVip: widget.isVip, |
| 505 | onSubscribe: widget.onSubscribe, | 508 | onSubscribe: widget.onSubscribe, |
| 506 | onReportViewed: widget.isSelected | 509 | onReportViewed: widget.isSelected |
| 507 | - ? () => _appReviewPromptLogic.maybeShowPrompt(context) | 510 | + ? _appReviewPromptLogic.recordPromptEligibility |
| 508 | : null, | 511 | : null, |
| 509 | loadingIndicator: const _HealthTrendLoadingIndicator(), | 512 | loadingIndicator: const _HealthTrendLoadingIndicator(), |
| 510 | ); | 513 | ); |
| 1 | import 'package:flutter/material.dart'; | 1 | import 'package:flutter/material.dart'; |
| 2 | import 'package:get/get.dart'; | 2 | import 'package:get/get.dart'; |
| 3 | 3 | ||
| 4 | +import '../../app_review_prompt/logic/app_review_prompt_logic.dart'; | ||
| 5 | +import '../../../../data/local/local_storage.dart'; | ||
| 6 | +import '../../../../core/services/user_state_service.dart'; | ||
| 4 | import '../controllers/home_controller.dart'; | 7 | import '../controllers/home_controller.dart'; |
| 5 | import '../widgets/df_tab_bar.dart'; | 8 | import '../widgets/df_tab_bar.dart'; |
| 6 | import '../../friends/views/friends_tab.dart'; | 9 | import '../../friends/views/friends_tab.dart'; |
| @@ -58,13 +61,21 @@ class _HomeReviewPromptGate extends StatefulWidget { | @@ -58,13 +61,21 @@ class _HomeReviewPromptGate extends StatefulWidget { | ||
| 58 | } | 61 | } |
| 59 | 62 | ||
| 60 | class _HomeReviewPromptGateState extends State<_HomeReviewPromptGate> { | 63 | class _HomeReviewPromptGateState extends State<_HomeReviewPromptGate> { |
| 64 | + late final AppReviewPromptLogic _appReviewPromptLogic; | ||
| 65 | + | ||
| 61 | @override | 66 | @override |
| 62 | void initState() { | 67 | void initState() { |
| 63 | super.initState(); | 68 | super.initState(); |
| 69 | + _appReviewPromptLogic = AppReviewPromptLogic( | ||
| 70 | + storage: Get.find<LocalStorage>(), | ||
| 71 | + userIdProvider: () => Get.find<UserStateService>().userId, | ||
| 72 | + ); | ||
| 64 | 73 | ||
| 65 | WidgetsBinding.instance.addPostFrameCallback((_) async { | 74 | WidgetsBinding.instance.addPostFrameCallback((_) async { |
| 66 | if (!mounted) return; | 75 | if (!mounted) return; |
| 67 | await widget.controller.maybeShowMembershipOffer(); | 76 | await widget.controller.maybeShowMembershipOffer(); |
| 77 | + if (!mounted) return; | ||
| 78 | + await _appReviewPromptLogic.maybeShowPendingPrompt(context); | ||
| 68 | }); | 79 | }); |
| 69 | } | 80 | } |
| 70 | 81 |
| @@ -9,6 +9,7 @@ abstract final class StorageConst { | @@ -9,6 +9,7 @@ abstract final class StorageConst { | ||
| 9 | static const String lastLoginMethodKey = 'last_login_method'; | 9 | static const String lastLoginMethodKey = 'last_login_method'; |
| 10 | static const String appReviewPromptNextShowAtKey = | 10 | static const String appReviewPromptNextShowAtKey = |
| 11 | 'app_review_prompt_next_show_at'; | 11 | 'app_review_prompt_next_show_at'; |
| 12 | + static const String appReviewPromptPendingKey = 'app_review_prompt_pending'; | ||
| 12 | static const String homeMembershipOfferPromptDateKey = | 13 | static const String homeMembershipOfferPromptDateKey = |
| 13 | 'home_membership_offer_prompt_date'; | 14 | 'home_membership_offer_prompt_date'; |
| 14 | static const String homeMembershipOfferPromptUserIdKey = | 15 | static const String homeMembershipOfferPromptUserIdKey = |
| @@ -49,21 +49,40 @@ class LocalStorage { | @@ -49,21 +49,40 @@ class LocalStorage { | ||
| 49 | 49 | ||
| 50 | // ─── App Review Prompt Storage ────────────────────────────────────────────── | 50 | // ─── App Review Prompt Storage ────────────────────────────────────────────── |
| 51 | 51 | ||
| 52 | - DateTime? get appReviewPromptNextShowAt { | 52 | + DateTime? appReviewPromptNextShowAt(int userId) { |
| 53 | final value = sharedPreferences.getInt( | 53 | final value = sharedPreferences.getInt( |
| 54 | - StorageConst.appReviewPromptNextShowAtKey, | 54 | + _appReviewPromptNextShowAtKey(userId), |
| 55 | ); | 55 | ); |
| 56 | if (value == null || value <= 0) return null; | 56 | if (value == null || value <= 0) return null; |
| 57 | return DateTime.fromMillisecondsSinceEpoch(value); | 57 | return DateTime.fromMillisecondsSinceEpoch(value); |
| 58 | } | 58 | } |
| 59 | 59 | ||
| 60 | - Future<void> setAppReviewPromptNextShowAt(DateTime value) async { | 60 | + Future<void> setAppReviewPromptNextShowAt(int userId, DateTime value) async { |
| 61 | await sharedPreferences.setInt( | 61 | await sharedPreferences.setInt( |
| 62 | - StorageConst.appReviewPromptNextShowAtKey, | 62 | + _appReviewPromptNextShowAtKey(userId), |
| 63 | value.millisecondsSinceEpoch, | 63 | value.millisecondsSinceEpoch, |
| 64 | ); | 64 | ); |
| 65 | } | 65 | } |
| 66 | 66 | ||
| 67 | + bool hasAppReviewPromptPending(int userId) { | ||
| 68 | + return sharedPreferences.getBool(_appReviewPromptPendingKey(userId)) ?? | ||
| 69 | + false; | ||
| 70 | + } | ||
| 71 | + | ||
| 72 | + Future<void> setAppReviewPromptPending(int userId) async { | ||
| 73 | + await sharedPreferences.setBool(_appReviewPromptPendingKey(userId), true); | ||
| 74 | + } | ||
| 75 | + | ||
| 76 | + Future<void> clearAppReviewPromptPending(int userId) async { | ||
| 77 | + await sharedPreferences.remove(_appReviewPromptPendingKey(userId)); | ||
| 78 | + } | ||
| 79 | + | ||
| 80 | + String _appReviewPromptNextShowAtKey(int userId) => | ||
| 81 | + '${StorageConst.appReviewPromptNextShowAtKey}_$userId'; | ||
| 82 | + | ||
| 83 | + String _appReviewPromptPendingKey(int userId) => | ||
| 84 | + '${StorageConst.appReviewPromptPendingKey}_$userId'; | ||
| 85 | + | ||
| 67 | // ─── Home Membership Offer Prompt Storage ───────────────────────────────── | 86 | // ─── Home Membership Offer Prompt Storage ───────────────────────────────── |
| 68 | 87 | ||
| 69 | int? get homeMembershipOfferPromptDate => | 88 | int? get homeMembershipOfferPromptDate => |
-
Please register or login to post a comment