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

29 KiB
Raw Blame History

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.dartfadeRoute<T>(Widget) PageRoute helper.
  • lib/state/sound_notifier.dartSoundEnabledNotifier (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.dartsoundEnabledProvider; 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.arbsoundAndVibration key (+ any sweep fixes).
  • pubspec.yamlflutter_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
// 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;:
  bool _soundEnabled = true;
  • In the constructor, after the _adsRemoved = ... block, add (default TRUE when missing):
      _soundEnabled =
          (json['flags'] as Map<String, dynamic>?)?['soundEnabled'] as bool? ??
              true;
  • Add getter near bool get adsRemoved:
  bool get soundEnabled => _soundEnabled;
  • Add setter near setAdsRemoved:
  Future<void> setSoundEnabled(bool value) {
    _soundEnabled = value;
    return _flush();
  }
  • In _flush(), extend the 'flags' map:
          '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

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
// 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

// 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:

import 'sound_notifier.dart';

Add the provider (near audioServiceProvider):

final soundEnabledProvider =
    NotifierProvider<SoundEnabledNotifier, bool>(SoundEnabledNotifier.new);

Replace the audioServiceProvider body so it applies the flag live:

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:

      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
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
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:
  "soundAndVibration": "Sound & vibration",
  • Step 2: Add l10n key (ko) — in app_ko.arb:
  "soundAndVibration": "소리 및 진동",
  • Step 3: Regenerateflutter 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:

  @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
flutter analyze lib/ui/screens/settings_screen.dart
flutter test

Expected: clean; green.

  • Step 6: Commit
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
// 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)
// 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:

file assets/icon/icon.png   # PNG image data, 1024 x 1024
  • Step 4: Commit
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
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
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
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
// 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
// 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
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:

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
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):

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
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
// 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:
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.dartdocs/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
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
flutter analyze            # No issues
flutter test               # all green (≥171 with the new sound/icon tests)
  • Step 2: Build + icon on device
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
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.