Compare commits
22 Commits
4cda34f0b7
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 7cecd89f6d | |||
| 410182cf7d | |||
| 42deeaf242 | |||
| 1695684fc9 | |||
| fa2784519b | |||
| 412cc08167 | |||
| 1a028b9852 | |||
| b8bfa00196 | |||
| 1ba30028b5 | |||
| a04bb3b847 | |||
| 0517fabdbb | |||
| d0a2be15ba | |||
| fa4247cd9b | |||
| ba4d4a662b | |||
| 638a177fbb | |||
| c185bd0886 | |||
| 544a2b8be4 | |||
| 221ea8346e | |||
| 6592b44387 | |||
| e7cd079a5d | |||
| bbf8cf3f08 | |||
| 5aee503c09 |
+105
-44
@@ -4,44 +4,72 @@ PODS:
|
||||
- audioplayers_darwin (0.0.1):
|
||||
- Flutter
|
||||
- FlutterMacOS
|
||||
- Firebase/CoreOnly (12.14.0):
|
||||
- FirebaseCore (~> 12.14.0)
|
||||
- Firebase/CoreOnly (12.15.0):
|
||||
- FirebaseCore (~> 12.15.0)
|
||||
- Firebase/Crashlytics (12.15.0):
|
||||
- Firebase/CoreOnly
|
||||
- FirebaseCrashlytics (~> 12.15.0)
|
||||
- firebase_analytics (12.4.2):
|
||||
- firebase_core
|
||||
- FirebaseAnalytics (= 12.14.0)
|
||||
- FirebaseAnalytics (= 12.15.0)
|
||||
- Flutter
|
||||
- firebase_core (4.10.0):
|
||||
- Firebase/CoreOnly (= 12.14.0)
|
||||
- firebase_core (4.11.0):
|
||||
- Firebase/CoreOnly (= 12.15.0)
|
||||
- Flutter
|
||||
- FirebaseAnalytics (12.14.0):
|
||||
- FirebaseAnalytics/Default (= 12.14.0)
|
||||
- FirebaseCore (~> 12.14.0)
|
||||
- FirebaseInstallations (~> 12.14.0)
|
||||
- firebase_crashlytics (5.2.4):
|
||||
- Firebase/Crashlytics (= 12.15.0)
|
||||
- firebase_core
|
||||
- Flutter
|
||||
- FirebaseAnalytics (12.15.0):
|
||||
- FirebaseAnalytics/Default (= 12.15.0)
|
||||
- FirebaseCore (~> 12.15.0)
|
||||
- FirebaseInstallations (~> 12.15.0)
|
||||
- GoogleUtilities/AppDelegateSwizzler (~> 8.1)
|
||||
- GoogleUtilities/MethodSwizzler (~> 8.1)
|
||||
- GoogleUtilities/Network (~> 8.1)
|
||||
- "GoogleUtilities/NSData+zlib (~> 8.1)"
|
||||
- nanopb (~> 3.30910.0)
|
||||
- FirebaseAnalytics/Default (12.14.0):
|
||||
- FirebaseCore (~> 12.14.0)
|
||||
- FirebaseInstallations (~> 12.14.0)
|
||||
- GoogleAppMeasurement/Default (= 12.14.0)
|
||||
- FirebaseAnalytics/Default (12.15.0):
|
||||
- FirebaseCore (~> 12.15.0)
|
||||
- FirebaseInstallations (~> 12.15.0)
|
||||
- GoogleAppMeasurement/Default (= 12.15.0)
|
||||
- GoogleUtilities/AppDelegateSwizzler (~> 8.1)
|
||||
- GoogleUtilities/MethodSwizzler (~> 8.1)
|
||||
- GoogleUtilities/Network (~> 8.1)
|
||||
- "GoogleUtilities/NSData+zlib (~> 8.1)"
|
||||
- nanopb (~> 3.30910.0)
|
||||
- FirebaseCore (12.14.0):
|
||||
- FirebaseCoreInternal (~> 12.14.0)
|
||||
- FirebaseCore (12.15.0):
|
||||
- FirebaseCoreInternal (~> 12.15.0)
|
||||
- GoogleUtilities/Environment (~> 8.1)
|
||||
- GoogleUtilities/Logger (~> 8.1)
|
||||
- FirebaseCoreInternal (12.14.0):
|
||||
- FirebaseCoreExtension (12.15.0):
|
||||
- FirebaseCore (~> 12.15.0)
|
||||
- FirebaseCoreInternal (12.15.0):
|
||||
- "GoogleUtilities/NSData+zlib (~> 8.1)"
|
||||
- FirebaseInstallations (12.14.0):
|
||||
- FirebaseCore (~> 12.14.0)
|
||||
- FirebaseCrashlytics (12.15.0):
|
||||
- FirebaseCore (~> 12.15.0)
|
||||
- FirebaseInstallations (~> 12.15.0)
|
||||
- FirebaseRemoteConfigInterop (~> 12.15.0)
|
||||
- FirebaseSessions (~> 12.15.0)
|
||||
- GoogleDataTransport (~> 10.1)
|
||||
- GoogleUtilities/Environment (~> 8.1)
|
||||
- nanopb (~> 3.30910.0)
|
||||
- PromisesObjC (~> 2.4)
|
||||
- FirebaseInstallations (12.15.0):
|
||||
- FirebaseCore (~> 12.15.0)
|
||||
- GoogleUtilities/Environment (~> 8.1)
|
||||
- GoogleUtilities/UserDefaults (~> 8.1)
|
||||
- PromisesObjC (~> 2.4)
|
||||
- FirebaseRemoteConfigInterop (12.15.0)
|
||||
- FirebaseSessions (12.15.0):
|
||||
- FirebaseCore (~> 12.15.0)
|
||||
- FirebaseCoreExtension (~> 12.15.0)
|
||||
- FirebaseInstallations (~> 12.15.0)
|
||||
- GoogleDataTransport (~> 10.1)
|
||||
- GoogleUtilities/Environment (~> 8.1)
|
||||
- GoogleUtilities/UserDefaults (~> 8.1)
|
||||
- nanopb (~> 3.30910.0)
|
||||
- PromisesSwift (~> 2.1)
|
||||
- Flutter (1.0.0)
|
||||
- Google-Mobile-Ads-SDK (12.14.0):
|
||||
- GoogleUserMessagingPlatform (>= 1.1)
|
||||
@@ -54,59 +82,64 @@ PODS:
|
||||
- GoogleUtilities/Logger (~> 8.1)
|
||||
- GoogleUtilities/Network (~> 8.1)
|
||||
- nanopb (~> 3.30910.0)
|
||||
- GoogleAppMeasurement/Core (12.14.0):
|
||||
- GoogleAppMeasurement/Core (12.15.0):
|
||||
- GoogleUtilities/AppDelegateSwizzler (~> 8.1)
|
||||
- GoogleUtilities/MethodSwizzler (~> 8.1)
|
||||
- GoogleUtilities/Network (~> 8.1)
|
||||
- "GoogleUtilities/NSData+zlib (~> 8.1)"
|
||||
- nanopb (~> 3.30910.0)
|
||||
- GoogleAppMeasurement/Default (12.14.0):
|
||||
- GoogleAppMeasurement/Default (12.15.0):
|
||||
- GoogleAdsOnDeviceConversion (~> 3.6.0)
|
||||
- GoogleAppMeasurement/Core (= 12.14.0)
|
||||
- GoogleAppMeasurement/IdentitySupport (= 12.14.0)
|
||||
- GoogleAppMeasurement/Core (= 12.15.0)
|
||||
- GoogleAppMeasurement/IdentitySupport (= 12.15.0)
|
||||
- GoogleUtilities/AppDelegateSwizzler (~> 8.1)
|
||||
- GoogleUtilities/MethodSwizzler (~> 8.1)
|
||||
- GoogleUtilities/Network (~> 8.1)
|
||||
- "GoogleUtilities/NSData+zlib (~> 8.1)"
|
||||
- nanopb (~> 3.30910.0)
|
||||
- GoogleAppMeasurement/IdentitySupport (12.14.0):
|
||||
- GoogleAppMeasurement/Core (= 12.14.0)
|
||||
- GoogleAppMeasurement/IdentitySupport (12.15.0):
|
||||
- GoogleAppMeasurement/Core (= 12.15.0)
|
||||
- GoogleUtilities/AppDelegateSwizzler (~> 8.1)
|
||||
- GoogleUtilities/MethodSwizzler (~> 8.1)
|
||||
- GoogleUtilities/Network (~> 8.1)
|
||||
- "GoogleUtilities/NSData+zlib (~> 8.1)"
|
||||
- nanopb (~> 3.30910.0)
|
||||
- GoogleDataTransport (10.1.0):
|
||||
- nanopb (~> 3.30910.0)
|
||||
- PromisesObjC (~> 2.4)
|
||||
- GoogleUserMessagingPlatform (3.1.0)
|
||||
- GoogleUtilities/AppDelegateSwizzler (8.1.0):
|
||||
- GoogleUtilities/AppDelegateSwizzler (8.1.1):
|
||||
- GoogleUtilities/Environment
|
||||
- GoogleUtilities/Logger
|
||||
- GoogleUtilities/Network
|
||||
- GoogleUtilities/Privacy
|
||||
- GoogleUtilities/Environment (8.1.0):
|
||||
- GoogleUtilities/Environment (8.1.1):
|
||||
- GoogleUtilities/Privacy
|
||||
- GoogleUtilities/Logger (8.1.0):
|
||||
- GoogleUtilities/Logger (8.1.1):
|
||||
- GoogleUtilities/Environment
|
||||
- GoogleUtilities/Privacy
|
||||
- GoogleUtilities/MethodSwizzler (8.1.0):
|
||||
- GoogleUtilities/MethodSwizzler (8.1.1):
|
||||
- GoogleUtilities/Logger
|
||||
- GoogleUtilities/Privacy
|
||||
- GoogleUtilities/Network (8.1.0):
|
||||
- GoogleUtilities/Network (8.1.1):
|
||||
- GoogleUtilities/Logger
|
||||
- "GoogleUtilities/NSData+zlib"
|
||||
- GoogleUtilities/Privacy
|
||||
- GoogleUtilities/Reachability
|
||||
- "GoogleUtilities/NSData+zlib (8.1.0)":
|
||||
- "GoogleUtilities/NSData+zlib (8.1.1)":
|
||||
- GoogleUtilities/Privacy
|
||||
- GoogleUtilities/Privacy (8.1.0)
|
||||
- GoogleUtilities/Reachability (8.1.0):
|
||||
- GoogleUtilities/Privacy (8.1.1)
|
||||
- GoogleUtilities/Reachability (8.1.1):
|
||||
- GoogleUtilities/Logger
|
||||
- GoogleUtilities/Privacy
|
||||
- GoogleUtilities/UserDefaults (8.1.0):
|
||||
- GoogleUtilities/UserDefaults (8.1.1):
|
||||
- GoogleUtilities/Logger
|
||||
- GoogleUtilities/Privacy
|
||||
- in_app_purchase_storekit (0.0.1):
|
||||
- Flutter
|
||||
- FlutterMacOS
|
||||
- in_app_review (2.0.0):
|
||||
- Flutter
|
||||
- nanopb (3.30910.0):
|
||||
- nanopb/decode (= 3.30910.0)
|
||||
- nanopb/encode (= 3.30910.0)
|
||||
@@ -115,10 +148,14 @@ PODS:
|
||||
- path_provider_foundation (0.0.1):
|
||||
- Flutter
|
||||
- FlutterMacOS
|
||||
- PromisesObjC (2.4.0)
|
||||
- PromisesObjC (2.4.1)
|
||||
- PromisesSwift (2.4.1):
|
||||
- PromisesObjC (= 2.4.1)
|
||||
- shared_preferences_foundation (0.0.1):
|
||||
- Flutter
|
||||
- FlutterMacOS
|
||||
- url_launcher_ios (0.0.1):
|
||||
- Flutter
|
||||
- webview_flutter_wkwebview (0.0.1):
|
||||
- Flutter
|
||||
- FlutterMacOS
|
||||
@@ -128,11 +165,14 @@ DEPENDENCIES:
|
||||
- audioplayers_darwin (from `.symlinks/plugins/audioplayers_darwin/darwin`)
|
||||
- firebase_analytics (from `.symlinks/plugins/firebase_analytics/ios`)
|
||||
- firebase_core (from `.symlinks/plugins/firebase_core/ios`)
|
||||
- firebase_crashlytics (from `.symlinks/plugins/firebase_crashlytics/ios`)
|
||||
- Flutter (from `Flutter`)
|
||||
- google_mobile_ads (from `.symlinks/plugins/google_mobile_ads/ios`)
|
||||
- in_app_purchase_storekit (from `.symlinks/plugins/in_app_purchase_storekit/darwin`)
|
||||
- in_app_review (from `.symlinks/plugins/in_app_review/ios`)
|
||||
- path_provider_foundation (from `.symlinks/plugins/path_provider_foundation/darwin`)
|
||||
- shared_preferences_foundation (from `.symlinks/plugins/shared_preferences_foundation/darwin`)
|
||||
- url_launcher_ios (from `.symlinks/plugins/url_launcher_ios/ios`)
|
||||
- webview_flutter_wkwebview (from `.symlinks/plugins/webview_flutter_wkwebview/darwin`)
|
||||
|
||||
SPEC REPOS:
|
||||
@@ -140,15 +180,21 @@ SPEC REPOS:
|
||||
- Firebase
|
||||
- FirebaseAnalytics
|
||||
- FirebaseCore
|
||||
- FirebaseCoreExtension
|
||||
- FirebaseCoreInternal
|
||||
- FirebaseCrashlytics
|
||||
- FirebaseInstallations
|
||||
- FirebaseRemoteConfigInterop
|
||||
- FirebaseSessions
|
||||
- Google-Mobile-Ads-SDK
|
||||
- GoogleAdsOnDeviceConversion
|
||||
- GoogleAppMeasurement
|
||||
- GoogleDataTransport
|
||||
- GoogleUserMessagingPlatform
|
||||
- GoogleUtilities
|
||||
- nanopb
|
||||
- PromisesObjC
|
||||
- PromisesSwift
|
||||
|
||||
EXTERNAL SOURCES:
|
||||
app_tracking_transparency:
|
||||
@@ -159,41 +205,56 @@ EXTERNAL SOURCES:
|
||||
:path: ".symlinks/plugins/firebase_analytics/ios"
|
||||
firebase_core:
|
||||
:path: ".symlinks/plugins/firebase_core/ios"
|
||||
firebase_crashlytics:
|
||||
:path: ".symlinks/plugins/firebase_crashlytics/ios"
|
||||
Flutter:
|
||||
:path: Flutter
|
||||
google_mobile_ads:
|
||||
:path: ".symlinks/plugins/google_mobile_ads/ios"
|
||||
in_app_purchase_storekit:
|
||||
:path: ".symlinks/plugins/in_app_purchase_storekit/darwin"
|
||||
in_app_review:
|
||||
:path: ".symlinks/plugins/in_app_review/ios"
|
||||
path_provider_foundation:
|
||||
:path: ".symlinks/plugins/path_provider_foundation/darwin"
|
||||
shared_preferences_foundation:
|
||||
:path: ".symlinks/plugins/shared_preferences_foundation/darwin"
|
||||
url_launcher_ios:
|
||||
:path: ".symlinks/plugins/url_launcher_ios/ios"
|
||||
webview_flutter_wkwebview:
|
||||
:path: ".symlinks/plugins/webview_flutter_wkwebview/darwin"
|
||||
|
||||
SPEC CHECKSUMS:
|
||||
app_tracking_transparency: 3d84f147f67ca82d3c15355c36b1fa6b66ca7c92
|
||||
audioplayers_darwin: 835ced6edd4c9fc8ebb0a7cc9e294a91d99917d5
|
||||
Firebase: 7cc10425300768ec86292688af5cb228f0604bde
|
||||
firebase_analytics: 9ea6c22e60e1d37ee76772de57b4addddb03e276
|
||||
firebase_core: 383e19b49a08df5d7a6cf5017616de6a357ed7af
|
||||
FirebaseAnalytics: 99364329a3ea4d1ab4a3744b5464371b7351c481
|
||||
FirebaseCore: 4939b340b9c598dc1f965d68f8fe57e630b65407
|
||||
FirebaseCoreInternal: 090369a5fffd7423cf88006ab4d2ccc2173a8db9
|
||||
FirebaseInstallations: 7cdc919e29dc54306edeffdbdc1eed1a40d7d1e7
|
||||
Firebase: a8539b633d474fbeb654c7043f9c1649e274045b
|
||||
firebase_analytics: e0a17f792099472235f9ec7f31d1d3a0730d4891
|
||||
firebase_core: fc23178af8ea070194d09031ae4198a9608a3d22
|
||||
firebase_crashlytics: 344bb168f55aee1086c6cdd0b105a9db018cd344
|
||||
FirebaseAnalytics: 9c9fa7915fc52ea03077000d5a7b6a8947b2d76e
|
||||
FirebaseCore: 2e86a4ea1684d4381707069e4a6d89ac808e901e
|
||||
FirebaseCoreExtension: 10d2a627977b39418759ad88ada80fbbd34f1c4f
|
||||
FirebaseCoreInternal: 6ab6a02c94446c026d2cf35cf5383842ebaa4992
|
||||
FirebaseCrashlytics: 87e76cc33259b076dd1f96cd829db76849338e08
|
||||
FirebaseInstallations: eb29ccbf64eaedf86fd5b2ccc7fabde567660b52
|
||||
FirebaseRemoteConfigInterop: 7e3d57ce4b1e958bb1d15403faa7178f46bbb5b7
|
||||
FirebaseSessions: acfe7eadca47cda94ac86592737204581bb1abf6
|
||||
Flutter: cabc95a1d2626b1b06e7179b784ebcf0c0cde467
|
||||
Google-Mobile-Ads-SDK: 4534fd2dfcd3f705c5485a6633c5188d03d4eed2
|
||||
google_mobile_ads: df3008bafbe1f2ad6862f87334e560d2f047f902
|
||||
GoogleAdsOnDeviceConversion: 80ce443fa1b4b5750913d53a04ecda644ff57744
|
||||
GoogleAppMeasurement: 1987ffa55055dfb22c52e363c31aa50c1e11d349
|
||||
GoogleAppMeasurement: a6d37949071d456e9147dac6789c4342e0e7a8c5
|
||||
GoogleDataTransport: aae35b7ea0c09004c3797d53c8c41f66f219d6a7
|
||||
GoogleUserMessagingPlatform: befe603da6501006420c206222acd449bba45a9c
|
||||
GoogleUtilities: 00c88b9a86066ef77f0da2fab05f65d7768ed8e1
|
||||
GoogleUtilities: 4f2618a4a1e762a1ee134a1e2323bba9843e06da
|
||||
in_app_purchase_storekit: 22cca7d08eebca9babdf4d07d0baccb73325d3c8
|
||||
in_app_review: 7dd1ea365263f834b8464673f9df72c80c17c937
|
||||
nanopb: fad817b59e0457d11a5dfbde799381cd727c1275
|
||||
path_provider_foundation: bb55f6dbba17d0dccd6737fe6f7f34fbd0376880
|
||||
PromisesObjC: f5707f49cb48b9636751c5b2e7d227e43fba9f47
|
||||
PromisesObjC: 752c3227f599e3467650e47ea36f433eeb10c273
|
||||
PromisesSwift: 217dea0fd5d2ad65222a109c48698add13cc1c5b
|
||||
shared_preferences_foundation: 7036424c3d8ec98dfe75ff1667cb0cd531ec82bb
|
||||
url_launcher_ios: 7a95fa5b60cc718a708b8f2966718e93db0cef1b
|
||||
webview_flutter_wkwebview: 8ebf4fded22593026f7dbff1fbff31ea98573c8d
|
||||
|
||||
PODFILE CHECKSUM: 7a0c05f8aeb53a8c858ca08a4666afaa242f0eb1
|
||||
|
||||
@@ -2,6 +2,7 @@ import 'dart:convert';
|
||||
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
import '../game/models/booster.dart';
|
||||
import 'streak.dart';
|
||||
|
||||
class StageProgress {
|
||||
@@ -51,6 +52,16 @@ class SaveRepository {
|
||||
_reviewRequested = (json['flags']
|
||||
as Map<String, dynamic>?)?['reviewRequested'] as bool? ??
|
||||
false;
|
||||
_boostersSeeded = (json['flags']
|
||||
as Map<String, dynamic>?)?['boostersSeeded'] as bool? ??
|
||||
false;
|
||||
final boosters = json['boosters'] as Map<String, dynamic>? ?? {};
|
||||
for (final t in BoosterType.values) {
|
||||
_boosters[t] = boosters[t.name] as int? ?? 0;
|
||||
}
|
||||
final daily = json['daily'] as Map<String, dynamic>?;
|
||||
_dailyLastClaimedYmd = daily?['lastYmd'] as String?;
|
||||
_dailyCalendarDay = daily?['day'] as int? ?? 0;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -68,6 +79,12 @@ class SaveRepository {
|
||||
bool _soundEnabled = true;
|
||||
bool _musicEnabled = true;
|
||||
bool _reviewRequested = false;
|
||||
bool _boostersSeeded = false;
|
||||
final Map<BoosterType, int> _boosters = {
|
||||
for (final t in BoosterType.values) t: 0,
|
||||
};
|
||||
String? _dailyLastClaimedYmd;
|
||||
int _dailyCalendarDay = 0;
|
||||
|
||||
StreakState get streak => _streak;
|
||||
bool get tutorialDone => _tutorialDone;
|
||||
@@ -109,6 +126,43 @@ class SaveRepository {
|
||||
return _flush();
|
||||
}
|
||||
|
||||
int boosterCount(BoosterType type) => _boosters[type] ?? 0;
|
||||
|
||||
Future<void> grantBooster(BoosterType type, [int n = 1]) {
|
||||
_boosters[type] = (_boosters[type] ?? 0) + n;
|
||||
return _flush();
|
||||
}
|
||||
|
||||
/// Spends one booster. Returns false (and changes nothing) when none are left.
|
||||
Future<bool> consumeBooster(BoosterType type) async {
|
||||
final have = _boosters[type] ?? 0;
|
||||
if (have <= 0) return false;
|
||||
_boosters[type] = have - 1;
|
||||
await _flush();
|
||||
return true;
|
||||
}
|
||||
|
||||
/// Grants one of each booster the first time it ever runs, so a new player
|
||||
/// can try every booster. Idempotent for the app's lifetime via a persisted
|
||||
/// flag — safe to call on every launch.
|
||||
Future<void> seedInitialBoostersIfNeeded() async {
|
||||
if (_boostersSeeded) return;
|
||||
_boostersSeeded = true;
|
||||
for (final t in BoosterType.values) {
|
||||
_boosters[t] = (_boosters[t] ?? 0) + 1;
|
||||
}
|
||||
await _flush();
|
||||
}
|
||||
|
||||
String? get dailyLastClaimedYmd => _dailyLastClaimedYmd;
|
||||
int get dailyCalendarDay => _dailyCalendarDay;
|
||||
|
||||
Future<void> recordDailyClaim(String ymd, int day) {
|
||||
_dailyLastClaimedYmd = ymd;
|
||||
_dailyCalendarDay = day;
|
||||
return _flush();
|
||||
}
|
||||
|
||||
Future<void> recordEndlessScore(int score) {
|
||||
if (score > _endlessBest) _endlessBest = score;
|
||||
return _flush();
|
||||
@@ -183,8 +237,11 @@ class SaveRepository {
|
||||
'soundEnabled': _soundEnabled,
|
||||
'musicEnabled': _musicEnabled,
|
||||
'reviewRequested': _reviewRequested,
|
||||
'boostersSeeded': _boostersSeeded,
|
||||
},
|
||||
'endless': {'best': _endlessBest},
|
||||
'boosters': {for (final t in BoosterType.values) t.name: _boosters[t]},
|
||||
'daily': {'lastYmd': _dailyLastClaimedYmd, 'day': _dailyCalendarDay},
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
// lib/game/daily/daily_reward.dart
|
||||
import '../models/booster.dart';
|
||||
|
||||
/// The result of evaluating the calendar for "today".
|
||||
class DailyResolution {
|
||||
const DailyResolution({required this.claimable, required this.day});
|
||||
final bool claimable;
|
||||
final int day; // 1..7, the day to show/grant
|
||||
}
|
||||
|
||||
/// Pure 7-day login-calendar logic. No storage or clock — callers pass the
|
||||
/// persisted state and today's date so it is fully unit-testable.
|
||||
class DailyRewardCalendar {
|
||||
const DailyRewardCalendar();
|
||||
|
||||
static const int cycle = 7;
|
||||
|
||||
String ymd(DateTime date) {
|
||||
final m = date.month.toString().padLeft(2, '0');
|
||||
final d = date.day.toString().padLeft(2, '0');
|
||||
return '${date.year}-$m-$d';
|
||||
}
|
||||
|
||||
DailyResolution resolve({
|
||||
required String? lastClaimedYmd,
|
||||
required int storedDay,
|
||||
required DateTime today,
|
||||
}) {
|
||||
final todayYmd = ymd(today);
|
||||
if (lastClaimedYmd == todayYmd) {
|
||||
return DailyResolution(claimable: false, day: storedDay);
|
||||
}
|
||||
final yesterday = ymd(today.subtract(const Duration(days: 1)));
|
||||
final int day;
|
||||
if (lastClaimedYmd == yesterday) {
|
||||
day = (storedDay % cycle) + 1; // advance, wrapping 7 -> 1
|
||||
} else {
|
||||
day = 1; // first ever, or a day was missed
|
||||
}
|
||||
return DailyResolution(claimable: true, day: day);
|
||||
}
|
||||
|
||||
/// Boosters granted for a given calendar day (1..7). Tunable.
|
||||
Map<BoosterType, int> rewardFor(int day) {
|
||||
switch (day) {
|
||||
case 1:
|
||||
return {BoosterType.hammer: 1};
|
||||
case 2:
|
||||
return {BoosterType.shuffle: 1};
|
||||
case 3:
|
||||
return {BoosterType.lineBomb: 1};
|
||||
case 4:
|
||||
return {BoosterType.hammer: 1, BoosterType.shuffle: 1};
|
||||
case 5:
|
||||
return {BoosterType.shuffle: 1, BoosterType.lineBomb: 1};
|
||||
case 6:
|
||||
return {BoosterType.hammer: 1, BoosterType.lineBomb: 1};
|
||||
case 7:
|
||||
default:
|
||||
return {
|
||||
BoosterType.hammer: 2,
|
||||
BoosterType.shuffle: 2,
|
||||
BoosterType.lineBomb: 2,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -216,4 +216,59 @@ class GameEngine {
|
||||
}
|
||||
_phase = GamePhase.lost;
|
||||
}
|
||||
|
||||
/// Booster: empties one filled cell. No move/score/combo effect, but a
|
||||
/// hammered gem DOES count toward a clear-gems objective (and can win the
|
||||
/// stage). Allowed only mid-attempt. Returns false on an empty cell or a
|
||||
/// finished attempt so the caller keeps the booster.
|
||||
bool useHammer(int x, int y) {
|
||||
if (_phase == GamePhase.won || _phase == GamePhase.lost) return false;
|
||||
if (!_grid.isOccupied(x, y)) return false;
|
||||
final wasGem = _grid.cellAt(x, y).type == CellType.gem;
|
||||
_grid = _grid.withCell(x, y, const Cell(CellType.empty));
|
||||
_resolveAfterBooster(
|
||||
wasGem ? const LinesCleared(lines: 0, gems: 1) : null);
|
||||
return true;
|
||||
}
|
||||
|
||||
/// Booster: re-deals the tray. No move/score/objective effect. Re-checks
|
||||
/// stuck so a dead board with a hopeless tray can become playable again.
|
||||
bool useShuffle() {
|
||||
if (_phase == GamePhase.won || _phase == GamePhase.lost) return false;
|
||||
_tray = _generator.nextTray(_grid);
|
||||
_checkStuck();
|
||||
return true;
|
||||
}
|
||||
|
||||
/// Booster: empties one row or one column (exactly one of [row]/[col]). No
|
||||
/// move/score effect, but it counts as clearing one line plus any gems on
|
||||
/// that line, toward the stage objectives (and can win the stage).
|
||||
bool useLineBomb({int? row, int? col}) {
|
||||
if (_phase == GamePhase.won || _phase == GamePhase.lost) return false;
|
||||
if ((row == null) == (col == null)) return false; // need exactly one
|
||||
var gems = 0;
|
||||
for (var i = 0; i < GridState.size; i++) {
|
||||
final x = col ?? i;
|
||||
final y = row ?? i;
|
||||
if (_grid.cellAt(x, y).type == CellType.gem) gems++;
|
||||
_grid = _grid.withCell(x, y, const Cell(CellType.empty));
|
||||
}
|
||||
_resolveAfterBooster(LinesCleared(lines: 1, gems: gems));
|
||||
return true;
|
||||
}
|
||||
|
||||
/// After a booster mutates the grid, feed any [cleared] event through the
|
||||
/// objectives so booster-cleared gems/lines count, then resolve the phase:
|
||||
/// a completed objective wins the stage, otherwise re-check stuck. Score and
|
||||
/// the move counter stay untouched — boosters are help, not a placement.
|
||||
void _resolveAfterBooster(LinesCleared? cleared) {
|
||||
if (cleared != null) {
|
||||
_objectives = [for (final obj in _objectives) obj.onEvent(cleared)];
|
||||
}
|
||||
if (!_stage.endless && _objectives.every((o) => o.isComplete)) {
|
||||
_phase = GamePhase.won;
|
||||
} else {
|
||||
_checkStuck();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
// lib/game/models/booster.dart
|
||||
|
||||
/// The three boosters the player can earn and spend. Pure model so the data
|
||||
/// and state layers can both reference it without a Flutter dependency.
|
||||
enum BoosterType { hammer, shuffle, lineBomb }
|
||||
+12
-1
@@ -61,5 +61,16 @@
|
||||
"adsRemovedThanks": "Ads removed — thank you!",
|
||||
"purchaseUnavailable": "Purchases are unavailable right now.",
|
||||
"soundAndVibration": "Sound & vibration",
|
||||
"music": "Music"
|
||||
"music": "Music",
|
||||
"boosterHammer": "Hammer",
|
||||
"boosterShuffle": "Shuffle",
|
||||
"boosterLineBomb": "Line Bomb",
|
||||
"boosterGetWithAd": "Watch an ad to get one",
|
||||
"dailyRewardTitle": "Daily Reward",
|
||||
"dailyClaim": "Claim",
|
||||
"dailyDoubleWithAd": "Watch ad for 2×",
|
||||
"boosterTapTarget": "Tap a cell",
|
||||
"boosterTapLine": "Tap a row or column",
|
||||
"boosterLineRow": "Row",
|
||||
"boosterLineCol": "Column"
|
||||
}
|
||||
|
||||
+12
-1
@@ -33,5 +33,16 @@
|
||||
"adsRemovedThanks": "광고가 제거되었습니다 — 감사합니다!",
|
||||
"purchaseUnavailable": "지금은 구매를 사용할 수 없습니다.",
|
||||
"soundAndVibration": "소리 및 진동",
|
||||
"music": "음악"
|
||||
"music": "음악",
|
||||
"boosterHammer": "해머",
|
||||
"boosterShuffle": "셔플",
|
||||
"boosterLineBomb": "줄 폭탄",
|
||||
"boosterGetWithAd": "광고 보고 1개 받기",
|
||||
"dailyRewardTitle": "출석 보상",
|
||||
"dailyClaim": "받기",
|
||||
"dailyDoubleWithAd": "광고 보고 2배",
|
||||
"boosterTapTarget": "칸을 선택하세요",
|
||||
"boosterTapLine": "줄을 선택하세요",
|
||||
"boosterLineRow": "가로",
|
||||
"boosterLineCol": "세로"
|
||||
}
|
||||
|
||||
@@ -28,6 +28,8 @@ const contentBaseUrl = String.fromEnvironment(
|
||||
Future<void> main() async {
|
||||
WidgetsFlutterBinding.ensureInitialized();
|
||||
final saveRepository = await SaveRepository.open();
|
||||
// New players start with one of each booster (idempotent after the first run).
|
||||
await saveRepository.seedInitialBoostersIfNeeded();
|
||||
|
||||
// Analytics: real GA4 traffic flows only from release builds so development
|
||||
// never pollutes production. If Firebase init fails (e.g. missing native
|
||||
|
||||
@@ -61,4 +61,16 @@ class AnalyticsService {
|
||||
void tutorialFinished({required bool skipped}) {
|
||||
_backend.logEvent('tutorial_finished', {'skipped': skipped ? 1 : 0});
|
||||
}
|
||||
|
||||
void boosterUsed({required String type}) =>
|
||||
_backend.logEvent('booster_used', {'type': type});
|
||||
|
||||
void boosterGranted(
|
||||
{required String type, required int count, required String source}) =>
|
||||
_backend.logEvent(
|
||||
'booster_granted', {'type': type, 'count': count, 'source': source});
|
||||
|
||||
void dailyRewardClaimed({required int day, required bool doubled}) =>
|
||||
_backend.logEvent(
|
||||
'daily_reward_claimed', {'day': day, 'doubled': doubled ? 1 : 0});
|
||||
}
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
// lib/state/booster_inventory_notifier.dart
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../data/save_repository.dart';
|
||||
import '../game/models/booster.dart';
|
||||
import 'providers.dart';
|
||||
|
||||
/// Live booster counts backed by [SaveRepository]. State is an immutable map.
|
||||
class BoosterInventoryNotifier extends Notifier<Map<BoosterType, int>> {
|
||||
SaveRepository get _save => ref.read(saveRepositoryProvider);
|
||||
|
||||
@override
|
||||
Map<BoosterType, int> build() => _snapshot();
|
||||
|
||||
Map<BoosterType, int> _snapshot() =>
|
||||
{for (final t in BoosterType.values) t: _save.boosterCount(t)};
|
||||
|
||||
Future<void> grant(BoosterType type, [int n = 1]) async {
|
||||
await _save.grantBooster(type, n);
|
||||
state = _snapshot();
|
||||
}
|
||||
|
||||
Future<bool> consume(BoosterType type) async {
|
||||
final ok = await _save.consumeBooster(type);
|
||||
if (ok) state = _snapshot();
|
||||
return ok;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
// lib/state/daily_reward_notifier.dart
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../data/save_repository.dart';
|
||||
import '../game/daily/daily_reward.dart';
|
||||
import 'providers.dart';
|
||||
|
||||
class DailyRewardNotifier extends Notifier<DailyResolution> {
|
||||
static const _cal = DailyRewardCalendar();
|
||||
|
||||
SaveRepository get _save => ref.read(saveRepositoryProvider);
|
||||
DateTime Function() get _now => ref.read(dailyNowProvider);
|
||||
|
||||
@override
|
||||
DailyResolution build() => _resolve();
|
||||
|
||||
DailyResolution _resolve() => _cal.resolve(
|
||||
lastClaimedYmd: _save.dailyLastClaimedYmd,
|
||||
storedDay: _save.dailyCalendarDay,
|
||||
today: _now(),
|
||||
);
|
||||
|
||||
/// Grants the current day's reward (×2 if [doubled]) and records the claim.
|
||||
Future<void> claim({bool doubled = false}) async {
|
||||
final r = state;
|
||||
if (!r.claimable) return;
|
||||
final reward = _cal.rewardFor(r.day);
|
||||
// Record the claim BEFORE granting, so a crash mid-claim forfeits at most
|
||||
// one reward rather than leaving the day unrecorded (which would let the
|
||||
// player re-claim and farm boosters on the next launch).
|
||||
await _save.recordDailyClaim(_cal.ymd(_now()), r.day);
|
||||
final inv = ref.read(boosterInventoryProvider.notifier);
|
||||
for (final entry in reward.entries) {
|
||||
await inv.grant(entry.key, entry.value * (doubled ? 2 : 1));
|
||||
}
|
||||
ref.read(analyticsProvider).dailyRewardClaimed(day: r.day, doubled: doubled);
|
||||
for (final e in reward.entries) {
|
||||
ref.read(analyticsProvider).boosterGranted(
|
||||
type: e.key.name,
|
||||
count: e.value * (doubled ? 2 : 1),
|
||||
source: 'daily',
|
||||
);
|
||||
}
|
||||
state = _resolve();
|
||||
}
|
||||
}
|
||||
@@ -2,12 +2,15 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../game/engine/game_engine.dart';
|
||||
import '../game/engine/piece_generator.dart';
|
||||
import '../game/models/booster.dart';
|
||||
import '../game/models/grid.dart';
|
||||
import '../game/models/objective.dart';
|
||||
import '../game/models/piece.dart';
|
||||
import '../game/models/stage.dart';
|
||||
import 'providers.dart';
|
||||
|
||||
enum BoosterUseResult { success, noBooster, invalidTarget }
|
||||
|
||||
/// Immutable snapshot of one engine moment; the only game state the UI sees.
|
||||
class GameViewState {
|
||||
const GameViewState({
|
||||
@@ -117,6 +120,42 @@ class GameSessionNotifier extends Notifier<GameViewState?> {
|
||||
_publish(lastPlacement: null);
|
||||
}
|
||||
|
||||
Future<BoosterUseResult> useHammer(int x, int y) =>
|
||||
_useBooster(BoosterType.hammer, () {
|
||||
final engine = _engine;
|
||||
if (engine == null) return false;
|
||||
return engine.useHammer(x, y);
|
||||
});
|
||||
|
||||
Future<BoosterUseResult> useShuffle() =>
|
||||
_useBooster(BoosterType.shuffle, () {
|
||||
final engine = _engine;
|
||||
if (engine == null) return false;
|
||||
return engine.useShuffle();
|
||||
});
|
||||
|
||||
Future<BoosterUseResult> useLineBomb({int? row, int? col}) =>
|
||||
_useBooster(BoosterType.lineBomb, () {
|
||||
final engine = _engine;
|
||||
if (engine == null) return false;
|
||||
return engine.useLineBomb(row: row, col: col);
|
||||
});
|
||||
|
||||
Future<BoosterUseResult> _useBooster(
|
||||
BoosterType type, bool Function() apply) async {
|
||||
final engine = _engine;
|
||||
if (engine == null) return BoosterUseResult.noBooster;
|
||||
final inv = ref.read(boosterInventoryProvider.notifier);
|
||||
if ((ref.read(boosterInventoryProvider)[type] ?? 0) <= 0) {
|
||||
return BoosterUseResult.noBooster;
|
||||
}
|
||||
if (!apply()) return BoosterUseResult.invalidTarget;
|
||||
await inv.consume(type);
|
||||
ref.read(analyticsProvider).boosterUsed(type: type.name);
|
||||
_publish(lastPlacement: null);
|
||||
return BoosterUseResult.success;
|
||||
}
|
||||
|
||||
void _publish({required PlacementResult? lastPlacement}) {
|
||||
final engine = _engine!;
|
||||
state = GameViewState(
|
||||
|
||||
@@ -3,6 +3,8 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import '../data/content_repository.dart';
|
||||
import '../data/save_repository.dart';
|
||||
import '../data/streak.dart';
|
||||
import '../game/daily/daily_reward.dart';
|
||||
import '../game/models/booster.dart';
|
||||
import '../game/models/season.dart';
|
||||
import '../services/ad_service.dart';
|
||||
import '../services/analytics_service.dart';
|
||||
@@ -13,6 +15,8 @@ import '../services/music_service.dart';
|
||||
import '../services/review_service.dart';
|
||||
import '../services/store_reviewer.dart';
|
||||
import 'ads_notifier.dart';
|
||||
import 'booster_inventory_notifier.dart';
|
||||
import 'daily_reward_notifier.dart';
|
||||
import 'endless_best_notifier.dart';
|
||||
import 'music_notifier.dart';
|
||||
import 'sound_notifier.dart';
|
||||
@@ -143,3 +147,16 @@ final activeThemeProvider = Provider<SeasonTheme>((ref) {
|
||||
final flow = ref.watch(seasonFlowProvider);
|
||||
return flow?.pack.theme ?? SeasonTheme.fallback;
|
||||
});
|
||||
|
||||
final boosterInventoryProvider =
|
||||
NotifierProvider<BoosterInventoryNotifier, Map<BoosterType, int>>(
|
||||
BoosterInventoryNotifier.new,
|
||||
);
|
||||
|
||||
/// Injectable clock for the daily calendar (overridden in tests).
|
||||
final dailyNowProvider = Provider<DateTime Function()>((ref) => DateTime.now);
|
||||
|
||||
final dailyRewardProvider =
|
||||
NotifierProvider<DailyRewardNotifier, DailyResolution>(
|
||||
DailyRewardNotifier.new,
|
||||
);
|
||||
|
||||
+181
-15
@@ -5,6 +5,7 @@ import 'package:flutter/services.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../../game/engine/game_engine.dart';
|
||||
import '../../game/models/booster.dart';
|
||||
import '../../game/models/grid.dart';
|
||||
import '../../l10n/gen/app_localizations.dart';
|
||||
import '../../services/audio_service.dart';
|
||||
@@ -14,6 +15,8 @@ import '../theme/palette.dart';
|
||||
import '../widgets/board_geometry.dart';
|
||||
import '../widgets/board_painter.dart';
|
||||
import '../widgets/board_widget.dart';
|
||||
import '../widgets/booster_bar.dart';
|
||||
import '../widgets/booster_hint.dart';
|
||||
import '../widgets/effects_overlay.dart';
|
||||
import '../widgets/hud_widget.dart';
|
||||
import '../widgets/piece_painter.dart';
|
||||
@@ -21,6 +24,9 @@ import '../widgets/season_background.dart';
|
||||
import '../widgets/tray_widget.dart';
|
||||
import '../widgets/tutorial_overlay.dart';
|
||||
|
||||
/// Which line a line-bomb clears, chosen from the tapped cell's row or column.
|
||||
enum _LineAxis { row, col }
|
||||
|
||||
/// Renders whatever session [gameSessionProvider] holds; callers start the
|
||||
/// stage (via SeasonFlowNotifier) before navigating here.
|
||||
class GameScreen extends ConsumerStatefulWidget {
|
||||
@@ -46,6 +52,10 @@ class _GameScreenState extends ConsumerState<GameScreen>
|
||||
int? _dragIndex;
|
||||
Offset? _dragGlobal;
|
||||
|
||||
/// Non-null while a targeted booster is armed and waiting for a board tap.
|
||||
/// Shuffle never sets this (it applies immediately).
|
||||
BoosterType? _arming;
|
||||
|
||||
/// How far the dragged piece floats above the finger so it stays visible.
|
||||
static const double _lift = 70;
|
||||
|
||||
@@ -109,6 +119,115 @@ class _GameScreenState extends ConsumerState<GameScreen>
|
||||
}
|
||||
}
|
||||
|
||||
/// True when a [SaveRepository] is wired up. The default provider throws
|
||||
/// until overridden (in app start and most tests); a couple of legacy widget
|
||||
/// tests mount GameScreen without it, and the booster bar tolerates that.
|
||||
bool _hasSaveRepository() {
|
||||
try {
|
||||
ref.read(saveRepositoryProvider);
|
||||
return true;
|
||||
} catch (_) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Booster targeting ----
|
||||
|
||||
/// Tapping a booster button. Empty → offer a rewarded ad; shuffle applies
|
||||
/// immediately; hammer/line-bomb arm targeting and show a hint.
|
||||
Future<void> _onBoosterTap(BoosterType type) async {
|
||||
if ((ref.read(boosterInventoryProvider)[type] ?? 0) <= 0) {
|
||||
await _offerBoosterAd(type);
|
||||
return;
|
||||
}
|
||||
if (type == BoosterType.shuffle) {
|
||||
await ref.read(gameSessionProvider.notifier).useShuffle();
|
||||
return;
|
||||
}
|
||||
// hammer / lineBomb need a board target; the floating BoosterHint above
|
||||
// the board shows the prompt (and lets the player cancel) while armed.
|
||||
setState(() => _arming = type);
|
||||
}
|
||||
|
||||
/// Converts a board tap into a cell, then applies the armed booster.
|
||||
Future<void> _onBoardTapUp(TapUpDetails details) async {
|
||||
final armed = _arming;
|
||||
if (armed == null) return;
|
||||
final box = _boardBox;
|
||||
if (box == null) return;
|
||||
final local = box.globalToLocal(details.globalPosition);
|
||||
final cell = BoardGeometry(boardSize: box.size.width).cellSize;
|
||||
final x = (local.dx / cell).floor();
|
||||
final y = (local.dy / cell).floor();
|
||||
if (x < 0 || x >= GridState.size || y < 0 || y >= GridState.size) return;
|
||||
|
||||
// Disarm synchronously, before any await, so a rapid second tap on the
|
||||
// board is a no-op rather than a redundant booster use / stacked dialog.
|
||||
setState(() => _arming = null);
|
||||
|
||||
final session = ref.read(gameSessionProvider.notifier);
|
||||
if (armed == BoosterType.hammer) {
|
||||
await session.useHammer(x, y);
|
||||
} else if (armed == BoosterType.lineBomb) {
|
||||
final axis = await _chooseLineAxis();
|
||||
if (axis == _LineAxis.row) {
|
||||
await session.useLineBomb(row: y);
|
||||
} else if (axis == _LineAxis.col) {
|
||||
await session.useLineBomb(col: x);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Small chooser for the line-bomb: clear the tapped row or column.
|
||||
Future<_LineAxis?> _chooseLineAxis() {
|
||||
final l10n = AppLocalizations.of(context)!;
|
||||
return showDialog<_LineAxis>(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
content: Text(l10n.boosterTapLine),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(context).pop(_LineAxis.row),
|
||||
child: Text('↔ ${l10n.boosterLineRow}'),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(context).pop(_LineAxis.col),
|
||||
child: Text('↕ ${l10n.boosterLineCol}'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// Task 15: an empty booster offers a rewarded ad; on reward, grant +1.
|
||||
Future<void> _offerBoosterAd(BoosterType type) async {
|
||||
final l10n = AppLocalizations.of(context)!;
|
||||
final confirmed = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
content: Text(l10n.boosterGetWithAd),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(context).pop(false),
|
||||
child: Text(l10n.giveUp),
|
||||
),
|
||||
FilledButton(
|
||||
onPressed: () => Navigator.of(context).pop(true),
|
||||
child: Text(l10n.boosterGetWithAd),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
if (confirmed != true) return;
|
||||
final earned = await ref.read(adServiceProvider).showRewarded();
|
||||
if (earned) {
|
||||
await ref.read(boosterInventoryProvider.notifier).grant(type, 1);
|
||||
ref
|
||||
.read(analyticsProvider)
|
||||
.boosterGranted(type: type.name, count: 1, source: 'ad');
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_shake.dispose();
|
||||
@@ -247,6 +366,13 @@ class _GameScreenState extends ConsumerState<GameScreen>
|
||||
final draggedTopLeft = _draggedPieceTopLeft(view);
|
||||
final boardBox = _boardBox;
|
||||
|
||||
// The booster bar needs the save-backed inventory. A few legacy widget
|
||||
// tests mount GameScreen without a SaveRepository override; in that case
|
||||
// the inventory provider throws, so only watch it (and mount the bar) when
|
||||
// the repository is actually wired up.
|
||||
final hasSave = _hasSaveRepository();
|
||||
final boosterCounts = hasSave ? ref.watch(boosterInventoryProvider) : null;
|
||||
|
||||
final theme = ref.watch(activeThemeProvider);
|
||||
return Scaffold(
|
||||
backgroundColor: Colors.transparent,
|
||||
@@ -276,22 +402,54 @@ class _GameScreenState extends ConsumerState<GameScreen>
|
||||
),
|
||||
HudWidget(view: view),
|
||||
Expanded(
|
||||
child: Center(
|
||||
child: AnimatedBuilder(
|
||||
animation: _shake,
|
||||
builder: (context, child) {
|
||||
final t = _shake.value;
|
||||
final dx =
|
||||
math.sin(t * math.pi * 10) * 6 * (1 - t);
|
||||
return Transform.translate(
|
||||
offset: Offset(dx, 0), child: child);
|
||||
},
|
||||
child: BoardWidget(
|
||||
key: _boardKey,
|
||||
view: view,
|
||||
ghost: ghost,
|
||||
child: Stack(
|
||||
children: [
|
||||
Positioned.fill(
|
||||
child: Center(
|
||||
child: AnimatedBuilder(
|
||||
animation: _shake,
|
||||
builder: (context, child) {
|
||||
final t = _shake.value;
|
||||
final dx = math.sin(t * math.pi * 10) *
|
||||
6 *
|
||||
(1 - t);
|
||||
return Transform.translate(
|
||||
offset: Offset(dx, 0), child: child);
|
||||
},
|
||||
// While a targeted booster is armed, taps on
|
||||
// the board pick a cell. When not arming,
|
||||
// onTapUp returns immediately so it never
|
||||
// steals the tray-drag placement gestures.
|
||||
child: GestureDetector(
|
||||
behavior: HitTestBehavior.deferToChild,
|
||||
onTapUp: _arming == null
|
||||
? null
|
||||
: (details) => _onBoardTapUp(details),
|
||||
child: BoardWidget(
|
||||
key: _boardKey,
|
||||
view: view,
|
||||
ghost: ghost,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
// Floating targeting prompt, in the empty space above
|
||||
// the centered board so it never covers cells.
|
||||
Positioned(
|
||||
top: 4,
|
||||
left: 0,
|
||||
right: 0,
|
||||
child: Center(
|
||||
child: BoosterHint(
|
||||
arming: _arming,
|
||||
accent: ThemeColors(theme).accent,
|
||||
onCancel: () =>
|
||||
setState(() => _arming = null),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
TrayWidget(
|
||||
@@ -305,6 +463,14 @@ class _GameScreenState extends ConsumerState<GameScreen>
|
||||
setState(() => _dragGlobal = global),
|
||||
onDragEnd: () => _onDragEnd(view),
|
||||
),
|
||||
if (view.phase == GamePhase.playing &&
|
||||
boosterCounts != null) ...[
|
||||
const SizedBox(height: 8),
|
||||
BoosterBar(
|
||||
counts: boosterCounts,
|
||||
onTap: _onBoosterTap,
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
@@ -6,6 +6,7 @@ import '../../game/models/stage.dart';
|
||||
import '../../l10n/gen/app_localizations.dart';
|
||||
import '../../state/providers.dart';
|
||||
import '../widgets/banner_ad_slot.dart';
|
||||
import '../widgets/daily_reward_sheet.dart';
|
||||
import '../widgets/fade_route.dart';
|
||||
import '../widgets/pressable_scale.dart';
|
||||
import '../widgets/season_background.dart';
|
||||
@@ -13,9 +14,14 @@ import 'game_screen.dart';
|
||||
import 'season_map_screen.dart';
|
||||
import 'settings_screen.dart';
|
||||
|
||||
class HomeScreen extends ConsumerWidget {
|
||||
class HomeScreen extends ConsumerStatefulWidget {
|
||||
const HomeScreen({super.key});
|
||||
|
||||
@override
|
||||
ConsumerState<HomeScreen> createState() => _HomeScreenState();
|
||||
}
|
||||
|
||||
class _HomeScreenState extends ConsumerState<HomeScreen> {
|
||||
static const _logoColors = [
|
||||
Color(0xFFFF7EB3),
|
||||
Color(0xFFFFD166),
|
||||
@@ -23,8 +29,52 @@ class HomeScreen extends ConsumerWidget {
|
||||
Color(0xFF7EDB9C),
|
||||
];
|
||||
|
||||
bool _dailyChecked = false;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
void initState() {
|
||||
super.initState();
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) => _maybeShowDailyReward());
|
||||
}
|
||||
|
||||
/// Once per home mount, surface the 7-day login reward if it is claimable
|
||||
/// today. Guards the deliberately-throwing [saveRepositoryProvider] default
|
||||
/// so a repo-less mount (e.g. a widget test) is a no-op rather than a crash.
|
||||
void _maybeShowDailyReward() {
|
||||
if (_dailyChecked || !mounted) return;
|
||||
_dailyChecked = true;
|
||||
try {
|
||||
ref.read(saveRepositoryProvider);
|
||||
} catch (_) {
|
||||
return;
|
||||
}
|
||||
final daily = ref.read(dailyRewardProvider);
|
||||
if (!daily.claimable) return;
|
||||
|
||||
showDialog<void>(
|
||||
context: context,
|
||||
builder: (dialogContext) => Dialog(
|
||||
child: DailyRewardSheet(
|
||||
day: daily.day,
|
||||
onClaim: (doubled) async {
|
||||
final notifier = ref.read(dailyRewardProvider.notifier);
|
||||
if (doubled) {
|
||||
// Watch an ad to double; if no ad was earned the base reward is
|
||||
// still granted so the player is never left empty-handed.
|
||||
final earned = await ref.read(adServiceProvider).showRewarded();
|
||||
await notifier.claim(doubled: earned);
|
||||
} else {
|
||||
await notifier.claim();
|
||||
}
|
||||
if (dialogContext.mounted) Navigator.of(dialogContext).pop();
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final l10n = AppLocalizations.of(context)!;
|
||||
final streak = ref.watch(streakProvider);
|
||||
final best = ref.watch(endlessBestProvider);
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
// lib/ui/widgets/booster_bar.dart
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../../game/models/booster.dart';
|
||||
|
||||
class BoosterBar extends StatelessWidget {
|
||||
const BoosterBar({super.key, required this.counts, required this.onTap});
|
||||
|
||||
final Map<BoosterType, int> counts;
|
||||
final void Function(BoosterType) onTap;
|
||||
|
||||
static const _icons = {
|
||||
BoosterType.hammer: Icons.gavel,
|
||||
BoosterType.shuffle: Icons.shuffle,
|
||||
BoosterType.lineBomb: Icons.clear_all,
|
||||
};
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
|
||||
children: [
|
||||
for (final t in BoosterType.values)
|
||||
_BoosterButton(
|
||||
key: ValueKey('booster_${t.name}'),
|
||||
icon: _icons[t]!,
|
||||
count: counts[t] ?? 0,
|
||||
onTap: () => onTap(t),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _BoosterButton extends StatelessWidget {
|
||||
const _BoosterButton(
|
||||
{super.key, required this.icon, required this.count, required this.onTap});
|
||||
final IconData icon;
|
||||
final int count;
|
||||
final VoidCallback onTap;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return InkWell(
|
||||
onTap: onTap,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(8),
|
||||
child: Column(mainAxisSize: MainAxisSize.min, children: [
|
||||
Icon(icon, size: 28),
|
||||
const SizedBox(height: 2),
|
||||
Text('$count'),
|
||||
]),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
// lib/ui/widgets/booster_hint.dart
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../../game/models/booster.dart';
|
||||
import '../../l10n/gen/app_localizations.dart';
|
||||
|
||||
/// A small floating pill that appears above the board while a targeted booster
|
||||
/// (hammer / line-bomb) is armed, telling the player what to tap. Glows with a
|
||||
/// gentle pulse in the season accent colour, slides/scales in, and cancels the
|
||||
/// armed state when tapped. Renders (invisibly, ignoring pointer) when nothing
|
||||
/// is armed so it can animate in/out in place without layout jumps.
|
||||
class BoosterHint extends StatefulWidget {
|
||||
const BoosterHint({
|
||||
super.key,
|
||||
required this.arming,
|
||||
required this.accent,
|
||||
required this.onCancel,
|
||||
});
|
||||
|
||||
final BoosterType? arming;
|
||||
final Color accent;
|
||||
final VoidCallback onCancel;
|
||||
|
||||
@override
|
||||
State<BoosterHint> createState() => _BoosterHintState();
|
||||
}
|
||||
|
||||
class _BoosterHintState extends State<BoosterHint>
|
||||
with SingleTickerProviderStateMixin {
|
||||
late final AnimationController _pulse = AnimationController(
|
||||
vsync: this,
|
||||
duration: const Duration(milliseconds: 1100),
|
||||
);
|
||||
|
||||
static const _icons = {
|
||||
BoosterType.hammer: Icons.gavel,
|
||||
BoosterType.shuffle: Icons.shuffle,
|
||||
BoosterType.lineBomb: Icons.clear_all,
|
||||
};
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_syncPulse();
|
||||
}
|
||||
|
||||
@override
|
||||
void didUpdateWidget(BoosterHint oldWidget) {
|
||||
super.didUpdateWidget(oldWidget);
|
||||
_syncPulse();
|
||||
}
|
||||
|
||||
/// Pulse only while a booster is armed — idle (no live ticker) otherwise, so
|
||||
/// it never spins during normal play or pins a timer open in widget tests.
|
||||
void _syncPulse() {
|
||||
if (widget.arming != null) {
|
||||
if (!_pulse.isAnimating) _pulse.repeat(reverse: true);
|
||||
} else if (_pulse.isAnimating) {
|
||||
_pulse
|
||||
..stop()
|
||||
..value = 0;
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_pulse.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final arming = widget.arming;
|
||||
final visible = arming != null;
|
||||
final l10n = AppLocalizations.of(context)!;
|
||||
final label = arming == BoosterType.lineBomb
|
||||
? l10n.boosterTapLine
|
||||
: l10n.boosterTapTarget;
|
||||
final accent = widget.accent;
|
||||
|
||||
return IgnorePointer(
|
||||
ignoring: !visible,
|
||||
child: AnimatedOpacity(
|
||||
opacity: visible ? 1 : 0,
|
||||
duration: const Duration(milliseconds: 160),
|
||||
child: AnimatedSlide(
|
||||
offset: visible ? Offset.zero : const Offset(0, -0.4),
|
||||
duration: const Duration(milliseconds: 220),
|
||||
curve: Curves.easeOutBack,
|
||||
child: GestureDetector(
|
||||
onTap: widget.onCancel,
|
||||
child: AnimatedBuilder(
|
||||
animation: _pulse,
|
||||
builder: (context, child) {
|
||||
final t = _pulse.value; // 0..1, breathing
|
||||
return Container(
|
||||
decoration: BoxDecoration(
|
||||
color: accent,
|
||||
borderRadius: BorderRadius.circular(999),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: accent.withValues(alpha: 0.22 + 0.28 * t),
|
||||
blurRadius: 10 + 12 * t,
|
||||
spreadRadius: 1 + 3 * t,
|
||||
),
|
||||
],
|
||||
),
|
||||
child: child,
|
||||
);
|
||||
},
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(7, 6, 12, 6),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Container(
|
||||
width: 26,
|
||||
height: 26,
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white.withValues(alpha: 0.22),
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
child: Icon(
|
||||
_icons[arming ?? BoosterType.hammer],
|
||||
size: 16,
|
||||
color: Colors.white,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
label,
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 13,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
const Icon(Icons.close, size: 16, color: Colors.white70),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
// lib/ui/widgets/daily_reward_sheet.dart
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../../game/daily/daily_reward.dart';
|
||||
import '../../game/models/booster.dart';
|
||||
import '../../l10n/gen/app_localizations.dart';
|
||||
|
||||
/// Presentational 7-day login calendar. [day] is today's calendar position
|
||||
/// (1..7); past days read as claimed, the future stays locked. [onClaim] fires
|
||||
/// with `false` for a plain claim and `true` for the watch-an-ad "2×" claim.
|
||||
class DailyRewardSheet extends StatelessWidget {
|
||||
const DailyRewardSheet({super.key, required this.day, required this.onClaim});
|
||||
|
||||
final int day;
|
||||
final void Function(bool doubled) onClaim;
|
||||
|
||||
static const _cal = DailyRewardCalendar();
|
||||
static const _icons = {
|
||||
BoosterType.hammer: Icons.gavel,
|
||||
BoosterType.shuffle: Icons.shuffle,
|
||||
BoosterType.lineBomb: Icons.clear_all,
|
||||
};
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final l10n = AppLocalizations.of(context)!;
|
||||
return Padding(
|
||||
padding: const EdgeInsets.all(20),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(
|
||||
l10n.dailyRewardTitle,
|
||||
style: Theme.of(context).textTheme.titleLarge,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Wrap(
|
||||
spacing: 8,
|
||||
runSpacing: 8,
|
||||
alignment: WrapAlignment.center,
|
||||
children: [
|
||||
for (var d = 1; d <= DailyRewardCalendar.cycle; d++)
|
||||
_DayCell(
|
||||
key: ValueKey('daily_day_$d'),
|
||||
day: d,
|
||||
reward: _cal.rewardFor(d),
|
||||
past: d < day,
|
||||
today: d == day,
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
FilledButton(
|
||||
key: const ValueKey('daily_claim'),
|
||||
onPressed: () => onClaim(false),
|
||||
child: Text(l10n.dailyClaim),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
TextButton(
|
||||
key: const ValueKey('daily_double'),
|
||||
onPressed: () => onClaim(true),
|
||||
child: Text(l10n.dailyDoubleWithAd),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _DayCell extends StatelessWidget {
|
||||
const _DayCell({
|
||||
super.key,
|
||||
required this.day,
|
||||
required this.reward,
|
||||
required this.past,
|
||||
required this.today,
|
||||
});
|
||||
|
||||
final int day;
|
||||
final Map<BoosterType, int> reward;
|
||||
final bool past;
|
||||
final bool today;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final scheme = Theme.of(context).colorScheme;
|
||||
return Opacity(
|
||||
opacity: past ? 0.45 : 1,
|
||||
child: Container(
|
||||
width: 66,
|
||||
padding: const EdgeInsets.symmetric(vertical: 8, horizontal: 4),
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
color: today
|
||||
? scheme.primary.withValues(alpha: 0.25)
|
||||
: Colors.white.withValues(alpha: 0.06),
|
||||
border: today
|
||||
? Border.all(color: scheme.primary, width: 2)
|
||||
: null,
|
||||
),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text('$day', style: Theme.of(context).textTheme.labelMedium),
|
||||
const SizedBox(height: 4),
|
||||
Wrap(
|
||||
spacing: 2,
|
||||
runSpacing: 2,
|
||||
alignment: WrapAlignment.center,
|
||||
children: [
|
||||
for (final entry in reward.entries)
|
||||
_RewardIcon(
|
||||
icon: DailyRewardSheet._icons[entry.key]!,
|
||||
count: entry.value,
|
||||
),
|
||||
],
|
||||
),
|
||||
if (past)
|
||||
const Padding(
|
||||
padding: EdgeInsets.only(top: 2),
|
||||
child: Icon(Icons.check_circle,
|
||||
size: 14, color: Colors.greenAccent),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _RewardIcon extends StatelessWidget {
|
||||
const _RewardIcon({required this.icon, required this.count});
|
||||
|
||||
final IconData icon;
|
||||
final int count;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(icon, size: 14),
|
||||
Text('$count', style: const TextStyle(fontSize: 11)),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
+1
-1
@@ -16,7 +16,7 @@ publish_to: 'none' # Remove this line if you wish to publish to pub.dev
|
||||
# https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html
|
||||
# In Windows, build-name is used as the major, minor, and patch parts
|
||||
# of the product and file versions while build-number is used as the build suffix.
|
||||
version: 1.0.0+3
|
||||
version: 1.1.0+4
|
||||
|
||||
environment:
|
||||
sdk: ^3.9.2
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
import 'package:block_seasons/data/save_repository.dart';
|
||||
import 'package:block_seasons/game/models/booster.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
void main() {
|
||||
TestWidgetsFlutterBinding.ensureInitialized();
|
||||
|
||||
Future<SaveRepository> fresh() async {
|
||||
SharedPreferences.setMockInitialValues({});
|
||||
return SaveRepository(await SharedPreferences.getInstance());
|
||||
}
|
||||
|
||||
test('booster counts start at zero', () async {
|
||||
final repo = await fresh();
|
||||
for (final t in BoosterType.values) {
|
||||
expect(repo.boosterCount(t), 0);
|
||||
}
|
||||
});
|
||||
|
||||
test('grantBooster adds and persists across reload', () async {
|
||||
final repo = await fresh();
|
||||
await repo.grantBooster(BoosterType.hammer, 2);
|
||||
expect(repo.boosterCount(BoosterType.hammer), 2);
|
||||
|
||||
final reloaded = SaveRepository(await SharedPreferences.getInstance());
|
||||
expect(reloaded.boosterCount(BoosterType.hammer), 2);
|
||||
});
|
||||
|
||||
test('consumeBooster decrements and returns false at zero', () async {
|
||||
final repo = await fresh();
|
||||
await repo.grantBooster(BoosterType.shuffle, 1);
|
||||
expect(await repo.consumeBooster(BoosterType.shuffle), isTrue);
|
||||
expect(repo.boosterCount(BoosterType.shuffle), 0);
|
||||
expect(await repo.consumeBooster(BoosterType.shuffle), isFalse);
|
||||
});
|
||||
|
||||
test('seedInitialBoosters grants 1 of each once, then is idempotent',
|
||||
() async {
|
||||
final repo = await fresh();
|
||||
await repo.seedInitialBoostersIfNeeded();
|
||||
for (final t in BoosterType.values) {
|
||||
expect(repo.boosterCount(t), 1, reason: t.name);
|
||||
}
|
||||
|
||||
// A second call (and a reload) must not grant again — the flag persists.
|
||||
await repo.seedInitialBoostersIfNeeded();
|
||||
final reloaded = SaveRepository(await SharedPreferences.getInstance());
|
||||
await reloaded.seedInitialBoostersIfNeeded();
|
||||
for (final t in BoosterType.values) {
|
||||
expect(reloaded.boosterCount(t), 1, reason: '${t.name} after reload');
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
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> fresh() async {
|
||||
SharedPreferences.setMockInitialValues({});
|
||||
return SaveRepository(await SharedPreferences.getInstance());
|
||||
}
|
||||
|
||||
test('daily claim fields start empty', () async {
|
||||
final repo = await fresh();
|
||||
expect(repo.dailyLastClaimedYmd, isNull);
|
||||
expect(repo.dailyCalendarDay, 0);
|
||||
});
|
||||
|
||||
test('recordDailyClaim persists day and ymd across reload', () async {
|
||||
final repo = await fresh();
|
||||
await repo.recordDailyClaim('2026-06-18', 3);
|
||||
expect(repo.dailyLastClaimedYmd, '2026-06-18');
|
||||
expect(repo.dailyCalendarDay, 3);
|
||||
|
||||
final reloaded = SaveRepository(await SharedPreferences.getInstance());
|
||||
expect(reloaded.dailyLastClaimedYmd, '2026-06-18');
|
||||
expect(reloaded.dailyCalendarDay, 3);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
import 'package:block_seasons/game/daily/daily_reward.dart';
|
||||
import 'package:block_seasons/game/models/booster.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
void main() {
|
||||
final cal = DailyRewardCalendar();
|
||||
|
||||
DateTime d(int day) => DateTime(2026, 6, day);
|
||||
|
||||
test('first ever claim is day 1', () {
|
||||
final r = cal.resolve(lastClaimedYmd: null, storedDay: 0, today: d(18));
|
||||
expect(r.claimable, isTrue);
|
||||
expect(r.day, 1);
|
||||
});
|
||||
|
||||
test('claiming again the same day is not claimable', () {
|
||||
final r = cal.resolve(
|
||||
lastClaimedYmd: '2026-06-18', storedDay: 1, today: d(18));
|
||||
expect(r.claimable, isFalse);
|
||||
});
|
||||
|
||||
test('a consecutive day advances the calendar', () {
|
||||
final r = cal.resolve(
|
||||
lastClaimedYmd: '2026-06-18', storedDay: 3, today: d(19));
|
||||
expect(r.claimable, isTrue);
|
||||
expect(r.day, 4);
|
||||
});
|
||||
|
||||
test('day 7 wraps back to day 1', () {
|
||||
final r = cal.resolve(
|
||||
lastClaimedYmd: '2026-06-18', storedDay: 7, today: d(19));
|
||||
expect(r.day, 1);
|
||||
});
|
||||
|
||||
test('a missed day resets to day 1', () {
|
||||
final r = cal.resolve(
|
||||
lastClaimedYmd: '2026-06-18', storedDay: 4, today: d(20));
|
||||
expect(r.claimable, isTrue);
|
||||
expect(r.day, 1);
|
||||
});
|
||||
|
||||
test('reward table covers days 1..7 and day 7 is the jackpot', () {
|
||||
expect(cal.rewardFor(1), {BoosterType.hammer: 1});
|
||||
expect(cal.rewardFor(7),
|
||||
{BoosterType.hammer: 2, BoosterType.shuffle: 2, BoosterType.lineBomb: 2});
|
||||
});
|
||||
|
||||
test('ymd formats a date as YYYY-MM-DD', () {
|
||||
expect(cal.ymd(DateTime(2026, 6, 7)), '2026-06-07');
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,193 @@
|
||||
import 'package:block_seasons/game/engine/game_engine.dart';
|
||||
import 'package:block_seasons/game/models/stage.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
StageConfig _stage() => StageConfig.fromJson({
|
||||
'id': 'b_test',
|
||||
'seed': 1,
|
||||
'moveLimit': 20,
|
||||
'preset': [
|
||||
{'x': 0, 'y': 0, 't': 'filled', 'c': 3},
|
||||
{'x': 1, 'y': 0, 't': 'filled', 'c': 4},
|
||||
],
|
||||
'objectives': [
|
||||
{'type': 'reachScore', 'target': 100000},
|
||||
],
|
||||
'stars': {
|
||||
'two': {'movesLeft': 5},
|
||||
'three': {'movesLeft': 10},
|
||||
},
|
||||
});
|
||||
|
||||
const _stars = {
|
||||
'two': {'movesLeft': 5},
|
||||
'three': {'movesLeft': 10},
|
||||
};
|
||||
|
||||
// Two gems plus one plain filled cell; objective is to clear both gems.
|
||||
StageConfig _gemStage() => StageConfig.fromJson({
|
||||
'id': 'b_gem',
|
||||
'seed': 1,
|
||||
'moveLimit': 20,
|
||||
'preset': [
|
||||
{'x': 0, 'y': 0, 't': 'gem'},
|
||||
{'x': 3, 'y': 3, 't': 'gem'},
|
||||
{'x': 5, 'y': 5, 't': 'filled', 'c': 2},
|
||||
],
|
||||
'objectives': [
|
||||
{'type': 'clearGems', 'count': 2},
|
||||
],
|
||||
'stars': _stars,
|
||||
});
|
||||
|
||||
// Two gems sitting on row 2; objective is to clear both gems.
|
||||
StageConfig _gemRowStage() => StageConfig.fromJson({
|
||||
'id': 'b_gemrow',
|
||||
'seed': 1,
|
||||
'moveLimit': 20,
|
||||
'preset': [
|
||||
{'x': 1, 'y': 2, 't': 'gem'},
|
||||
{'x': 4, 'y': 2, 't': 'gem'},
|
||||
],
|
||||
'objectives': [
|
||||
{'type': 'clearGems', 'count': 2},
|
||||
],
|
||||
'stars': _stars,
|
||||
});
|
||||
|
||||
// Objective is to clear one line.
|
||||
StageConfig _lineStage() => StageConfig.fromJson({
|
||||
'id': 'b_line',
|
||||
'seed': 1,
|
||||
'moveLimit': 20,
|
||||
'preset': [
|
||||
{'x': 0, 'y': 0, 't': 'filled', 'c': 1},
|
||||
],
|
||||
'objectives': [
|
||||
{'type': 'clearLines', 'count': 1},
|
||||
],
|
||||
'stars': _stars,
|
||||
});
|
||||
|
||||
void main() {
|
||||
test('useHammer empties a filled cell without scoring or spending a move', () {
|
||||
final e = GameEngine(_stage());
|
||||
final score0 = e.score;
|
||||
final moves0 = e.movesUsed;
|
||||
|
||||
expect(e.grid.isOccupied(0, 0), isTrue);
|
||||
final ok = e.useHammer(0, 0);
|
||||
|
||||
expect(ok, isTrue);
|
||||
expect(e.grid.isOccupied(0, 0), isFalse);
|
||||
expect(e.score, score0, reason: 'no points from a booster');
|
||||
expect(e.movesUsed, moves0, reason: 'no move spent');
|
||||
});
|
||||
|
||||
test('useHammer on an empty cell returns false and changes nothing', () {
|
||||
final e = GameEngine(_stage());
|
||||
expect(e.grid.isOccupied(5, 5), isFalse);
|
||||
expect(e.useHammer(5, 5), isFalse);
|
||||
expect(e.grid.isOccupied(5, 5), isFalse);
|
||||
});
|
||||
|
||||
test('useHammer is rejected once the stage is won or lost', () {
|
||||
final e = GameEngine(_stage());
|
||||
e.declineAndLose();
|
||||
expect(e.useHammer(0, 0), isFalse);
|
||||
});
|
||||
|
||||
test('useShuffle replaces the tray without spending a move or scoring', () {
|
||||
final e = GameEngine(_stage());
|
||||
final before = List.of(e.tray);
|
||||
final score0 = e.score;
|
||||
final moves0 = e.movesUsed;
|
||||
|
||||
final ok = e.useShuffle();
|
||||
|
||||
expect(ok, isTrue);
|
||||
expect(e.tray.length, before.length);
|
||||
expect(e.score, score0);
|
||||
expect(e.movesUsed, moves0);
|
||||
});
|
||||
|
||||
test('useShuffle is rejected after the attempt finishes', () {
|
||||
final e = GameEngine(_stage());
|
||||
e.declineAndLose();
|
||||
expect(e.useShuffle(), isFalse);
|
||||
});
|
||||
|
||||
test('useLineBomb(row:) empties that row, no scoring or move', () {
|
||||
final e = GameEngine(_stage()); // row 0 has cells at x=0,1
|
||||
final score0 = e.score;
|
||||
final moves0 = e.movesUsed;
|
||||
|
||||
final ok = e.useLineBomb(row: 0);
|
||||
|
||||
expect(ok, isTrue);
|
||||
for (var x = 0; x < 8; x++) {
|
||||
expect(e.grid.isOccupied(x, 0), isFalse, reason: 'col $x');
|
||||
}
|
||||
expect(e.score, score0);
|
||||
expect(e.movesUsed, moves0);
|
||||
});
|
||||
|
||||
test('useLineBomb(col:) empties that column', () {
|
||||
final e = GameEngine(_stage()); // (0,0) filled
|
||||
expect(e.useLineBomb(col: 0), isTrue);
|
||||
for (var y = 0; y < 8; y++) {
|
||||
expect(e.grid.isOccupied(0, y), isFalse, reason: 'row $y');
|
||||
}
|
||||
});
|
||||
|
||||
test('useLineBomb requires exactly one of row/col', () {
|
||||
final e = GameEngine(_stage());
|
||||
expect(e.useLineBomb(), isFalse);
|
||||
expect(e.useLineBomb(row: 0, col: 0), isFalse);
|
||||
});
|
||||
|
||||
test('useLineBomb is rejected after the attempt finishes', () {
|
||||
final e = GameEngine(_stage());
|
||||
e.declineAndLose();
|
||||
expect(e.useLineBomb(row: 0), isFalse);
|
||||
});
|
||||
|
||||
// --- Boosters count toward objectives (owner decision 2026-06-18) ---
|
||||
|
||||
test('useHammer on a gem counts toward the gem objective and wins the stage '
|
||||
'when it clears the last one', () {
|
||||
final e = GameEngine(_gemStage()); // 2 gems, objective clearGems(2)
|
||||
expect(e.objectives.first.current, 0);
|
||||
|
||||
expect(e.useHammer(0, 0), isTrue); // first gem
|
||||
expect(e.objectives.first.current, 1);
|
||||
expect(e.phase, GamePhase.playing);
|
||||
|
||||
expect(e.useHammer(3, 3), isTrue); // last gem -> objective complete
|
||||
expect(e.objectives.first.current, 2);
|
||||
expect(e.phase, GamePhase.won, reason: 'clearing the last gem wins');
|
||||
});
|
||||
|
||||
test('useHammer on a plain filled cell does not change the gem objective', () {
|
||||
final e = GameEngine(_gemStage());
|
||||
expect(e.useHammer(5, 5), isTrue); // a non-gem filled cell
|
||||
expect(e.objectives.first.current, 0);
|
||||
expect(e.phase, GamePhase.playing);
|
||||
});
|
||||
|
||||
test('useLineBomb counts the gems in the cleared line toward the objective',
|
||||
() {
|
||||
final e = GameEngine(_gemRowStage()); // 2 gems on row 2, clearGems(2)
|
||||
expect(e.useLineBomb(row: 2), isTrue);
|
||||
expect(e.objectives.first.current, 2);
|
||||
expect(e.phase, GamePhase.won);
|
||||
});
|
||||
|
||||
test('useLineBomb counts as a cleared line toward the line objective', () {
|
||||
final e = GameEngine(_lineStage()); // clearLines(1)
|
||||
expect(e.objectives.first.current, 0);
|
||||
expect(e.useLineBomb(row: 0), isTrue);
|
||||
expect(e.objectives.first.current, 1);
|
||||
expect(e.phase, GamePhase.won);
|
||||
});
|
||||
}
|
||||
@@ -47,4 +47,23 @@ void main() {
|
||||
});
|
||||
expect(backend.events[3].$2, {'score': 500, 'new_best': 1});
|
||||
});
|
||||
|
||||
test('booster + daily events carry their fields', () {
|
||||
final backend = _RecordingBackend();
|
||||
final a = AnalyticsService(backend);
|
||||
|
||||
a.boosterUsed(type: 'hammer');
|
||||
a.boosterGranted(type: 'hammer', count: 2, source: 'daily');
|
||||
a.dailyRewardClaimed(day: 7, doubled: true);
|
||||
|
||||
expect(backend.events.map((e) => e.$1).toList(), [
|
||||
'booster_used',
|
||||
'booster_granted',
|
||||
'daily_reward_claimed',
|
||||
]);
|
||||
expect(backend.events[0].$2, {'type': 'hammer'});
|
||||
expect(backend.events[1].$2,
|
||||
{'type': 'hammer', 'count': 2, 'source': 'daily'});
|
||||
expect(backend.events[2].$2, {'day': 7, 'doubled': 1});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
import 'package:block_seasons/data/save_repository.dart';
|
||||
import 'package:block_seasons/game/models/booster.dart';
|
||||
import 'package:block_seasons/state/providers.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
void main() {
|
||||
TestWidgetsFlutterBinding.ensureInitialized();
|
||||
|
||||
Future<ProviderContainer> container() async {
|
||||
SharedPreferences.setMockInitialValues({});
|
||||
final repo = SaveRepository(await SharedPreferences.getInstance());
|
||||
return ProviderContainer(
|
||||
overrides: [saveRepositoryProvider.overrideWithValue(repo)],
|
||||
);
|
||||
}
|
||||
|
||||
test('exposes counts and updates on grant/consume', () async {
|
||||
final c = await container();
|
||||
final notifier = c.read(boosterInventoryProvider.notifier);
|
||||
|
||||
expect(c.read(boosterInventoryProvider)[BoosterType.hammer], 0);
|
||||
|
||||
await notifier.grant(BoosterType.hammer, 2);
|
||||
expect(c.read(boosterInventoryProvider)[BoosterType.hammer], 2);
|
||||
|
||||
expect(await notifier.consume(BoosterType.hammer), isTrue);
|
||||
expect(c.read(boosterInventoryProvider)[BoosterType.hammer], 1);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import 'package:block_seasons/data/save_repository.dart';
|
||||
import 'package:block_seasons/game/models/booster.dart';
|
||||
import 'package:block_seasons/state/providers.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
void main() {
|
||||
TestWidgetsFlutterBinding.ensureInitialized();
|
||||
|
||||
Future<ProviderContainer> container(DateTime today) async {
|
||||
SharedPreferences.setMockInitialValues({});
|
||||
final repo = SaveRepository(await SharedPreferences.getInstance());
|
||||
return ProviderContainer(overrides: [
|
||||
saveRepositoryProvider.overrideWithValue(repo),
|
||||
dailyNowProvider.overrideWithValue(() => today),
|
||||
]);
|
||||
}
|
||||
|
||||
test('first day is claimable as day 1 and claim grants the reward', () async {
|
||||
final c = await container(DateTime(2026, 6, 18));
|
||||
expect(c.read(dailyRewardProvider).claimable, isTrue);
|
||||
expect(c.read(dailyRewardProvider).day, 1);
|
||||
|
||||
await c.read(dailyRewardProvider.notifier).claim();
|
||||
expect(c.read(boosterInventoryProvider)[BoosterType.hammer], 1); // day 1
|
||||
expect(c.read(dailyRewardProvider).claimable, isFalse);
|
||||
});
|
||||
|
||||
test('doubled claim grants twice the reward', () async {
|
||||
final c = await container(DateTime(2026, 6, 18));
|
||||
await c.read(dailyRewardProvider.notifier).claim(doubled: true);
|
||||
expect(c.read(boosterInventoryProvider)[BoosterType.hammer], 2);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
import 'package:block_seasons/data/save_repository.dart';
|
||||
import 'package:block_seasons/game/engine/piece_generator.dart';
|
||||
import 'package:block_seasons/core/rng.dart';
|
||||
import 'package:block_seasons/game/models/booster.dart';
|
||||
import 'package:block_seasons/game/models/stage.dart';
|
||||
import 'package:block_seasons/state/game_session_notifier.dart';
|
||||
import 'package:block_seasons/state/providers.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
StageConfig _stage() => StageConfig.fromJson({
|
||||
'id': 'gs_b',
|
||||
'seed': 1,
|
||||
'moveLimit': 20,
|
||||
'preset': [
|
||||
{'x': 0, 'y': 0, 't': 'filled', 'c': 3},
|
||||
],
|
||||
'objectives': [
|
||||
{'type': 'reachScore', 'target': 100000},
|
||||
],
|
||||
'stars': {
|
||||
'two': {'movesLeft': 5},
|
||||
'three': {'movesLeft': 10},
|
||||
},
|
||||
});
|
||||
|
||||
void main() {
|
||||
TestWidgetsFlutterBinding.ensureInitialized();
|
||||
|
||||
Future<ProviderContainer> container() async {
|
||||
SharedPreferences.setMockInitialValues({});
|
||||
final repo = SaveRepository(await SharedPreferences.getInstance());
|
||||
final c = ProviderContainer(
|
||||
overrides: [saveRepositoryProvider.overrideWithValue(repo)],
|
||||
);
|
||||
c.read(gameSessionProvider.notifier)
|
||||
.startStage(_stage(), generator: PieceGenerator(SeededRng(1)));
|
||||
return c;
|
||||
}
|
||||
|
||||
test('hammer consumes a booster only when one is owned and target valid',
|
||||
() async {
|
||||
final c = await container();
|
||||
final session = c.read(gameSessionProvider.notifier);
|
||||
final inv = c.read(boosterInventoryProvider.notifier);
|
||||
|
||||
// No booster -> noBooster, grid unchanged.
|
||||
expect(await session.useHammer(0, 0), BoosterUseResult.noBooster);
|
||||
|
||||
await inv.grant(BoosterType.hammer, 1);
|
||||
expect(await session.useHammer(0, 0), BoosterUseResult.success);
|
||||
expect(c.read(boosterInventoryProvider)[BoosterType.hammer], 0);
|
||||
expect(c.read(gameSessionProvider)!.grid.isOccupied(0, 0), isFalse);
|
||||
});
|
||||
|
||||
test('an invalid target keeps the booster', () async {
|
||||
final c = await container();
|
||||
final session = c.read(gameSessionProvider.notifier);
|
||||
await c.read(boosterInventoryProvider.notifier).grant(BoosterType.hammer, 1);
|
||||
|
||||
expect(await session.useHammer(5, 5), BoosterUseResult.invalidTarget);
|
||||
expect(c.read(boosterInventoryProvider)[BoosterType.hammer], 1);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import 'package:block_seasons/game/models/booster.dart';
|
||||
import 'package:block_seasons/ui/widgets/booster_bar.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
void main() {
|
||||
testWidgets('renders three boosters with their counts', (tester) async {
|
||||
BoosterType? tapped;
|
||||
await tester.pumpWidget(MaterialApp(
|
||||
home: Scaffold(
|
||||
body: BoosterBar(
|
||||
counts: const {
|
||||
BoosterType.hammer: 3,
|
||||
BoosterType.shuffle: 0,
|
||||
BoosterType.lineBomb: 1,
|
||||
},
|
||||
onTap: (t) => tapped = t,
|
||||
),
|
||||
),
|
||||
));
|
||||
|
||||
expect(find.text('3'), findsOneWidget);
|
||||
expect(find.text('1'), findsOneWidget);
|
||||
await tester.tap(find.byKey(const ValueKey('booster_hammer')));
|
||||
expect(tapped, BoosterType.hammer);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
import 'package:block_seasons/game/models/booster.dart';
|
||||
import 'package:block_seasons/l10n/gen/app_localizations.dart';
|
||||
import 'package:block_seasons/ui/widgets/booster_hint.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
void main() {
|
||||
Widget wrap(Widget child) => MaterialApp(
|
||||
locale: const Locale('en'),
|
||||
localizationsDelegates: AppLocalizations.localizationsDelegates,
|
||||
supportedLocales: AppLocalizations.supportedLocales,
|
||||
home: Scaffold(body: Center(child: child)),
|
||||
);
|
||||
|
||||
testWidgets('shows the cell hint for hammer and cancels on tap',
|
||||
(tester) async {
|
||||
var cancelled = false;
|
||||
await tester.pumpWidget(wrap(BoosterHint(
|
||||
arming: BoosterType.hammer,
|
||||
accent: const Color(0xFF5B7FFF),
|
||||
onCancel: () => cancelled = true,
|
||||
)));
|
||||
await tester.pump(const Duration(milliseconds: 250)); // settle entrance
|
||||
|
||||
expect(find.text('Tap a cell'), findsOneWidget);
|
||||
|
||||
await tester.tap(find.byType(BoosterHint));
|
||||
expect(cancelled, isTrue);
|
||||
});
|
||||
|
||||
testWidgets('shows the line hint for the line bomb', (tester) async {
|
||||
await tester.pumpWidget(wrap(BoosterHint(
|
||||
arming: BoosterType.lineBomb,
|
||||
accent: const Color(0xFF5B7FFF),
|
||||
onCancel: () {},
|
||||
)));
|
||||
await tester.pump(const Duration(milliseconds: 250));
|
||||
|
||||
expect(find.text('Tap a row or column'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('ignores taps when nothing is armed', (tester) async {
|
||||
var cancelled = false;
|
||||
await tester.pumpWidget(wrap(BoosterHint(
|
||||
arming: null,
|
||||
accent: const Color(0xFF5B7FFF),
|
||||
onCancel: () => cancelled = true,
|
||||
)));
|
||||
await tester.pump(const Duration(milliseconds: 250));
|
||||
|
||||
await tester.tap(find.byType(BoosterHint), warnIfMissed: false);
|
||||
expect(cancelled, isFalse);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import 'package:block_seasons/l10n/gen/app_localizations.dart';
|
||||
import 'package:block_seasons/ui/widgets/daily_reward_sheet.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
void main() {
|
||||
Widget wrap(Widget child) => MaterialApp(
|
||||
locale: const Locale('en'),
|
||||
localizationsDelegates: AppLocalizations.localizationsDelegates,
|
||||
supportedLocales: AppLocalizations.supportedLocales,
|
||||
home: Scaffold(body: Center(child: child)),
|
||||
);
|
||||
|
||||
testWidgets('renders 7 day cells with today highlighted', (tester) async {
|
||||
await tester.pumpWidget(wrap(
|
||||
DailyRewardSheet(day: 3, onClaim: (_) {}),
|
||||
));
|
||||
|
||||
for (var d = 1; d <= 7; d++) {
|
||||
expect(find.byKey(ValueKey('daily_day_$d')), findsOneWidget,
|
||||
reason: 'day $d cell');
|
||||
}
|
||||
});
|
||||
|
||||
testWidgets('claim and 2x buttons fire onClaim with the right flag',
|
||||
(tester) async {
|
||||
final claims = <bool>[];
|
||||
await tester.pumpWidget(wrap(
|
||||
DailyRewardSheet(day: 1, onClaim: claims.add),
|
||||
));
|
||||
|
||||
await tester.tap(find.byKey(const ValueKey('daily_claim')));
|
||||
expect(claims, [false]);
|
||||
|
||||
await tester.tap(find.byKey(const ValueKey('daily_double')));
|
||||
expect(claims, [false, true]);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
// Widget tests for the booster bar mounted in the game screen.
|
||||
//
|
||||
// Board-geometry taps (hammer / line-bomb cell selection) are NOT covered here:
|
||||
// the board is laid out inside an Expanded/Center whose pixel geometry is not
|
||||
// deterministic in a widget test, so a pixel-accurate cell tap is unreliable.
|
||||
// Those paths are left to manual QA (see the agent report). What we CAN verify
|
||||
// deterministically is the inventory side-effects:
|
||||
// - shuffle applies immediately and spends one booster
|
||||
// - tapping an empty booster shows the ad dialog whose confirm grants +1
|
||||
import 'package:block_seasons/core/rng.dart';
|
||||
import 'package:block_seasons/data/save_repository.dart';
|
||||
import 'package:block_seasons/game/engine/piece_generator.dart';
|
||||
import 'package:block_seasons/game/models/booster.dart';
|
||||
import 'package:block_seasons/game/models/stage.dart';
|
||||
import 'package:block_seasons/l10n/gen/app_localizations.dart';
|
||||
import 'package:block_seasons/services/ad_service.dart';
|
||||
import 'package:block_seasons/state/providers.dart';
|
||||
import 'package:block_seasons/ui/screens/game_screen.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
StageConfig _stage() => StageConfig.fromJson({
|
||||
'id': 'ui_b',
|
||||
'seed': 1,
|
||||
'moveLimit': 20,
|
||||
'preset': [
|
||||
{'x': 0, 'y': 0, 't': 'filled', 'c': 3},
|
||||
],
|
||||
'objectives': [
|
||||
{'type': 'reachScore', 'target': 100000},
|
||||
],
|
||||
'stars': {
|
||||
'two': {'movesLeft': 5},
|
||||
'three': {'movesLeft': 10},
|
||||
},
|
||||
});
|
||||
|
||||
/// A real, uninitialized [AdService]: with no SDK loaded its [showRewarded]
|
||||
/// takes the "no ad available -> grant the reward" path and resolves true
|
||||
/// without touching the platform — exactly the rewarded-earn case we want.
|
||||
AdService _earnAd() => AdService(adsRemoved: () => true);
|
||||
|
||||
Widget _wrap(ProviderContainer c) => UncontrolledProviderScope(
|
||||
container: c,
|
||||
child: MaterialApp(
|
||||
debugShowCheckedModeBanner: false,
|
||||
locale: const Locale('en'),
|
||||
localizationsDelegates: AppLocalizations.localizationsDelegates,
|
||||
supportedLocales: AppLocalizations.supportedLocales,
|
||||
home: const GameScreen(),
|
||||
),
|
||||
);
|
||||
|
||||
void main() {
|
||||
TestWidgetsFlutterBinding.ensureInitialized();
|
||||
|
||||
Future<ProviderContainer> startedContainer({AdService? ad}) async {
|
||||
SharedPreferences.setMockInitialValues({});
|
||||
final repo = SaveRepository(await SharedPreferences.getInstance());
|
||||
final c = ProviderContainer(overrides: [
|
||||
saveRepositoryProvider.overrideWithValue(repo),
|
||||
if (ad != null) adServiceProvider.overrideWithValue(ad),
|
||||
]);
|
||||
c.read(gameSessionProvider.notifier).startStage(
|
||||
_stage(),
|
||||
generator: PieceGenerator(SeededRng(1)),
|
||||
);
|
||||
return c;
|
||||
}
|
||||
|
||||
testWidgets('tapping shuffle applies immediately and spends one booster',
|
||||
(tester) async {
|
||||
final c = await startedContainer();
|
||||
await c.read(boosterInventoryProvider.notifier).grant(BoosterType.shuffle);
|
||||
expect(c.read(boosterInventoryProvider)[BoosterType.shuffle], 1);
|
||||
|
||||
await tester.pumpWidget(_wrap(c));
|
||||
await tester.pump();
|
||||
|
||||
await tester.tap(find.byKey(const ValueKey('booster_shuffle')));
|
||||
await tester.pump();
|
||||
await tester.pump();
|
||||
|
||||
expect(c.read(boosterInventoryProvider)[BoosterType.shuffle], 0);
|
||||
c.dispose();
|
||||
});
|
||||
|
||||
testWidgets('tapping an empty booster offers an ad that grants +1',
|
||||
(tester) async {
|
||||
final c = await startedContainer(ad: _earnAd());
|
||||
expect(c.read(boosterInventoryProvider)[BoosterType.hammer], 0);
|
||||
|
||||
await tester.pumpWidget(_wrap(c));
|
||||
await tester.pump();
|
||||
|
||||
await tester.tap(find.byKey(const ValueKey('booster_hammer')));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
// The dialog offers the "watch an ad to get one" action; confirm it.
|
||||
final l10n = AppLocalizations.of(
|
||||
tester.element(find.byType(GameScreen)),
|
||||
)!;
|
||||
await tester.tap(find.text(l10n.boosterGetWithAd).last);
|
||||
await tester.pump();
|
||||
await tester.pump();
|
||||
|
||||
expect(c.read(boosterInventoryProvider)[BoosterType.hammer], 1);
|
||||
c.dispose();
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user