diff --git a/lib/data/save_repository.dart b/lib/data/save_repository.dart index a9a5bfb..26978ba 100644 --- a/lib/data/save_repository.dart +++ b/lib/data/save_repository.dart @@ -48,6 +48,9 @@ class SaveRepository { _musicEnabled = (json['flags'] as Map?)?['musicEnabled'] as bool? ?? true; + _reviewRequested = (json['flags'] + as Map?)?['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 markTutorialDone() { _tutorialDone = true; return _flush(); @@ -92,6 +104,11 @@ class SaveRepository { return _flush(); } + Future markReviewRequested() { + _reviewRequested = true; + return _flush(); + } + Future 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}, }), diff --git a/lib/services/review_prompt_policy.dart b/lib/services/review_prompt_policy.dart new file mode 100644 index 0000000..ed672a3 --- /dev/null +++ b/lib/services/review_prompt_policy.dart @@ -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; + } +} diff --git a/lib/services/review_service.dart b/lib/services/review_service.dart new file mode 100644 index 0000000..6392da6 --- /dev/null +++ b/lib/services/review_service.dart @@ -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 isAvailable(); + Future 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 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 */} + } +} diff --git a/lib/services/store_reviewer.dart b/lib/services/store_reviewer.dart new file mode 100644 index 0000000..fd4ea21 --- /dev/null +++ b/lib/services/store_reviewer.dart @@ -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 isAvailable() => _inAppReview.isAvailable(); + + @override + Future requestReview() => _inAppReview.requestReview(); +} diff --git a/lib/state/providers.dart b/lib/state/providers.dart index df60b2c..40ba84e 100644 --- a/lib/state/providers.dart +++ b/lib/state/providers.dart @@ -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( (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( + (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((ref) { diff --git a/lib/ui/screens/game_screen.dart b/lib/ui/screens/game_screen.dart index a66b650..847e6de 100644 --- a/lib/ui/screens/game_screen.dart +++ b/lib/ui/screens/game_screen.dart @@ -175,6 +175,12 @@ class _GameScreenState extends ConsumerState 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?; diff --git a/pubspec.lock b/pubspec.lock index 16d90e8..0f8928d 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -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: diff --git a/pubspec.yaml b/pubspec.yaml index 64c2892..5c4b9f3 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -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: diff --git a/test/data/save_repository_review_test.dart b/test/data/save_repository_review_test.dart new file mode 100644 index 0000000..d7b6a54 --- /dev/null +++ b/test/data/save_repository_review_test.dart @@ -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 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); + }); +} diff --git a/test/services/review_prompt_policy_test.dart b/test/services/review_prompt_policy_test.dart new file mode 100644 index 0000000..6382110 --- /dev/null +++ b/test/services/review_prompt_policy_test.dart @@ -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, + ); + }); +} diff --git a/test/services/review_service_test.dart b/test/services/review_service_test.dart new file mode 100644 index 0000000..6c2d3c4 --- /dev/null +++ b/test/services/review_service_test.dart @@ -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 isAvailable() async => available; + + @override + Future requestReview() async { + requestCalls++; + if (throwOnRequest) throw Exception('store unavailable'); + } +} + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + Future 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); + }); +}