From 870fcb003ef541bb7759058af57b70c9c00bfc6b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?No=C3=A9=20Gnanih?= Date: Fri, 9 Oct 2026 15:51:20 +0000 Subject: [PATCH] feat: ask for a review after a success, not at launch --- lib/core/providers/library_provider.dart | 8 +- lib/core/services/review/service.dart | 59 +++++++++++--- lib/view/modals/run_code_sheet.dart | 24 ++++-- test/services/review_prompt_test.dart | 98 ++++++++++++++++++++---- 4 files changed, 152 insertions(+), 37 deletions(-) diff --git a/lib/core/providers/library_provider.dart b/lib/core/providers/library_provider.dart index 0d48418..88bf3a2 100644 --- a/lib/core/providers/library_provider.dart +++ b/lib/core/providers/library_provider.dart @@ -2,6 +2,7 @@ import 'dart:async'; import 'dart:convert'; import 'package:flutter/services.dart'; +import 'package:flutter/widgets.dart'; import 'package:riverpod_annotation/riverpod_annotation.dart'; import '../catalog/models.dart'; @@ -192,7 +193,12 @@ void quickSettingsTile(Ref ref) { } @Riverpod(keepAlive: true) -ReviewPrompt reviewPrompt(Ref ref) => ReviewPrompt(locator(), ref.watch(telephonyServiceProvider)); +ReviewPrompt reviewPrompt(Ref ref) { + final prompt = ReviewPrompt(locator(), ref.watch(telephonyServiceProvider)); + final lifecycle = AppLifecycleListener(onResume: () => unawaited(prompt.onResume())); + ref.onDispose(lifecycle.dispose); + return prompt; +} /// One-time work before the first screen: the catalog is loaded, what the /// legacy app saved (favorites, personal codes, country) is imported and the diff --git a/lib/core/services/review/service.dart b/lib/core/services/review/service.dart index 8022ba2..44e00c4 100644 --- a/lib/core/services/review/service.dart +++ b/lib/core/services/review/service.dart @@ -2,29 +2,64 @@ import '../shared_preferences/keys.dart'; import '../shared_preferences/service.dart'; import '../telephony/service.dart'; -/// Asks once for a store review, like the legacy app: after [minDays] days -/// of use and [minLaunches] launches, through the Play In-App Review flow. -/// Play decides whether the dialog actually shows (it limits how often). +/// Asks once for a store review, through the Play In-App Review flow, right +/// after a code worked: once the operator's reply was read in the app (when +/// its sheet closes), or once the user is back from the phone app the code +/// ran in. Only for someone who uses the app: [minLaunches] launches, at +/// least [minDays] days after the first one. Asked once in the app's life; +/// Play then decides whether the dialog actually shows (it limits how often). class ReviewPrompt { - static const minDays = 10; - static const minLaunches = 10; + static const minDays = 3; + static const minLaunches = 5; final SharedPreferencesService _prefs; final TelephonyService _telephony; final DateTime Function() _now; + /// A reply was shown in the run sheet, still open. + bool _answered = false; + + /// A code went to the phone app: the app is in the background until the + /// user comes back. + bool _inPhoneApp = false; + ReviewPrompt(this._prefs, this._telephony, {DateTime Function()? now}) : _now = now ?? DateTime.now; + /// Counts a launch. Future onLaunch() async { - final now = _now(); - final firstLaunch = _prefs.getInt(PrefKeys.firstLaunch); - if (firstLaunch == null) await _prefs.setInt(PrefKeys.firstLaunch, now.millisecondsSinceEpoch); - final launches = (_prefs.getInt(PrefKeys.launchCount) ?? 0) + 1; - await _prefs.setInt(PrefKeys.launchCount, launches); + if (_prefs.getInt(PrefKeys.firstLaunch) == null) await _prefs.setInt(PrefKeys.firstLaunch, _now().millisecondsSinceEpoch); + await _prefs.setInt(PrefKeys.launchCount, (_prefs.getInt(PrefKeys.launchCount) ?? 0) + 1); + } + + /// The operator's reply to a code is on screen. + void onCodeAnswered() => _answered = true; + + /// A code was handed to the phone app (called, or typed in the dialer). + void onCodeSentToPhoneApp() { + _answered = false; + _inPhoneApp = true; + } + + /// The run sheet closed. + Future onSheetClosed() async { + if (!_answered) return; + _answered = false; + await _askOnce(); + } + /// The app is back in the foreground. + Future onResume() async { + if (!_inPhoneApp) return; + _inPhoneApp = false; + await _askOnce(); + } + + Future _askOnce() async { if (_prefs.getBool(PrefKeys.reviewRequested) ?? false) return; - final usedFor = now.difference(DateTime.fromMillisecondsSinceEpoch(firstLaunch ?? now.millisecondsSinceEpoch)); - if (launches < minLaunches || usedFor.inDays < minDays) return; + final firstLaunch = _prefs.getInt(PrefKeys.firstLaunch); + final launches = _prefs.getInt(PrefKeys.launchCount) ?? 0; + if (firstLaunch == null || launches < minLaunches) return; + if (_now().difference(DateTime.fromMillisecondsSinceEpoch(firstLaunch)).inDays < minDays) return; if (await _telephony.requestReview()) await _prefs.setBool(PrefKeys.reviewRequested, true); } diff --git a/lib/view/modals/run_code_sheet.dart b/lib/view/modals/run_code_sheet.dart index 9aaa5cd..bcda19c 100644 --- a/lib/view/modals/run_code_sheet.dart +++ b/lib/view/modals/run_code_sheet.dart @@ -9,16 +9,23 @@ import '../../core/catalog/models.dart'; import '../../core/providers/catalog_provider.dart'; import '../../core/providers/library_provider.dart'; import '../../core/services/i18n/translations.g.dart'; +import '../../core/services/review/service.dart'; import '../../core/services/telephony/service.dart'; import '../components/codes/code_texts.dart'; -Future showRunCodeSheet(BuildContext context, {required UssdCode code, Operator? operator}) => showModalBottomSheet( - context: context, - isScrollControlled: true, - useSafeArea: true, - showDragHandle: true, - builder: (context) => RunCodeSheet(code: code, operator: operator), -); +/// Once closed, the sheet may bring up the review prompt, if the operator's +/// reply was read in it (see [ReviewPrompt]). +Future showRunCodeSheet(BuildContext context, {required UssdCode code, Operator? operator}) async { + final prompt = ProviderScope.containerOf(context, listen: false).read(reviewPromptProvider); + await showModalBottomSheet( + context: context, + isScrollControlled: true, + useSafeArea: true, + showDragHandle: true, + builder: (context) => RunCodeSheet(code: code, operator: operator), + ); + await prompt.onSheetClosed(); +} /// Confirms a code before running it (USSD codes can buy bundles or send /// money), after asking for its params when it has some. @@ -72,6 +79,7 @@ class _RunCodeSheetState extends ConsumerState { final result = await ref.read(telephonyServiceProvider).sendUssd(widget.code.fill(_values)); if (!mounted) return; if (result case UssdAnswered(:final text)) { + ref.read(reviewPromptProvider).onCodeAnswered(); setState(() { _dialing = false; _response = text; @@ -89,9 +97,11 @@ class _RunCodeSheetState extends ConsumerState { final messenger = ScaffoldMessenger.of(context); final t = context.t; final navigator = Navigator.of(context); + final prompt = ref.read(reviewPromptProvider); final outcome = await ref.read(telephonyServiceProvider).dial(widget.code.fill(_values), direct: direct, isDeviceCode: widget.code.isDeviceCode); + if (outcome == DialOutcome.called || outcome == DialOutcome.dialerOpened) prompt.onCodeSentToPhoneApp(); if (!mounted) return; navigator.pop(); final message = switch (outcome) { diff --git a/test/services/review_prompt_test.dart b/test/services/review_prompt_test.dart index 6fadf33..4e2ff74 100644 --- a/test/services/review_prompt_test.dart +++ b/test/services/review_prompt_test.dart @@ -9,6 +9,7 @@ void main() { late SharedPreferencesService prefs; late FakeTelephonyService telephony; late DateTime now; + late ReviewPrompt prompt; setUpAll(() => SharedPreferences.setMockInitialValues({})); @@ -16,49 +17,112 @@ void main() { prefs = (await SharedPreferencesService.getInstance())!; await prefs.preferences!.clear(); telephony = FakeTelephonyService(); - now = DateTime(2026, 10, 6); + now = DateTime(2026, 10, 9); + prompt = ReviewPrompt(prefs, telephony, now: () => now); }); Future launch(int times) async { for (var i = 0; i < times; i++) { - await ReviewPrompt(prefs, telephony, now: () => now).onLaunch(); + await prompt.onLaunch(); } } - test('asks after 10 launches over at least 10 days, like the legacy app', () async { - await launch(9); - now = now.add(const Duration(days: 10)); - expect(telephony.reviewRequests, 0); + /// A regular user: 5 launches over 3 days. + Future becomeRegular() async { + await launch(5); + now = now.add(const Duration(days: 3)); + } + Future readReply() async { + prompt.onCodeAnswered(); + await prompt.onSheetClosed(); + } + + Future returnFromPhoneApp() async { + prompt.onCodeSentToPhoneApp(); + await prompt.onResume(); + } + + test('launching the app never asks for a review', () async { + await launch(20); + now = now.add(const Duration(days: 30)); await launch(1); + + expect(telephony.reviewRequests, 0); + }); + + test('asks when the sheet closes after the operator replied', () async { + await becomeRegular(); + prompt.onCodeAnswered(); + expect(telephony.reviewRequests, 0, reason: 'not over the reply'); + + await prompt.onSheetClosed(); expect(telephony.reviewRequests, 1); }); - test('waits for 10 days even after many launches', () async { - await launch(30); - now = now.add(const Duration(days: 9)); - await launch(1); + test('asks when the user is back from the phone app', () async { + await becomeRegular(); + prompt.onCodeSentToPhoneApp(); + await prompt.onSheetClosed(); + expect(telephony.reviewRequests, 0, reason: 'the phone app is opening'); + + await prompt.onResume(); + expect(telephony.reviewRequests, 1); + }); + test('a reply continued in the dialer waits for the return to the app', () async { + await becomeRegular(); + prompt + ..onCodeAnswered() + ..onCodeSentToPhoneApp(); + await prompt.onSheetClosed(); expect(telephony.reviewRequests, 0); + + await prompt.onResume(); + expect(telephony.reviewRequests, 1); }); - test('asks only once', () async { - await launch(10); + test('without a code that worked, closing a sheet or coming back asks nothing', () async { + await becomeRegular(); + await prompt.onSheetClosed(); + await prompt.onResume(); + + expect(telephony.reviewRequests, 0); + }); + + test('waits for 5 launches and 3 days', () async { + await launch(4); now = now.add(const Duration(days: 10)); + await readReply(); + expect(telephony.reviewRequests, 0); + + await prefs.preferences!.clear(); + await launch(10); + now = now.add(const Duration(days: 2)); + await returnFromPhoneApp(); + expect(telephony.reviewRequests, 0); + }); + + test('asks only once, whatever the number of codes run', () async { + await becomeRegular(); + for (var i = 0; i < 5; i++) { + await readReply(); + await returnFromPhoneApp(); + } await launch(5); + await readReply(); expect(telephony.reviewRequests, 1); }); - test('tries again on the next launch when the review flow could not run', () async { + test('tries again after the next code when the review flow could not run', () async { telephony.reviewAvailable = false; - await launch(1); - now = now.add(const Duration(days: 10)); - await launch(9); + await becomeRegular(); + await readReply(); expect(telephony.reviewRequests, 1); telephony.reviewAvailable = true; - await launch(2); + await returnFromPhoneApp(); expect(telephony.reviewRequests, 2); }); }