Compare commits

...

12 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
22 changed files with 1146 additions and 74 deletions
+105 -44
View File
@@ -4,44 +4,72 @@ PODS:
- audioplayers_darwin (0.0.1): - audioplayers_darwin (0.0.1):
- Flutter - Flutter
- FlutterMacOS - FlutterMacOS
- Firebase/CoreOnly (12.14.0): - Firebase/CoreOnly (12.15.0):
- FirebaseCore (~> 12.14.0) - FirebaseCore (~> 12.15.0)
- Firebase/Crashlytics (12.15.0):
- Firebase/CoreOnly
- FirebaseCrashlytics (~> 12.15.0)
- firebase_analytics (12.4.2): - firebase_analytics (12.4.2):
- firebase_core - firebase_core
- FirebaseAnalytics (= 12.14.0) - FirebaseAnalytics (= 12.15.0)
- Flutter - Flutter
- firebase_core (4.10.0): - firebase_core (4.11.0):
- Firebase/CoreOnly (= 12.14.0) - Firebase/CoreOnly (= 12.15.0)
- Flutter - Flutter
- FirebaseAnalytics (12.14.0): - firebase_crashlytics (5.2.4):
- FirebaseAnalytics/Default (= 12.14.0) - Firebase/Crashlytics (= 12.15.0)
- FirebaseCore (~> 12.14.0) - firebase_core
- FirebaseInstallations (~> 12.14.0) - Flutter
- FirebaseAnalytics (12.15.0):
- FirebaseAnalytics/Default (= 12.15.0)
- FirebaseCore (~> 12.15.0)
- FirebaseInstallations (~> 12.15.0)
- GoogleUtilities/AppDelegateSwizzler (~> 8.1) - GoogleUtilities/AppDelegateSwizzler (~> 8.1)
- GoogleUtilities/MethodSwizzler (~> 8.1) - GoogleUtilities/MethodSwizzler (~> 8.1)
- GoogleUtilities/Network (~> 8.1) - GoogleUtilities/Network (~> 8.1)
- "GoogleUtilities/NSData+zlib (~> 8.1)" - "GoogleUtilities/NSData+zlib (~> 8.1)"
- nanopb (~> 3.30910.0) - nanopb (~> 3.30910.0)
- FirebaseAnalytics/Default (12.14.0): - FirebaseAnalytics/Default (12.15.0):
- FirebaseCore (~> 12.14.0) - FirebaseCore (~> 12.15.0)
- FirebaseInstallations (~> 12.14.0) - FirebaseInstallations (~> 12.15.0)
- GoogleAppMeasurement/Default (= 12.14.0) - GoogleAppMeasurement/Default (= 12.15.0)
- GoogleUtilities/AppDelegateSwizzler (~> 8.1) - GoogleUtilities/AppDelegateSwizzler (~> 8.1)
- GoogleUtilities/MethodSwizzler (~> 8.1) - GoogleUtilities/MethodSwizzler (~> 8.1)
- GoogleUtilities/Network (~> 8.1) - GoogleUtilities/Network (~> 8.1)
- "GoogleUtilities/NSData+zlib (~> 8.1)" - "GoogleUtilities/NSData+zlib (~> 8.1)"
- nanopb (~> 3.30910.0) - nanopb (~> 3.30910.0)
- FirebaseCore (12.14.0): - FirebaseCore (12.15.0):
- FirebaseCoreInternal (~> 12.14.0) - FirebaseCoreInternal (~> 12.15.0)
- GoogleUtilities/Environment (~> 8.1) - GoogleUtilities/Environment (~> 8.1)
- GoogleUtilities/Logger (~> 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)" - "GoogleUtilities/NSData+zlib (~> 8.1)"
- FirebaseInstallations (12.14.0): - FirebaseCrashlytics (12.15.0):
- FirebaseCore (~> 12.14.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/Environment (~> 8.1)
- GoogleUtilities/UserDefaults (~> 8.1) - GoogleUtilities/UserDefaults (~> 8.1)
- PromisesObjC (~> 2.4) - 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) - Flutter (1.0.0)
- Google-Mobile-Ads-SDK (12.14.0): - Google-Mobile-Ads-SDK (12.14.0):
- GoogleUserMessagingPlatform (>= 1.1) - GoogleUserMessagingPlatform (>= 1.1)
@@ -54,59 +82,64 @@ PODS:
- GoogleUtilities/Logger (~> 8.1) - GoogleUtilities/Logger (~> 8.1)
- GoogleUtilities/Network (~> 8.1) - GoogleUtilities/Network (~> 8.1)
- nanopb (~> 3.30910.0) - nanopb (~> 3.30910.0)
- GoogleAppMeasurement/Core (12.14.0): - GoogleAppMeasurement/Core (12.15.0):
- GoogleUtilities/AppDelegateSwizzler (~> 8.1) - GoogleUtilities/AppDelegateSwizzler (~> 8.1)
- GoogleUtilities/MethodSwizzler (~> 8.1) - GoogleUtilities/MethodSwizzler (~> 8.1)
- GoogleUtilities/Network (~> 8.1) - GoogleUtilities/Network (~> 8.1)
- "GoogleUtilities/NSData+zlib (~> 8.1)" - "GoogleUtilities/NSData+zlib (~> 8.1)"
- nanopb (~> 3.30910.0) - nanopb (~> 3.30910.0)
- GoogleAppMeasurement/Default (12.14.0): - GoogleAppMeasurement/Default (12.15.0):
- GoogleAdsOnDeviceConversion (~> 3.6.0) - GoogleAdsOnDeviceConversion (~> 3.6.0)
- GoogleAppMeasurement/Core (= 12.14.0) - GoogleAppMeasurement/Core (= 12.15.0)
- GoogleAppMeasurement/IdentitySupport (= 12.14.0) - GoogleAppMeasurement/IdentitySupport (= 12.15.0)
- GoogleUtilities/AppDelegateSwizzler (~> 8.1) - GoogleUtilities/AppDelegateSwizzler (~> 8.1)
- GoogleUtilities/MethodSwizzler (~> 8.1) - GoogleUtilities/MethodSwizzler (~> 8.1)
- GoogleUtilities/Network (~> 8.1) - GoogleUtilities/Network (~> 8.1)
- "GoogleUtilities/NSData+zlib (~> 8.1)" - "GoogleUtilities/NSData+zlib (~> 8.1)"
- nanopb (~> 3.30910.0) - nanopb (~> 3.30910.0)
- GoogleAppMeasurement/IdentitySupport (12.14.0): - GoogleAppMeasurement/IdentitySupport (12.15.0):
- GoogleAppMeasurement/Core (= 12.14.0) - GoogleAppMeasurement/Core (= 12.15.0)
- GoogleUtilities/AppDelegateSwizzler (~> 8.1) - GoogleUtilities/AppDelegateSwizzler (~> 8.1)
- GoogleUtilities/MethodSwizzler (~> 8.1) - GoogleUtilities/MethodSwizzler (~> 8.1)
- GoogleUtilities/Network (~> 8.1) - GoogleUtilities/Network (~> 8.1)
- "GoogleUtilities/NSData+zlib (~> 8.1)" - "GoogleUtilities/NSData+zlib (~> 8.1)"
- nanopb (~> 3.30910.0) - nanopb (~> 3.30910.0)
- GoogleDataTransport (10.1.0):
- nanopb (~> 3.30910.0)
- PromisesObjC (~> 2.4)
- GoogleUserMessagingPlatform (3.1.0) - GoogleUserMessagingPlatform (3.1.0)
- GoogleUtilities/AppDelegateSwizzler (8.1.0): - GoogleUtilities/AppDelegateSwizzler (8.1.1):
- GoogleUtilities/Environment - GoogleUtilities/Environment
- GoogleUtilities/Logger - GoogleUtilities/Logger
- GoogleUtilities/Network - GoogleUtilities/Network
- GoogleUtilities/Privacy - GoogleUtilities/Privacy
- GoogleUtilities/Environment (8.1.0): - GoogleUtilities/Environment (8.1.1):
- GoogleUtilities/Privacy - GoogleUtilities/Privacy
- GoogleUtilities/Logger (8.1.0): - GoogleUtilities/Logger (8.1.1):
- GoogleUtilities/Environment - GoogleUtilities/Environment
- GoogleUtilities/Privacy - GoogleUtilities/Privacy
- GoogleUtilities/MethodSwizzler (8.1.0): - GoogleUtilities/MethodSwizzler (8.1.1):
- GoogleUtilities/Logger - GoogleUtilities/Logger
- GoogleUtilities/Privacy - GoogleUtilities/Privacy
- GoogleUtilities/Network (8.1.0): - GoogleUtilities/Network (8.1.1):
- GoogleUtilities/Logger - GoogleUtilities/Logger
- "GoogleUtilities/NSData+zlib" - "GoogleUtilities/NSData+zlib"
- GoogleUtilities/Privacy - GoogleUtilities/Privacy
- GoogleUtilities/Reachability - GoogleUtilities/Reachability
- "GoogleUtilities/NSData+zlib (8.1.0)": - "GoogleUtilities/NSData+zlib (8.1.1)":
- GoogleUtilities/Privacy - GoogleUtilities/Privacy
- GoogleUtilities/Privacy (8.1.0) - GoogleUtilities/Privacy (8.1.1)
- GoogleUtilities/Reachability (8.1.0): - GoogleUtilities/Reachability (8.1.1):
- GoogleUtilities/Logger - GoogleUtilities/Logger
- GoogleUtilities/Privacy - GoogleUtilities/Privacy
- GoogleUtilities/UserDefaults (8.1.0): - GoogleUtilities/UserDefaults (8.1.1):
- GoogleUtilities/Logger - GoogleUtilities/Logger
- GoogleUtilities/Privacy - GoogleUtilities/Privacy
- in_app_purchase_storekit (0.0.1): - in_app_purchase_storekit (0.0.1):
- Flutter - Flutter
- FlutterMacOS - FlutterMacOS
- in_app_review (2.0.0):
- Flutter
- nanopb (3.30910.0): - nanopb (3.30910.0):
- nanopb/decode (= 3.30910.0) - nanopb/decode (= 3.30910.0)
- nanopb/encode (= 3.30910.0) - nanopb/encode (= 3.30910.0)
@@ -115,10 +148,14 @@ PODS:
- path_provider_foundation (0.0.1): - path_provider_foundation (0.0.1):
- Flutter - Flutter
- FlutterMacOS - FlutterMacOS
- PromisesObjC (2.4.0) - PromisesObjC (2.4.1)
- PromisesSwift (2.4.1):
- PromisesObjC (= 2.4.1)
- shared_preferences_foundation (0.0.1): - shared_preferences_foundation (0.0.1):
- Flutter - Flutter
- FlutterMacOS - FlutterMacOS
- url_launcher_ios (0.0.1):
- Flutter
- webview_flutter_wkwebview (0.0.1): - webview_flutter_wkwebview (0.0.1):
- Flutter - Flutter
- FlutterMacOS - FlutterMacOS
@@ -128,11 +165,14 @@ DEPENDENCIES:
- audioplayers_darwin (from `.symlinks/plugins/audioplayers_darwin/darwin`) - audioplayers_darwin (from `.symlinks/plugins/audioplayers_darwin/darwin`)
- firebase_analytics (from `.symlinks/plugins/firebase_analytics/ios`) - firebase_analytics (from `.symlinks/plugins/firebase_analytics/ios`)
- firebase_core (from `.symlinks/plugins/firebase_core/ios`) - firebase_core (from `.symlinks/plugins/firebase_core/ios`)
- firebase_crashlytics (from `.symlinks/plugins/firebase_crashlytics/ios`)
- Flutter (from `Flutter`) - Flutter (from `Flutter`)
- google_mobile_ads (from `.symlinks/plugins/google_mobile_ads/ios`) - google_mobile_ads (from `.symlinks/plugins/google_mobile_ads/ios`)
- in_app_purchase_storekit (from `.symlinks/plugins/in_app_purchase_storekit/darwin`) - 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`) - path_provider_foundation (from `.symlinks/plugins/path_provider_foundation/darwin`)
- shared_preferences_foundation (from `.symlinks/plugins/shared_preferences_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`) - webview_flutter_wkwebview (from `.symlinks/plugins/webview_flutter_wkwebview/darwin`)
SPEC REPOS: SPEC REPOS:
@@ -140,15 +180,21 @@ SPEC REPOS:
- Firebase - Firebase
- FirebaseAnalytics - FirebaseAnalytics
- FirebaseCore - FirebaseCore
- FirebaseCoreExtension
- FirebaseCoreInternal - FirebaseCoreInternal
- FirebaseCrashlytics
- FirebaseInstallations - FirebaseInstallations
- FirebaseRemoteConfigInterop
- FirebaseSessions
- Google-Mobile-Ads-SDK - Google-Mobile-Ads-SDK
- GoogleAdsOnDeviceConversion - GoogleAdsOnDeviceConversion
- GoogleAppMeasurement - GoogleAppMeasurement
- GoogleDataTransport
- GoogleUserMessagingPlatform - GoogleUserMessagingPlatform
- GoogleUtilities - GoogleUtilities
- nanopb - nanopb
- PromisesObjC - PromisesObjC
- PromisesSwift
EXTERNAL SOURCES: EXTERNAL SOURCES:
app_tracking_transparency: app_tracking_transparency:
@@ -159,41 +205,56 @@ EXTERNAL SOURCES:
:path: ".symlinks/plugins/firebase_analytics/ios" :path: ".symlinks/plugins/firebase_analytics/ios"
firebase_core: firebase_core:
:path: ".symlinks/plugins/firebase_core/ios" :path: ".symlinks/plugins/firebase_core/ios"
firebase_crashlytics:
:path: ".symlinks/plugins/firebase_crashlytics/ios"
Flutter: Flutter:
:path: Flutter :path: Flutter
google_mobile_ads: google_mobile_ads:
:path: ".symlinks/plugins/google_mobile_ads/ios" :path: ".symlinks/plugins/google_mobile_ads/ios"
in_app_purchase_storekit: in_app_purchase_storekit:
:path: ".symlinks/plugins/in_app_purchase_storekit/darwin" :path: ".symlinks/plugins/in_app_purchase_storekit/darwin"
in_app_review:
:path: ".symlinks/plugins/in_app_review/ios"
path_provider_foundation: path_provider_foundation:
:path: ".symlinks/plugins/path_provider_foundation/darwin" :path: ".symlinks/plugins/path_provider_foundation/darwin"
shared_preferences_foundation: shared_preferences_foundation:
:path: ".symlinks/plugins/shared_preferences_foundation/darwin" :path: ".symlinks/plugins/shared_preferences_foundation/darwin"
url_launcher_ios:
:path: ".symlinks/plugins/url_launcher_ios/ios"
webview_flutter_wkwebview: webview_flutter_wkwebview:
:path: ".symlinks/plugins/webview_flutter_wkwebview/darwin" :path: ".symlinks/plugins/webview_flutter_wkwebview/darwin"
SPEC CHECKSUMS: SPEC CHECKSUMS:
app_tracking_transparency: 3d84f147f67ca82d3c15355c36b1fa6b66ca7c92 app_tracking_transparency: 3d84f147f67ca82d3c15355c36b1fa6b66ca7c92
audioplayers_darwin: 835ced6edd4c9fc8ebb0a7cc9e294a91d99917d5 audioplayers_darwin: 835ced6edd4c9fc8ebb0a7cc9e294a91d99917d5
Firebase: 7cc10425300768ec86292688af5cb228f0604bde Firebase: a8539b633d474fbeb654c7043f9c1649e274045b
firebase_analytics: 9ea6c22e60e1d37ee76772de57b4addddb03e276 firebase_analytics: e0a17f792099472235f9ec7f31d1d3a0730d4891
firebase_core: 383e19b49a08df5d7a6cf5017616de6a357ed7af firebase_core: fc23178af8ea070194d09031ae4198a9608a3d22
FirebaseAnalytics: 99364329a3ea4d1ab4a3744b5464371b7351c481 firebase_crashlytics: 344bb168f55aee1086c6cdd0b105a9db018cd344
FirebaseCore: 4939b340b9c598dc1f965d68f8fe57e630b65407 FirebaseAnalytics: 9c9fa7915fc52ea03077000d5a7b6a8947b2d76e
FirebaseCoreInternal: 090369a5fffd7423cf88006ab4d2ccc2173a8db9 FirebaseCore: 2e86a4ea1684d4381707069e4a6d89ac808e901e
FirebaseInstallations: 7cdc919e29dc54306edeffdbdc1eed1a40d7d1e7 FirebaseCoreExtension: 10d2a627977b39418759ad88ada80fbbd34f1c4f
FirebaseCoreInternal: 6ab6a02c94446c026d2cf35cf5383842ebaa4992
FirebaseCrashlytics: 87e76cc33259b076dd1f96cd829db76849338e08
FirebaseInstallations: eb29ccbf64eaedf86fd5b2ccc7fabde567660b52
FirebaseRemoteConfigInterop: 7e3d57ce4b1e958bb1d15403faa7178f46bbb5b7
FirebaseSessions: acfe7eadca47cda94ac86592737204581bb1abf6
Flutter: cabc95a1d2626b1b06e7179b784ebcf0c0cde467 Flutter: cabc95a1d2626b1b06e7179b784ebcf0c0cde467
Google-Mobile-Ads-SDK: 4534fd2dfcd3f705c5485a6633c5188d03d4eed2 Google-Mobile-Ads-SDK: 4534fd2dfcd3f705c5485a6633c5188d03d4eed2
google_mobile_ads: df3008bafbe1f2ad6862f87334e560d2f047f902 google_mobile_ads: df3008bafbe1f2ad6862f87334e560d2f047f902
GoogleAdsOnDeviceConversion: 80ce443fa1b4b5750913d53a04ecda644ff57744 GoogleAdsOnDeviceConversion: 80ce443fa1b4b5750913d53a04ecda644ff57744
GoogleAppMeasurement: 1987ffa55055dfb22c52e363c31aa50c1e11d349 GoogleAppMeasurement: a6d37949071d456e9147dac6789c4342e0e7a8c5
GoogleDataTransport: aae35b7ea0c09004c3797d53c8c41f66f219d6a7
GoogleUserMessagingPlatform: befe603da6501006420c206222acd449bba45a9c GoogleUserMessagingPlatform: befe603da6501006420c206222acd449bba45a9c
GoogleUtilities: 00c88b9a86066ef77f0da2fab05f65d7768ed8e1 GoogleUtilities: 4f2618a4a1e762a1ee134a1e2323bba9843e06da
in_app_purchase_storekit: 22cca7d08eebca9babdf4d07d0baccb73325d3c8 in_app_purchase_storekit: 22cca7d08eebca9babdf4d07d0baccb73325d3c8
in_app_review: 7dd1ea365263f834b8464673f9df72c80c17c937
nanopb: fad817b59e0457d11a5dfbde799381cd727c1275 nanopb: fad817b59e0457d11a5dfbde799381cd727c1275
path_provider_foundation: bb55f6dbba17d0dccd6737fe6f7f34fbd0376880 path_provider_foundation: bb55f6dbba17d0dccd6737fe6f7f34fbd0376880
PromisesObjC: f5707f49cb48b9636751c5b2e7d227e43fba9f47 PromisesObjC: 752c3227f599e3467650e47ea36f433eeb10c273
PromisesSwift: 217dea0fd5d2ad65222a109c48698add13cc1c5b
shared_preferences_foundation: 7036424c3d8ec98dfe75ff1667cb0cd531ec82bb shared_preferences_foundation: 7036424c3d8ec98dfe75ff1667cb0cd531ec82bb
url_launcher_ios: 7a95fa5b60cc718a708b8f2966718e93db0cef1b
webview_flutter_wkwebview: 8ebf4fded22593026f7dbff1fbff31ea98573c8d webview_flutter_wkwebview: 8ebf4fded22593026f7dbff1fbff31ea98573c8d
PODFILE CHECKSUM: 7a0c05f8aeb53a8c858ca08a4666afaa242f0eb1 PODFILE CHECKSUM: 7a0c05f8aeb53a8c858ca08a4666afaa242f0eb1
+17
View File
@@ -52,6 +52,9 @@ class SaveRepository {
_reviewRequested = (json['flags'] _reviewRequested = (json['flags']
as Map<String, dynamic>?)?['reviewRequested'] as bool? ?? as Map<String, dynamic>?)?['reviewRequested'] as bool? ??
false; false;
_boostersSeeded = (json['flags']
as Map<String, dynamic>?)?['boostersSeeded'] as bool? ??
false;
final boosters = json['boosters'] as Map<String, dynamic>? ?? {}; final boosters = json['boosters'] as Map<String, dynamic>? ?? {};
for (final t in BoosterType.values) { for (final t in BoosterType.values) {
_boosters[t] = boosters[t.name] as int? ?? 0; _boosters[t] = boosters[t.name] as int? ?? 0;
@@ -76,6 +79,7 @@ class SaveRepository {
bool _soundEnabled = true; bool _soundEnabled = true;
bool _musicEnabled = true; bool _musicEnabled = true;
bool _reviewRequested = false; bool _reviewRequested = false;
bool _boostersSeeded = false;
final Map<BoosterType, int> _boosters = { final Map<BoosterType, int> _boosters = {
for (final t in BoosterType.values) t: 0, for (final t in BoosterType.values) t: 0,
}; };
@@ -138,6 +142,18 @@ class SaveRepository {
return true; 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; String? get dailyLastClaimedYmd => _dailyLastClaimedYmd;
int get dailyCalendarDay => _dailyCalendarDay; int get dailyCalendarDay => _dailyCalendarDay;
@@ -221,6 +237,7 @@ class SaveRepository {
'soundEnabled': _soundEnabled, 'soundEnabled': _soundEnabled,
'musicEnabled': _musicEnabled, 'musicEnabled': _musicEnabled,
'reviewRequested': _reviewRequested, 'reviewRequested': _reviewRequested,
'boostersSeeded': _boostersSeeded,
}, },
'endless': {'best': _endlessBest}, 'endless': {'best': _endlessBest},
'boosters': {for (final t in BoosterType.values) t.name: _boosters[t]}, 'boosters': {for (final t in BoosterType.values) t.name: _boosters[t]},
+30 -9
View File
@@ -217,19 +217,22 @@ class GameEngine {
_phase = GamePhase.lost; _phase = GamePhase.lost;
} }
/// Booster: empties one filled cell. No move/score/combo/objective effect. /// Booster: empties one filled cell. No move/score/combo effect, but a
/// Allowed only mid-attempt (playing or stuck). Returns false on an empty /// hammered gem DOES count toward a clear-gems objective (and can win the
/// cell or finished attempt so the caller keeps the booster. /// 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) { bool useHammer(int x, int y) {
if (_phase == GamePhase.won || _phase == GamePhase.lost) return false; if (_phase == GamePhase.won || _phase == GamePhase.lost) return false;
if (!_grid.isOccupied(x, y)) 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)); _grid = _grid.withCell(x, y, const Cell(CellType.empty));
_checkStuck(); _resolveAfterBooster(
wasGem ? const LinesCleared(lines: 0, gems: 1) : null);
return true; return true;
} }
/// Booster: re-deals the tray. No move/score effect. Re-checks stuck so a /// Booster: re-deals the tray. No move/score/objective effect. Re-checks
/// dead board with a hopeless tray can become playable again. /// stuck so a dead board with a hopeless tray can become playable again.
bool useShuffle() { bool useShuffle() {
if (_phase == GamePhase.won || _phase == GamePhase.lost) return false; if (_phase == GamePhase.won || _phase == GamePhase.lost) return false;
_tray = _generator.nextTray(_grid); _tray = _generator.nextTray(_grid);
@@ -237,17 +240,35 @@ class GameEngine {
return true; return true;
} }
/// Booster: empties one row or one column (exactly one of [row]/[col]). /// Booster: empties one row or one column (exactly one of [row]/[col]). No
/// No move/score/objective effect. Re-checks stuck. /// 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}) { bool useLineBomb({int? row, int? col}) {
if (_phase == GamePhase.won || _phase == GamePhase.lost) return false; if (_phase == GamePhase.won || _phase == GamePhase.lost) return false;
if ((row == null) == (col == null)) return false; // need exactly one if ((row == null) == (col == null)) return false; // need exactly one
var gems = 0;
for (var i = 0; i < GridState.size; i++) { for (var i = 0; i < GridState.size; i++) {
final x = col ?? i; final x = col ?? i;
final y = row ?? i; final y = row ?? i;
if (_grid.cellAt(x, y).type == CellType.gem) gems++;
_grid = _grid.withCell(x, y, const Cell(CellType.empty)); _grid = _grid.withCell(x, y, const Cell(CellType.empty));
} }
_checkStuck(); _resolveAfterBooster(LinesCleared(lines: 1, gems: gems));
return true; 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();
}
}
} }
+12 -1
View File
@@ -61,5 +61,16 @@
"adsRemovedThanks": "Ads removed — thank you!", "adsRemovedThanks": "Ads removed — thank you!",
"purchaseUnavailable": "Purchases are unavailable right now.", "purchaseUnavailable": "Purchases are unavailable right now.",
"soundAndVibration": "Sound & vibration", "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": "광고가 제거되었습니다 — 감사합니다!", "adsRemovedThanks": "광고가 제거되었습니다 — 감사합니다!",
"purchaseUnavailable": "지금은 구매를 사용할 수 없습니다.", "purchaseUnavailable": "지금은 구매를 사용할 수 없습니다.",
"soundAndVibration": "소리 및 진동", "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 { Future<void> main() async {
WidgetsFlutterBinding.ensureInitialized(); WidgetsFlutterBinding.ensureInitialized();
final saveRepository = await SaveRepository.open(); 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 // Analytics: real GA4 traffic flows only from release builds so development
// never pollutes production. If Firebase init fails (e.g. missing native // 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}) { void tutorialFinished({required bool skipped}) {
_backend.logEvent('tutorial_finished', {'skipped': skipped ? 1 : 0}); _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});
} }
+12 -1
View File
@@ -25,11 +25,22 @@ class DailyRewardNotifier extends Notifier<DailyResolution> {
final r = state; final r = state;
if (!r.claimable) return; if (!r.claimable) return;
final reward = _cal.rewardFor(r.day); 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); final inv = ref.read(boosterInventoryProvider.notifier);
for (final entry in reward.entries) { for (final entry in reward.entries) {
await inv.grant(entry.key, entry.value * (doubled ? 2 : 1)); await inv.grant(entry.key, entry.value * (doubled ? 2 : 1));
} }
await _save.recordDailyClaim(_cal.ymd(_now()), r.day); 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(); state = _resolve();
} }
} }
+1
View File
@@ -151,6 +151,7 @@ class GameSessionNotifier extends Notifier<GameViewState?> {
} }
if (!apply()) return BoosterUseResult.invalidTarget; if (!apply()) return BoosterUseResult.invalidTarget;
await inv.consume(type); await inv.consume(type);
ref.read(analyticsProvider).boosterUsed(type: type.name);
_publish(lastPlacement: null); _publish(lastPlacement: null);
return BoosterUseResult.success; return BoosterUseResult.success;
} }
+181 -15
View File
@@ -5,6 +5,7 @@ import 'package:flutter/services.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../game/engine/game_engine.dart'; import '../../game/engine/game_engine.dart';
import '../../game/models/booster.dart';
import '../../game/models/grid.dart'; import '../../game/models/grid.dart';
import '../../l10n/gen/app_localizations.dart'; import '../../l10n/gen/app_localizations.dart';
import '../../services/audio_service.dart'; import '../../services/audio_service.dart';
@@ -14,6 +15,8 @@ import '../theme/palette.dart';
import '../widgets/board_geometry.dart'; import '../widgets/board_geometry.dart';
import '../widgets/board_painter.dart'; import '../widgets/board_painter.dart';
import '../widgets/board_widget.dart'; import '../widgets/board_widget.dart';
import '../widgets/booster_bar.dart';
import '../widgets/booster_hint.dart';
import '../widgets/effects_overlay.dart'; import '../widgets/effects_overlay.dart';
import '../widgets/hud_widget.dart'; import '../widgets/hud_widget.dart';
import '../widgets/piece_painter.dart'; import '../widgets/piece_painter.dart';
@@ -21,6 +24,9 @@ import '../widgets/season_background.dart';
import '../widgets/tray_widget.dart'; import '../widgets/tray_widget.dart';
import '../widgets/tutorial_overlay.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 /// Renders whatever session [gameSessionProvider] holds; callers start the
/// stage (via SeasonFlowNotifier) before navigating here. /// stage (via SeasonFlowNotifier) before navigating here.
class GameScreen extends ConsumerStatefulWidget { class GameScreen extends ConsumerStatefulWidget {
@@ -46,6 +52,10 @@ class _GameScreenState extends ConsumerState<GameScreen>
int? _dragIndex; int? _dragIndex;
Offset? _dragGlobal; 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. /// How far the dragged piece floats above the finger so it stays visible.
static const double _lift = 70; 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 @override
void dispose() { void dispose() {
_shake.dispose(); _shake.dispose();
@@ -247,6 +366,13 @@ class _GameScreenState extends ConsumerState<GameScreen>
final draggedTopLeft = _draggedPieceTopLeft(view); final draggedTopLeft = _draggedPieceTopLeft(view);
final boardBox = _boardBox; 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); final theme = ref.watch(activeThemeProvider);
return Scaffold( return Scaffold(
backgroundColor: Colors.transparent, backgroundColor: Colors.transparent,
@@ -276,22 +402,54 @@ class _GameScreenState extends ConsumerState<GameScreen>
), ),
HudWidget(view: view), HudWidget(view: view),
Expanded( Expanded(
child: Center( child: Stack(
child: AnimatedBuilder( children: [
animation: _shake, Positioned.fill(
builder: (context, child) { child: Center(
final t = _shake.value; child: AnimatedBuilder(
final dx = animation: _shake,
math.sin(t * math.pi * 10) * 6 * (1 - t); builder: (context, child) {
return Transform.translate( final t = _shake.value;
offset: Offset(dx, 0), child: child); final dx = math.sin(t * math.pi * 10) *
}, 6 *
child: BoardWidget( (1 - t);
key: _boardKey, return Transform.translate(
view: view, offset: Offset(dx, 0), child: child);
ghost: ghost, },
// 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( TrayWidget(
@@ -305,6 +463,14 @@ class _GameScreenState extends ConsumerState<GameScreen>
setState(() => _dragGlobal = global), setState(() => _dragGlobal = global),
onDragEnd: () => _onDragEnd(view), 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 '../../l10n/gen/app_localizations.dart';
import '../../state/providers.dart'; import '../../state/providers.dart';
import '../widgets/banner_ad_slot.dart'; import '../widgets/banner_ad_slot.dart';
import '../widgets/daily_reward_sheet.dart';
import '../widgets/fade_route.dart'; import '../widgets/fade_route.dart';
import '../widgets/pressable_scale.dart'; import '../widgets/pressable_scale.dart';
import '../widgets/season_background.dart'; import '../widgets/season_background.dart';
@@ -13,9 +14,14 @@ import 'game_screen.dart';
import 'season_map_screen.dart'; import 'season_map_screen.dart';
import 'settings_screen.dart'; import 'settings_screen.dart';
class HomeScreen extends ConsumerWidget { class HomeScreen extends ConsumerStatefulWidget {
const HomeScreen({super.key}); const HomeScreen({super.key});
@override
ConsumerState<HomeScreen> createState() => _HomeScreenState();
}
class _HomeScreenState extends ConsumerState<HomeScreen> {
static const _logoColors = [ static const _logoColors = [
Color(0xFFFF7EB3), Color(0xFFFF7EB3),
Color(0xFFFFD166), Color(0xFFFFD166),
@@ -23,8 +29,52 @@ class HomeScreen extends ConsumerWidget {
Color(0xFF7EDB9C), Color(0xFF7EDB9C),
]; ];
bool _dailyChecked = false;
@override @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 l10n = AppLocalizations.of(context)!;
final streak = ref.watch(streakProvider); final streak = ref.watch(streakProvider);
final best = ref.watch(endlessBestProvider); 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 # 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 # 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. # 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: environment:
sdk: ^3.9.2 sdk: ^3.9.2
@@ -34,4 +34,21 @@ void main() {
expect(repo.boosterCount(BoosterType.shuffle), 0); expect(repo.boosterCount(BoosterType.shuffle), 0);
expect(await repo.consumeBooster(BoosterType.shuffle), isFalse); 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');
}
});
} }
+89
View File
@@ -19,6 +19,56 @@ StageConfig _stage() => StageConfig.fromJson({
}, },
}); });
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() { void main() {
test('useHammer empties a filled cell without scoring or spending a move', () { test('useHammer empties a filled cell without scoring or spending a move', () {
final e = GameEngine(_stage()); final e = GameEngine(_stage());
@@ -101,4 +151,43 @@ void main() {
e.declineAndLose(); e.declineAndLose();
expect(e.useLineBomb(row: 0), isFalse); 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}); 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});
});
} }
+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();
});
}