4df30c3f40
App ships universal (iPhone+iPad), so the App Store requires iPad screenshots. Render the 3 store screens at 2048x2732 (iPad 12.9"/13"). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
255 lines
8.9 KiB
Dart
255 lines
8.9 KiB
Dart
// 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<void> _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<void> _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<void> _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<String, dynamic>);
|
|
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');
|
|
}
|
|
}
|
|
});
|
|
}
|