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:
2026-06-18 11:13:55 +09:00
parent 395e4a189b
commit cec4c3e427
11 changed files with 422 additions and 0 deletions
+18
View File
@@ -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},
}),
+38
View File
@@ -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;
}
}
+49
View File
@@ -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 */}
}
}
+21
View File
@@ -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();
}
+11
View File
@@ -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) {
+6
View File
@@ -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?;