feat(review): request a store review after a 3-star win, once
Adds an in-app review prompt gated by ReviewPromptPolicy: only after a 3-star stage win, once the player has cleared >=5 stages, at most once ever (persisted reviewRequested flag). ReviewService swallows all failures and only burns the one-shot when the store actually shows the sheet, so an unavailable store retries on a later win. StoreReviewer wraps in_app_review behind a Reviewer seam so unit tests skip platform channels. 13 new tests; full suite 194 green. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -48,6 +48,9 @@ class SaveRepository {
|
||||
_musicEnabled =
|
||||
(json['flags'] as Map<String, dynamic>?)?['musicEnabled'] as bool? ??
|
||||
true;
|
||||
_reviewRequested = (json['flags']
|
||||
as Map<String, dynamic>?)?['reviewRequested'] as bool? ??
|
||||
false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -64,6 +67,7 @@ class SaveRepository {
|
||||
bool _adsRemoved = false;
|
||||
bool _soundEnabled = true;
|
||||
bool _musicEnabled = true;
|
||||
bool _reviewRequested = false;
|
||||
|
||||
StreakState get streak => _streak;
|
||||
bool get tutorialDone => _tutorialDone;
|
||||
@@ -72,6 +76,14 @@ class SaveRepository {
|
||||
bool get soundEnabled => _soundEnabled;
|
||||
bool get musicEnabled => _musicEnabled;
|
||||
|
||||
/// Whether we've already asked the player for a store review (one-time).
|
||||
bool get reviewRequested => _reviewRequested;
|
||||
|
||||
/// Distinct stages the player has cleared (any season, >=1 star). Used to
|
||||
/// gate the review prompt behind a player who is clearly invested.
|
||||
int get stagesClearedCount =>
|
||||
_progress.values.where((p) => p.stars > 0).length;
|
||||
|
||||
Future<void> markTutorialDone() {
|
||||
_tutorialDone = true;
|
||||
return _flush();
|
||||
@@ -92,6 +104,11 @@ class SaveRepository {
|
||||
return _flush();
|
||||
}
|
||||
|
||||
Future<void> markReviewRequested() {
|
||||
_reviewRequested = true;
|
||||
return _flush();
|
||||
}
|
||||
|
||||
Future<void> recordEndlessScore(int score) {
|
||||
if (score > _endlessBest) _endlessBest = score;
|
||||
return _flush();
|
||||
@@ -165,6 +182,7 @@ class SaveRepository {
|
||||
'adsRemoved': _adsRemoved,
|
||||
'soundEnabled': _soundEnabled,
|
||||
'musicEnabled': _musicEnabled,
|
||||
'reviewRequested': _reviewRequested,
|
||||
},
|
||||
'endless': {'best': _endlessBest},
|
||||
}),
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
// lib/services/review_prompt_policy.dart
|
||||
|
||||
/// Decides — as pure logic, with no plugin or storage dependency — whether the
|
||||
/// app should ask the player for a store review right now.
|
||||
///
|
||||
/// Store review prompts only land when they catch the player at a genuine high
|
||||
/// point, and both OSes hard-throttle how often the native sheet can appear.
|
||||
/// So we ask at most once (the caller persists [alreadyRequested]), and only
|
||||
/// after a clean, high-scoring win once the player is clearly invested.
|
||||
class ReviewPromptPolicy {
|
||||
const ReviewPromptPolicy({
|
||||
this.minStagesWon = 5,
|
||||
this.requiredStars = 3,
|
||||
});
|
||||
|
||||
/// How many stages the player must have cleared before we'll ask, so the
|
||||
/// prompt never interrupts a newcomer still deciding if they like the game.
|
||||
final int minStagesWon;
|
||||
|
||||
/// The star count the triggering win must reach — a top-marks finish is the
|
||||
/// emotional peak we want to ride.
|
||||
final int requiredStars;
|
||||
|
||||
/// True when a just-finished stage result should trigger the review prompt.
|
||||
/// [alreadyRequested] is the persisted one-time guard owned by the caller.
|
||||
bool shouldRequest({
|
||||
required bool alreadyRequested,
|
||||
required bool won,
|
||||
required int stars,
|
||||
required int totalStagesWon,
|
||||
}) {
|
||||
if (alreadyRequested) return false;
|
||||
if (!won) return false;
|
||||
if (stars < requiredStars) return false;
|
||||
if (totalStagesWon < minStagesWon) return false;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
// lib/services/review_service.dart
|
||||
import '../data/save_repository.dart';
|
||||
import 'review_prompt_policy.dart';
|
||||
|
||||
/// A thin seam over the in_app_review plugin so unit tests never reach platform
|
||||
/// channels. The release wiring uses [StoreReviewer] (see store_reviewer.dart),
|
||||
/// which forwards to the real InAppReview instance.
|
||||
abstract class Reviewer {
|
||||
Future<bool> isAvailable();
|
||||
Future<void> requestReview();
|
||||
}
|
||||
|
||||
/// Decides and triggers the native "rate this app" sheet at most once, on a
|
||||
/// genuine high point. Holds no plugin dependency — that lives behind
|
||||
/// [Reviewer] — so the decision path is fully unit-testable.
|
||||
class ReviewService {
|
||||
ReviewService({
|
||||
required SaveRepository save,
|
||||
required Reviewer reviewer,
|
||||
ReviewPromptPolicy policy = const ReviewPromptPolicy(),
|
||||
}) : _save = save,
|
||||
_reviewer = reviewer,
|
||||
_policy = policy;
|
||||
|
||||
final SaveRepository _save;
|
||||
final Reviewer _reviewer;
|
||||
final ReviewPromptPolicy _policy;
|
||||
|
||||
/// Call right after a stage win has been recorded to progress. Asks for a
|
||||
/// review if the policy allows and the store can show the sheet, then never
|
||||
/// again. The one-shot flag is only burned once the sheet is actually
|
||||
/// requested, so an unavailable store retries on a later win. Every failure
|
||||
/// is swallowed — a review prompt must never break gameplay.
|
||||
Future<void> maybeRequestAfterWin({required int stars}) async {
|
||||
final allowed = _policy.shouldRequest(
|
||||
alreadyRequested: _save.reviewRequested,
|
||||
won: true,
|
||||
stars: stars,
|
||||
totalStagesWon: _save.stagesClearedCount,
|
||||
);
|
||||
if (!allowed) return;
|
||||
try {
|
||||
if (await _reviewer.isAvailable()) {
|
||||
await _reviewer.requestReview();
|
||||
await _save.markReviewRequested();
|
||||
}
|
||||
} catch (_) {/* never break gameplay over a review prompt */}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
// lib/services/store_reviewer.dart
|
||||
import 'package:in_app_review/in_app_review.dart';
|
||||
|
||||
import 'review_service.dart';
|
||||
|
||||
/// Production [Reviewer] that forwards to the in_app_review plugin's native
|
||||
/// "rate this app" sheet (SKStoreReviewController on iOS, In-App Review API on
|
||||
/// Android). Kept in its own file so [ReviewService] and its tests stay free of
|
||||
/// the platform-channel dependency.
|
||||
class StoreReviewer implements Reviewer {
|
||||
StoreReviewer([InAppReview? inAppReview])
|
||||
: _inAppReview = inAppReview ?? InAppReview.instance;
|
||||
|
||||
final InAppReview _inAppReview;
|
||||
|
||||
@override
|
||||
Future<bool> isAvailable() => _inAppReview.isAvailable();
|
||||
|
||||
@override
|
||||
Future<void> requestReview() => _inAppReview.requestReview();
|
||||
}
|
||||
@@ -10,6 +10,8 @@ import '../services/audio_service.dart';
|
||||
import '../services/consent_service.dart';
|
||||
import '../services/iap_service.dart';
|
||||
import '../services/music_service.dart';
|
||||
import '../services/review_service.dart';
|
||||
import '../services/store_reviewer.dart';
|
||||
import 'ads_notifier.dart';
|
||||
import 'endless_best_notifier.dart';
|
||||
import 'music_notifier.dart';
|
||||
@@ -126,6 +128,15 @@ final analyticsProvider = Provider<AnalyticsService>(
|
||||
(ref) => AnalyticsService(DebugAnalyticsBackend()),
|
||||
);
|
||||
|
||||
/// Asks for a store review at most once, after a genuine high point. Reads the
|
||||
/// one-time flag and cleared-stage count live from [saveRepositoryProvider].
|
||||
final reviewServiceProvider = Provider<ReviewService>(
|
||||
(ref) => ReviewService(
|
||||
save: ref.read(saveRepositoryProvider),
|
||||
reviewer: StoreReviewer(),
|
||||
),
|
||||
);
|
||||
|
||||
/// The visual theme of whatever season is in play; fallback outside seasons
|
||||
/// (home, endless). Pure model — UI converts via ThemeColors.
|
||||
final activeThemeProvider = Provider<SeasonTheme>((ref) {
|
||||
|
||||
@@ -175,6 +175,12 @@ class _GameScreenState extends ConsumerState<GameScreen>
|
||||
movesUsed: next.moveLimit - next.movesLeft,
|
||||
);
|
||||
}
|
||||
// recordWin (above) has already updated progress synchronously, so
|
||||
// the policy's cleared-stage count includes this win. Fire-and-forget
|
||||
// — a review prompt must never block the result card.
|
||||
ref
|
||||
.read(reviewServiceProvider)
|
||||
.maybeRequestAfterWin(stars: next.starsEarned);
|
||||
}
|
||||
final stackBox =
|
||||
_stackKey.currentContext?.findRenderObject() as RenderBox?;
|
||||
|
||||
@@ -413,6 +413,22 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.4.8+1"
|
||||
in_app_review:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: in_app_review
|
||||
sha256: "364db0c160b37fe7fad9cdaa18f968473924b17368869010af902760421b6bf8"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.0.12"
|
||||
in_app_review_platform_interface:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: in_app_review_platform_interface
|
||||
sha256: fed2c755f2125caa9ae10495a3c163aa7fab5af3585a9c62ef4a6920c5b45f10
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.0.5"
|
||||
intl:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -842,6 +858,70 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.4.0"
|
||||
url_launcher:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: url_launcher
|
||||
sha256: f6a7e5c4835bb4e3026a04793a4199ca2d14c739ec378fdfe23fc8075d0439f8
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "6.3.2"
|
||||
url_launcher_android:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: url_launcher_android
|
||||
sha256: "3bb000251e55d4a209aa0e2e563309dc9bb2befea2295fd0cec1f51760aac572"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "6.3.29"
|
||||
url_launcher_ios:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: url_launcher_ios
|
||||
sha256: cfde38aa257dae62ffe79c87fab20165dfdf6988c1d31b58ebf59b9106062aad
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "6.3.6"
|
||||
url_launcher_linux:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: url_launcher_linux
|
||||
sha256: d5e14138b3bc193a0f63c10a53c94b91d399df0512b1f29b94a043db7482384a
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.2.2"
|
||||
url_launcher_macos:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: url_launcher_macos
|
||||
sha256: "368adf46f71ad3c21b8f06614adb38346f193f3a59ba8fe9a2fd74133070ba18"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.2.5"
|
||||
url_launcher_platform_interface:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: url_launcher_platform_interface
|
||||
sha256: "552f8a1e663569be95a8190206a38187b531910283c3e982193e4f2733f01029"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.3.2"
|
||||
url_launcher_web:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: url_launcher_web
|
||||
sha256: "4bd2b7b4dc4d4d0b94e5babfffbca8eac1a126c7f3d6ecbc1a11013faa3abba2"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.4.1"
|
||||
url_launcher_windows:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: url_launcher_windows
|
||||
sha256: "712c70ab1b99744ff066053cbe3e80c73332b38d46e5e945c98689b2e66fc15f"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.1.5"
|
||||
uuid:
|
||||
dependency: transitive
|
||||
description:
|
||||
|
||||
@@ -47,6 +47,7 @@ dependencies:
|
||||
google_mobile_ads: ^7.0.0
|
||||
in_app_purchase: ^3.2.3
|
||||
app_tracking_transparency: ^2.0.7
|
||||
in_app_review: ^2.0.12
|
||||
|
||||
dev_dependencies:
|
||||
flutter_test:
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
import 'package:block_seasons/data/save_repository.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
void main() {
|
||||
TestWidgetsFlutterBinding.ensureInitialized();
|
||||
|
||||
Future<SaveRepository> freshRepo() async {
|
||||
SharedPreferences.setMockInitialValues({});
|
||||
return SaveRepository(await SharedPreferences.getInstance());
|
||||
}
|
||||
|
||||
test('reviewRequested starts false', () async {
|
||||
final repo = await freshRepo();
|
||||
expect(repo.reviewRequested, isFalse);
|
||||
});
|
||||
|
||||
test('markReviewRequested flips the flag and persists across reload', () async {
|
||||
final repo = await freshRepo();
|
||||
await repo.markReviewRequested();
|
||||
expect(repo.reviewRequested, isTrue);
|
||||
|
||||
// A fresh repository over the same prefs must see the saved flag.
|
||||
final reloaded = SaveRepository(await SharedPreferences.getInstance());
|
||||
expect(reloaded.reviewRequested, isTrue);
|
||||
});
|
||||
|
||||
test('stagesClearedCount counts distinct stages cleared with >=1 star',
|
||||
() async {
|
||||
final repo = await freshRepo();
|
||||
expect(repo.stagesClearedCount, 0);
|
||||
|
||||
await repo.recordResult(
|
||||
seasonId: 'season_001', stageId: 's1', stars: 1, score: 100);
|
||||
await repo.recordResult(
|
||||
seasonId: 'season_001', stageId: 's2', stars: 3, score: 300);
|
||||
// Replaying the same stage must not double-count.
|
||||
await repo.recordResult(
|
||||
seasonId: 'season_001', stageId: 's1', stars: 2, score: 500);
|
||||
|
||||
expect(repo.stagesClearedCount, 2);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
import 'package:block_seasons/services/review_prompt_policy.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
void main() {
|
||||
const policy = ReviewPromptPolicy(); // minStagesWon: 5, requiredStars: 3
|
||||
|
||||
test('asks for a review on a 3-star win once enough stages are cleared', () {
|
||||
expect(
|
||||
policy.shouldRequest(
|
||||
alreadyRequested: false,
|
||||
won: true,
|
||||
stars: 3,
|
||||
totalStagesWon: 5,
|
||||
),
|
||||
isTrue,
|
||||
);
|
||||
});
|
||||
|
||||
test('never asks again once a review has been requested', () {
|
||||
expect(
|
||||
policy.shouldRequest(
|
||||
alreadyRequested: true,
|
||||
won: true,
|
||||
stars: 3,
|
||||
totalStagesWon: 20,
|
||||
),
|
||||
isFalse,
|
||||
);
|
||||
});
|
||||
|
||||
test('does not ask after a loss', () {
|
||||
expect(
|
||||
policy.shouldRequest(
|
||||
alreadyRequested: false,
|
||||
won: false,
|
||||
stars: 0,
|
||||
totalStagesWon: 10,
|
||||
),
|
||||
isFalse,
|
||||
);
|
||||
});
|
||||
|
||||
test('does not ask before enough stages are cleared', () {
|
||||
expect(
|
||||
policy.shouldRequest(
|
||||
alreadyRequested: false,
|
||||
won: true,
|
||||
stars: 3,
|
||||
totalStagesWon: 4, // one short of the 5-stage gate
|
||||
),
|
||||
isFalse,
|
||||
);
|
||||
});
|
||||
|
||||
test('does not ask on a win below the star bar (2 stars)', () {
|
||||
expect(
|
||||
policy.shouldRequest(
|
||||
alreadyRequested: false,
|
||||
won: true,
|
||||
stars: 2,
|
||||
totalStagesWon: 10,
|
||||
),
|
||||
isFalse,
|
||||
);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
import 'package:block_seasons/data/save_repository.dart';
|
||||
import 'package:block_seasons/services/review_service.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
class _FakeReviewer implements Reviewer {
|
||||
_FakeReviewer({this.available = true, this.throwOnRequest = false});
|
||||
|
||||
bool available;
|
||||
bool throwOnRequest;
|
||||
int requestCalls = 0;
|
||||
|
||||
@override
|
||||
Future<bool> isAvailable() async => available;
|
||||
|
||||
@override
|
||||
Future<void> requestReview() async {
|
||||
requestCalls++;
|
||||
if (throwOnRequest) throw Exception('store unavailable');
|
||||
}
|
||||
}
|
||||
|
||||
void main() {
|
||||
TestWidgetsFlutterBinding.ensureInitialized();
|
||||
|
||||
Future<SaveRepository> repoWithStagesCleared(int n) async {
|
||||
SharedPreferences.setMockInitialValues({});
|
||||
final repo = SaveRepository(await SharedPreferences.getInstance());
|
||||
for (var i = 0; i < n; i++) {
|
||||
await repo.recordResult(
|
||||
seasonId: 'season_001', stageId: 's$i', stars: 3, score: 100);
|
||||
}
|
||||
return repo;
|
||||
}
|
||||
|
||||
test('requests a review and marks the flag on a qualifying win', () async {
|
||||
final repo = await repoWithStagesCleared(5);
|
||||
final reviewer = _FakeReviewer();
|
||||
final service = ReviewService(save: repo, reviewer: reviewer);
|
||||
|
||||
await service.maybeRequestAfterWin(stars: 3);
|
||||
|
||||
expect(reviewer.requestCalls, 1);
|
||||
expect(repo.reviewRequested, isTrue);
|
||||
});
|
||||
|
||||
test('never requests twice', () async {
|
||||
final repo = await repoWithStagesCleared(5);
|
||||
await repo.markReviewRequested();
|
||||
final reviewer = _FakeReviewer();
|
||||
final service = ReviewService(save: repo, reviewer: reviewer);
|
||||
|
||||
await service.maybeRequestAfterWin(stars: 3);
|
||||
|
||||
expect(reviewer.requestCalls, 0);
|
||||
});
|
||||
|
||||
test('does not request when the policy is not satisfied', () async {
|
||||
final repo = await repoWithStagesCleared(2); // below the 5-stage gate
|
||||
final reviewer = _FakeReviewer();
|
||||
final service = ReviewService(save: repo, reviewer: reviewer);
|
||||
|
||||
await service.maybeRequestAfterWin(stars: 3);
|
||||
|
||||
expect(reviewer.requestCalls, 0);
|
||||
expect(repo.reviewRequested, isFalse);
|
||||
});
|
||||
|
||||
test('does not burn the one-shot when the store is unavailable', () async {
|
||||
final repo = await repoWithStagesCleared(5);
|
||||
final reviewer = _FakeReviewer(available: false);
|
||||
final service = ReviewService(save: repo, reviewer: reviewer);
|
||||
|
||||
await service.maybeRequestAfterWin(stars: 3);
|
||||
|
||||
expect(reviewer.requestCalls, 0);
|
||||
expect(repo.reviewRequested, isFalse, reason: 'retry on a later win');
|
||||
});
|
||||
|
||||
test('swallows reviewer errors without throwing', () async {
|
||||
final repo = await repoWithStagesCleared(5);
|
||||
final reviewer = _FakeReviewer(throwOnRequest: true);
|
||||
final service = ReviewService(save: repo, reviewer: reviewer);
|
||||
|
||||
// Must complete normally despite the plugin throwing.
|
||||
await service.maybeRequestAfterWin(stars: 3);
|
||||
expect(reviewer.requestCalls, 1);
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user