Compare commits

...

22 Commits

Author SHA1 Message Date
airkjw 7cecd89f6d chore(ios): pin Firebase pods at 12.15.0 for the 1.1.0+4 iOS build
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-19 10:29:48 +09:00
airkjw 410182cf7d feat(ui): floating pulse hint for booster targeting
Replaces the plain bottom SnackBar with a BoosterHint pill that floats in
the empty space above the board: season-accent coloured, a breathing glow
that pulses only while armed (idle otherwise — no wasted ticker), slides/
fades in, shows the booster icon + prompt, and cancels on tap. 3 widget
tests; full suite 234 green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-18 20:53:52 +09:00
airkjw 42deeaf242 fix(boosters): count hammer/line-bomb clears toward objectives and win
Owner playtesting found that hammering a gem (or line-bombing a gem line)
removed it visually but did not satisfy the clear-gems objective and never
completed the stage — it felt broken. Per owner decision, booster clears
now count: a hammered gem emits gems:1, a line-bomb emits lines:1 + the
line's gem count, both folded through the objectives. After any booster the
engine resolves the phase (completed objective -> won, else re-check stuck),
which was the direct cause of the stage not finishing. Score and the move
counter remain untouched. Reverses the earlier no-objective-credit rule.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-18 20:15:00 +09:00
airkjw 1695684fc9 chore: bump version to 1.1.0+4 (boosters + daily reward release)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-18 19:58:44 +09:00
airkjw fa2784519b fix(boosters): address final-review findings
- daily claim: record the claim before granting boosters, so a crash
  mid-claim forfeits at most one reward instead of allowing a re-claim
  (booster farming) on next launch.
- game screen: disarm the booster target synchronously before awaiting,
  so a rapid second board tap can't double-fire a use or stack a dialog.
- new players: seed one of each booster once (idempotent persisted flag),
  fulfilling the spec's starting inventory. Wired in main().

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-18 19:36:24 +09:00
airkjw 412cc08167 fix(l10n): localize line-bomb row/column chooser labels
The line-bomb axis chooser hardcoded Korean 가로/세로; route them through
new boosterLineRow/boosterLineCol keys (EN: Row/Column).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-18 19:27:41 +09:00
airkjw 1a028b9852 feat(ui): 7-day daily-reward popup on home
Presentational DailyRewardSheet (7 cells, today highlighted, reward icons
from the calendar table, claim + watch-ad-2x buttons). HomeScreen becomes a
ConsumerStatefulWidget that shows it once per mount via a post-frame guard;
the 2x path grants the doubled reward only if the rewarded ad was earned,
else the base reward. Guards the throwing saveRepositoryProvider default so
a repo-less mount is a no-op.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-18 19:26:43 +09:00
airkjw b8bfa00196 feat(ui): rewarded-ad grant for an empty booster
Tapping an empty booster opens the get-one dialog; confirming watches a
rewarded ad and, on earn, grants +1 of that booster and logs booster_granted
(source: ad). Covered by a widget test using an uninitialized AdService whose
showRewarded() resolves true.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-18 12:41:27 +09:00
airkjw 1ba30028b5 feat(ui): booster bar targeting in the game screen
Mount BoosterBar below the tray (only while playing), guarded so legacy
GameScreen tests without a SaveRepository keep passing. Tapping a booster
arms targeting: shuffle applies immediately; hammer/line-bomb arm a board
tap (hammer clears a cell, line-bomb opens a row/column chooser). An empty
booster opens a get-one dialog (ad grant lands in Task 15).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-18 12:40:13 +09:00
airkjw a04bb3b847 feat(ui): presentational booster bar 2026-06-18 12:28:25 +09:00
airkjw 0517fabdbb feat(l10n): booster + daily-reward strings (EN/KO)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-18 12:26:31 +09:00
airkjw d0a2be15ba feat(analytics): booster + daily-reward events
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-18 12:24:02 +09:00
airkjw fa4247cd9b feat(state): DailyRewardNotifier with injectable clock 2026-06-18 12:21:02 +09:00
airkjw ba4d4a662b feat(state): coordinate booster use with inventory in the session 2026-06-18 12:17:30 +09:00
airkjw 638a177fbb feat(state): BoosterInventoryNotifier 2026-06-18 12:14:34 +09:00
airkjw c185bd0886 feat(daily): pure 7-day login-calendar logic + reward table 2026-06-18 12:11:15 +09:00
airkjw 544a2b8be4 feat(save): persist daily-reward claim state 2026-06-18 12:09:06 +09:00
airkjw 221ea8346e feat(save): persist booster inventory 2026-06-18 12:08:17 +09:00
airkjw 6592b44387 feat(model): BoosterType enum 2026-06-18 12:07:19 +09:00
airkjw e7cd079a5d feat(engine): line-bomb booster clears a row or column 2026-06-18 12:05:14 +09:00
airkjw bbf8cf3f08 feat(engine): shuffle booster re-deals the tray 2026-06-18 12:04:00 +09:00
airkjw 5aee503c09 feat(engine): hammer booster removes one cell, no scoring 2026-06-18 12:02:55 +09:00
31 changed files with 1752 additions and 64 deletions
+105 -44
View File
@@ -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
+57
View File
@@ -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},
}),
);
}
+67
View File
@@ -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,
};
}
}
}
+55
View File
@@ -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();
}
}
}
+5
View File
@@ -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
View File
@@ -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
View File
@@ -33,5 +33,16 @@
"adsRemovedThanks": "광고가 제거되었습니다 — 감사합니다!",
"purchaseUnavailable": "지금은 구매를 사용할 수 없습니다.",
"soundAndVibration": "소리 및 진동",
"music": "음악"
"music": "음악",
"boosterHammer": "해머",
"boosterShuffle": "셔플",
"boosterLineBomb": "줄 폭탄",
"boosterGetWithAd": "광고 보고 1개 받기",
"dailyRewardTitle": "출석 보상",
"dailyClaim": "받기",
"dailyDoubleWithAd": "광고 보고 2배",
"boosterTapTarget": "칸을 선택하세요",
"boosterTapLine": "줄을 선택하세요",
"boosterLineRow": "가로",
"boosterLineCol": "세로"
}
+2
View File
@@ -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
+12
View File
@@ -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});
}
+28
View File
@@ -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;
}
}
+46
View File
@@ -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();
}
}
+39
View File
@@ -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(
+17
View File
@@ -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
View File
@@ -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,
),
],
],
),
),
+52 -2
View File
@@ -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);
+57
View File
@@ -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'),
]),
),
);
}
}
+149
View File
@@ -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),
],
),
),
),
),
),
),
);
}
}
+147
View File
@@ -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
View File
@@ -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');
}
});
}
+29
View File
@@ -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);
});
}
+51
View File
@@ -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');
});
}
+193
View File
@@ -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);
});
}
+19
View File
@@ -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});
});
}
+31
View File
@@ -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);
});
}
+65
View File
@@ -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);
});
}
+27
View File
@@ -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);
});
}
+54
View File
@@ -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);
});
}
+38
View File
@@ -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]);
});
}
+112
View File
@@ -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();
});
}