Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 7 additions & 1 deletion lib/core/providers/library_provider.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -192,7 +193,12 @@ void quickSettingsTile(Ref ref) {
}

@Riverpod(keepAlive: true)
ReviewPrompt reviewPrompt(Ref ref) => ReviewPrompt(locator<SharedPreferencesService>(), ref.watch(telephonyServiceProvider));
ReviewPrompt reviewPrompt(Ref ref) {
final prompt = ReviewPrompt(locator<SharedPreferencesService>(), 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
Expand Down
59 changes: 47 additions & 12 deletions lib/core/services/review/service.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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<void> 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<void> onSheetClosed() async {
if (!_answered) return;
_answered = false;
await _askOnce();
}

/// The app is back in the foreground.
Future<void> onResume() async {
if (!_inPhoneApp) return;
_inPhoneApp = false;
await _askOnce();
}

Future<void> _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);
}
Expand Down
24 changes: 17 additions & 7 deletions lib/view/modals/run_code_sheet.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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<void> showRunCodeSheet(BuildContext context, {required UssdCode code, Operator? operator}) => showModalBottomSheet<void>(
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<void> showRunCodeSheet(BuildContext context, {required UssdCode code, Operator? operator}) async {
final prompt = ProviderScope.containerOf(context, listen: false).read(reviewPromptProvider);
await showModalBottomSheet<void>(
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.
Expand Down Expand Up @@ -72,6 +79,7 @@ class _RunCodeSheetState extends ConsumerState<RunCodeSheet> {
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;
Expand All @@ -89,9 +97,11 @@ class _RunCodeSheetState extends ConsumerState<RunCodeSheet> {
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) {
Expand Down
98 changes: 81 additions & 17 deletions test/services/review_prompt_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -9,56 +9,120 @@ void main() {
late SharedPreferencesService prefs;
late FakeTelephonyService telephony;
late DateTime now;
late ReviewPrompt prompt;

setUpAll(() => SharedPreferences.setMockInitialValues({}));

setUp(() async {
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<void> 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<void> becomeRegular() async {
await launch(5);
now = now.add(const Duration(days: 3));
}

Future<void> readReply() async {
prompt.onCodeAnswered();
await prompt.onSheetClosed();
}

Future<void> 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);
});
}
Loading