diff --git a/docs/superpowers/plans/2026-06-13-phase6-polish.md b/docs/superpowers/plans/2026-06-13-phase6-polish.md new file mode 100644 index 0000000..3d79e53 --- /dev/null +++ b/docs/superpowers/plans/2026-06-13-phase6-polish.md @@ -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(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?)?['soundEnabled'] as bool? ?? + true; +``` +- Add getter near `bool get adsRemoved`: +```dart + bool get soundEnabled => _soundEnabled; +``` +- Add setter near `setAdsRemoved`: +```dart + Future 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 { + @override + bool build() => ref.read(saveRepositoryProvider).soundEnabled; + + Future toggle() => set(!state); + + Future 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.new); +``` +Replace the `audioServiceProvider` body so it applies the flag live: +```dart +final audioServiceProvider = Provider((ref) { + final service = AudioService(enabled: ref.read(soundEnabledProvider)); + ref.listen(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(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 _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 createState() => _PressableScaleState(); +} + +class _PressableScaleState extends State { + 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 fadeRoute(Widget page) { + return PageRouteBuilder( + 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.`. 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 --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 " +``` +(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//.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 4–5, 9) ✓; l10n finalize + KO pass (Tasks 3, 7) ✓; sound/haptics toggle (Tasks 1–3) ✓; 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`, `PressableScale` — used identically across tasks. `paintGlossyTile(canvas, rect, color, radiusFactor:)` matches the real signature in `tile_painter.dart`. + +**Note on execution:** Tasks 1–6 are subagent-friendly (deterministic, testable). Tasks 7–9 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.