// Headless store-screenshot generator. Renders the app's key screens to PNGs // at both store device sizes, in English, with a real font loaded (the // flutter_test default font draws every glyph as a box). // // flutter test test/tool/generate_store_screenshots_test.dart // // Output: docs/store/screenshots/{ios,android}/*.png import 'dart:convert'; import 'dart:io'; import 'dart:ui' as ui; import 'package:block_seasons/core/rng.dart'; import 'package:block_seasons/data/save_repository.dart'; import 'package:block_seasons/game/engine/piece_generator.dart'; import 'package:block_seasons/game/models/season.dart'; import 'package:block_seasons/game/models/stage.dart'; import 'package:block_seasons/l10n/gen/app_localizations.dart'; import 'package:block_seasons/state/providers.dart'; import 'package:block_seasons/ui/screens/game_screen.dart'; import 'package:block_seasons/ui/screens/home_screen.dart'; import 'package:flutter/material.dart'; import 'package:flutter/rendering.dart'; import 'package:flutter/services.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:shared_preferences/shared_preferences.dart'; typedef _Device = ({String dir, int pw, int ph, double dpr}); const _devices = <_Device>[ (dir: 'ios', pw: 1290, ph: 2796, dpr: 3.0), // iPhone 6.7" (dir: 'android', pw: 1080, ph: 1920, dpr: 3.0), // Play phone (dir: 'ipad', pw: 2048, ph: 2732, dpr: 2.0), // iPad 12.9"/13" ]; // A lively mid-game board: three gems to clear plus scattered colored blocks. final _playStage = StageConfig.fromJson({ 'id': 'shot_play', 'seed': 777, 'moveLimit': 20, 'preset': [ {'x': 3, 'y': 1, 't': 'gem'}, {'x': 5, 'y': 3, 't': 'gem'}, {'x': 2, 'y': 5, 't': 'gem'}, {'x': 0, 'y': 7, 't': 'filled', 'c': 0}, {'x': 1, 'y': 7, 't': 'filled', 'c': 3}, {'x': 2, 'y': 7, 't': 'filled', 'c': 5}, {'x': 3, 'y': 7, 't': 'filled', 'c': 2}, {'x': 5, 'y': 7, 't': 'filled', 'c': 6}, {'x': 6, 'y': 7, 't': 'filled', 'c': 1}, {'x': 7, 'y': 7, 't': 'filled', 'c': 4}, {'x': 0, 'y': 6, 't': 'filled', 'c': 3}, {'x': 1, 'y': 6, 't': 'filled', 'c': 0}, {'x': 6, 'y': 6, 't': 'filled', 'c': 4}, {'x': 7, 'y': 6, 't': 'filled', 'c': 7}, {'x': 0, 'y': 5, 't': 'filled', 'c': 5}, {'x': 7, 'y': 5, 't': 'filled', 'c': 2}, {'x': 5, 'y': 0, 't': 'filled', 'c': 3}, {'x': 2, 'y': 2, 't': 'filled', 'c': 7}, ], 'objectives': [ {'type': 'clearGems', 'count': 3}, ], 'stars': { 'two': {'movesLeft': 5}, 'three': {'movesLeft': 10}, }, 'generatorProfile': 'mid', }); // A denser, higher-tension board for the third shot (score-chase objective so // it stays mid-play rather than completing). final _denseStage = StageConfig.fromJson({ 'id': 'shot_dense', 'seed': 321, 'moveLimit': 16, 'preset': [ {'x': 2, 'y': 1, 't': 'gem'}, {'x': 6, 'y': 2, 't': 'gem'}, {'x': 0, 'y': 3, 't': 'filled', 'c': 6}, {'x': 1, 'y': 3, 't': 'filled', 'c': 6}, {'x': 7, 'y': 3, 't': 'filled', 'c': 2}, {'x': 0, 'y': 4, 't': 'filled', 'c': 1}, {'x': 3, 'y': 4, 't': 'filled', 'c': 4}, {'x': 4, 'y': 4, 't': 'filled', 'c': 4}, {'x': 7, 'y': 4, 't': 'filled', 'c': 0}, {'x': 0, 'y': 5, 't': 'filled', 'c': 3}, {'x': 1, 'y': 5, 't': 'filled', 'c': 3}, {'x': 2, 'y': 5, 't': 'filled', 'c': 5}, {'x': 5, 'y': 5, 't': 'filled', 'c': 7}, {'x': 6, 'y': 5, 't': 'filled', 'c': 1}, {'x': 7, 'y': 5, 't': 'filled', 'c': 1}, {'x': 0, 'y': 6, 't': 'filled', 'c': 2}, {'x': 1, 'y': 6, 't': 'filled', 'c': 0}, {'x': 2, 'y': 6, 't': 'filled', 'c': 0}, {'x': 3, 'y': 6, 't': 'filled', 'c': 5}, {'x': 5, 'y': 6, 't': 'filled', 'c': 6}, {'x': 6, 'y': 6, 't': 'filled', 'c': 3}, {'x': 0, 'y': 7, 't': 'filled', 'c': 4}, {'x': 1, 'y': 7, 't': 'filled', 'c': 4}, {'x': 2, 'y': 7, 't': 'filled', 'c': 2}, {'x': 4, 'y': 7, 't': 'filled', 'c': 7}, {'x': 5, 'y': 7, 't': 'filled', 'c': 7}, {'x': 6, 'y': 7, 't': 'filled', 'c': 5}, {'x': 7, 'y': 7, 't': 'filled', 'c': 0}, ], 'objectives': [ {'type': 'reachScore', 'target': 6000}, ], 'stars': { 'two': {'movesLeft': 4}, 'three': {'movesLeft': 8}, }, 'generatorProfile': 'hard', }); Future _loadFont() async { final bytes = File('/System/Library/Fonts/Supplemental/Arial.ttf').readAsBytesSync(); final loader = FontLoader('Arial') ..addFont(Future.value(ByteData.view(bytes.buffer))); await loader.load(); } Widget _wrap(ProviderContainer c, Widget screen, GlobalKey key) => UncontrolledProviderScope( container: c, child: RepaintBoundary( key: key, child: MaterialApp( debugShowCheckedModeBanner: false, locale: const Locale('en'), localizationsDelegates: AppLocalizations.localizationsDelegates, supportedLocales: AppLocalizations.supportedLocales, theme: ThemeData( fontFamily: 'Arial', colorScheme: ColorScheme.fromSeed( seedColor: const Color(0xFF5B7FFF), brightness: Brightness.dark, ), useMaterial3: true, ), home: screen, ), ), ); // Captures the boundary layer to a PNG at [dpr]x — the same OffsetLayer.toImage // call the golden matcher makes, but scaled up for store-resolution output. Future _shoot( WidgetTester tester, GlobalKey key, String dir, String file, double dpr) async { var ro = key.currentContext!.findRenderObject()!; while (!ro.isRepaintBoundary) { ro = ro.parent!; } final layer = ro.debugLayer! as OffsetLayer; final bounds = ro.paintBounds; // Real-async raster work must run inside runAsync, or it corrupts the // binding's frame pipeline for the next pump. final bytes = await tester.runAsync(() async { final image = await layer.toImage(bounds, pixelRatio: dpr); final data = await image.toByteData(format: ui.ImageByteFormat.png); image.dispose(); return data; }); final f = File('docs/store/screenshots/$dir/$file') ..parent.createSync(recursive: true); f.writeAsBytesSync(bytes!.buffer.asUint8List()); } /// The ambient season background animates forever, so pumpAndSettle would /// hang. Pump a couple of fixed frames to let layout + intro fades land. Future _steady(WidgetTester tester) async { await tester.pump(); await tester.pump(const Duration(milliseconds: 450)); } void main() { testWidgets('generate store screenshots', (tester) async { await _loadFont(); SharedPreferences.setMockInitialValues({}); final repo = SaveRepository(await SharedPreferences.getInstance()); await repo.recordEndlessScore(12480); await repo.markTutorialDone(); final pack = SeasonPack.fromJson(jsonDecode( File('assets/seasons/season_001/pack.json').readAsStringSync()) as Map); for (var i = 1; i <= 5; i++) { await repo.recordResult( seasonId: 'season_001', stageId: pack.stages[i - 1].id, stars: i.isEven ? 2 : 3, score: 1000 + i * 220, ); } for (final d in _devices) { tester.view.devicePixelRatio = d.dpr; tester.view.physicalSize = Size(d.pw.toDouble(), d.ph.toDouble()); addTearDown(tester.view.resetPhysicalSize); addTearDown(tester.view.resetDevicePixelRatio); // 1) Home { final key = GlobalKey(); final c = ProviderContainer( overrides: [saveRepositoryProvider.overrideWithValue(repo)]); await tester.pumpWidget(_wrap(c, const HomeScreen(), key)); await _steady(tester); await _shoot(tester, key, d.dir, '01_home.png', d.dpr); c.dispose(); } // 3) Gameplay { final key = GlobalKey(); final c = ProviderContainer( overrides: [saveRepositoryProvider.overrideWithValue(repo)]); c.read(gameSessionProvider.notifier).startStage( _playStage, generator: PieceGenerator(SeededRng(7)), ); await tester.pumpWidget(_wrap(c, const GameScreen(), key)); await _steady(tester); await _shoot(tester, key, d.dir, '02_play.png', d.dpr); c.dispose(); } // 3b) A denser board (score chase) { final key = GlobalKey(); final c = ProviderContainer( overrides: [saveRepositoryProvider.overrideWithValue(repo)]); c.read(gameSessionProvider.notifier).startStage( _denseStage, generator: PieceGenerator(SeededRng(13)), ); await tester.pumpWidget(_wrap(c, const GameScreen(), key)); await _steady(tester); await _shoot(tester, key, d.dir, '03_levels.png', d.dpr); c.dispose(); } } for (final d in _devices) { for (final f in ['01_home', '02_play', '03_levels']) { expect(File('docs/store/screenshots/${d.dir}/$f.png').existsSync(), isTrue, reason: '${d.dir}/$f'); } } }); }