Compare commits

..

101 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
airkjw 4cda34f0b7 docs(plan): boosters & daily reward implementation plan
17 bite-sized TDD tasks: engine booster ops -> inventory/daily persistence
-> pure daily-calendar -> notifiers -> analytics/l10n -> booster bar +
targeting -> rewarded-ad grants -> daily popup -> integration. Verification
is flutter test + analyze only (builds are owner-commanded).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-18 11:57:51 +09:00
airkjw 9f1e0d2cd5 docs(spec): boosters & daily reward design
Brainstormed design for a lightweight booster economy (hammer/shuffle/
line-bomb) earned via a 7-day login calendar and rewarded ads, used in
a stage via a booster bar. Boosters mutate the grid only — no move cost,
no score/combo, no objective credit — so stage balance is preserved
while they can rescue a dead board. Approved by owner; next: impl plan.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-18 11:50:54 +09:00
airkjw 02021b540e feat(crashlytics): report release crashes to Firebase Crashlytics
Routes Flutter framework errors (FlutterError.onError) and uncaught
async/platform errors (PlatformDispatcher.onError) to Crashlytics, but
only in release builds — debug keeps its red error screens and console
traces, and collection is disabled via setCrashlyticsCollectionEnabled
(kReleaseMode) so development crashes never reach the dashboard. Adds
the Crashlytics Gradle plugin alongside the existing google-services
FlutterFire config. iOS dSYM upload run script still to be added in
Xcode (symbolication only; crash capture works without it).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-18 11:17:27 +09:00
airkjw cec4c3e427 feat(review): request a store review after a 3-star win, once
Adds an in-app review prompt gated by ReviewPromptPolicy: only after a
3-star stage win, once the player has cleared >=5 stages, at most once
ever (persisted reviewRequested flag). ReviewService swallows all
failures and only burns the one-shot when the store actually shows the
sheet, so an unavailable store retries on a later win. StoreReviewer
wraps in_app_review behind a Reviewer seam so unit tests skip platform
channels. 13 new tests; full suite 194 green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-18 11:13:55 +09:00
airkjw 395e4a189b ios: fix ITMS-91064 — set NSPrivacyTracking=false, drop empty domains
Apple rejected build 2: NSPrivacyTracking was true with an empty
NSPrivacyTrackingDomains. Listing AdMob's ad-serving domains would block
ads before ATT consent (same domains serve non-personalized ads), so we
set NSPrivacyTracking=false and remove the domains key — mirroring
Google's own AdMob SDK manifest (DeviceID data type stays tracking=true,
no top-level tracking/domains). Bump to build 3.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-15 13:06:16 +09:00
airkjw 4df30c3f40 test(store): add 13-inch iPad screenshots (universal app requires them)
App ships universal (iPhone+iPad), so the App Store requires iPad
screenshots. Render the 3 store screens at 2048x2732 (iPad 12.9"/13").

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-15 12:20:33 +09:00
airkjw c78dea71e0 docs(store): remove IAP references (ad-only launch)
Drop the In-App Purchases section from the privacy policy and the
'Remove Ads' bullet from the store description, since the app now ships
ad-supported only. Renumber policy sections; bump policy date to Jun 15.
Redeployed the policy to block-seasons.web.app.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-15 12:10:36 +09:00
airkjw 8b5bbd9531 feat: ship ad-supported only — gate Remove Ads IAP behind kIapEnabled=false
The developer is an individual without a Korean business registration, so
the App Store / Play paid-apps (merchant) agreements can't be completed.
Hide the Remove Ads + Restore tiles and skip IAP init; ads always show.
AdMob revenue is independent of those agreements. Reversible: flip
kIapEnabled to re-enable once a merchant agreement exists. Bump to build 2;
drop the now-unused IAP review-screenshot generator.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-15 11:55:48 +09:00
airkjw e9d7f7cef6 test(store): match IAP review screenshot price to $1.99 (actual price) 2026-06-15 10:48:51 +09:00
airkjw 3e136dc288 test(store): generate IAP review screenshot for App Store (remove_ads)
Headless render of the Settings screen showing the Remove Ads purchase
point + price + Restore Purchases, for the App Store IAP review-screenshot
requirement. Forces a desktop target platform so the real IapService can
be constructed without the store plugin making async channel calls.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-15 10:46:54 +09:00
airkjw ea01da9b62 ios: declare ITSAppUsesNonExemptEncryption=false (HTTPS-only, export-exempt)
App uses only standard OS-provided encryption (HTTPS via Firebase/AdMob),
which qualifies for export compliance exemption. Baking the key into
Info.plist avoids the per-build encryption prompt on future uploads.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-15 10:21:45 +09:00
airkjw 8e3ed2951d docs(store): add real landing page for web.app root (support/marketing URL)
Replaces the default Firebase placeholder so the App Store support and
marketing URLs (https://block-seasons.web.app) show a real Block Seasons
page. Deployed to Firebase Hosting root as index.html.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-15 10:11:16 +09:00
airkjw b79960c949 docs(store): host privacy policy + app-ads.txt on Firebase, fill submission URLs
Deployed privacy-policy.html and app-ads.txt to block-seasons.web.app
(gru.farm root is an external site builder, not the NAS, so app-ads.txt
could not live there). Updated phase7 guide with the live URLs.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-14 21:55:21 +09:00
airkjw 08372995bc docs(store): generate store screenshots (iOS 6.7" + Android phone)
Headless screenshot harness renders Home, Gameplay, and a denser
score-chase board to PNGs at 1290x2796 (iOS) and 1080x1920 (Android),
in English with a real font loaded. Captures the boundary layer at 3x
inside runAsync (the only way it completes under the test binding).
Season-map cut deferred — its scrollable build stalls headlessly.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-14 21:20:37 +09:00
airkjw 30572e3912 docs(store): self-serve submission guide + privacy policy + Play 512 icon
Rewrote the Phase 7 guide to reflect completed Play setup (app, AAB,
payments profile, remove_ads) and lay out remaining ordered steps for
both stores with copy/answers inline. Added a hostable bilingual
privacy policy page and a 512x512 Play store icon.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-14 19:49:41 +09:00
airkjw c7c558cb96 release: drop test-only build tag from settings footer
The '(build 4)' marker was a device build-delivery debugging aid, now
resolved. Public release shows a clean version string.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-14 14:47:33 +09:00
airkjw 7c7c7afad0 content: regenerate Season 1 with a gentle onboarding difficulty curve
Generator: raise the early bot-win-rate floor to ~0.97 (near-guaranteed first
stages), widen the early move-buffer margin, and delay score-chase/line-sprint
objectives until stage 9/13 so the opening stretch is pure clear-a-gem. Result:
stage 1 now 16 moves (was 11), stages 1-9 all simple gems at 90-99% bot win.
Preserves theme.bgm=season_001; manifest SHA regenerated. Settings tag -> build 4.
2026-06-14 11:02:29 +09:00
airkjw 1682578501 feat: start at Season 1, add menu/season_001 BGM tracks, settings build tag
- activeSeason now returns the first season (Season 1 'First Bloom') so a new
  player starts at spring instead of the newest season.
- Bundle the owner-picked CC0 tracks menu.mp3 + season_001.mp3 (BGM now audible).
- Settings footer shows 'v1.0.0 (build 3)' so test builds are identifiable.
180 tests green, analyze clean.
2026-06-14 10:32:12 +09:00
airkjw 8947221b27 feat(audio): looping per-season background music system
MusicService (looping audioplayers player, independent of SFX) driven by the
active season theme's new 'bgm' key; switches track on season change, pauses on
app background, all failures swallowed. Separate Music on/off toggle in Settings
(persisted, independent of SFX). Season packs carry bgm keys (menu/season_001/
season_002), manifest regenerated. Assets slot assets/audio/bgm/ ready — drop in
menu.mp3/season_001.mp3/season_002.mp3 (CC0) and it plays; silent until then.
180 tests green, analyze clean.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-14 09:31:10 +09:00
airkjw 2310aabdb9 fix(ui): move game-screen close button to its own top row (was overlapping HUD)
On device the floating top-left X (48px IconButton) overlapped the HUD moves
chip. Move it into the column flow above the HUD, left-aligned, so it never
collides. Found via on-device test build.
2026-06-14 09:02:01 +09:00
airkjw a9380a7b27 fix(android): launcher label 'Block Seasons' (was default 'block_seasons') 2026-06-13 23:14:12 +09:00
airkjw 40abc26f5d build(release): Android signing, iOS privacy manifest, store assets (Phase 7)
- Android: release keystore signing wired via gitignored key.properties (falls
  back to debug when absent). Verified: signed AAB built (signer CN=Block Seasons).
- iOS: app PrivacyInfo.xcprivacy (ATT tracking flag, device-id/usage data types,
  UserDefaults+FileTimestamp required-reason APIs) registered in the Runner target.
- Store: app-ads.txt (pub-5605900229781491), EN/KO listing copy, owner submission
  guide (privacy labels, app-ads hosting, upload/submit steps).
Secrets (keystore, key.properties) are gitignored — owner backs them up.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-13 22:49:43 +09:00
airkjw 734c8a4cf7 feat(ads): wire real AdMob ids (Phase 5 finalize)
Replaces the TODO_REAL_* ad-unit placeholders with the owner's real AdMob
console ids (publisher pub-5605900229781491) and the native AdMob app ids
(iOS ~8397095848, Android ~8257495040) in place of the Google sample ids.
Debug builds still serve Google test ads via kDebugMode; release builds now
use the real units.
2026-06-13 19:05:10 +09:00
airkjw b31228d987 docs: AdMob + IAP real-id setup guide for owner (Phase 5 finalize)
Step-by-step owner guide to register the AdMob apps + 6 ad units, create the
remove_ads IAP in both stores, and the exact values to send back so the
TODO_REAL_* ad ids and native AdMob app ids get swapped in.
2026-06-13 18:31:20 +09:00
airkjw 219da8677a Merge Phase 6: localization finalize + brand icon + juice + store assets
Brand app icon (navy 2x2 glossy block mark, vector-drawn, no AI image) applied
on iOS+Android via flutter_launcher_icons. Sound & vibration toggle (themed
Settings). Juice: button press feedback + fade screen transitions. Play feature
graphic (Titan One wordmark). l10n EN/KO verified (no hardcoded strings, key
parity). 176 tests green; analyze clean.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-13 18:26:48 +09:00
airkjw ac49168c02 test(juice): PressableScale doesn't swallow the inner button tap 2026-06-13 18:26:20 +09:00
airkjw 23f90d5b89 style: drop unnecessary dart:ui import in app_icon_painter 2026-06-13 18:25:13 +09:00
airkjw bc62127d1a docs: Phase 6 verified — brand app icon applied on device build 2026-06-13 18:24:36 +09:00
airkjw b0839aba2a fix(store): render feature-graphic wordmark with real font (Titan One, OFL)
The flutter_test default font draws every glyph as a box, so the wordmark was
unreadable. Load the OFL Titan One TTF via FontLoader in the generator and
render 'Block Seasons' + tagline with it; fit the text within the 1024 width.
Font used only by the asset generator, not bundled in the app.
2026-06-13 18:20:11 +09:00
airkjw 7fe2bc2776 feat(store): Play feature graphic (1024x500) generator
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-13 18:15:11 +09:00
airkjw 536807e7c8 feat(juice): button press feedback + fade screen transitions
Adds PressableScale (0.94 squish on tap-down) around the Adventure
FilledButton, Classic OutlinedButton, and each season-map stage node.
Replaces all in-app MaterialPageRoute pushes with a gentle fade+scale
PageRouteBuilder (320ms in, 240ms out) via a new fadeRoute helper.
2026-06-13 18:12:07 +09:00
airkjw e1098949be build(brand): generate iOS/Android launcher icons from brand mark
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-13 18:08:13 +09:00
airkjw 099ced377d feat(brand): app icon painter + generated 1024px icon PNGs
Introduces AppIconMark (navy gradient field + 2×2 glossy blocks reusing
paintGlossyTile) and a testWidgets generator that writes icon.png,
icon_background.png, and icon_foreground.png to assets/icon/ for the
upcoming flutter_launcher_icons task.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-13 18:06:18 +09:00
airkjw 498fb6af83 feat(settings): sound & vibration toggle; themed settings screen
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-13 18:01:23 +09:00
airkjw 3ca038ec65 feat(settings): soundEnabled provider gates SFX and haptics
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-13 17:59:08 +09:00
airkjw 93397988a2 feat(settings): persist soundEnabled flag (additive, default true)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-13 17:56:22 +09:00
airkjw ea42c76f84 docs: Phase 6 implementation plan (icon, sound toggle, juice, l10n, store assets)
9 tasks. Icon + feature graphic drawn via CustomPainter and rasterized to PNG
under flutter test (no SVG tooling), consumed by flutter_launcher_icons. Sound
& vibration toggle follows the repo-backed Notifier pattern. Juice: press
feedback + fade routes + themed settings. Tasks 1-6 subagent-friendly; 7-9
controller-run (KO overflow, screenshots, icon-on-device).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-13 15:11:45 +09:00
airkjw 84a6749b5e docs: Phase 6 design spec (icon + l10n finalize + juice + store assets)
App icon = clean 2x2 glossy block mark (vector, no AI image). l10n EN/KO
finalize + KO overflow pass. Light juice: sound/haptics toggle, themed
settings, button press feedback, screen transitions. Store feature graphic +
EN/KO screenshots. Procedural visuals mean no raster background assets needed.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-13 15:06:39 +09:00
airkjw 6a0b543970 Merge Phase 5: monetization (AdMob + IAP)
AdMob ads (interstitial/rewarded/banner) with a pure-Dart frequency policy,
a compliant UMP->ATT->init consent flow, and a remove_ads non-consumable IAP
with Restore. Single repo-backed ownership source (adsRemovedProvider); all
ad/IAP/consent failures swallowed. Runs on Google test ids today; owner swaps
real ids by config. 169 tests green; opus final review passed.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-13 14:43:46 +09:00
airkjw c5c6af0313 docs: Phase 5 UMP consent flow verified on simulator (evidence)
First-launch UMP consent form displays, ATT requested, MobileAds SDK
initialized, no crash. Settings gear present, home renders. Banner visual +
sandbox IAP are owner device-test items (consent form blocks automated taps).
2026-06-13 14:29:52 +09:00
airkjw 7bea9c1456 fix(iap): eagerly start IAP service at launch (final review I-1)
iapServiceProvider was lazy and only read by Settings, so the purchase stream
attached only after opening Settings — deferred/interrupted/restored
transactions wouldn't be delivered or completed until then. Read it in the
post-frame callback so the stream is live for the whole session.
2026-06-13 14:29:20 +09:00
airkjw 640b23804f fix(ads): banner retries load when SDK becomes ready
On a cold start the consent flow is still running when home first builds, so
createBanner returns null and the slot stayed empty until a rebuild. AdService
now exposes an isReady ValueNotifier; BannerAdSlot listens and retries its load
once MobileAds finishes initializing. Verified: analyze clean, 169 tests green.
2026-06-13 14:23:45 +09:00
airkjw 70f87ab8f2 feat(iap): settings screen with remove-ads purchase and restore
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-13 14:17:28 +09:00
airkjw 40c2204d7b feat(ads): home/map banner slot (hidden when removed or unloaded)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-13 14:13:29 +09:00
airkjw 1ec59ba80d fix(ads): reset ad round in startStage so nextStage advance counts (review fix)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-13 14:09:02 +09:00
airkjw 297449ccce feat(ads): stage-end interstitial gated by policy; restart resets round 2026-06-13 14:03:52 +09:00
airkjw 3943653a23 feat(ads): rewarded ad gates the continue/extra-moves rescue 2026-06-13 14:02:31 +09:00
airkjw 6c4304cfd8 style(test): drop unused import in ads_notifier_test 2026-06-13 14:00:03 +09:00
airkjw 662ee55e1d feat(ads): run consent flow (UMP->ATT->init) after the first frame
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-13 13:59:22 +09:00
airkjw 539afd1dad feat(ads): ref-constructed ad/consent/iap providers, single ownership source 2026-06-13 13:57:30 +09:00
airkjw 6d2ffebb92 feat(iap): remove_ads purchase/restore service + adsRemoved notifier 2026-06-13 13:55:44 +09:00
airkjw e43fda8551 feat(ads): ConsentService enforcing UMP -> ATT -> SDK init order 2026-06-13 13:53:10 +09:00
airkjw 4744aa167a feat(ads): AdService for interstitial/rewarded/banner with policy gating 2026-06-13 13:49:18 +09:00
airkjw eb258c7324 feat(ads): ad id config with test/real switch via dart-define 2026-06-13 13:46:44 +09:00
airkjw 947d5566a2 feat(iap): persist adsRemoved flag (additive, saveVersion 1)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-13 13:45:33 +09:00
airkjw f560b9d4c8 feat(ads): pure-Dart interstitial frequency policy with tests 2026-06-13 13:42:55 +09:00
airkjw 2422a94b9a build(ios): commit pod artifacts for google_mobile_ads (Podfile.lock, pbxproj)
pod install for google_mobile_ads added Google-Mobile-Ads-SDK 12.14.0 and a
[CP] Copy Pods Resources build phase. Tracking both keeps the build
reproducible (follow-up to 245a065).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-13 13:41:29 +09:00
airkjw 245a065ac7 build: add AdMob/IAP/ATT plugins and native ad config (test ids)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-13 13:40:23 +09:00
airkjw 0781e817d0 docs: Phase 5 monetization (AdMob + IAP) implementation plan
14 tasks: pure-Dart frequency policy (TDD), adsRemoved persistence, ad config
(test ids), AdService/ConsentService/IapService, ref-constructed providers
(single ownership source), rewarded-gated rescue, stage-end interstitial,
home/map banner, settings (remove ads + restore), simulator verification.
Runs on Google test ids today; owner real ids slot in by config later.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-13 12:50:59 +09:00
airkjw 3a83c0a2b1 docs: add Firebase-integrated build runtime screenshot (evidence)
Firebase Analytics SDK initialized on the iOS simulator; debug build renders
gameplay and (per system log) disables GA4 collection. Evidence for the
analytics wiring milestone.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-13 12:20:38 +09:00
airkjw 74fe1858d4 feat(analytics): disable GA4 collection in debug builds
setAnalyticsCollectionEnabled(kReleaseMode) after Firebase init so the native
SDK's automatic events (session_start, screen_view) stay out of production
analytics in debug too — not only our custom events. Verified on simulator:
release builds collect, debug builds do not.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-13 12:18:05 +09:00
airkjw 41b0180b44 feat(analytics): wire Firebase Analytics into app startup
flutterfire configure registered the iOS/Android apps under project
block-seasons and generated firebase_options.dart + native config. main()
now initializes Firebase and routes analytics through FirebaseAnalyticsBackend
in release builds (console logger in debug, so dev traffic never pollutes
GA4). Firebase init is guarded — failure falls back to the debug logger
rather than blocking startup.

firebase.json keeps the existing Hosting config and gains the FlutterFire
platform section. Client config files are committed (they ship in the binary;
Firebase security is enforced by rules, not config secrecy).

flutter analyze clean, all 161 tests green.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-13 12:10:03 +09:00
airkjw e3fb5959c5 build(ios): raise deployment target to iOS 15.0 for Firebase SDK
firebase_core 4.x bundles the Firebase iOS SDK which requires iOS 15.0+.
Sets the Podfile platform and the three Xcode build configs accordingly.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-13 12:01:43 +09:00
airkjw 5cd9d0ab10 feat(analytics): add FirebaseAnalyticsBackend (firebase wiring pt.1)
Adds firebase_core + firebase_analytics and a FirebaseAnalyticsBackend that
adapts the existing AnalyticsBackend interface to GA4. Kept in its own file
so the typed AnalyticsService and DebugAnalyticsBackend stay free of the
firebase dependency (unit tests never pull in platform channels).

Not yet wired into main() — that needs lib/firebase_options.dart from
flutterfire configure (owner step). All 161 tests still green.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-13 12:00:45 +09:00
airkjw 3e25e3b9ca chore: add Firebase Hosting config for season content delivery
firebase init hosting wired to project block-seasons, public dir = deploy.
Generated season payload (deploy/content/) and CLI cache (.firebase/) are
gitignored — content/ is the source of truth, regenerated by the deploy
script in docs/firebase-hosting-guide.md.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-13 09:18:53 +09:00
airkjw af7bb83fb9 Merge Phase 4: remote seasons + analytics
Manifest-driven season delivery (SHA256 + atomic cache, bundled
fallback), Season 2 'Summer Tide' remote-only content, analytics
abstraction with debug backend, rescue crash fix.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 22:49:42 +09:00
airkjw b5134ef86d fix: season list re-reads via provider dependency; ignore pid files
seasonsProvider now watches seasonRefreshProvider instead of relying on
a HomeScreen ref.listen, making the refresh ordering-independent. Removes
the redundant listener from HomeScreen. Appends *.pid to .gitignore.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 22:49:24 +09:00
airkjw 8555397c43 feat: phase 4 remote seasons verified end-to-end; owner hosting guide
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 22:43:53 +09:00
airkjw ba70db3e60 feat: season 2 'Summer Tide' content and manifest generator
Adds Summer Tide (season_002) — 30 stages with deep-teal gradient and
cyan accent — generated remote-only (no copyToAssets). Introduces
tool/make_manifest.dart to rebuild content/manifest.json from all
season packs with SHA-256 verification support.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 13:45:22 +09:00
airkjw 9763968db9 fix: hide spent rescue to prevent StateError crash; log per-attempt starts
Expose engine.rescueUsed getter and surface it through GameViewState so
the result overlay can omit the watch-ad FilledButton after a rescue has
been consumed, preventing a second tap from calling useContinue /
addExtraMoves and hitting their StateError guard. Give-up is promoted to
FilledButton when rescue is unavailable for clear affordance.

Also emit stageStart / endlessStart analytics in restart() so every
attempt (not just the first) is bracketed by a matching start event.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 13:41:59 +09:00
airkjw 074a21ea2b feat: analytics abstraction with debug backend and game event wiring
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 13:35:31 +09:00
airkjw 6d2d97bfcc fix: guard journey map against an empty season list
Prevents StateError when data builder is called with empty list
by displaying loading indicator instead of passing empty list to
activeSeason(), matching title screen behavior.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 13:31:22 +09:00
airkjw 4fa5564975 feat: session content sync trigger and newest-season selection
Add seasonRefreshProvider (once-per-session FutureProvider) and activeSeason()
helper; HomeScreen listens and invalidates seasonsProvider when new packs arrive;
season_map_screen and season_title_screen switch from list.first to activeSeason.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 13:28:39 +09:00
airkjw 73a56aeeb1 perf: async cache reads in content repository 2026-06-12 13:24:14 +09:00
airkjw e722fe2ce1 feat: content repository merges cached seasons with bundled fallback
Extends ContentRepository with optional cacheDir: cached packs in
<cacheDir>/seasons/*/pack.json merge with bundled ones (cached wins
for same id), corrupt/future-schema packs silently ignored, refresh()
fires ContentDownloader.sync() once per session. main() wires the real
cache+downloader instance; default constructor stays bundled-only for tests.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 13:15:35 +09:00
airkjw a820e97237 fix: harden downloader against path traversal, URL escape, oversized bodies
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 13:11:18 +09:00
airkjw bfa9c09b28 feat: content downloader with sha256 verify and atomic cache
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 13:05:56 +09:00
airkjw c7bdb9b9c9 feat: remote manifest model and content dependencies
Add http, crypto, path_provider deps; introduce RemoteManifest /
ManifestSeason immutable models with fromJson/toJson, schema-version
guard (FormatException on unsupported schema), and fallback for missing
current field. 3/3 TDD tests pass, flutter analyze clean.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 13:02:17 +09:00
airkjw 63ac8c6b9e docs: add Phase 4 remote seasons implementation plan
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 12:44:41 +09:00
184 changed files with 15189 additions and 1299 deletions
+5
View File
@@ -0,0 +1,5 @@
{
"projects": {
"default": "block-seasons"
}
}
+13
View File
@@ -48,3 +48,16 @@ app.*.map.json
lib/l10n/gen/
.superpowers/
CLAUDE.md
*.pid
# Firebase Hosting (CLI deploy cache + generated content payload)
.firebase/
/deploy/content/
# Android release signing — NEVER commit (owner backs these up out-of-band)
android/key.properties
*.jks
*.keystore
# Kotlin/Gradle build caches
android/.kotlin/
+32 -3
View File
@@ -1,10 +1,26 @@
import java.io.FileInputStream
import java.util.Properties
plugins {
id("com.android.application")
// START: FlutterFire Configuration
id("com.google.gms.google-services")
id("com.google.firebase.crashlytics")
// END: FlutterFire Configuration
id("kotlin-android")
// The Flutter Gradle Plugin must be applied after the Android and Kotlin Gradle plugins.
id("dev.flutter.flutter-gradle-plugin")
}
// Release signing is read from android/key.properties (gitignored). When that
// file is absent (CI, a fresh clone, another machine) the release build falls
// back to debug signing so `flutter build`/`flutter run --release` still works.
val keystoreProperties = Properties()
val keystorePropertiesFile = rootProject.file("key.properties")
if (keystorePropertiesFile.exists()) {
keystoreProperties.load(FileInputStream(keystorePropertiesFile))
}
android {
namespace = "com.airkjw.block_seasons"
compileSdk = flutter.compileSdkVersion
@@ -31,11 +47,24 @@ android {
versionName = flutter.versionName
}
signingConfigs {
create("release") {
if (keystorePropertiesFile.exists()) {
keyAlias = keystoreProperties["keyAlias"] as String
keyPassword = keystoreProperties["keyPassword"] as String
storeFile = file(keystoreProperties["storeFile"] as String)
storePassword = keystoreProperties["storePassword"] as String
}
}
}
buildTypes {
release {
// TODO: Add your own signing config for the release build.
// Signing with the debug keys for now, so `flutter run --release` works.
signingConfig = signingConfigs.getByName("debug")
signingConfig = if (keystorePropertiesFile.exists()) {
signingConfigs.getByName("release")
} else {
signingConfigs.getByName("debug")
}
}
}
}
+29
View File
@@ -0,0 +1,29 @@
{
"project_info": {
"project_number": "190209969950",
"project_id": "block-seasons",
"storage_bucket": "block-seasons.firebasestorage.app"
},
"client": [
{
"client_info": {
"mobilesdk_app_id": "1:190209969950:android:e08dc30877b1821b44c30f",
"android_client_info": {
"package_name": "com.airkjw.blockseasons"
}
},
"oauth_client": [],
"api_key": [
{
"current_key": "AIzaSyCd_3Tw5IxlO6ysp3XMQ9tBsOM1yMYl2MU"
}
],
"services": {
"appinvite_service": {
"other_platform_oauth_client": []
}
}
}
],
"configuration_version": "1"
}
+4 -1
View File
@@ -1,6 +1,6 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<application
android:label="block_seasons"
android:label="Block Seasons"
android:name="${applicationName}"
android:icon="@mipmap/ic_launcher">
<activity
@@ -25,6 +25,9 @@
<category android:name="android.intent.category.LAUNCHER"/>
</intent-filter>
</activity>
<meta-data
android:name="com.google.android.gms.ads.APPLICATION_ID"
android:value="ca-app-pub-5605900229781491~8257495040"/>
<!-- Don't delete the meta-data below.
This is used by the Flutter tool to generate GeneratedPluginRegistrant.java -->
<meta-data
Binary file not shown.

After

Width:  |  Height:  |  Size: 7.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 43 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

@@ -0,0 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@drawable/ic_launcher_background"/>
<foreground>
<inset
android:drawable="@drawable/ic_launcher_foreground"
android:inset="16%" />
</foreground>
</adaptive-icon>
Binary file not shown.

Before

Width:  |  Height:  |  Size: 544 B

After

Width:  |  Height:  |  Size: 3.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 442 B

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 721 B

After

Width:  |  Height:  |  Size: 3.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.0 KiB

After

Width:  |  Height:  |  Size: 8.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.4 KiB

After

Width:  |  Height:  |  Size: 9.2 KiB

+4
View File
@@ -20,6 +20,10 @@ pluginManagement {
plugins {
id("dev.flutter.flutter-plugin-loader") version "1.0.0"
id("com.android.application") version "8.9.1" apply false
// START: FlutterFire Configuration
id("com.google.gms.google-services") version("4.4.4") apply false
id("com.google.firebase.crashlytics") version("3.0.3") apply false
// END: FlutterFire Configuration
id("org.jetbrains.kotlin.android") version "2.1.0" apply false
}
+7
View File
@@ -0,0 +1,7 @@
Drop background-music tracks here as MP3, named by theme key:
menu.mp3 — home/menu (SeasonTheme.fallback, bgm="menu")
season_001.mp3 — Season 1 "First Bloom"
season_002.mp3 — Season 2 "Summer Tide"
Use CC0 / royalty-free, commercial-safe tracks (see docs). The app plays
whatever is present and stays silent (no error) for any missing track.
Binary file not shown.
Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 422 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 326 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 115 KiB

File diff suppressed because it is too large Load Diff
+19
View File
@@ -0,0 +1,19 @@
{
"schemaVersion": 1,
"minAppBuild": 1,
"current": "season_002",
"seasons": [
{
"seasonId": "season_001",
"version": 1,
"packUrl": "seasons/season_001/pack.json",
"sha256": "8d09f85bf2d2af8c80d7de20b17b4f65a7df5587e8728a7182fed82b9e507f4a"
},
{
"seasonId": "season_002",
"version": 1,
"packUrl": "seasons/season_002/pack.json",
"sha256": "7be1d0082d9fa81b25938c340801baf9cc0deecdbbd0cdc2d75af443e9fb8552"
}
]
}
File diff suppressed because it is too large Load Diff
+58 -58
View File
@@ -1,66 +1,66 @@
# season_001 difficulty report
60 stages, 80 bot runs each, generated in 10s.
60 stages, 80 bot runs each, generated in 9s.
| stage | objective | moves | bot win rate | 2★/3★ movesLeft |
|---|---|---|---|---|
| season_001_001 | clearGems 2 | 11 | 89% | 4/5 |
| season_001_002 | clearGems 1 | 7 | 79% | 3/4 |
| season_001_003 | clearGems 1 | 6 | 90% | 2/3 |
| season_001_004 | clearGems 2 | 13 | 84% | 5/6 |
| season_001_005 | reachScore 888 | 28 | 99% | 9/12 |
| season_001_006 | clearGems 3 | 11 | 79% | 4/5 |
| season_001_007 | clearLines 4 | 19 | 100% | 6/8 |
| season_001_008 | clearGems 2 | 6 | 75% | 2/3 |
| season_001_009 | clearGems 2 | 8 | 75% | 3/4 |
| season_001_010 | reachScore 1017 | 27 | 99% | 8/10 |
| season_001_011 | clearGems 2 | 7 | 79% | 2/3 |
| season_001_012 | clearGems 3 | 22 | 79% | 9/12 |
| season_001_013 | clearGems 2 | 12 | 79% | 2/5 |
| season_001_014 | clearLines 5 | 24 | 100% | 7/9 |
| season_001_015 | reachScore 1243 | 30 | 100% | 8/11 |
| season_001_016 | clearGems 4 | 23 | 73% | 7/10 |
| season_001_017 | clearGems 3 | 18 | 73% | 10/11 |
| season_001_018 | clearGems 4 | 14 | 83% | 4/7 |
| season_001_019 | clearGems 3 | 16 | 78% | 5/6 |
| season_001_020 | reachScore 1478 | 32 | 99% | 7/11 |
| season_001_021 | clearLines 5 | 22 | 100% | 6/7 |
| season_001_022 | clearGems 4 | 26 | 86% | 10/13 |
| season_001_023 | clearGems 3 | 10 | 70% | 3/4 |
| season_001_024 | clearGems 3 | 18 | 80% | 5/8 |
| season_001_025 | reachScore 1707 | 28 | 85% | 3/6 |
| season_001_026 | clearGems 5 | 19 | 76% | 3/7 |
| season_001_027 | clearGems 5 | 17 | 86% | 4/8 |
| season_001_028 | clearLines 6 | 20 | 95% | 3/4 |
| season_001_029 | clearGems 4 | 23 | 88% | 7/10 |
| season_001_030 | reachScore 1838 | 28 | 86% | 3/6 |
| season_001_031 | clearGems 5 | 28 | 81% | 8/12 |
| season_001_032 | clearGems 5 | 23 | 74% | 5/9 |
| season_001_001 | clearGems 2 | 16 | 99% | 9/10 |
| season_001_002 | clearGems 1 | 11 | 96% | 7/8 |
| season_001_003 | clearGems 1 | 7 | 98% | 3/4 |
| season_001_004 | clearGems 2 | 20 | 96% | 10/13 |
| season_001_005 | clearGems 1 | 13 | 96% | 8/9 |
| season_001_006 | clearGems 3 | 16 | 94% | 9/10 |
| season_001_007 | clearGems 3 | 20 | 98% | 10/13 |
| season_001_008 | clearGems 2 | 10 | 95% | 6/7 |
| season_001_009 | clearGems 2 | 11 | 91% | 4/7 |
| season_001_010 | reachScore 1017 | 37 | 99% | 18/20 |
| season_001_011 | clearGems 2 | 11 | 90% | 6/7 |
| season_001_012 | clearGems 3 | 29 | 90% | 14/19 |
| season_001_013 | clearGems 2 | 15 | 93% | 5/8 |
| season_001_014 | clearLines 5 | 32 | 100% | 15/17 |
| season_001_015 | reachScore 1243 | 39 | 100% | 17/20 |
| season_001_016 | clearGems 4 | 30 | 91% | 11/16 |
| season_001_017 | clearGems 3 | 24 | 89% | 14/17 |
| season_001_018 | clearGems 4 | 19 | 94% | 9/12 |
| season_001_019 | clearGems 3 | 20 | 90% | 7/10 |
| season_001_020 | reachScore 1478 | 42 | 99% | 17/21 |
| season_001_021 | clearLines 5 | 29 | 100% | 13/14 |
| season_001_022 | clearGems 4 | 34 | 93% | 18/21 |
| season_001_023 | clearGems 3 | 13 | 84% | 6/7 |
| season_001_024 | clearGems 3 | 23 | 90% | 10/12 |
| season_001_025 | reachScore 1707 | 46 | 100% | 21/24 |
| season_001_026 | clearGems 5 | 25 | 95% | 9/12 |
| season_001_027 | clearGems 5 | 21 | 93% | 8/12 |
| season_001_028 | clearLines 6 | 29 | 100% | 12/13 |
| season_001_029 | clearGems 4 | 29 | 98% | 12/16 |
| season_001_030 | reachScore 1838 | 41 | 100% | 15/19 |
| season_001_031 | clearGems 5 | 36 | 90% | 14/20 |
| season_001_032 | clearGems 5 | 29 | 89% | 10/15 |
| season_001_033 | clearGems 4 | 24 | 73% | 11/14 |
| season_001_034 | clearGems 4 | 21 | 74% | 5/8 |
| season_001_034 | clearGems 4 | 27 | 94% | 8/14 |
| season_001_035 | clearLines 8 | 24 | 88% | 2/4 |
| season_001_036 | clearGems 6 | 25 | 65% | 5/8 |
| season_001_037 | clearGems 6 | 17 | 86% | 6/9 |
| season_001_038 | clearGems 6 | 29 | 78% | 10/15 |
| season_001_039 | clearGems 6 | 29 | 73% | 6/12 |
| season_001_040 | reachScore 2328 | 32 | 80% | 2/6 |
| season_001_041 | clearGems 5 | 17 | 73% | 5/8 |
| season_001_042 | clearLines 9 | 25 | 78% | 1/4 |
| season_001_043 | clearGems 6 | 22 | 79% | 5/9 |
| season_001_044 | clearGems 6 | 26 | 75% | 6/10 |
| season_001_036 | clearGems 6 | 31 | 85% | 9/13 |
| season_001_037 | clearGems 6 | 21 | 95% | 8/12 |
| season_001_038 | clearGems 6 | 36 | 85% | 16/22 |
| season_001_039 | clearGems 6 | 35 | 83% | 10/17 |
| season_001_040 | reachScore 2328 | 33 | 81% | 3/7 |
| season_001_041 | clearGems 5 | 21 | 81% | 8/12 |
| season_001_042 | clearLines 9 | 27 | 95% | 2/5 |
| season_001_043 | clearGems 6 | 27 | 88% | 9/14 |
| season_001_044 | clearGems 6 | 31 | 91% | 9/15 |
| season_001_045 | reachScore 2451 | 34 | 88% | 4/6 |
| season_001_046 | clearGems 6 | 22 | 74% | 6/9 |
| season_001_047 | clearGems 7 | 21 | 79% | 5/8 |
| season_001_048 | clearGems 7 | 26 | 71% | 6/11 |
| season_001_049 | clearLines 9 | 24 | 68% | 1/2 |
| season_001_050 | reachScore 2726 | 37 | 93% | 4/8 |
| season_001_051 | clearGems 6 | 24 | 78% | 5/10 |
| season_001_052 | clearGems 6 | 21 | 65% | 5/8 |
| season_001_053 | clearGems 6 | 28 | 83% | 9/14 |
| season_001_054 | clearGems 7 | 21 | 78% | 6/8 |
| season_001_055 | reachScore 2978 | 39 | 91% | 5/8 |
| season_001_056 | clearLines 11 | 30 | 83% | 2/4 |
| season_001_057 | clearGems 7 | 16 | 74% | 5/7 |
| season_001_058 | clearGems 8 | 20 | 85% | 7/10 |
| season_001_059 | clearGems 8 | 23 | 59% | 6/10 |
| season_001_060 | reachScore 3145 | 37 | 60% | 1/5 |
| season_001_046 | clearGems 6 | 26 | 83% | 9/13 |
| season_001_047 | clearGems 7 | 24 | 88% | 8/11 |
| season_001_048 | clearGems 7 | 30 | 75% | 10/15 |
| season_001_049 | clearLines 9 | 25 | 86% | 2/3 |
| season_001_050 | reachScore 2726 | 33 | 60% | 2/6 |
| season_001_051 | clearGems 6 | 28 | 90% | 9/13 |
| season_001_052 | clearGems 6 | 25 | 83% | 8/12 |
| season_001_053 | clearGems 6 | 32 | 88% | 13/18 |
| season_001_054 | clearGems 7 | 22 | 84% | 6/9 |
| season_001_055 | reachScore 2978 | 38 | 89% | 4/7 |
| season_001_056 | clearLines 11 | 28 | 69% | 1/2 |
| season_001_057 | clearGems 7 | 18 | 76% | 7/8 |
| season_001_058 | clearGems 8 | 22 | 88% | 9/12 |
| season_001_059 | clearGems 8 | 26 | 71% | 7/13 |
| season_001_060 | reachScore 3145 | 38 | 71% | 2/5 |
+1 -1
View File
@@ -2,7 +2,7 @@
"seasonId": "season_001",
"version": 1,
"title": { "en": "First Bloom", "ko": "첫 개화" },
"theme": { "tileSet": "spring", "background": "background.webp" },
"theme": { "tileSet": "spring", "background": "background.webp", "bgm": "season_001" },
"stageCount": 60,
"baseSeed": 20260611,
"runsPerStage": 80,
File diff suppressed because it is too large Load Diff
+36
View File
@@ -0,0 +1,36 @@
# season_002 difficulty report
30 stages, 80 bot runs each, generated in 4s.
| stage | objective | moves | bot win rate | 2★/3★ movesLeft |
|---|---|---|---|---|
| season_002_001 | clearGems 1 | 7 | 83% | 3/4 |
| season_002_002 | clearGems 1 | 8 | 79% | 4/5 |
| season_002_003 | clearGems 2 | 11 | 91% | 4/5 |
| season_002_004 | clearGems 2 | 12 | 86% | 5/6 |
| season_002_005 | reachScore 990 | 25 | 100% | 6/9 |
| season_002_006 | clearGems 3 | 24 | 74% | 11/17 |
| season_002_007 | clearLines 5 | 23 | 100% | 6/8 |
| season_002_008 | clearGems 2 | 6 | 75% | 2/3 |
| season_002_009 | clearGems 4 | 14 | 86% | 6/7 |
| season_002_010 | reachScore 1476 | 31 | 100% | 9/11 |
| season_002_011 | clearGems 4 | 16 | 95% | 6/9 |
| season_002_012 | clearGems 4 | 15 | 70% | 5/7 |
| season_002_013 | clearGems 4 | 20 | 75% | 9/11 |
| season_002_014 | clearLines 7 | 27 | 99% | 5/7 |
| season_002_015 | reachScore 1766 | 30 | 96% | 4/8 |
| season_002_016 | clearGems 5 | 10 | 73% | 3/5 |
| season_002_017 | clearGems 5 | 21 | 65% | 7/10 |
| season_002_018 | clearGems 6 | 27 | 74% | 9/13 |
| season_002_019 | clearGems 6 | 20 | 73% | 4/7 |
| season_002_020 | reachScore 2185 | 33 | 93% | 5/8 |
| season_002_021 | clearLines 8 | 23 | 79% | 1/3 |
| season_002_022 | clearGems 5 | 28 | 85% | 6/12 |
| season_002_023 | clearGems 7 | 26 | 76% | 6/10 |
| season_002_024 | clearGems 7 | 21 | 83% | 5/10 |
| season_002_025 | reachScore 2692 | 39 | 93% | 7/11 |
| season_002_026 | clearGems 6 | 31 | 59% | 9/15 |
| season_002_027 | clearGems 6 | 13 | 65% | 2/5 |
| season_002_028 | clearLines 10 | 27 | 89% | 2/4 |
| season_002_029 | clearGems 7 | 27 | 69% | 8/10 |
| season_002_030 | reachScore 3006 | 37 | 80% | 2/6 |
+15
View File
@@ -0,0 +1,15 @@
{
"seasonId": "season_002",
"version": 1,
"title": { "en": "Summer Tide", "ko": "여름 파도" },
"theme": {
"tileSet": "summer",
"background": "",
"backgroundGradient": [4278854704, 4279253322, 4280179302],
"accentColor": 4285517301,
"particleType": "petals"
},
"stageCount": 30,
"baseSeed": 20260612,
"runsPerStage": 80
}
+33
View File
@@ -0,0 +1,33 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Page Not Found</title>
<style media="screen">
body { background: #ECEFF1; color: rgba(0,0,0,0.87); font-family: Roboto, Helvetica, Arial, sans-serif; margin: 0; padding: 0; }
#message { background: white; max-width: 360px; margin: 100px auto 16px; padding: 32px 24px 16px; border-radius: 3px; }
#message h3 { color: #888; font-weight: normal; font-size: 16px; margin: 16px 0 12px; }
#message h2 { color: #ffa100; font-weight: bold; font-size: 16px; margin: 0 0 8px; }
#message h1 { font-size: 22px; font-weight: 300; color: rgba(0,0,0,0.6); margin: 0 0 16px;}
#message p { line-height: 140%; margin: 16px 0 24px; font-size: 14px; }
#message a { display: block; text-align: center; background: #039be5; text-transform: uppercase; text-decoration: none; color: white; padding: 16px; border-radius: 4px; }
#message, #message a { box-shadow: 0 1px 3px rgba(0,0,0,0.12), 0 1px 2px rgba(0,0,0,0.24); }
#load { color: rgba(0,0,0,0.4); text-align: center; font-size: 13px; }
@media (max-width: 600px) {
body, #message { margin-top: 0; background: white; box-shadow: none; }
body { border-top: 16px solid #ffa100; }
}
</style>
</head>
<body>
<div id="message">
<h2>404</h2>
<h1>Page Not Found</h1>
<p>The specified file was not found on this website. Please check the URL for mistakes and try again.</p>
<h3>Why am I seeing this?</h3>
<p>This page was generated by the Firebase Command-Line Interface. To modify it, edit the <code>404.html</code> file in your project's configured <code>public</code> directory.</p>
</div>
</body>
</html>
+1
View File
@@ -0,0 +1 @@
google.com, pub-5605900229781491, DIRECT, f08c47fec0942fa0
+69
View File
@@ -0,0 +1,69 @@
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Block Seasons — 시즌마다 새로워지는 블록 퍼즐</title>
<meta name="description" content="Block Seasons는 8×8 보드에 블록을 놓아 줄을 지우고, 몇 주마다 새 테마 시즌을 즐기는 편안한 블록 퍼즐입니다.">
<style>
:root{ --navy:#0E1430; --navy2:#1B2350; --accent:#5B7FFF; --ink:#EAF0FF; --muted:#9DA9C7; }
*{ box-sizing:border-box; }
body{ margin:0; font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,"Noto Sans KR",sans-serif;
background:linear-gradient(160deg,var(--navy),var(--navy2)); color:var(--ink); line-height:1.6; }
.wrap{ max-width:680px; margin:0 auto; padding:56px 24px 72px; }
.mark{ display:flex; gap:6px; margin-bottom:28px; }
.mark span{ width:30px; height:30px; border-radius:8px; box-shadow:inset 0 -3px 0 rgba(0,0,0,.18), 0 2px 6px rgba(0,0,0,.3); }
.b1{ background:#6E8BFF; } .b2{ background:#F4B6C2; } .b3{ background:#7FD4C0; } .b4{ background:#F6CF76; }
h1{ font-size:2.2rem; margin:0 0 6px; letter-spacing:-.5px; }
.tag{ color:var(--accent); font-weight:600; margin:0 0 28px; font-size:1.05rem; }
p{ color:var(--ink); }
.lead{ font-size:1.05rem; }
ul{ padding-left:1.1rem; } li{ margin:.3rem 0; color:var(--ink); }
.muted{ color:var(--muted); }
h2{ font-size:1.1rem; margin:2.4rem 0 .6rem; color:#fff; }
.links{ display:flex; flex-wrap:wrap; gap:12px; margin:30px 0 8px; }
.links a{ display:inline-block; text-decoration:none; padding:12px 20px; border-radius:10px;
background:var(--accent); color:#fff; font-weight:600; }
.links a.alt{ background:transparent; border:1px solid rgba(255,255,255,.25); color:var(--ink); }
hr{ border:none; border-top:1px solid rgba(255,255,255,.12); margin:40px 0 24px; }
footer{ color:var(--muted); font-size:.86rem; }
a.inline{ color:var(--accent); }
</style>
</head>
<body>
<div class="wrap">
<div class="mark"><span class="b1"></span><span class="b2"></span><span class="b3"></span><span class="b4"></span></div>
<h1>Block Seasons</h1>
<p class="tag">시즌마다 새로워지는 블록 퍼즐 · A seasonal block puzzle</p>
<p class="lead">8×8 보드에 세 조각을 드래그해 가로·세로 줄을 지우는, 편안하고 예쁜 블록 퍼즐입니다.
몇 주마다 새 테마의 시즌과 스테이지가 앱 업데이트 없이 도착하고, 시즌 1은 오프라인으로도 즐길 수 있어요.</p>
<ul>
<li>시즌제 — 몇 주마다 새 테마와 스테이지</li>
<li>일러스트 여정 맵 + 엔드리스 모드</li>
<li>광고 강요 없는 공정한 설계, 일회성 ‘광고 제거’ 지원</li>
<li>오프라인 플레이 (시즌 1 내장)</li>
</ul>
<p class="muted">A cozy 8×8 block puzzle. Drop three pieces, clear lines, and enjoy a fresh themed
season every few weeks — no app update needed. Season 1 plays fully offline.</p>
<div class="links">
<a href="mailto:airkjw@gmail.com">문의 / Contact</a>
<a class="alt" href="/privacy-policy.html">개인정보처리방침 / Privacy</a>
</div>
<h2>지원 / Support</h2>
<p class="muted">문의 사항은 <a class="inline" href="mailto:airkjw@gmail.com">airkjw@gmail.com</a> 으로 보내주세요.
보통 2~3일 내에 답변드립니다. · For support, email
<a class="inline" href="mailto:airkjw@gmail.com">airkjw@gmail.com</a>.</p>
<hr>
<footer>© 2026 Joungwook Kwon · Block Seasons</footer>
</div>
</body>
</html>
+105
View File
@@ -0,0 +1,105 @@
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Block Seasons — Privacy Policy / 개인정보처리방침</title>
<style>
body{max-width:760px;margin:0 auto;padding:24px;font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif;line-height:1.6;color:#1a1a2e}
h1{font-size:1.5rem} h2{font-size:1.15rem;margin-top:2rem;border-bottom:1px solid #ddd;padding-bottom:4px}
h3{font-size:1rem;margin-top:1.4rem} code{background:#f0f0f5;padding:1px 5px;border-radius:4px}
.meta{color:#666;font-size:.9rem} hr{margin:3rem 0;border:none;border-top:2px solid #eee}
a{color:#3a5fcd}
</style>
</head>
<body>
<!-- ========================= KOREAN ========================= -->
<h1>Block Seasons 개인정보처리방침</h1>
<p class="meta">최종 업데이트: 2026년 6월 14일 · 문의: <a href="mailto:airkjw@gmail.com">airkjw@gmail.com</a></p>
<p>본 방침은 모바일 게임 <strong>Block Seasons</strong>(이하 "본 앱")의 개인정보 처리에 관한 내용을 설명합니다. 본 앱은 계정 가입이 필요 없으며, 이름·이메일 등 직접적인 개인 식별 정보를 수집하지 않습니다.</p>
<h2>1. 수집하는 정보</h2>
<ul>
<li><strong>광고 식별자</strong> (Android 광고 ID / Apple IDFA): 광고 게재 및 측정을 위해 광고 파트너(Google AdMob)가 사용합니다.</li>
<li><strong>사용 데이터</strong> (앱 이용 통계, 화면·이벤트 상호작용): 앱 품질 개선과 분석을 위해 Firebase Analytics가 수집합니다.</li>
<li><strong>기기 정보</strong> (기기 모델, 운영체제 버전, 대략적 지역 등): 광고·분석의 기본 진단 정보로 사용됩니다.</li>
</ul>
<p>본 앱 개발자는 위 정보를 통해 개인을 식별하지 않으며, 별도의 서버에 개인정보를 저장하지 않습니다. 게임 진행·설정은 기기 내부(로컬)에만 저장됩니다.</p>
<h2>2. 정보 이용 목적</h2>
<ul>
<li>광고 게재 및 수익 창출 (무료 제공을 위한 광고 기반 모델)</li>
<li>앱 사용성 분석 및 기능·난이도 개선</li>
<li>오류 진단 및 안정성 향상</li>
</ul>
<h2>3. 제3자 제공 및 처리</h2>
<p>본 앱은 다음 제3자 서비스를 사용하며, 해당 서비스의 정책에 따라 정보가 처리됩니다.</p>
<ul>
<li><strong>Google AdMob</strong> (광고) — <a href="https://policies.google.com/privacy">Google 개인정보처리방침</a></li>
<li><strong>Google Firebase / Analytics</strong> (분석) — <a href="https://firebase.google.com/support/privacy">Firebase 개인정보 보호</a></li>
</ul>
<h2>4. 추적 및 맞춤 광고 (iOS)</h2>
<p>iOS에서는 앱 실행 시 <strong>추적 허용(App Tracking Transparency)</strong> 동의를 요청합니다. 동의하면 맞춤형 광고가 제공될 수 있고, 거부해도 본 앱의 모든 기능을 정상적으로 이용할 수 있으며 비맞춤형 광고가 표시됩니다.</p>
<h2>5. 인앱 구매</h2>
<p>일회성 "광고 제거(Remove Ads)" 인앱 구매를 제공합니다. 결제는 Apple App Store 또는 Google Play를 통해 처리되며, 개발자는 결제 카드 등 결제 수단 정보를 수집하거나 보관하지 않습니다.</p>
<h2>6. 아동의 개인정보</h2>
<p>본 앱은 만 13세 미만 아동을 주 대상으로 하지 않으며, 아동의 개인정보를 고의로 수집하지 않습니다.</p>
<h2>7. 데이터 보관 및 삭제</h2>
<p>로컬 저장 데이터는 앱 삭제 시 함께 제거됩니다. 광고/분석 데이터의 처리·삭제는 위 제3자 정책을 따릅니다. 관련 문의는 아래 이메일로 연락 주십시오.</p>
<h2>8. 문의</h2>
<p>개인정보 관련 문의: <a href="mailto:airkjw@gmail.com">airkjw@gmail.com</a></p>
<hr>
<!-- ========================= ENGLISH ========================= -->
<h1>Block Seasons Privacy Policy</h1>
<p class="meta">Last updated: June 14, 2026 · Contact: <a href="mailto:airkjw@gmail.com">airkjw@gmail.com</a></p>
<p>This policy describes how the mobile game <strong>Block Seasons</strong> ("the App") handles information. The App requires no account sign-up and does not collect directly identifying personal information such as your name or email.</p>
<h2>1. Information We Collect</h2>
<ul>
<li><strong>Advertising identifier</strong> (Android Advertising ID / Apple IDFA): used by our advertising partner (Google AdMob) to serve and measure ads.</li>
<li><strong>Usage data</strong> (app interaction, screen and event analytics): collected by Firebase Analytics to improve app quality.</li>
<li><strong>Device information</strong> (device model, OS version, coarse region): used for advertising and analytics diagnostics.</li>
</ul>
<p>The developer does not use this information to identify you personally and stores no personal data on its own servers. Game progress and settings are stored only locally on your device.</p>
<h2>2. How We Use Information</h2>
<ul>
<li>To serve ads and generate revenue (an ad-supported free model)</li>
<li>To analyze usage and improve features and difficulty</li>
<li>To diagnose errors and improve stability</li>
</ul>
<h2>3. Third Parties</h2>
<ul>
<li><strong>Google AdMob</strong> (advertising) — <a href="https://policies.google.com/privacy">Google Privacy Policy</a></li>
<li><strong>Google Firebase / Analytics</strong> (analytics) — <a href="https://firebase.google.com/support/privacy">Firebase Privacy</a></li>
</ul>
<h2>4. Tracking &amp; Personalized Ads (iOS)</h2>
<p>On iOS the App requests <strong>App Tracking Transparency</strong> permission. If you allow it, personalized ads may be shown. If you decline, the App works fully and shows non-personalized ads.</p>
<h2>5. In-App Purchases</h2>
<p>A one-time "Remove Ads" purchase is offered. Payment is handled by the Apple App Store or Google Play; the developer does not collect or store your payment details.</p>
<h2>6. Children's Privacy</h2>
<p>The App is not primarily directed at children under 13 and does not knowingly collect personal information from children.</p>
<h2>7. Data Retention &amp; Deletion</h2>
<p>Locally stored data is removed when the App is uninstalled. Advertising and analytics data follow the third-party policies above. For requests, contact us below.</p>
<h2>8. Contact</h2>
<p>Privacy inquiries: <a href="mailto:airkjw@gmail.com">airkjw@gmail.com</a></p>
</body>
</html>
+1
View File
@@ -0,0 +1 @@
google.com, pub-5605900229781491, DIRECT, f08c47fec0942fa0
+105
View File
@@ -0,0 +1,105 @@
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Block Seasons — Privacy Policy / 개인정보처리방침</title>
<style>
body{max-width:760px;margin:0 auto;padding:24px;font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif;line-height:1.6;color:#1a1a2e}
h1{font-size:1.5rem} h2{font-size:1.15rem;margin-top:2rem;border-bottom:1px solid #ddd;padding-bottom:4px}
h3{font-size:1rem;margin-top:1.4rem} code{background:#f0f0f5;padding:1px 5px;border-radius:4px}
.meta{color:#666;font-size:.9rem} hr{margin:3rem 0;border:none;border-top:2px solid #eee}
a{color:#3a5fcd}
</style>
</head>
<body>
<!-- ========================= KOREAN ========================= -->
<h1>Block Seasons 개인정보처리방침</h1>
<p class="meta">최종 업데이트: 2026년 6월 14일 · 문의: <a href="mailto:airkjw@gmail.com">airkjw@gmail.com</a></p>
<p>본 방침은 모바일 게임 <strong>Block Seasons</strong>(이하 "본 앱")의 개인정보 처리에 관한 내용을 설명합니다. 본 앱은 계정 가입이 필요 없으며, 이름·이메일 등 직접적인 개인 식별 정보를 수집하지 않습니다.</p>
<h2>1. 수집하는 정보</h2>
<ul>
<li><strong>광고 식별자</strong> (Android 광고 ID / Apple IDFA): 광고 게재 및 측정을 위해 광고 파트너(Google AdMob)가 사용합니다.</li>
<li><strong>사용 데이터</strong> (앱 이용 통계, 화면·이벤트 상호작용): 앱 품질 개선과 분석을 위해 Firebase Analytics가 수집합니다.</li>
<li><strong>기기 정보</strong> (기기 모델, 운영체제 버전, 대략적 지역 등): 광고·분석의 기본 진단 정보로 사용됩니다.</li>
</ul>
<p>본 앱 개발자는 위 정보를 통해 개인을 식별하지 않으며, 별도의 서버에 개인정보를 저장하지 않습니다. 게임 진행·설정은 기기 내부(로컬)에만 저장됩니다.</p>
<h2>2. 정보 이용 목적</h2>
<ul>
<li>광고 게재 및 수익 창출 (무료 제공을 위한 광고 기반 모델)</li>
<li>앱 사용성 분석 및 기능·난이도 개선</li>
<li>오류 진단 및 안정성 향상</li>
</ul>
<h2>3. 제3자 제공 및 처리</h2>
<p>본 앱은 다음 제3자 서비스를 사용하며, 해당 서비스의 정책에 따라 정보가 처리됩니다.</p>
<ul>
<li><strong>Google AdMob</strong> (광고) — <a href="https://policies.google.com/privacy">Google 개인정보처리방침</a></li>
<li><strong>Google Firebase / Analytics</strong> (분석) — <a href="https://firebase.google.com/support/privacy">Firebase 개인정보 보호</a></li>
</ul>
<h2>4. 추적 및 맞춤 광고 (iOS)</h2>
<p>iOS에서는 앱 실행 시 <strong>추적 허용(App Tracking Transparency)</strong> 동의를 요청합니다. 동의하면 맞춤형 광고가 제공될 수 있고, 거부해도 본 앱의 모든 기능을 정상적으로 이용할 수 있으며 비맞춤형 광고가 표시됩니다.</p>
<h2>5. 인앱 구매</h2>
<p>일회성 "광고 제거(Remove Ads)" 인앱 구매를 제공합니다. 결제는 Apple App Store 또는 Google Play를 통해 처리되며, 개발자는 결제 카드 등 결제 수단 정보를 수집하거나 보관하지 않습니다.</p>
<h2>6. 아동의 개인정보</h2>
<p>본 앱은 만 13세 미만 아동을 주 대상으로 하지 않으며, 아동의 개인정보를 고의로 수집하지 않습니다.</p>
<h2>7. 데이터 보관 및 삭제</h2>
<p>로컬 저장 데이터는 앱 삭제 시 함께 제거됩니다. 광고/분석 데이터의 처리·삭제는 위 제3자 정책을 따릅니다. 관련 문의는 아래 이메일로 연락 주십시오.</p>
<h2>8. 문의</h2>
<p>개인정보 관련 문의: <a href="mailto:airkjw@gmail.com">airkjw@gmail.com</a></p>
<hr>
<!-- ========================= ENGLISH ========================= -->
<h1>Block Seasons Privacy Policy</h1>
<p class="meta">Last updated: June 14, 2026 · Contact: <a href="mailto:airkjw@gmail.com">airkjw@gmail.com</a></p>
<p>This policy describes how the mobile game <strong>Block Seasons</strong> ("the App") handles information. The App requires no account sign-up and does not collect directly identifying personal information such as your name or email.</p>
<h2>1. Information We Collect</h2>
<ul>
<li><strong>Advertising identifier</strong> (Android Advertising ID / Apple IDFA): used by our advertising partner (Google AdMob) to serve and measure ads.</li>
<li><strong>Usage data</strong> (app interaction, screen and event analytics): collected by Firebase Analytics to improve app quality.</li>
<li><strong>Device information</strong> (device model, OS version, coarse region): used for advertising and analytics diagnostics.</li>
</ul>
<p>The developer does not use this information to identify you personally and stores no personal data on its own servers. Game progress and settings are stored only locally on your device.</p>
<h2>2. How We Use Information</h2>
<ul>
<li>To serve ads and generate revenue (an ad-supported free model)</li>
<li>To analyze usage and improve features and difficulty</li>
<li>To diagnose errors and improve stability</li>
</ul>
<h2>3. Third Parties</h2>
<ul>
<li><strong>Google AdMob</strong> (advertising) — <a href="https://policies.google.com/privacy">Google Privacy Policy</a></li>
<li><strong>Google Firebase / Analytics</strong> (analytics) — <a href="https://firebase.google.com/support/privacy">Firebase Privacy</a></li>
</ul>
<h2>4. Tracking &amp; Personalized Ads (iOS)</h2>
<p>On iOS the App requests <strong>App Tracking Transparency</strong> permission. If you allow it, personalized ads may be shown. If you decline, the App works fully and shows non-personalized ads.</p>
<h2>5. In-App Purchases</h2>
<p>A one-time "Remove Ads" purchase is offered. Payment is handled by the Apple App Store or Google Play; the developer does not collect or store your payment details.</p>
<h2>6. Children's Privacy</h2>
<p>The App is not primarily directed at children under 13 and does not knowingly collect personal information from children.</p>
<h2>7. Data Retention &amp; Deletion</h2>
<p>Locally stored data is removed when the App is uninstalled. Advertising and analytics data follow the third-party policies above. For requests, contact us below.</p>
<h2>8. Contact</h2>
<p>Privacy inquiries: <a href="mailto:airkjw@gmail.com">airkjw@gmail.com</a></p>
</body>
</html>
+66
View File
@@ -0,0 +1,66 @@
# Firebase Hosting 시즌 배포 가이드 (오너용)
앱은 시작할 때마다 `CONTENT_BASE_URL/manifest.json`을 확인하고, 새 시즌 팩을
SHA256 검증 후 내려받습니다. 호스팅은 정적 파일 서버이기만 하면 되며,
Firebase Hosting 무료 플랜이면 충분합니다.
## 1회 설정 (약 15분)
1. https://console.firebase.google.com → **프로젝트 추가** → 이름 `block-seasons`
→ Google Analytics **사용 설정** (이후 분석 연동에 사용).
2. 터미널에서:
```bash
npm install -g firebase-tools
firebase login
cd "/Volumes/Macintosh 2nd/Project/My_Game_Project/BlockSeasons"
firebase init hosting
# → Use an existing project → block-seasons
# → public 디렉터리: deploy
# → single-page app: No / 자동 빌드: No
```
3. 생성된 호스팅 도메인(예: `https://block-seasons.web.app`)을 Claude에게
알려주세요 — 앱의 `CONTENT_BASE_URL` 기본값(lib/main.dart)을 그 도메인으로
맞추고, `flutterfire configure`를 함께 진행해 Firebase Analytics 백엔드도
연결합니다.
## 시즌 배포 (매 시즌 약 1분)
새 시즌 팩을 생성한 뒤(`dart run tool/stage_generator/generate.dart ...`):
```bash
cd "/Volumes/Macintosh 2nd/Project/My_Game_Project/BlockSeasons"
dart run tool/make_manifest.dart
rm -rf deploy/content && mkdir -p deploy/content/seasons
cp content/manifest.json deploy/content/
for d in content/season_*/; do
id=$(basename "$d")
mkdir -p "deploy/content/seasons/$id"
cp "$d/pack.json" "deploy/content/seasons/$id/"
done
firebase deploy --only hosting
```
배포 직후 모든 유저의 **다음 콜드 스타트**에서 새 시즌이 나타납니다.
앱 업데이트·스토어 심사가 필요 없습니다.
## 동작 방식 요약 (참고)
- `manifest.json`: 시즌 목록 + 버전 + SHA256. `tool/make_manifest.dart`가 생성.
- 클라이언트: 버전이 다른 팩만 다운로드 → SHA256 일치 시에만 원자적으로 캐시
교체. 검증 실패·오프라인·서버 오류는 전부 조용히 무시되고 기존 캐시 또는
번들 시즌 1로 동작.
- 시즌 1은 앱에 번들되어 있어 인터넷이 한 번도 연결되지 않아도 게임이
완전히 동작합니다 (E2E 검증 완료: docs/screenshots/sim_offline_fallback.png).
- 원격 시즌 등장 검증: docs/screenshots/sim_remote_season2.png ("SEASON 2 ·
여름 파도"가 로컬 서버 배포만으로 등장).
## 주의
- `pack.json`을 수정하면 반드시 `make_manifest.dart`를 다시 실행해 SHA256을
갱신해야 합니다 (불일치 시 클라이언트가 팩을 거부).
- 시즌 팩에 새 필수 필드를 도입하는 스키마 변경 시 `schemaVersion`을 올리면
구버전 앱은 그 팩을 무시합니다 (크래시 없음).
- `minAppBuild` 필드는 아직 클라이언트가 강제하지 않습니다 — 앱 버전 의존
콘텐츠를 배포하기 전에 강제 로직을 추가해야 합니다 (Phase 7 체크리스트).
+108
View File
@@ -0,0 +1,108 @@
# AdMob + IAP 실제 ID 준비 가이드 (오너용)
코드(Phase 5)는 이미 완성돼 있고 **Google 테스트 광고**로 동작합니다. 이 문서대로
각 콘솔에서 앱/광고단위/상품을 만들고, 마지막 "**Claude에게 보낼 값**" 목록을
알려주시면 제가 실제 ID로 교체해 마무리합니다.
공통 정보:
- 번들 ID(앱 식별자): **`com.airkjw.blockseasons`** (iOS·Android 동일)
- 앱 이름: **Block Seasons**
---
## 1. AdMob — 앱 2개 + 광고단위 6개 (약 15분)
https://apps.admob.com → 좌측 **앱****앱 추가**.
### iOS 앱 등록
1. 플랫폼: **iOS**.
2. "앱이 앱 스토어에 등록되어 있나요?" → 아직이면 **아니요** 선택 후 수동 등록
(앱 이름 `Block Seasons` 입력). 나중에 스토어 출시 후 연결 가능.
3. 생성되면 그 앱의 **앱 ID**(`ca-app-pub-...~...` 형식)를 적어둡니다 → **iOS 앱 ID**.
4. 그 앱 안에서 **광고 단위** 3개 생성:
- **전면(Interstitial)** — 이름 예: `ios_interstitial`
- **보상형(Rewarded)** — 이름 예: `ios_rewarded`
- **배너(Banner)** — 이름 예: `ios_banner`
각 광고 단위 ID(`ca-app-pub-.../...` 형식)를 적어둡니다.
### Android 앱 등록
1. 플랫폼: **Android**, 같은 방식으로 등록 → **Android 앱 ID** 기록.
2. 광고 단위 3개 생성: `android_interstitial`, `android_rewarded`, `android_banner` → 각 ID 기록.
> 광고 형식 설정은 기본값으로 둬도 됩니다. (보상형은 보상 금액/이름을 물어보면
> 임의로 1 / "reward"로 두세요 — 우리 코드는 금액을 쓰지 않습니다.)
> ⚠️ **본인 광고를 직접 클릭하지 마세요** (AdMob 무효 트래픽 정책). 실기기
> 테스트는 디버그 빌드(자동으로 테스트 광고)로 하거나, AdMob **설정 → 테스트
> 기기**에 본인 기기 광고 ID를 등록하세요.
---
## 2. App Store Connect — 앱 + remove_ads IAP (약 10분)
https://appstoreconnect.apple.com
1. **앱 → +** → 새 앱 생성: 플랫폼 iOS, 이름 `Block Seasons`,
번들 ID `com.airkjw.blockseasons`(Apple Developer에서 먼저 App ID 등록 필요),
SKU 임의(예: `blockseasons01`).
2. 좌측 **수익화 → 앱 내 구입 → +**:
- 유형: **비소모성(Non-Consumable)**
- **제품 ID: `remove_ads`** ← 코드가 이 값을 그대로 찾습니다. **반드시 동일하게.**
- 참조 이름: `Remove Ads`, 가격: 원하는 등급(예: ₩1,500 / $0.99) 선택.
- 현지화(영문/한글) 표시 이름·설명 입력 후 저장.
3. **Sandbox 테스터** 계정을 만들어두면(사용자 및 액세스 → Sandbox) 결제 테스트 가능.
---
## 3. Google Play Console — 앱 + remove_ads 상품 (약 10분)
https://play.google.com/console
1. **앱 만들기**: 이름 `Block Seasons`, 게임/무료 선택.
2. 먼저 **내부 테스트 트랙에 서명된 빌드(AAB) 1개를 업로드**해야 인앱 상품을 만들 수
있습니다 (Play 제약). 이 빌드는 Phase 7에서 제가 만들어 드립니다 — 지금은
1~2단계만 해두고, 상품 생성은 그 빌드 업로드 후 진행해도 됩니다.
3. **수익 창출 → 제품 → 인앱 상품 → 상품 만들기**:
- **제품 ID: `remove_ads`** ← iOS와 동일하게.
- 이름 `Remove Ads`, 가격 설정, 활성화.
4. **라이선스 테스터**(설정 → 라이선스 테스트)에 본인 Google 계정을 넣으면 샌드박스 결제 가능.
---
## 4. (Phase 7) app-ads.txt — 광고 수익 인증
AdMob이 광고 인벤토리 판매를 인증하려면, 스토어 리스팅의 "개발자 웹사이트"
도메인 루트에 `app-ads.txt`가 있어야 합니다. 내용 한 줄:
```
google.com, pub-XXXXXXXXXXXXXXXX, DIRECT, f08c47fec0942fa0
```
(`pub-XXXX...`는 AdMob 게시자 ID — AdMob 설정에 표시됨.) GitHub Pages 무료 사이트면
충분합니다. **이건 Phase 7에서 함께 처리**하니 지금은 게시자 ID만 같이 적어주세요.
---
## ✅ Claude에게 보낼 값 (이것만 주시면 코드 교체 끝)
```
AdMob 게시자 ID: pub-________________
AdMob iOS 앱 ID: ca-app-pub-________~________
AdMob Android 앱 ID: ca-app-pub-________~________
iOS 전면 광고단위: ca-app-pub-________/________
iOS 보상형 광고단위: ca-app-pub-________/________
iOS 배너 광고단위: ca-app-pub-________/________
Android 전면 광고단위: ca-app-pub-________/________
Android 보상형 광고단위: ca-app-pub-________/________
Android 배너 광고단위: ca-app-pub-________/________
IAP 제품 ID: remove_ads (그대로면 OK / 다르게 만들었으면 알려주세요)
```
이 값을 받으면 제가:
1. `lib/services/ad_config.dart``_real*` 6개 광고단위 ID 교체
2. `ios/Runner/Info.plist``GADApplicationIdentifier` → iOS 앱 ID로 교체
3. `android/app/src/main/AndroidManifest.xml``APPLICATION_ID` → Android 앱 ID로 교체
4. (Phase 7) app-ads.txt 생성
까지 처리하고, 릴리스 빌드가 실제 광고를 띄우도록 마무리합니다.
(IAP 제품 ID가 `remove_ads`면 IAP 코드는 변경 불필요.)
Binary file not shown.

After

Width:  |  Height:  |  Size: 5.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 492 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 369 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 208 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 278 KiB

+1
View File
@@ -0,0 +1 @@
google.com, pub-5605900229781491, DIRECT, f08c47fec0942fa0
Binary file not shown.

After

Width:  |  Height:  |  Size: 233 KiB

+167
View File
@@ -0,0 +1,167 @@
# Phase 7 — 스토어 제출 가이드 (오너 셀프 진행용)
> 이 문서 하나만 위에서부터 따라가면 **Google Play + Apple App Store 제출**까지 끝납니다.
> 코드/빌드/카피/개인정보처리방침은 모두 준비돼 있습니다. 여기 적힌 답변·문구를 **그대로 복붙**하세요.
> 막히면 Claude에게 "이 단계 막혔어" 하고 화면을 보여주면 됩니다.
마지막 업데이트: 2026-06-14
---
## ✅ 지금까지 완료된 것
**Google Play**
- 앱 생성: **Block Seasons** (`com.airkjw.blockseasons`)
- AAB 업로드: 내부 테스트 트랙에 `1 (1.0.0)` 게시됨
- 테스터 목록 설정 (`0614 테스트`)
- **판매자(결제) 프로필 설정 완료**
- **`remove_ads` 인앱상품 생성 + 활성** (173개 지역, "이전 버전과의 호환성" ✔)
**Apple App Store**
- App ID `com.airkjw.blockseasons` 등록, App Store Connect에 앱 생성
- `remove_ads` 비소모성 IAP 생성 + 가격 설정 (상태: 메타데이터 누락 → 아래 5단계에서 마무리)
**공통 자산 (이미 만들어 둠)**
- 앱 아이콘 512px: `docs/store/play_icon_512.png`
- 피처 그래픽 1024×500: `docs/store/feature_graphic.png`
- ✅ **개인정보처리방침 호스팅 완료**: `https://block-seasons.web.app/privacy-policy.html`
- ✅ **app-ads.txt 호스팅 완료**: `https://block-seasons.web.app/app-ads.txt`
- 스토어 카피 EN/KO: `docs/store/store-listing.md`
- ✅ **스크린샷** (각 3장: 홈·플레이·점수전):
- iOS 6.7"(1290×2796): `docs/store/screenshots/ios/`
- Android 폰(1080×1920): `docs/store/screenshots/android/`
- (시즌 여정 맵 컷은 추후 추가 예정 — 현재 3장으로 제출 충분)
---
# 0단계 — 먼저 해둘 3가지 (양 스토어 공통 전제)
### 0-1. 🔑 안드로이드 서명키 백업 (가장 중요, 분실 시 영구 업데이트 불가)
- 파일 `BlockSeasons/android/app/upload-keystore.jks`**2곳 이상**(클라우드+외장 등)에 백업
- 비밀번호 `35f52bb88a79b4279d3acce7935c33c9` (alias `upload`)을 비밀번호 관리자에 저장
- (Play 앱 서명을 쓰므로 이건 "업로드 키"지만 그래도 백업 필수)
### 0-2. ✅ 개인정보처리방침 + app-ads.txt 호스팅 — **완료** (Firebase Hosting)
두 파일을 이미 살아있는 Firebase Hosting(`block-seasons.web.app`)에 배포 완료. **아래 URL을 스토어 폼에 그대로 복붙**하세요.
| 항목 | 스토어에 넣을 값 |
|---|---|
| **개인정보처리방침 URL** | `https://block-seasons.web.app/privacy-policy.html` |
| **app-ads.txt** | `https://block-seasons.web.app/app-ads.txt` (live, text/plain ✔) |
| **개발자 웹사이트 / 마케팅 URL** | `https://block-seasons.web.app` |
> ⚠️ AdMob이 app-ads.txt를 인식하려면 **스토어의 "웹사이트/마케팅 URL"을 반드시 `https://block-seasons.web.app`로** 적어야 합니다(여기 루트의 app-ads.txt를 크롤링). `web.app`은 그 자체가 독립 루트 도메인이라 인식에 문제없음.
> 참고: `gru.farm` 루트는 NAS가 아니라 외부 사이트빌더(아임웹)라서 app-ads.txt를 못 올림 → Firebase로 호스팅함.
> 재배포 방법(파일 수정 시): `docs/store/`의 원본을 `deploy/`로 복사 후 `firebase deploy --only hosting --project block-seasons`.
### 0-3. 결제 계약 확인
- **Google**: 판매자 프로필 완료됨 ✔ (이미 했음)
- **Apple**: 아래 App Store **5-④ 유료 앱 계약**을 완료해야 IAP 판매·심사 가능 (세금/뱅킹 — 본인이 입력)
---
# A. GOOGLE PLAY — 남은 순서
위치: Play Console → Block Seasons → **대시보드 → "앱 설정"** 항목을 위에서부터 채우면 됩니다.
(내부 테스트 빌드는 이미 올라가 있으니, 아래는 **프로덕션 출시에 필요한 정책/리스팅** 작성입니다.)
### A-1. 앱 액세스 권한
**"모든 기능을 제한 없이 사용할 수 있습니다"** 선택 (로그인 없음).
### A-2. 광고
**"예, 앱에 광고가 있습니다."**
### A-3. 콘텐츠 등급 (설문)
- 이메일 입력 → 카테고리 **"게임"**
- 폭력/성적/언어/약물/도박 등 전부 **아니요** (단순 블록 퍼즐)
- 결과: **전체이용가 / Everyone / PEGI 3** 예상
### A-4. 타겟층 및 콘텐츠
- 대상 연령: **만 13세 이상**(13~15, 16~17, 18세 이상) 선택 권장 — **만 13세 미만은 선택하지 말 것**
(아동 대상이 되면 'Designed for Families' 정책 + 아동 개인정보 의무가 생겨 광고 단가에 불리)
- "앱이 아동의 관심을 끌도록 디자인되었나요?" → **아니요**
### A-5. 데이터 보안 (Data safety) — 아래 값 그대로
- 데이터 수집·공유: **예**
- **기기 또는 기타 ID** (광고 ID): 수집함 · 목적 **광고 또는 마케팅** · **Google과 공유함** · 사용자 연결 안 함
- **앱 활동 / 앱 상호작용**: 수집함 · 목적 **분석** · 공유 안 함 · 사용자 연결 안 함
- 전송 중 암호화: **예**
- 사용자가 데이터 삭제 요청 가능: 개인정보처리방침 문의 이메일 기재로 충족
- ⚠️ "아동 대상 앱" → **아니요**
### A-6. 기타 선언
- 정부 앱 / 금융 기능 / 건강 앱 → 전부 **아니요**
- 뉴스 앱 → 아니요. 콘텐츠 가이드라인·미국 수출법 → **동의**
### A-7. 메인 스토어 등록정보
`docs/store/store-listing.md`에서 복붙:
- **앱 이름**: `Block Seasons`
- **간단한 설명** (≤80): KO `블록을 놓아 줄을 지우고, 몇 주마다 새 테마 시즌을 즐기세요. 광고 강요 없는 편안한 퍼즐.`
- **자세한 설명** (≤4000): store-listing.md의 KO(또는 EN) 본문
- **앱 아이콘** (512×512): `docs/store/play_icon_512.png`
- **그래픽 이미지** (1024×500): `docs/store/feature_graphic.png`
- **휴대전화 스크린샷** (최소 2장): `docs/store/screenshots/android/` (3장)
- **카테고리**: 게임 → 퍼즐 · **태그**: 퍼즐/캐주얼
- **개인정보처리방침 URL**: `https://block-seasons.web.app/privacy-policy.html`
- **웹사이트(선택)**: `https://block-seasons.web.app` ← app-ads.txt 인식용으로 이 주소 권장
- **연락처 이메일**: `airkjw@gmail.com`
### A-8. 프로덕션 출시
1. **테스트 및 출시 → 프로덕션 → 새 버전 만들기**
2. 내부 테스트의 빌드를 **"라이브러리에서 추가"**로 가져오거나 AAB 재업로드(같은 `app-release.aab`)
3. 출시명/노트 입력 → 검토 → **출시 시작**(심사 제출)
4. ⚠️ **신규 개인 개발자 계정**은 "비공개 테스트 20명 × 14일" 후에야 프로덕션 가능 규정이 있습니다.
- 너는 이미 출시된 앱(복지앱)이 있어 **해당 없을 가능성이 높음**. 프로덕션에서 막히면 알려줘 — 비공개 테스트로 우회 안내할게.
### A-9. (출시 후) 샌드박스 결제 테스트
- 설정 → **라이선스 테스트**에 본인 Google 계정 추가 → 내부 테스트 앱에서 `remove_ads` 구매가 **실제 청구 없이** 테스트됨.
---
# B. APPLE APP STORE — 남은 순서
위치: [App Store Connect](https://appstoreconnect.apple.com) → Block Seasons
### B-1. 빌드 업로드 (Xcode 권장 — CLI는 경로 공백 이슈 회피)
1. Xcode로 `ios/Runner.xcworkspace` 열기
2. 상단 기기 선택을 **"Any iOS Device (arm64)"**
3. 메뉴 **Product ▸ Archive** (서명은 Xcode 자동 서명)
4. Organizer 창 → **Distribute App ▸ App Store Connect ▸ Upload**
5. 처리에 수십 분 → App Store Connect → **TestFlight**에 빌드가 나타남
> Claude가 대신 IPA를 만들기 어려운 이유: iOS 서명은 너의 Apple 인증서가 필요해서 Xcode에서 해야 안전함. 막히면 단계별로 안내할게.
### B-2. 앱 정보 / 버전 정보
- 카테고리: **게임 → 퍼즐**, 연령 등급 설문 → **4+**
- 부제(≤30): KO `시즌마다 새로워지는 블록 퍼즐`
- 프로모션 텍스트·설명·키워드: `docs/store/store-listing.md`에서 복붙
- **개인정보처리방침 URL**: `https://block-seasons.web.app/privacy-policy.html`
- **스크린샷**: `docs/store/screenshots/ios/` (1290×2796=6.7", 3장). 6.5"는 같은 컷 재업로드 또는 생략 가능
### B-3. 앱 개인정보 (App Privacy) — 아래 값 그대로
| 데이터 | 수집 | 목적 | 사용자 연결 | 추적 |
|---|---|---|---|---|
| 기기 ID(광고 식별자) | 예 | 제3자 광고 | 아니요 | **예** |
| 사용 데이터(제품 상호작용) | 예 | 분석, 앱 기능 | 아니요 | 아니요 |
- 추적 사용 이유(ATT) 문구 예: "비맞춤형 대신 맞춤형 광고를 제공하기 위해 사용합니다."
### B-4. IAP 마무리 (`remove_ads`)
- 표시 이름: EN `Remove Ads` / KO `광고 제거`
- 설명: `배너·전면 광고를 영구히 제거합니다. 보상형 영상은 그대로 사용할 수 있어요.`
- **리뷰용 스크린샷**: 앱 **설정 화면**(광고 제거 버튼 보이는 캡처) 1장 첨부
- ⚠️ **첫 IAP는 앱 버전과 함께 제출**해야 함 (버전 화면에서 IAP 포함시켜 제출)
### B-5. 계약 / 제출
- ④ **유료 앱 계약(Paid Apps)**: 비즈니스 → 계약/세금/뱅킹 완료 (본인 입력) — IAP 판매·심사 전제
- 앱 심사 정보: 로그인 없음 → 데모 계정 불필요, 비고에 "No login required" 정도
- **심사 제출**
---
# 권장 진행 순서 (최단 경로)
1. **0-1 키스토어 백업** (5분, 지금)
2. ✅ **0-2 개인정보처리방침 + app-ads.txt 호스팅 완료** (`block-seasons.web.app` — 스토어 폼에 URL 복붙만 하면 됨)
3. **스크린샷 준비됨** ✅ (`docs/store/screenshots/`) — 리스팅에 바로 업로드
4. **Apple B-1 빌드 업로드 시작** (처리에 시간 걸리니 먼저 걸어두기)
5. Play A-1~A-7 + Apple B-2~B-4 문항 채우기 (대부분 위 표 복붙)
6. Apple B-5 유료앱 계약 → 양쪽 **심사 제출**
문항별로 막히면 그 화면만 보여줘 — 그 부분만 같이 풀자.
Binary file not shown.

After

Width:  |  Height:  |  Size: 152 KiB

+99
View File
@@ -0,0 +1,99 @@
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Block Seasons — Privacy Policy / 개인정보처리방침</title>
<style>
body{max-width:760px;margin:0 auto;padding:24px;font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif;line-height:1.6;color:#1a1a2e}
h1{font-size:1.5rem} h2{font-size:1.15rem;margin-top:2rem;border-bottom:1px solid #ddd;padding-bottom:4px}
h3{font-size:1rem;margin-top:1.4rem} code{background:#f0f0f5;padding:1px 5px;border-radius:4px}
.meta{color:#666;font-size:.9rem} hr{margin:3rem 0;border:none;border-top:2px solid #eee}
a{color:#3a5fcd}
</style>
</head>
<body>
<!-- ========================= KOREAN ========================= -->
<h1>Block Seasons 개인정보처리방침</h1>
<p class="meta">최종 업데이트: 2026년 6월 15일 · 문의: <a href="mailto:airkjw@gmail.com">airkjw@gmail.com</a></p>
<p>본 방침은 모바일 게임 <strong>Block Seasons</strong>(이하 "본 앱")의 개인정보 처리에 관한 내용을 설명합니다. 본 앱은 계정 가입이 필요 없으며, 이름·이메일 등 직접적인 개인 식별 정보를 수집하지 않습니다.</p>
<h2>1. 수집하는 정보</h2>
<ul>
<li><strong>광고 식별자</strong> (Android 광고 ID / Apple IDFA): 광고 게재 및 측정을 위해 광고 파트너(Google AdMob)가 사용합니다.</li>
<li><strong>사용 데이터</strong> (앱 이용 통계, 화면·이벤트 상호작용): 앱 품질 개선과 분석을 위해 Firebase Analytics가 수집합니다.</li>
<li><strong>기기 정보</strong> (기기 모델, 운영체제 버전, 대략적 지역 등): 광고·분석의 기본 진단 정보로 사용됩니다.</li>
</ul>
<p>본 앱 개발자는 위 정보를 통해 개인을 식별하지 않으며, 별도의 서버에 개인정보를 저장하지 않습니다. 게임 진행·설정은 기기 내부(로컬)에만 저장됩니다.</p>
<h2>2. 정보 이용 목적</h2>
<ul>
<li>광고 게재 및 수익 창출 (무료 제공을 위한 광고 기반 모델)</li>
<li>앱 사용성 분석 및 기능·난이도 개선</li>
<li>오류 진단 및 안정성 향상</li>
</ul>
<h2>3. 제3자 제공 및 처리</h2>
<p>본 앱은 다음 제3자 서비스를 사용하며, 해당 서비스의 정책에 따라 정보가 처리됩니다.</p>
<ul>
<li><strong>Google AdMob</strong> (광고) — <a href="https://policies.google.com/privacy">Google 개인정보처리방침</a></li>
<li><strong>Google Firebase / Analytics</strong> (분석) — <a href="https://firebase.google.com/support/privacy">Firebase 개인정보 보호</a></li>
</ul>
<h2>4. 추적 및 맞춤 광고 (iOS)</h2>
<p>iOS에서는 앱 실행 시 <strong>추적 허용(App Tracking Transparency)</strong> 동의를 요청합니다. 동의하면 맞춤형 광고가 제공될 수 있고, 거부해도 본 앱의 모든 기능을 정상적으로 이용할 수 있으며 비맞춤형 광고가 표시됩니다.</p>
<h2>5. 아동의 개인정보</h2>
<p>본 앱은 만 13세 미만 아동을 주 대상으로 하지 않으며, 아동의 개인정보를 고의로 수집하지 않습니다.</p>
<h2>6. 데이터 보관 및 삭제</h2>
<p>로컬 저장 데이터는 앱 삭제 시 함께 제거됩니다. 광고/분석 데이터의 처리·삭제는 위 제3자 정책을 따릅니다. 관련 문의는 아래 이메일로 연락 주십시오.</p>
<h2>7. 문의</h2>
<p>개인정보 관련 문의: <a href="mailto:airkjw@gmail.com">airkjw@gmail.com</a></p>
<hr>
<!-- ========================= ENGLISH ========================= -->
<h1>Block Seasons Privacy Policy</h1>
<p class="meta">Last updated: June 15, 2026 · Contact: <a href="mailto:airkjw@gmail.com">airkjw@gmail.com</a></p>
<p>This policy describes how the mobile game <strong>Block Seasons</strong> ("the App") handles information. The App requires no account sign-up and does not collect directly identifying personal information such as your name or email.</p>
<h2>1. Information We Collect</h2>
<ul>
<li><strong>Advertising identifier</strong> (Android Advertising ID / Apple IDFA): used by our advertising partner (Google AdMob) to serve and measure ads.</li>
<li><strong>Usage data</strong> (app interaction, screen and event analytics): collected by Firebase Analytics to improve app quality.</li>
<li><strong>Device information</strong> (device model, OS version, coarse region): used for advertising and analytics diagnostics.</li>
</ul>
<p>The developer does not use this information to identify you personally and stores no personal data on its own servers. Game progress and settings are stored only locally on your device.</p>
<h2>2. How We Use Information</h2>
<ul>
<li>To serve ads and generate revenue (an ad-supported free model)</li>
<li>To analyze usage and improve features and difficulty</li>
<li>To diagnose errors and improve stability</li>
</ul>
<h2>3. Third Parties</h2>
<ul>
<li><strong>Google AdMob</strong> (advertising) — <a href="https://policies.google.com/privacy">Google Privacy Policy</a></li>
<li><strong>Google Firebase / Analytics</strong> (analytics) — <a href="https://firebase.google.com/support/privacy">Firebase Privacy</a></li>
</ul>
<h2>4. Tracking &amp; Personalized Ads (iOS)</h2>
<p>On iOS the App requests <strong>App Tracking Transparency</strong> permission. If you allow it, personalized ads may be shown. If you decline, the App works fully and shows non-personalized ads.</p>
<h2>5. Children's Privacy</h2>
<p>The App is not primarily directed at children under 13 and does not knowingly collect personal information from children.</p>
<h2>6. Data Retention &amp; Deletion</h2>
<p>Locally stored data is removed when the App is uninstalled. Advertising and analytics data follow the third-party policies above. For requests, contact us below.</p>
<h2>7. Contact</h2>
<p>Privacy inquiries: <a href="mailto:airkjw@gmail.com">airkjw@gmail.com</a></p>
</body>
</html>
Binary file not shown.

After

Width:  |  Height:  |  Size: 143 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 215 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 276 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 164 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 308 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 401 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 146 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 614 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 693 KiB

+86
View File
@@ -0,0 +1,86 @@
# Store Listing Copy — Block Seasons
Paste these into App Store Connect (App Information / Version) and Google Play
Console (Main store listing). Character limits noted; both EN and KO provided.
---
## App name
- **EN:** `Block Seasons` (13)
- **KO:** `블록 시즌즈` — or keep `Block Seasons` (영문 그대로도 무방)
## Subtitle (iOS, ≤30) / Short description (Play, ≤80)
- **EN (iOS subtitle):** `Seasonal block puzzle bliss` (27)
- **KO (iOS subtitle):** `시즌마다 새로워지는 블록 퍼즐` (16)
- **EN (Play short, ≤80):** `Drop blocks, clear lines, and chase a fresh themed season every few weeks.` (73)
- **KO (Play short, ≤80):** `블록을 놓아 줄을 지우고, 몇 주마다 새 테마 시즌을 즐기세요. 광고 강요 없는 편안한 퍼즐.` (47)
## Keywords (iOS, ≤100, comma-separated)
- **EN:** `block,puzzle,blocks,brain,grid,tetris,blast,relax,season,line,casual,offline,jewel,combo`
- **KO:** `블록,퍼즐,블럭,두뇌,그리드,테트리스,블라스트,힐링,시즌,라인,캐주얼,오프라인,콤보`
## Promotional text (iOS, ≤170)
- **EN:** `New Season 2 "Summer Tide" is live — cool teal blocks and 30 fresh stages. No internet? No problem: Season 1 plays fully offline.` (131)
- **KO:** `새 시즌 2 "여름 파도" 공개 — 시원한 청록 블록과 30개 새 스테이지. 인터넷이 없어도 시즌 1은 완전 오프라인으로 즐길 수 있어요.` (66)
## Description (long, ≤4000)
### EN
```
Block Seasons is a cozy, beautiful block puzzle you can actually relax with.
Drag three pieces at a time onto an 8×8 board, fill rows and columns, and watch
them clear in a satisfying glossy burst. Easy to pick up, deep enough to chase
"just one more" — chase combos, beat your best, and feel the board breathe.
WHAT MAKES IT DIFFERENT
• Seasons — every few weeks a brand-new themed season arrives with fresh stages
and its own look, no app update needed.
• A journey, not a grid — wind your way up an illustrated map, one stage at a time.
• Endless mode — no limits, no objectives, just you and your high score.
• Glossy, hand-tuned visuals and a calm, drifting season backdrop.
• Plays offline — Season 1 is built in, so you can play on a plane, a subway,
anywhere.
FAIR BY DESIGN
• No forced video before every move. Ads are spaced out, and rescue/continue is
always your choice.
Whether you have two minutes or twenty, Block Seasons is the puzzle that's always
in season. Download free and start your first season today.
```
### KO
```
블록 시즌즈는 정말로 편안하게 즐길 수 있는, 예쁜 블록 퍼즐입니다.
한 번에 세 조각을 8×8 보드에 드래그해서 가로·세로 줄을 채우고, 반짝이는 글로시
연출과 함께 줄이 사라지는 쾌감을 느껴보세요. 시작은 쉽지만 "한 판만 더"를 부르는
깊이가 있습니다 — 콤보를 노리고, 최고 점수를 갱신하세요.
무엇이 다른가요
• 시즌제 — 몇 주마다 새 테마의 시즌과 스테이지가 앱 업데이트 없이 도착합니다.
• 그리드가 아닌 여정 — 일러스트 맵을 따라 한 스테이지씩 올라갑니다.
• 엔드리스 모드 — 제한도 목표도 없이, 오직 최고 점수에 도전.
• 손으로 다듬은 글로시 비주얼과 잔잔하게 흐르는 시즌 배경.
• 오프라인 플레이 — 시즌 1이 내장돼 비행기·지하철 어디서든 즐길 수 있습니다.
설계부터 공정하게
• 매 수마다 강제 영상 광고 없음. 광고는 충분히 띄엄띄엄, 구조/계속하기는 항상 선택.
2분이든 20분이든, 블록 시즌즈는 언제나 제철인 퍼즐입니다. 무료로 받고 첫 시즌을
시작하세요.
```
---
## Category & age
- Primary category: **Games → Puzzle** (iOS) / **Puzzle** (Play).
- Age rating: **Everyone / 4+** — but answer the questionnaires honestly and DO
NOT mark the app as "directed at children" (it is not — this protects ad rates
and avoids child-privacy obligations).
## Required URLs (owner)
- Support URL + Marketing URL: a simple page is fine (GitHub Pages works).
- **Privacy Policy URL: required by both stores** — a hosted privacy policy page.
See docs/store/phase7-submission-guide.md for what it must cover.
+69
View File
@@ -0,0 +1,69 @@
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Block Seasons — 시즌마다 새로워지는 블록 퍼즐</title>
<meta name="description" content="Block Seasons는 8×8 보드에 블록을 놓아 줄을 지우고, 몇 주마다 새 테마 시즌을 즐기는 편안한 블록 퍼즐입니다.">
<style>
:root{ --navy:#0E1430; --navy2:#1B2350; --accent:#5B7FFF; --ink:#EAF0FF; --muted:#9DA9C7; }
*{ box-sizing:border-box; }
body{ margin:0; font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,"Noto Sans KR",sans-serif;
background:linear-gradient(160deg,var(--navy),var(--navy2)); color:var(--ink); line-height:1.6; }
.wrap{ max-width:680px; margin:0 auto; padding:56px 24px 72px; }
.mark{ display:flex; gap:6px; margin-bottom:28px; }
.mark span{ width:30px; height:30px; border-radius:8px; box-shadow:inset 0 -3px 0 rgba(0,0,0,.18), 0 2px 6px rgba(0,0,0,.3); }
.b1{ background:#6E8BFF; } .b2{ background:#F4B6C2; } .b3{ background:#7FD4C0; } .b4{ background:#F6CF76; }
h1{ font-size:2.2rem; margin:0 0 6px; letter-spacing:-.5px; }
.tag{ color:var(--accent); font-weight:600; margin:0 0 28px; font-size:1.05rem; }
p{ color:var(--ink); }
.lead{ font-size:1.05rem; }
ul{ padding-left:1.1rem; } li{ margin:.3rem 0; color:var(--ink); }
.muted{ color:var(--muted); }
h2{ font-size:1.1rem; margin:2.4rem 0 .6rem; color:#fff; }
.links{ display:flex; flex-wrap:wrap; gap:12px; margin:30px 0 8px; }
.links a{ display:inline-block; text-decoration:none; padding:12px 20px; border-radius:10px;
background:var(--accent); color:#fff; font-weight:600; }
.links a.alt{ background:transparent; border:1px solid rgba(255,255,255,.25); color:var(--ink); }
hr{ border:none; border-top:1px solid rgba(255,255,255,.12); margin:40px 0 24px; }
footer{ color:var(--muted); font-size:.86rem; }
a.inline{ color:var(--accent); }
</style>
</head>
<body>
<div class="wrap">
<div class="mark"><span class="b1"></span><span class="b2"></span><span class="b3"></span><span class="b4"></span></div>
<h1>Block Seasons</h1>
<p class="tag">시즌마다 새로워지는 블록 퍼즐 · A seasonal block puzzle</p>
<p class="lead">8×8 보드에 세 조각을 드래그해 가로·세로 줄을 지우는, 편안하고 예쁜 블록 퍼즐입니다.
몇 주마다 새 테마의 시즌과 스테이지가 앱 업데이트 없이 도착하고, 시즌 1은 오프라인으로도 즐길 수 있어요.</p>
<ul>
<li>시즌제 — 몇 주마다 새 테마와 스테이지</li>
<li>일러스트 여정 맵 + 엔드리스 모드</li>
<li>광고 강요 없는 공정한 설계, 일회성 ‘광고 제거’ 지원</li>
<li>오프라인 플레이 (시즌 1 내장)</li>
</ul>
<p class="muted">A cozy 8×8 block puzzle. Drop three pieces, clear lines, and enjoy a fresh themed
season every few weeks — no app update needed. Season 1 plays fully offline.</p>
<div class="links">
<a href="mailto:airkjw@gmail.com">문의 / Contact</a>
<a class="alt" href="/privacy-policy.html">개인정보처리방침 / Privacy</a>
</div>
<h2>지원 / Support</h2>
<p class="muted">문의 사항은 <a class="inline" href="mailto:airkjw@gmail.com">airkjw@gmail.com</a> 으로 보내주세요.
보통 2~3일 내에 답변드립니다. · For support, email
<a class="inline" href="mailto:airkjw@gmail.com">airkjw@gmail.com</a>.</p>
<hr>
<footer>© 2026 Joungwook Kwon · Block Seasons</footer>
</div>
</body>
</html>
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,743 @@
# Phase 6 — Localization Finalize + Icon + Juice + Store Assets Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Ship the pre-release polish — a real app icon, a Sound & vibration setting, light feel polish (button press, screen transitions, themed Settings), an EN/KO localization sweep, and store assets (feature graphic + screenshots).
**Architecture:** The app icon and feature graphic are drawn in Dart with `CustomPainter`/`Canvas` (reusing the in-game `paintGlossyTile`) and rasterized to PNG headlessly under `flutter test` — no external SVG/raster tooling needed. `flutter_launcher_icons` consumes those PNGs to generate every platform size. The Sound setting follows the established repo-backed Riverpod Notifier pattern (`adsRemovedProvider`/`endlessBestProvider`). Juice items are small reusable widgets/route helpers.
**Tech Stack:** Flutter, Riverpod 3 (plain Notifiers), `flutter_launcher_icons`, shared_preferences, `dart:ui` Picture→Image→PNG.
---
## Design constants
- Navy background gradient: `0xFF101736 → 0xFF192555 → 0xFF2C3168` (top-left → bottom-right).
- Brand block colors: pink `0xFFFF7EB3`, yellow `0xFFFFD166`, cyan `0xFF6FCDF5`, green `0xFF7EDB9C`.
- Icon layout: 2×2 block grid, group = 60% of canvas (master) / 52% (adaptive foreground), gap = 5% of canvas, block `radiusFactor` 0.24. Pink TL, yellow TR, cyan BL, green BR.
## File Structure
**New:**
- `lib/ui/branding/app_icon_painter.dart` — paints the icon (navy bg + 2×2 glossy blocks). Reused by the generator.
- `lib/ui/branding/feature_graphic_painter.dart` — paints the 1024×500 Play feature graphic.
- `lib/ui/widgets/pressable_scale.dart` — tap-down scale feedback wrapper.
- `lib/ui/widgets/fade_route.dart``fadeRoute<T>(Widget)` PageRoute helper.
- `lib/state/sound_notifier.dart``SoundEnabledNotifier` (repo-backed bool).
- `test/tool/generate_brand_assets_test.dart` — renders icon + feature-graphic PNGs to disk.
- `test/data/save_repository_sound_test.dart`, `test/state/sound_notifier_test.dart`.
- `assets/icon/icon.png`, `icon_foreground.png`, `icon_background.png` (generated, committed).
- `docs/store/feature_graphic.png`, `docs/store/screenshots/{en,ko}/*.png` (committed).
- `flutter_launcher_icons.yaml`.
**Modified:**
- `lib/data/save_repository.dart` — additive `soundEnabled` flag.
- `lib/state/providers.dart``soundEnabledProvider`; `audioServiceProvider` applies it.
- `lib/ui/screens/game_screen.dart` — gate the 3 `HapticFeedback` calls by sound flag.
- `lib/ui/screens/settings_screen.dart` — Sound switch + game theming.
- `lib/ui/screens/home_screen.dart`, `season_map_screen.dart` — press feedback + fade routes.
- `lib/l10n/app_en.arb`, `app_ko.arb``soundAndVibration` key (+ any sweep fixes).
- `pubspec.yaml``flutter_launcher_icons` dev dep.
---
## Task 1: Sound setting persistence (SaveRepository) — TDD
**Files:** Modify `lib/data/save_repository.dart`; Test `test/data/save_repository_sound_test.dart`
- [ ] **Step 1: Write the failing test**
```dart
// test/data/save_repository_sound_test.dart
import 'package:block_seasons/data/save_repository.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:shared_preferences/shared_preferences.dart';
void main() {
setUp(() => SharedPreferences.setMockInitialValues({}));
test('soundEnabled defaults true and persists across reopen', () async {
final repo = await SaveRepository.open();
expect(repo.soundEnabled, isTrue);
await repo.setSoundEnabled(false);
expect(repo.soundEnabled, isFalse);
final reopened = await SaveRepository.open();
expect(reopened.soundEnabled, isFalse);
});
test('legacy save without the sound flag reads as true', () async {
SharedPreferences.setMockInitialValues({
'save_v1': '{"saveVersion":1,"progress":{},"flags":{"tutorialDone":true}}',
});
final repo = await SaveRepository.open();
expect(repo.soundEnabled, isTrue);
});
}
```
- [ ] **Step 2: Run it; expect FAIL** (`soundEnabled`/`setSoundEnabled` undefined): `flutter test test/data/save_repository_sound_test.dart`
- [ ] **Step 3: Implement (additive, default true)**
In `lib/data/save_repository.dart`:
- Add field after `bool _adsRemoved = false;`:
```dart
bool _soundEnabled = true;
```
- In the constructor, after the `_adsRemoved = ...` block, add (default TRUE when missing):
```dart
_soundEnabled =
(json['flags'] as Map<String, dynamic>?)?['soundEnabled'] as bool? ??
true;
```
- Add getter near `bool get adsRemoved`:
```dart
bool get soundEnabled => _soundEnabled;
```
- Add setter near `setAdsRemoved`:
```dart
Future<void> setSoundEnabled(bool value) {
_soundEnabled = value;
return _flush();
}
```
- In `_flush()`, extend the `'flags'` map:
```dart
'flags': {
'tutorialDone': _tutorialDone,
'adsRemoved': _adsRemoved,
'soundEnabled': _soundEnabled,
},
```
- [ ] **Step 4: Run it; expect PASS (2 tests).** Then `flutter test` (full suite) stays green.
- [ ] **Step 5: Commit**
```bash
git add lib/data/save_repository.dart test/data/save_repository_sound_test.dart
git commit -m "feat(settings): persist soundEnabled flag (additive, default true)"
```
---
## Task 2: soundEnabledProvider + audio/haptics wiring — TDD
**Files:** Create `lib/state/sound_notifier.dart`; Modify `lib/state/providers.dart`, `lib/ui/screens/game_screen.dart`; Test `test/state/sound_notifier_test.dart`
- [ ] **Step 1: Write the failing notifier test**
```dart
// test/state/sound_notifier_test.dart
import 'package:block_seasons/data/save_repository.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() {
test('reads persisted sound flag and toggles + persists', () async {
SharedPreferences.setMockInitialValues({});
final repo = await SaveRepository.open();
final c = ProviderContainer(
overrides: [saveRepositoryProvider.overrideWithValue(repo)],
);
addTearDown(c.dispose);
expect(c.read(soundEnabledProvider), isTrue);
await c.read(soundEnabledProvider.notifier).toggle();
expect(c.read(soundEnabledProvider), isFalse);
expect(repo.soundEnabled, isFalse);
});
}
```
- [ ] **Step 2: Run it; expect FAIL** (`soundEnabledProvider` undefined).
- [ ] **Step 3: Create the notifier**
```dart
// lib/state/sound_notifier.dart
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'providers.dart';
/// SFX + gameplay haptics on/off, seeded from the save repository.
class SoundEnabledNotifier extends Notifier<bool> {
@override
bool build() => ref.read(saveRepositoryProvider).soundEnabled;
Future<void> toggle() => set(!state);
Future<void> set(bool value) async {
if (state == value) return;
await ref.read(saveRepositoryProvider).setSoundEnabled(value);
state = value;
}
}
```
- [ ] **Step 4: Register provider + drive AudioService** in `lib/state/providers.dart`
Add import:
```dart
import 'sound_notifier.dart';
```
Add the provider (near `audioServiceProvider`):
```dart
final soundEnabledProvider =
NotifierProvider<SoundEnabledNotifier, bool>(SoundEnabledNotifier.new);
```
Replace the `audioServiceProvider` body so it applies the flag live:
```dart
final audioServiceProvider = Provider<AudioService>((ref) {
final service = AudioService(enabled: ref.read(soundEnabledProvider));
ref.listen<bool>(soundEnabledProvider, (_, next) => service.enabled = next);
ref.onDispose(service.dispose);
return service;
});
```
- [ ] **Step 5: Gate gameplay haptics by the flag** in `lib/ui/screens/game_screen.dart`
In `_onSessionChange`, the placement block currently calls `HapticFeedback.mediumImpact()`, `HapticFeedback.heavyImpact()`, `HapticFeedback.lightImpact()`. Read the flag once at the top of that placement branch and guard each call. Concretely, where the code does `if (placement.linesCleared > 0) { audio.play(...); HapticFeedback.mediumImpact(); ... } else { audio.play(Sfx.place); HapticFeedback.lightImpact(); }`, capture:
```dart
final hapticsOn = ref.read(soundEnabledProvider);
```
just before that `if`, and wrap each of the three `HapticFeedback.*` calls as `if (hapticsOn) HapticFeedback.mediumImpact();` etc. (Audio is already gated inside `AudioService.play`.)
- [ ] **Step 6: Run the notifier test (PASS) + full suite + analyze**
```bash
flutter test test/state/sound_notifier_test.dart
flutter analyze lib/state/providers.dart lib/state/sound_notifier.dart lib/ui/screens/game_screen.dart
flutter test
```
Expected: notifier test passes, analyze clean, full suite green.
- [ ] **Step 7: Commit**
```bash
git add lib/state/sound_notifier.dart lib/state/providers.dart lib/ui/screens/game_screen.dart test/state/sound_notifier_test.dart
git commit -m "feat(settings): soundEnabled provider gates SFX and haptics"
```
---
## Task 3: Settings screen — Sound switch + game theming + l10n
**Files:** Modify `lib/ui/screens/settings_screen.dart`, `lib/l10n/app_en.arb`, `lib/l10n/app_ko.arb`
- [ ] **Step 1: Add l10n key (en)** — in `app_en.arb`:
```json
"soundAndVibration": "Sound & vibration",
```
- [ ] **Step 2: Add l10n key (ko)** — in `app_ko.arb`:
```json
"soundAndVibration": "소리 및 진동",
```
- [ ] **Step 3: Regenerate**`flutter gen-l10n` (adds `soundAndVibration` getter).
- [ ] **Step 4: Add the Sound switch + theme the screen**
In `settings_screen.dart`, (a) read sound state, (b) add a `SwitchListTile` at the top of the list, (c) wrap the body in the game's `SeasonBackground` with a transparent scaffold. Replace the `build` return with:
```dart
@override
Widget build(BuildContext context, WidgetRef ref) {
final l10n = AppLocalizations.of(context)!;
final adsRemoved = ref.watch(adsRemovedProvider);
final soundOn = ref.watch(soundEnabledProvider);
final iap = ref.read(iapServiceProvider);
ref.listen<bool>(adsRemovedProvider, (prev, next) {
if (next && !(prev ?? false)) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(l10n.adsRemovedThanks)),
);
}
});
return Stack(
fit: StackFit.expand,
children: [
const SeasonBackground(theme: SeasonTheme.fallback),
Scaffold(
backgroundColor: Colors.transparent,
appBar: AppBar(
backgroundColor: Colors.transparent,
title: Text(l10n.settings),
),
body: ListView(
padding: const EdgeInsets.all(16),
children: [
SwitchListTile(
title: Text(l10n.soundAndVibration),
value: soundOn,
onChanged: (v) =>
ref.read(soundEnabledProvider.notifier).set(v),
),
const Divider(),
ListTile(
title: Text(l10n.removeAds),
subtitle: Text(l10n.removeAdsDescription),
trailing: adsRemoved
? const Icon(Icons.check_circle, color: Colors.green)
: Text(iap.product?.price ?? ''),
onTap: adsRemoved
? null
: () async {
if (!iap.available || iap.product == null) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(l10n.purchaseUnavailable)),
);
return;
}
await iap.buyRemoveAds();
},
),
const Divider(),
ListTile(
leading: const Icon(Icons.restore),
title: Text(l10n.restorePurchases),
onTap: () => iap.restorePurchases(),
),
],
),
),
],
);
}
```
Add imports: `import '../../game/models/season.dart';` (for `SeasonTheme`) and `import '../widgets/season_background.dart';`.
- [ ] **Step 5: Analyze + full suite**
```bash
flutter analyze lib/ui/screens/settings_screen.dart
flutter test
```
Expected: clean; green.
- [ ] **Step 6: Commit**
```bash
git add lib/ui/screens/settings_screen.dart lib/l10n/app_en.arb lib/l10n/app_ko.arb
git commit -m "feat(settings): sound & vibration toggle; themed settings screen"
```
---
## Task 4: App icon painter + PNG generator
**Files:** Create `lib/ui/branding/app_icon_painter.dart`, `test/tool/generate_brand_assets_test.dart`; output `assets/icon/*.png`
- [ ] **Step 1: Write the icon painter**
```dart
// lib/ui/branding/app_icon_painter.dart
import 'dart:ui';
import 'package:flutter/material.dart';
import '../widgets/tile_painter.dart';
/// Draws the Block Seasons brand mark: deep-navy field + a 2×2 grid of glossy
/// brand-color blocks. Shared by the launcher-icon and feature-graphic
/// generators so the brand stays identical everywhere.
class AppIconMark {
static const navy = [Color(0xFF101736), Color(0xFF192555), Color(0xFF2C3168)];
static const pink = Color(0xFFFF7EB3);
static const yellow = Color(0xFFFFD166);
static const cyan = Color(0xFF6FCDF5);
static const green = Color(0xFF7EDB9C);
/// Fills [rect] with the navy gradient.
static void paintBackground(Canvas canvas, Rect rect) {
final paint = Paint()
..shader = const LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: navy,
).createShader(rect);
canvas.drawRect(rect, paint);
}
/// Paints the 2×2 glossy blocks centered in a square of side [size], the
/// block group occupying [groupFraction] of the side.
static void paintBlocks(Canvas canvas, double size,
{double groupFraction = 0.6}) {
final group = size * groupFraction;
final gap = size * 0.05;
final block = (group - gap) / 2;
final m = (size - group) / 2;
final far = m + block + gap;
void tile(double x, double y, Color c) => paintGlossyTile(
canvas, Rect.fromLTWH(x, y, block, block), c, radiusFactor: 0.24);
tile(m, m, pink);
tile(far, m, yellow);
tile(m, far, cyan);
tile(far, far, green);
}
}
```
- [ ] **Step 2: Write the generator (renders PNGs under flutter test)**
```dart
// test/tool/generate_brand_assets_test.dart
import 'dart:io';
import 'dart:ui';
import 'package:block_seasons/ui/branding/app_icon_painter.dart';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
Future<void> _writePng(String path, int size, void Function(Canvas) draw) async {
final recorder = PictureRecorder();
final canvas = Canvas(recorder);
draw(canvas);
final picture = recorder.endRecording();
final image = await picture.toImage(size, size);
final bytes = await image.toByteData(format: ImageByteFormat.png);
File(path).parent.createSync(recursive: true);
File(path).writeAsBytesSync(bytes!.buffer.asUint8List());
}
void main() {
testWidgets('generate launcher icon PNGs', (tester) async {
const s = 1024;
final full = Rect.fromLTWH(0, 0, s.toDouble(), s.toDouble());
// Master (iOS + fallback): opaque navy + blocks at 60%.
await _writePng('assets/icon/icon.png', s, (c) {
AppIconMark.paintBackground(c, full);
AppIconMark.paintBlocks(c, s.toDouble(), groupFraction: 0.6);
});
// Adaptive background: navy only.
await _writePng('assets/icon/icon_background.png', s, (c) {
AppIconMark.paintBackground(c, full);
});
// Adaptive foreground: blocks only (transparent), 52% for the safe zone.
await _writePng('assets/icon/icon_foreground.png', s, (c) {
AppIconMark.paintBlocks(c, s.toDouble(), groupFraction: 0.52);
});
for (final f in ['icon.png', 'icon_background.png', 'icon_foreground.png']) {
expect(File('assets/icon/$f').existsSync(), isTrue, reason: f);
}
});
}
```
- [ ] **Step 3: Generate the PNGs**
Run: `flutter test test/tool/generate_brand_assets_test.dart`
Expected: PASS; `assets/icon/icon.png`, `icon_background.png`, `icon_foreground.png` exist (1024×1024). Verify visually:
```bash
file assets/icon/icon.png # PNG image data, 1024 x 1024
```
- [ ] **Step 4: Commit**
```bash
git add lib/ui/branding/app_icon_painter.dart test/tool/generate_brand_assets_test.dart assets/icon/icon.png assets/icon/icon_background.png assets/icon/icon_foreground.png
git commit -m "feat(brand): app icon painter + generated 1024px icon PNGs"
```
---
## Task 5: flutter_launcher_icons — generate platform icons
**Files:** Modify `pubspec.yaml`; Create `flutter_launcher_icons.yaml`
- [ ] **Step 1: Add the dev dependency**
Run: `flutter pub add --dev flutter_launcher_icons`
Expected: `flutter_launcher_icons` under `dev_dependencies`.
- [ ] **Step 2: Write `flutter_launcher_icons.yaml`**
```yaml
flutter_launcher_icons:
android: true
ios: true
image_path: "assets/icon/icon.png"
remove_alpha_ios: true
min_sdk_android: 21
adaptive_icon_background: "assets/icon/icon_background.png"
adaptive_icon_foreground: "assets/icon/icon_foreground.png"
```
- [ ] **Step 3: Generate**
Run: `dart run flutter_launcher_icons`
Expected: "Successfully generated launcher icons". It overwrites
`ios/Runner/Assets.xcassets/AppIcon.appiconset/*` and
`android/app/src/main/res/mipmap-*/*`.
- [ ] **Step 4: Sanity-check outputs**
```bash
ls ios/Runner/Assets.xcassets/AppIcon.appiconset/ | head
ls android/app/src/main/res/mipmap-hdpi/
```
Expected: regenerated icon PNGs present; an `mipmap-anydpi-v26/ic_launcher.xml` (adaptive) on Android.
- [ ] **Step 5: Commit**
```bash
git add pubspec.yaml pubspec.lock flutter_launcher_icons.yaml ios/Runner/Assets.xcassets/AppIcon.appiconset android/app/src/main/res
git commit -m "build(brand): generate iOS/Android launcher icons from brand mark"
```
---
## Task 6: Juice — press feedback + fade transitions
**Files:** Create `lib/ui/widgets/pressable_scale.dart`, `lib/ui/widgets/fade_route.dart`; Modify `lib/ui/screens/home_screen.dart`, `lib/ui/screens/season_map_screen.dart`
- [ ] **Step 1: Press-scale wrapper**
```dart
// lib/ui/widgets/pressable_scale.dart
import 'package:flutter/material.dart';
/// Wraps a tappable child with a quick scale-down on press for tactile feel.
/// Delegates the actual tap to [onTap]; pass the child WITHOUT its own
/// onPressed (or keep it — this only adds the visual squish).
class PressableScale extends StatefulWidget {
const PressableScale({super.key, required this.child, this.onTap});
final Widget child;
final VoidCallback? onTap;
@override
State<PressableScale> createState() => _PressableScaleState();
}
class _PressableScaleState extends State<PressableScale> {
bool _down = false;
void _set(bool v) => setState(() => _down = v);
@override
Widget build(BuildContext context) {
return GestureDetector(
onTapDown: (_) => _set(true),
onTapUp: (_) => _set(false),
onTapCancel: () => _set(false),
onTap: widget.onTap,
child: AnimatedScale(
scale: _down ? 0.94 : 1.0,
duration: const Duration(milliseconds: 90),
curve: Curves.easeOut,
child: widget.child,
),
);
}
}
```
- [ ] **Step 2: Fade route helper**
```dart
// lib/ui/widgets/fade_route.dart
import 'package:flutter/material.dart';
/// A gentle fade(+slight scale) page transition for in-app navigation.
Route<T> fadeRoute<T>(Widget page) {
return PageRouteBuilder<T>(
transitionDuration: const Duration(milliseconds: 320),
reverseTransitionDuration: const Duration(milliseconds: 240),
pageBuilder: (_, __, ___) => page,
transitionsBuilder: (_, animation, __, child) {
final curved =
CurvedAnimation(parent: animation, curve: Curves.easeOutCubic);
return FadeTransition(
opacity: curved,
child: ScaleTransition(
scale: Tween(begin: 0.98, end: 1.0).animate(curved),
child: child,
),
);
},
);
}
```
- [ ] **Step 3: Use fade routes + press feedback on Home**
In `home_screen.dart`:
- Add imports `import '../widgets/fade_route.dart';` and `import '../widgets/pressable_scale.dart';`.
- Replace the two `Navigator.of(context).push(MaterialPageRoute(builder: (_) => const SeasonMapScreen()))` and `... const GameScreen()` calls with `Navigator.of(context).push(fadeRoute(const SeasonMapScreen()))` / `fadeRoute(const GameScreen())`. (Keep the settings-gear navigation on the default route, or switch it too — optional.)
- Wrap the Adventure `FilledButton` and Classic `OutlinedButton` each in `PressableScale(child: ...)`. The buttons keep their own `onPressed` (the PressableScale `onTap` is left null — it only adds the squish; the button handles the tap). Do NOT double-fire navigation.
- [ ] **Step 4: Use press feedback on map nodes**
In `season_map_screen.dart`, the stage node widgets (the tappable `Key('stage_node_$i')` elements that start a stage) — wrap each node's existing tappable widget in `PressableScale`, preserving its current onTap. Also change the node's stage-start navigation to `fadeRoute(const GameScreen())` if it pushes GameScreen. Add the imports.
- [ ] **Step 5: Analyze + full suite + a transitions smoke widget test**
Run: `flutter analyze lib/ui/widgets/pressable_scale.dart lib/ui/widgets/fade_route.dart lib/ui/screens/home_screen.dart lib/ui/screens/season_map_screen.dart`
Expected: clean.
Run: `flutter test`
Expected: green. The existing home/map widget tests must still find and tap the same buttons/nodes — `PressableScale` keeps them tappable via `onTap`/the inner button. If a test taps by widget type (e.g. `FilledButton`) it still works; if any test breaks because the tappable moved, fix the finder minimally and report.
- [ ] **Step 6: Commit**
```bash
git add lib/ui/widgets/pressable_scale.dart lib/ui/widgets/fade_route.dart lib/ui/screens/home_screen.dart lib/ui/screens/season_map_screen.dart
git commit -m "feat(juice): button press feedback + fade screen transitions"
```
---
## Task 7: Localization sweep + KO integrity (controller-run)
**Files:** Possibly `lib/l10n/app_en.arb`, `app_ko.arb`, and any screen with a hardcoded string. Verification is manual on the simulator — the controller runs this, not a subagent.
- [ ] **Step 1: Hardcoded-string sweep**
Run:
```bash
grep -rnE "Text\((['\"])" lib/ui | grep -vE "l10n\.|AppLocalizations|style:|\\\$|Text\(''\)"
```
For each hit, decide: is it user-facing copy? If yes, move it to an ARB key (en + ko) and reference `l10n.<key>`. The known dynamic ones (`Text('$e')` error states, `Text('${view.score}')`) are NOT copy — leave them.
- [ ] **Step 2: EN/KO key parity check**
```bash
diff <(grep -oE '"[a-zA-Z0-9_]+":' lib/l10n/app_en.arb | grep -v '^"@' | sort -u) \
<(grep -oE '"[a-zA-Z0-9_]+":' lib/l10n/app_ko.arb | grep -v '^"@' | sort -u)
```
Resolve any message key present in one ARB but not the other. (`@`-prefixed metadata lives only in the en template — that's expected.)
- [ ] **Step 3: KO overflow pass on the simulator**
Build & run under the Korean locale and walk every screen (splash, season title, home, map, game HUD + all result overlays — clear/fail/stuck/out-of-moves/endless game-over, settings, tutorial, streak snackbar):
```bash
flutter run -d <ios-sim-id> --dart-define=... # then switch device language to Korean, or
xcrun simctl spawn booted defaults write -g AppleLanguages '("ko")' # before launch
```
Capture screenshots of each screen in KO. Fix any overflow/truncation (wrap text, `maxLines`, `FittedBox`, reduce font, or shorten the KO string). Re-verify.
- [ ] **Step 4: Commit any fixes**
```bash
git add -A
git commit -m "i18n: localize remaining strings; fix KO overflow on <screens>"
```
(If no fixes were needed, record that in the Phase report instead of an empty commit.)
---
## Task 8: Store assets — feature graphic + screenshots (controller-run)
**Files:** Create `lib/ui/branding/feature_graphic_painter.dart`; extend `test/tool/generate_brand_assets_test.dart`; output `docs/store/feature_graphic.png`, `docs/store/screenshots/{en,ko}/*.png`
- [ ] **Step 1: Feature graphic painter**
```dart
// lib/ui/branding/feature_graphic_painter.dart
import 'dart:ui';
import 'package:flutter/material.dart';
import 'app_icon_painter.dart';
/// Paints the Play feature graphic (1024×500): navy field, the brand blocks on
/// the left, wordmark + tagline on the right.
class FeatureGraphic {
static void paint(Canvas canvas, Size size) {
final rect = Offset.zero & size;
AppIconMark.paintBackground(canvas, rect);
// Blocks on the left, vertically centered.
canvas.save();
final blockArea = size.height * 0.74;
canvas.translate(size.height * 0.16, (size.height - blockArea) / 2);
AppIconMark.paintBlocks(canvas, blockArea, groupFraction: 0.92);
canvas.restore();
void text(String s, double dy, double fontSize, FontWeight w, Color c) {
final tp = TextPainter(
text: TextSpan(
text: s,
style: TextStyle(
color: c, fontSize: fontSize, fontWeight: w, letterSpacing: 0.5),
),
textDirection: TextDirection.ltr,
)..layout();
tp.paint(canvas, Offset(size.height * 1.02, dy));
}
text('Block Seasons', size.height * 0.34, 76, FontWeight.w900, Colors.white);
text('A new season of blocks every few weeks.', size.height * 0.50, 30,
FontWeight.w500, const Color(0xFFB9C4E6));
}
}
```
- [ ] **Step 2: Extend the generator** — add to `test/tool/generate_brand_assets_test.dart` a second `testWidgets` that renders the feature graphic. Add a non-square writer:
```dart
import 'package:block_seasons/ui/branding/feature_graphic_painter.dart';
// ... inside main():
testWidgets('generate feature graphic', (tester) async {
const w = 1024, h = 500;
final recorder = PictureRecorder();
final canvas = Canvas(recorder);
FeatureGraphic.paint(canvas, const Size(1024, 500));
final picture = recorder.endRecording();
final image = await picture.toImage(w, h);
final bytes = await image.toByteData(format: ImageByteFormat.png);
File('docs/store/feature_graphic.png').parent.createSync(recursive: true);
File('docs/store/feature_graphic.png')
.writeAsBytesSync(bytes!.buffer.asUint8List());
expect(File('docs/store/feature_graphic.png').existsSync(), isTrue);
});
```
Run: `flutter test test/tool/generate_brand_assets_test.dart``docs/store/feature_graphic.png` (1024×500) exists.
- [ ] **Step 3: Capture EN + KO screenshots (simulator, controller-run)**
For each locale in {en, ko}: set the simulator language, launch the app, and capture: home, season map, gameplay (mid-combo), stage win (stars), endless game-over. Save under `docs/store/screenshots/<locale>/<screen>.png`. (Use the burst/file-size technique from prior phases to grab transient frames; tap navigation via the booted sim or computer-use if available.)
- [ ] **Step 4: Commit**
```bash
git add lib/ui/branding/feature_graphic_painter.dart test/tool/generate_brand_assets_test.dart docs/store
git commit -m "feat(store): feature graphic + EN/KO screenshot set"
```
---
## Task 9: Final verification
**Files:** none (verification only)
- [ ] **Step 1: Static + tests**
```bash
flutter analyze # No issues
flutter test # all green (≥171 with the new sound/icon tests)
```
- [ ] **Step 2: Build + icon on device**
```bash
flutter build ios --debug --simulator
xcrun simctl install booted build/ios/iphonesimulator/Runner.app
```
Confirm the **home-screen app icon** is the navy 2×2 block mark (not the default Flutter icon). Screenshot it to `docs/screenshots/sim_app_icon.png`.
- [ ] **Step 3: Sound toggle smoke**
Launch, open Settings, toggle Sound off → play a stage and confirm no SFX/haptics; toggle on → SFX return. Relaunch → setting persisted.
- [ ] **Step 4: Commit evidence**
```bash
git add docs/screenshots/sim_app_icon.png
git commit -m "docs: Phase 6 verified — real app icon, sound toggle, KO clean"
```
---
## Self-Review
**Spec coverage:** icon (Tasks 45, 9) ✓; l10n finalize + KO pass (Tasks 3, 7) ✓; sound/haptics toggle (Tasks 13) ✓; themed settings (Task 3) ✓; button press + transitions (Task 6) ✓; feature graphic + screenshots (Task 8) ✓. All spec success criteria map to Task 9 / 7 / 3.
**Placeholder scan:** Task 7 and Task 8 Step 3 are controller-run manual/visual steps (KO overflow, screenshots) — they describe concrete actions and outputs, not deferred work. The only "decide per hit" is the hardcoded-string sweep, which is inherent to an audit; the grep + rule are explicit.
**Type/name consistency:** `soundEnabled`/`setSoundEnabled` (repo), `soundEnabledProvider`/`SoundEnabledNotifier.toggle/set`, `AppIconMark.paintBackground/paintBlocks(groupFraction:)`, `FeatureGraphic.paint`, `fadeRoute<T>`, `PressableScale` — used identically across tasks. `paintGlossyTile(canvas, rect, color, radiusFactor:)` matches the real signature in `tile_painter.dart`.
**Note on execution:** Tasks 16 are subagent-friendly (deterministic, testable). Tasks 79 are controller-run (simulator-visual: KO overflow, screenshots, icon-on-device, sound smoke) — the controller executes these directly rather than dispatching, mirroring Phase 5's Task 14.
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,104 @@
# Phase 6 — Localization Finalize + Icon + Juice + Store Assets (Design)
**Date:** 2026-06-13
**Status:** Approved (owner approved design 2026-06-13)
## Context
Phases 05 are complete (gameplay, seasons, endless, commercial polish, remote
content, Firebase analytics, AdMob + IAP). Phase 6 is the pre-release polish
round. The master plan assumed AI-generated background/tilesheet **images**, but
Phase 3.5 implemented all in-game visuals **procedurally** (CustomPainter glossy
tiles, painted season backgrounds). So image assets are NOT needed for gameplay;
the only image deliverables are the **app icon** and **store graphics**, both of
which are produced as vectors in the game's existing visual language — no
external AI image generation required.
Owner is a non-developer; Claude produces all code and all vector art.
## Visual identity (established, reused here)
- Deep navy background gradient `#101736 → #192555 → #2C3168` (season-1 palette).
- Glossy rounded blocks in four brand colors: pink `#FF7EB3`, yellow `#FFD166`,
cyan `#6FCDF5`, green `#7EDB9C` (each rendered light→base→dark vertical
gradient + a soft white highlight ellipse near the top — same look as the
in-game `paintGlossyTile`).
## Workstreams
### 1. App icon (DECIDED: "Clean 2×2 block mark")
- Composition: rounded-square, deep-navy background, a centered 2×2 grid of the
four glossy brand-color blocks (pink top-left, yellow top-right, cyan
bottom-left, green bottom-right). No cherry-blossom accent (icon must be a
permanent, season-independent brand asset). Mirrors the existing in-app
`_logoMark`.
- Blocks kept within the central safe zone so Android's circular/rounded mask
never clips them.
- Deliverables:
- A master **SVG** committed to the repo (source of truth).
- A **1024×1024 PNG**, opaque (no alpha) for the iOS App Store.
- **Android adaptive icon**: foreground = the 2×2 blocks (transparent
surround), background = the navy gradient (flat or gradient drawable).
- All platform sizes generated via the `flutter_launcher_icons` package
(config in `pubspec.yaml` / `flutter_launcher_icons.yaml`), replacing the
default Flutter icon on both platforms.
- Tooling: rasterize SVG→PNG with a deterministic, scriptable converter
(`rsvg-convert`, or a tiny Dart/`image` script, or `flutter_svg` render). The
conversion command is checked in so the icon can be regenerated.
### 2. Localization finalize (EN + KO)
- Audit every user-facing screen for hardcoded (non-l10n) strings; move any to
ARB. Screens to sweep: splash, season title, home, season map, game (HUD +
result overlays), settings, tutorial, snackbars.
- Verify EN copy is store-quality and KO copy reads natively (no machine-y
phrasing).
- **KO integrity pass:** run the app under the `ko` locale on the simulator and
confirm no text overflow/truncation/clipping on any screen (Korean strings are
often longer/shorter than English — buttons, chips, result cards, settings
rows). Fix overflows (wrap, ellipsis, sizing) where found.
- Add any new keys introduced by workstream 3 (sound/haptics toggle labels).
### 3. Juice / polish pass (light, on top of Phase 3.5; BGM stays on hold)
- **Sound on/off setting** (real gap — commercial games need it): a single
toggle in Settings that enables/disables SFX, persisted in `SaveRepository`
(additive flag, saveVersion stays 1) and applied through `AudioService`. The
same flag also gates gameplay haptics (no separate haptics toggle — one
"Sound & vibration" control keeps Settings simple).
- **Settings screen theming:** the just-added Settings screen is a plain
`ListView`; give it the game's look (transparent scaffold over
`SeasonBackground`, themed text/cards) so it matches the rest of the app.
- **Button press feedback:** subtle scale-on-press for the primary buttons
(home Adventure/Classic, map nodes) for a more tactile "game" feel.
- **Screen transitions:** a gentle shared fade(-through) transition for
home → map → game navigation instead of the default platform slide.
### 4. Store assets
- **Feature graphic (Play, 1024×500):** navy + glossy blocks + "Block Seasons"
wordmark + a short tagline, in the icon's visual language. Produced as vector
→ PNG and committed under `docs/store/`.
- **Screenshots:** captured from the running app on the simulator — home,
season map, gameplay (mid-combo), stage win (stars), endless game-over — in
**both EN and KO**. Stored under `docs/store/screenshots/<locale>/`.
- Exact per-store framing/sizes (iOS 6.7"/6.5", Play phone) are finalized at
submission time in **Phase 7**; Phase 6 captures the raw, clean source shots.
## Out of scope (explicit)
- BGM / music (on hold by prior decision).
- AI-generated raster backgrounds/tilesheets (visuals are procedural).
- Real AdMob unit IDs and store IAP product creation (owner prerequisites,
tracked for the Phase 5 finalize step between Phase 6 and Phase 7).
- Final per-store screenshot framing (Phase 7).
## Success criteria
- Real app icon shows on both iOS and Android (no default Flutter icon).
- KO full play-through has zero text overflow/clipping; no hardcoded strings.
- Sound toggle works and persists across relaunch.
- Feature graphic + EN/KO screenshot set committed under `docs/store/`.
- `flutter analyze` clean; full test suite green (≥169, plus any new tests for
the sound-setting persistence).
@@ -0,0 +1,141 @@
# 부스터 & 데일리 보상 — 설계 (Boosters & Daily Reward)
작성일: 2026-06-18 · 상태: 승인됨(오너) → 구현 계획 단계로
## 목표 / 맥락
광고 수익형 퍼즐게임 Block Seasons의 **리텐션(복귀) + 광고 노출**을 동시에 키우는
키스톤 기능. 현재 게임엔 플레이어에게 줄 "보상 대상"이 없어 데일리 보상·보상형
광고를 붙일 곳이 없었음 → **가벼운 부스터(파워업) 경제**를 도입해 둘 다 해금한다.
사업자등록이 없어 IAP는 막혀 있으므로 **재화/상점 없이** 부스터를 직접 주고받는다.
## 확정된 결정 (브레인스토밍)
1. **부스터 3종**: 🔨해머 / 🔀셔플 / 💥줄폭탄
2. **획득**: 재화 없음 — 데일리 보상 + 보상형 광고로 부스터를 직접 받아 **비축**
3. **데일리**: 7일 출석 캘린더 (점증, Day7 잭팟) + "광고 보고 2배"
4. **사용**: 게임 중 **부스터 바 → 대상 지정**
## 부스터 규칙 (밸런스, 오너 승인)
- **이동 수 미소모** — 부스터는 보조라 무브 카운터를 깎지 않는다.
- **점수·콤보 미부여, 목표 미반영** — 그리드만 직접 바꾸고 점수/목표 이벤트 파이프라인을
타지 않는다. 줄폭탄으로 "줄 N개" 목표를 깨거나 해머로 보석을 제거해 "보석 N개"
목표를 달성할 수 없다(스테이지 난이도 보존). 콤보 상태도 건드리지 않는다(전진·리셋 둘 다 없음).
- **막힌 보드 되살리기 허용** — 부스터 사용 후 phase를 재평가(`_checkStuck`)해, 죽은 판
(boardDead)을 다시 playing으로 되돌릴 수 있다 → 보상형 '컨티뉴'의 대안.
- **사용 가능 시점** — phase가 `playing` 또는 `stuck`일 때만. `won`/`lost` 후엔 불가.
- **사용 한도** — 별도 횟수 제한 없음. 보유량이 곧 한도(가진 만큼만 사용).
- **엔드리스 모드 포함** — 엔드리스에서도 사용 가능.
### 부스터별 동작
| 부스터 | 동작 | 대상 |
|---|---|---|
| 🔨 해머 | 채워진 칸 1개를 비움 | 칸 1개 탭 |
| 🔀 셔플 | 현재 트레이(3조각)를 새로 교체 | 즉시(대상 없음) |
| 💥 줄폭탄 | 가로 또는 세로 한 줄 전체를 비움 | 줄(행/열) 선택 |
## 아키텍처 (기존 레이어 준수: ui → state → game|data|services)
### 1) 엔진 (순수 Dart, `lib/game/engine/game_engine.dart`)
세 메서드 추가. 성공 시 `true`, 잘못된 대상/시점이면 `false`(보유량 차감 안 하도록).
```
bool useHammer(int x, int y) // (x,y)가 채워진 칸이면 비움
bool useShuffle() // 트레이 재추첨
bool useLineBomb({int? row, int? col}) // row 또는 col 중 하나의 줄을 비움
```
공통: 이동/점수/콤보/목표 불변, 마지막에 `_checkStuck()` 호출. `playing`/`stuck`에서만 허용.
줄폭탄은 `row``col` 중 정확히 하나만 지정(둘 다/둘 다 없음이면 `false`).
### 2) 모델 (순수 Dart, `lib/game/models/booster.dart`)
```
enum BoosterType { hammer, shuffle, lineBomb }
```
### 3) 저장 (`lib/data/save_repository.dart`, JSON blob 확장)
- 보유량: `int boosterCount(BoosterType)`, `Future<void> grantBooster(BoosterType, [int n = 1])`,
`Future<bool> consumeBooster(BoosterType)`(0이면 false).
- 데일리: `String? dailyLastClaimedYmd`, `int dailyCalendarDay`(1~7),
`Future<void> recordDailyClaim(String ymd, int day)`.
- JSON에 `boosters: {hammer, shuffle, lineBomb}``daily: {lastYmd, day}` 추가
(기존 streak의 ymd 유틸 재사용).
### 4) 상태 (Riverpod, `lib/state/`)
- `BoosterInventoryNotifier` — 보유량 노출 + `grant/consume`.
- `DailyRewardNotifier` — 오늘 받을 수 있는지 + 캘린더 day 계산 + claim.
- `GameSessionNotifier`에 사용 메서드 추가: `useHammer(x,y)` / `useShuffle()` /
`useLineBomb(...)`. 흐름: **엔진 먼저 호출 → 성공 시에만 인벤토리 차감 → 뷰 갱신**.
보유 0이면 호출하지 않음(UI가 광고 제안).
- `GameViewState`에 grid가 이미 있어 부스터 후 UI 재렌더 가능.
### 5) 데일리 캘린더 로직 (순수, 테스트 가능)
- 오늘 ymd 계산. `lastClaimedYmd == 오늘` → 이미 받음(비활성).
- 받을 수 있는 경우의 day:
- `lastClaimedYmd == 어제``day = (이전 day % 7) + 1`(연속, 7→1 순환).
- 그 외(하루 이상 빠짐/최초) → `day = 1`(리셋).
- claim 시 해당 day 보상 지급 + `lastClaimedYmd = 오늘`, `calendarDay = day` 저장.
- **보상표(제안, 튜닝 가능)**:
| Day | 보상 |
|---|---|
| 1 | 🔨×1 |
| 2 | 🔀×1 |
| 3 | 💥×1 |
| 4 | 🔨×1 🔀×1 |
| 5 | 🔀×1 💥×1 |
| 6 | 🔨×1 💥×1 |
| 7 | 🔨×2 🔀×2 💥×2 (잭팟) |
- 시작 보유량: 각 1개(최초 1회).
### 6) UI (`lib/ui/`)
- **부스터 바 위젯** — 보드 아래 3버튼(아이콘+개수). 탭 시 타겟팅 모드 진입.
- 해머: 채워진 칸 탭 → 제거.
- 줄폭탄: 보드 가장자리에 행/열 핸들 표시 → 핸들 탭으로 줄 선택(가장 명확한 UX,
구현 중 세부 조정 가능).
- 셔플: 즉시 적용.
- 개수 0인 버튼 탭 → "광고 보고 1개 받기" 다이얼로그.
- **데일리 팝업** — 홈 화면 진입 시 받을 수 있으면 표시. 7칸(과거=체크/딤, 오늘=하이라이트,
미래=잠금) + [받기] + [광고 보고 2배].
### 7) 광고 (`lib/services/ad_service.dart` 재사용)
- 부스터 0개 → 보상형 광고 → 성공 시 해당 부스터 +1.
- 데일리 2배 → 보상형 광고 → 성공 시 보상 2배 지급.
- `showRewarded()`는 광고 미로드 시에도 true(기존 플레이어 친화 폴백) → 부스터는 지급됨.
### 8) 분석 (`lib/services/analytics_service.dart`)
- `booster_used {type}`
- `booster_granted {type, count, source: start|daily|ad}`
- `daily_reward_claimed {day, doubled}`
### 9) l10n (`lib/l10n/`)
- 부스터 이름·설명, 데일리 보상 UI, "광고 보고 받기/2배" CTA — EN/KO.
## 테스트 전략 (TDD)
- **엔진**: 해머(칸 제거·이동/점수/목표 불변·빈 칸이면 false·죽은 판 되살림),
셔플(새 트레이·불변·재stuck), 줄폭탄(행/열 제거·불변·재stuck·row^col 검증),
won/lost 후 사용 차단.
- **저장**: grant/consume/영속, 0에서 consume=false, 데일리 ymd/day 영속.
- **데일리 로직**: 연속 시 day+1, 빠짐 시 리셋, 같은 날 재수령 불가, 보상표 지급, 2배.
- **세션 노티파이어**: 성공 시 차감, 잘못된 대상/0개면 미차감.
- **위젯**: 부스터 바 개수 렌더·타겟팅, 데일리 팝업 상태.
## 구현 단계 (계획에서 상세화)
1. 엔진 부스터 3종 (TDD, 순수)
2. `BoosterType` + SaveRepository 인벤토리 (TDD)
3. 인벤토리/세션 노티파이어 + 사용 흐름 (TDD)
4. 부스터 바 UI + 타겟팅 (위젯 테스트)
5. 데일리 캘린더 로직 + 노티파이어 (TDD)
6. 데일리 팝업 UI
7. 보상형 광고 지급(0-상태 + 데일리 2배)
8. 분석 + l10n
9. 통합 + 전체 테스트 그린
## 비목표 (YAGNI)
- 코인/재화/상점, 부스터 회전·되돌리기, IAP 부스터 판매, 부스터 합성/업그레이드.
## 리스크 / 메모
- 부스터가 점수·목표에 반영되지 않으므로 기존 스테이지 밸런스는 그대로 유효.
- 보상형 광고 미로드 시에도 부스터를 지급(폴백) → 신규 앱 no-fill 기간에도 게임 흐름 유지.
- 새 빌드 필요(부스터·데일리는 다음 릴리스부터). 빌드는 오너 명령 시에만.
@@ -0,0 +1,28 @@
zardu.plum@gmail.com
zardu.sqa.101@gmail.com
zardu.sqa.102@gmail.com
zardu.sqa.103@gmail.com
zardu.sqa.104@gmail.com
zardu.sqa.105@gmail.com
zardu.sqa.106@gmail.com
zardu.sqa.107@gmail.com
zardu.sqa.108@gmail.com
zardu.sqa.109@gmail.com
zardu.sqa.110@gmail.com
zardu.sqa.111@gmail.com
zardu.sqa.112@gmail.com
zardu.sqa.113@gmail.com
zardu.sqa.114@gmail.com
zardu.sqa.115@gmail.com
zardu.sqa.116@gmail.com
zardu.sqa.117@gmail.com
zardu.sqa.118@gmail.com
zardu.sqa.119@gmail.com
zardu.sqa.120@gmail.com
zardu.sqa.121@gmail.com
axiom.kor@gmail.com
boson.seoul@gmail.com
cepheid.space@gmail.com
diffraction.rayman@gmail.com
duality.frame@gmail.com
quantum.tteokshop@gmail.com
1 zardu.plum gmail.com
2 zardu.sqa.101 gmail.com
3 zardu.sqa.102 gmail.com
4 zardu.sqa.103 gmail.com
5 zardu.sqa.104 gmail.com
6 zardu.sqa.105 gmail.com
7 zardu.sqa.106 gmail.com
8 zardu.sqa.107 gmail.com
9 zardu.sqa.108 gmail.com
10 zardu.sqa.109 gmail.com
11 zardu.sqa.110 gmail.com
12 zardu.sqa.111 gmail.com
13 zardu.sqa.112 gmail.com
14 zardu.sqa.113 gmail.com
15 zardu.sqa.114 gmail.com
16 zardu.sqa.115 gmail.com
17 zardu.sqa.116 gmail.com
18 zardu.sqa.117 gmail.com
19 zardu.sqa.118 gmail.com
20 zardu.sqa.119 gmail.com
21 zardu.sqa.120 gmail.com
22 zardu.sqa.121 gmail.com
23 axiom.kor gmail.com
24 boson.seoul gmail.com
25 cepheid.space gmail.com
26 diffraction.rayman gmail.com
27 duality.frame gmail.com
28 quantum.tteokshop gmail.com
+1
View File
@@ -0,0 +1 @@
{"hosting":{"public":"deploy","ignore":["firebase.json","**/.*","**/node_modules/**"]},"flutter":{"platforms":{"android":{"default":{"projectId":"block-seasons","appId":"1:190209969950:android:e08dc30877b1821b44c30f","fileOutput":"android/app/google-services.json"}},"ios":{"default":{"projectId":"block-seasons","appId":"1:190209969950:ios:f9d4578ec86f92c844c30f","uploadDebugSymbols":false,"fileOutput":"ios/Runner/GoogleService-Info.plist"}},"dart":{"lib/firebase_options.dart":{"projectId":"block-seasons","configurations":{"android":"1:190209969950:android:e08dc30877b1821b44c30f","ios":"1:190209969950:ios:f9d4578ec86f92c844c30f"}}}}}}
+8
View File
@@ -0,0 +1,8 @@
flutter_launcher_icons:
android: true
ios: true
image_path: "assets/icon/icon.png"
remove_alpha_ios: true
min_sdk_android: 21
adaptive_icon_background: "assets/icon/icon_background.png"
adaptive_icon_foreground: "assets/icon/icon_foreground.png"
+2 -2
View File
@@ -1,5 +1,5 @@
# Uncomment this line to define a global platform for your project
# platform :ios, '13.0'
# Firebase iOS SDK (firebase_core 4.x) requires a minimum of iOS 15.0.
platform :ios, '15.0'
# CocoaPods analytics sends network stats synchronously affecting flutter build latency.
ENV['COCOAPODS_DISABLE_STATS'] = 'true'
+226 -1
View File
@@ -1,37 +1,262 @@
PODS:
- app_tracking_transparency (0.0.1):
- Flutter
- audioplayers_darwin (0.0.1):
- Flutter
- FlutterMacOS
- 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.15.0)
- Flutter
- firebase_core (4.11.0):
- Firebase/CoreOnly (= 12.15.0)
- Flutter
- 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.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.15.0):
- FirebaseCoreInternal (~> 12.15.0)
- GoogleUtilities/Environment (~> 8.1)
- GoogleUtilities/Logger (~> 8.1)
- FirebaseCoreExtension (12.15.0):
- FirebaseCore (~> 12.15.0)
- FirebaseCoreInternal (12.15.0):
- "GoogleUtilities/NSData+zlib (~> 8.1)"
- 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)
- google_mobile_ads (7.0.0):
- Flutter
- Google-Mobile-Ads-SDK (~> 12.14.0)
- webview_flutter_wkwebview
- GoogleAdsOnDeviceConversion (3.6.0):
- GoogleUtilities/Environment (~> 8.1)
- GoogleUtilities/Logger (~> 8.1)
- GoogleUtilities/Network (~> 8.1)
- nanopb (~> 3.30910.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.15.0):
- GoogleAdsOnDeviceConversion (~> 3.6.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.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.1):
- GoogleUtilities/Environment
- GoogleUtilities/Logger
- GoogleUtilities/Network
- GoogleUtilities/Privacy
- GoogleUtilities/Environment (8.1.1):
- GoogleUtilities/Privacy
- GoogleUtilities/Logger (8.1.1):
- GoogleUtilities/Environment
- GoogleUtilities/Privacy
- GoogleUtilities/MethodSwizzler (8.1.1):
- GoogleUtilities/Logger
- GoogleUtilities/Privacy
- GoogleUtilities/Network (8.1.1):
- GoogleUtilities/Logger
- "GoogleUtilities/NSData+zlib"
- GoogleUtilities/Privacy
- GoogleUtilities/Reachability
- "GoogleUtilities/NSData+zlib (8.1.1)":
- GoogleUtilities/Privacy
- GoogleUtilities/Privacy (8.1.1)
- GoogleUtilities/Reachability (8.1.1):
- GoogleUtilities/Logger
- GoogleUtilities/Privacy
- 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)
- nanopb/decode (3.30910.0)
- nanopb/encode (3.30910.0)
- path_provider_foundation (0.0.1):
- Flutter
- FlutterMacOS
- 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
DEPENDENCIES:
- app_tracking_transparency (from `.symlinks/plugins/app_tracking_transparency/ios`)
- 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:
trunk:
- 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:
:path: ".symlinks/plugins/app_tracking_transparency/ios"
audioplayers_darwin:
:path: ".symlinks/plugins/audioplayers_darwin/darwin"
firebase_analytics:
: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: 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: a6d37949071d456e9147dac6789c4342e0e7a8c5
GoogleDataTransport: aae35b7ea0c09004c3797d53c8c41f66f219d6a7
GoogleUserMessagingPlatform: befe603da6501006420c206222acd449bba45a9c
GoogleUtilities: 4f2618a4a1e762a1ee134a1e2323bba9843e06da
in_app_purchase_storekit: 22cca7d08eebca9babdf4d07d0baccb73325d3c8
in_app_review: 7dd1ea365263f834b8464673f9df72c80c17c937
nanopb: fad817b59e0457d11a5dfbde799381cd727c1275
path_provider_foundation: bb55f6dbba17d0dccd6737fe6f7f34fbd0376880
PromisesObjC: 752c3227f599e3467650e47ea36f433eeb10c273
PromisesSwift: 217dea0fd5d2ad65222a109c48698add13cc1c5b
shared_preferences_foundation: 7036424c3d8ec98dfe75ff1667cb0cd531ec82bb
url_launcher_ios: 7a95fa5b60cc718a708b8f2966718e93db0cef1b
webview_flutter_wkwebview: 8ebf4fded22593026f7dbff1fbff31ea98573c8d
PODFILE CHECKSUM: 3c63482e143d1b91d2d2560aee9fb04ecc74ac7e
PODFILE CHECKSUM: 7a0c05f8aeb53a8c858ca08a4666afaa242f0eb1
COCOAPODS: 1.16.2
+31 -5
View File
@@ -15,7 +15,9 @@
97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FD1CF9000F007C117D /* Assets.xcassets */; };
97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */; };
A1624C49AABB61D3BB6EBA00 /* Pods_RunnerTests.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 7F021B835BC4E346AE82B4C9 /* Pods_RunnerTests.framework */; };
BC732790904D77939BB8C135 /* PrivacyInfo.xcprivacy in Resources */ = {isa = PBXBuildFile; fileRef = FE18481AC23043B44AB64814 /* PrivacyInfo.xcprivacy */; };
D444497F007A61A9102D174D /* Pods_Runner.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 92F5ACA56D636C056F52DDE6 /* Pods_Runner.framework */; };
E746073DDE80B82D8D3C9659 /* GoogleService-Info.plist in Resources */ = {isa = PBXBuildFile; fileRef = 43C03408D7DF6E3F6C4EC9C7 /* GoogleService-Info.plist */; };
/* End PBXBuildFile section */
/* Begin PBXContainerItemProxy section */
@@ -47,6 +49,7 @@
331C807B294A618700263BE5 /* RunnerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RunnerTests.swift; sourceTree = "<group>"; };
331C8081294A63A400263BE5 /* RunnerTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = RunnerTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; name = AppFrameworkInfo.plist; path = Flutter/AppFrameworkInfo.plist; sourceTree = "<group>"; };
43C03408D7DF6E3F6C4EC9C7 /* GoogleService-Info.plist */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.plist.xml; name = "GoogleService-Info.plist"; path = "Runner/GoogleService-Info.plist"; sourceTree = "<group>"; };
55914DA7E8E89CB02E73C3F5 /* Pods-RunnerTests.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.profile.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.profile.xcconfig"; sourceTree = "<group>"; };
6BD9A45428DD4E519FC38754 /* Pods-Runner.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.profile.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.profile.xcconfig"; sourceTree = "<group>"; };
74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "Runner-Bridging-Header.h"; sourceTree = "<group>"; };
@@ -65,6 +68,7 @@
97C147021CF9000F007C117D /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
B4B2233E92790E4E03907BD2 /* Pods-Runner.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.debug.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig"; sourceTree = "<group>"; };
B9983A741CFB90A0857F31CD /* Pods-Runner.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.release.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig"; sourceTree = "<group>"; };
FE18481AC23043B44AB64814 /* PrivacyInfo.xcprivacy */ = {isa = PBXFileReference; includeInIndex = 1; path = PrivacyInfo.xcprivacy; sourceTree = "<group>"; };
/* End PBXFileReference section */
/* Begin PBXFrameworksBuildPhase section */
@@ -129,6 +133,7 @@
331C8082294A63A400263BE5 /* RunnerTests */,
2EECBA43D42E2853F949CCFC /* Pods */,
9CFCC4FE458D4EC11DAF9E88 /* Frameworks */,
43C03408D7DF6E3F6C4EC9C7 /* GoogleService-Info.plist */,
);
sourceTree = "<group>";
};
@@ -152,6 +157,7 @@
1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */,
74858FAE1ED2DC5600515810 /* AppDelegate.swift */,
74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */,
FE18481AC23043B44AB64814 /* PrivacyInfo.xcprivacy */,
);
path = Runner;
sourceTree = "<group>";
@@ -199,6 +205,7 @@
9705A1C41CF9048500538489 /* Embed Frameworks */,
3B06AD1E1E4923F5004D2608 /* Thin Binary */,
B8E60F64310B9A81A7741264 /* [CP] Embed Pods Frameworks */,
2838ED76467446CF49AD274C /* [CP] Copy Pods Resources */,
);
buildRules = (
);
@@ -264,12 +271,31 @@
3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */,
97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */,
97C146FC1CF9000F007C117D /* Main.storyboard in Resources */,
E746073DDE80B82D8D3C9659 /* GoogleService-Info.plist in Resources */,
BC732790904D77939BB8C135 /* PrivacyInfo.xcprivacy in Resources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXResourcesBuildPhase section */
/* Begin PBXShellScriptBuildPhase section */
2838ED76467446CF49AD274C /* [CP] Copy Pods Resources */ = {
isa = PBXShellScriptBuildPhase;
buildActionMask = 2147483647;
files = (
);
inputFileListPaths = (
"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources-${CONFIGURATION}-input-files.xcfilelist",
);
name = "[CP] Copy Pods Resources";
outputFileListPaths = (
"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources-${CONFIGURATION}-output-files.xcfilelist",
);
runOnlyForDeploymentPostprocessing = 0;
shellPath = /bin/sh;
shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources.sh\"\n";
showEnvVarsInLog = 0;
};
3B06AD1E1E4923F5004D2608 /* Thin Binary */ = {
isa = PBXShellScriptBuildPhase;
alwaysOutOfDate = 1;
@@ -455,7 +481,7 @@
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 13.0;
IPHONEOS_DEPLOYMENT_TARGET = 15.0;
MTL_ENABLE_DEBUG_INFO = NO;
SDKROOT = iphoneos;
SUPPORTED_PLATFORMS = iphoneos;
@@ -540,7 +566,7 @@
isa = XCBuildConfiguration;
buildSettings = {
ALWAYS_SEARCH_USER_PATHS = NO;
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = AppIcon;
CLANG_ANALYZER_NONNULL = YES;
CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x";
CLANG_CXX_LIBRARY = "libc++";
@@ -585,7 +611,7 @@
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 13.0;
IPHONEOS_DEPLOYMENT_TARGET = 15.0;
MTL_ENABLE_DEBUG_INFO = YES;
ONLY_ACTIVE_ARCH = YES;
SDKROOT = iphoneos;
@@ -597,7 +623,7 @@
isa = XCBuildConfiguration;
buildSettings = {
ALWAYS_SEARCH_USER_PATHS = NO;
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = AppIcon;
CLANG_ANALYZER_NONNULL = YES;
CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x";
CLANG_CXX_LIBRARY = "libc++";
@@ -636,7 +662,7 @@
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 13.0;
IPHONEOS_DEPLOYMENT_TARGET = 15.0;
MTL_ENABLE_DEBUG_INFO = NO;
SDKROOT = iphoneos;
SUPPORTED_PLATFORMS = iphoneos;
@@ -1,122 +1 @@
{
"images" : [
{
"size" : "20x20",
"idiom" : "iphone",
"filename" : "Icon-App-20x20@2x.png",
"scale" : "2x"
},
{
"size" : "20x20",
"idiom" : "iphone",
"filename" : "Icon-App-20x20@3x.png",
"scale" : "3x"
},
{
"size" : "29x29",
"idiom" : "iphone",
"filename" : "Icon-App-29x29@1x.png",
"scale" : "1x"
},
{
"size" : "29x29",
"idiom" : "iphone",
"filename" : "Icon-App-29x29@2x.png",
"scale" : "2x"
},
{
"size" : "29x29",
"idiom" : "iphone",
"filename" : "Icon-App-29x29@3x.png",
"scale" : "3x"
},
{
"size" : "40x40",
"idiom" : "iphone",
"filename" : "Icon-App-40x40@2x.png",
"scale" : "2x"
},
{
"size" : "40x40",
"idiom" : "iphone",
"filename" : "Icon-App-40x40@3x.png",
"scale" : "3x"
},
{
"size" : "60x60",
"idiom" : "iphone",
"filename" : "Icon-App-60x60@2x.png",
"scale" : "2x"
},
{
"size" : "60x60",
"idiom" : "iphone",
"filename" : "Icon-App-60x60@3x.png",
"scale" : "3x"
},
{
"size" : "20x20",
"idiom" : "ipad",
"filename" : "Icon-App-20x20@1x.png",
"scale" : "1x"
},
{
"size" : "20x20",
"idiom" : "ipad",
"filename" : "Icon-App-20x20@2x.png",
"scale" : "2x"
},
{
"size" : "29x29",
"idiom" : "ipad",
"filename" : "Icon-App-29x29@1x.png",
"scale" : "1x"
},
{
"size" : "29x29",
"idiom" : "ipad",
"filename" : "Icon-App-29x29@2x.png",
"scale" : "2x"
},
{
"size" : "40x40",
"idiom" : "ipad",
"filename" : "Icon-App-40x40@1x.png",
"scale" : "1x"
},
{
"size" : "40x40",
"idiom" : "ipad",
"filename" : "Icon-App-40x40@2x.png",
"scale" : "2x"
},
{
"size" : "76x76",
"idiom" : "ipad",
"filename" : "Icon-App-76x76@1x.png",
"scale" : "1x"
},
{
"size" : "76x76",
"idiom" : "ipad",
"filename" : "Icon-App-76x76@2x.png",
"scale" : "2x"
},
{
"size" : "83.5x83.5",
"idiom" : "ipad",
"filename" : "Icon-App-83.5x83.5@2x.png",
"scale" : "2x"
},
{
"size" : "1024x1024",
"idiom" : "ios-marketing",
"filename" : "Icon-App-1024x1024@1x.png",
"scale" : "1x"
}
],
"info" : {
"version" : 1,
"author" : "xcode"
}
}
{"images":[{"size":"20x20","idiom":"iphone","filename":"Icon-App-20x20@2x.png","scale":"2x"},{"size":"20x20","idiom":"iphone","filename":"Icon-App-20x20@3x.png","scale":"3x"},{"size":"29x29","idiom":"iphone","filename":"Icon-App-29x29@1x.png","scale":"1x"},{"size":"29x29","idiom":"iphone","filename":"Icon-App-29x29@2x.png","scale":"2x"},{"size":"29x29","idiom":"iphone","filename":"Icon-App-29x29@3x.png","scale":"3x"},{"size":"40x40","idiom":"iphone","filename":"Icon-App-40x40@2x.png","scale":"2x"},{"size":"40x40","idiom":"iphone","filename":"Icon-App-40x40@3x.png","scale":"3x"},{"size":"57x57","idiom":"iphone","filename":"Icon-App-57x57@1x.png","scale":"1x"},{"size":"57x57","idiom":"iphone","filename":"Icon-App-57x57@2x.png","scale":"2x"},{"size":"60x60","idiom":"iphone","filename":"Icon-App-60x60@2x.png","scale":"2x"},{"size":"60x60","idiom":"iphone","filename":"Icon-App-60x60@3x.png","scale":"3x"},{"size":"20x20","idiom":"ipad","filename":"Icon-App-20x20@1x.png","scale":"1x"},{"size":"20x20","idiom":"ipad","filename":"Icon-App-20x20@2x.png","scale":"2x"},{"size":"29x29","idiom":"ipad","filename":"Icon-App-29x29@1x.png","scale":"1x"},{"size":"29x29","idiom":"ipad","filename":"Icon-App-29x29@2x.png","scale":"2x"},{"size":"40x40","idiom":"ipad","filename":"Icon-App-40x40@1x.png","scale":"1x"},{"size":"40x40","idiom":"ipad","filename":"Icon-App-40x40@2x.png","scale":"2x"},{"size":"50x50","idiom":"ipad","filename":"Icon-App-50x50@1x.png","scale":"1x"},{"size":"50x50","idiom":"ipad","filename":"Icon-App-50x50@2x.png","scale":"2x"},{"size":"72x72","idiom":"ipad","filename":"Icon-App-72x72@1x.png","scale":"1x"},{"size":"72x72","idiom":"ipad","filename":"Icon-App-72x72@2x.png","scale":"2x"},{"size":"76x76","idiom":"ipad","filename":"Icon-App-76x76@1x.png","scale":"1x"},{"size":"76x76","idiom":"ipad","filename":"Icon-App-76x76@2x.png","scale":"2x"},{"size":"83.5x83.5","idiom":"ipad","filename":"Icon-App-83.5x83.5@2x.png","scale":"2x"},{"size":"1024x1024","idiom":"ios-marketing","filename":"Icon-App-1024x1024@1x.png","scale":"1x"}],"info":{"version":1,"author":"xcode"}}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

After

Width:  |  Height:  |  Size: 258 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 295 B

After

Width:  |  Height:  |  Size: 601 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 406 B

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 450 B

After

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 282 B

After

Width:  |  Height:  |  Size: 960 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 462 B

After

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 704 B

After

Width:  |  Height:  |  Size: 3.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 406 B

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 586 B

After

Width:  |  Height:  |  Size: 3.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 862 B

After

Width:  |  Height:  |  Size: 5.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 862 B

After

Width:  |  Height:  |  Size: 5.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.6 KiB

After

Width:  |  Height:  |  Size: 9.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 762 B

After

Width:  |  Height:  |  Size: 2.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.2 KiB

After

Width:  |  Height:  |  Size: 8.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.4 KiB

After

Width:  |  Height:  |  Size: 8.6 KiB

+30
View File
@@ -0,0 +1,30 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>API_KEY</key>
<string>AIzaSyAIl2LM2fDrr7OH70K7uJnAwKwXMzPCBMI</string>
<key>GCM_SENDER_ID</key>
<string>190209969950</string>
<key>PLIST_VERSION</key>
<string>1</string>
<key>BUNDLE_ID</key>
<string>com.airkjw.blockseasons</string>
<key>PROJECT_ID</key>
<string>block-seasons</string>
<key>STORAGE_BUCKET</key>
<string>block-seasons.firebasestorage.app</string>
<key>IS_ADS_ENABLED</key>
<false></false>
<key>IS_ANALYTICS_ENABLED</key>
<false></false>
<key>IS_APPINVITE_ENABLED</key>
<true></true>
<key>IS_GCM_ENABLED</key>
<true></true>
<key>IS_SIGNIN_ENABLED</key>
<true></true>
<key>GOOGLE_APP_ID</key>
<string>1:190209969950:ios:f9d4578ec86f92c844c30f</string>
</dict>
</plist>

Some files were not shown because too many files have changed in this diff Show More