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:
@@ -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();
|
||||
}
|
||||
Reference in New Issue
Block a user