Files
BlockSeasons/docs/superpowers/plans/2026-06-13-phase6-polish.md
T
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

744 lines
29 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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.