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>
This commit is contained in:
2026-06-13 15:11:45 +09:00
parent 84a6749b5e
commit ea42c76f84
@@ -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.