docs(store): generate store screenshots (iOS 6.7" + Android phone)
Headless screenshot harness renders Home, Gameplay, and a denser score-chase board to PNGs at 1290x2796 (iOS) and 1080x1920 (Android), in English with a real font loaded. Captures the boundary layer at 3x inside runAsync (the only way it completes under the test binding). Season-map cut deferred — its scrollable build stalls headlessly. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -26,7 +26,10 @@
|
||||
- 개인정보처리방침 페이지: `docs/store/privacy-policy.html`
|
||||
- app-ads.txt: `docs/store/app-ads.txt`
|
||||
- 스토어 카피 EN/KO: `docs/store/store-listing.md`
|
||||
- ⬜ **스크린샷**: 아직 없음 → Claude에게 "스크린샷 뽑아줘" 요청하면 시뮬레이터로 생성
|
||||
- ✅ **스크린샷** (각 3장: 홈·플레이·점수전):
|
||||
- iOS 6.7"(1290×2796): `docs/store/screenshots/ios/`
|
||||
- Android 폰(1080×1920): `docs/store/screenshots/android/`
|
||||
- (시즌 여정 맵 컷은 추후 추가 예정 — 현재 3장으로 제출 충분)
|
||||
|
||||
---
|
||||
|
||||
@@ -95,7 +98,7 @@
|
||||
- **자세한 설명** (≤4000): store-listing.md의 KO(또는 EN) 본문
|
||||
- **앱 아이콘** (512×512): `docs/store/play_icon_512.png`
|
||||
- **그래픽 이미지** (1024×500): `docs/store/feature_graphic.png`
|
||||
- **휴대전화 스크린샷** (최소 2장): ⬜ Claude에게 요청해 받기
|
||||
- **휴대전화 스크린샷** (최소 2장): `docs/store/screenshots/android/` (3장)
|
||||
- **카테고리**: 게임 → 퍼즐 · **태그**: 퍼즐/캐주얼
|
||||
- **개인정보처리방침 URL**: 0-2에서 올린 주소
|
||||
- **연락처 이메일**: `airkjw@gmail.com`
|
||||
@@ -129,7 +132,7 @@
|
||||
- 부제(≤30): KO `시즌마다 새로워지는 블록 퍼즐`
|
||||
- 프로모션 텍스트·설명·키워드: `docs/store/store-listing.md`에서 복붙
|
||||
- **개인정보처리방침 URL**: 0-2 주소
|
||||
- **스크린샷** (6.7" 및 6.5" 필수): ⬜ Claude에게 요청
|
||||
- **스크린샷**: `docs/store/screenshots/ios/` (1290×2796=6.7", 3장). 6.5"는 같은 컷 재업로드 또는 생략 가능
|
||||
|
||||
### B-3. 앱 개인정보 (App Privacy) — 아래 값 그대로
|
||||
| 데이터 | 수집 | 목적 | 사용자 연결 | 추적 |
|
||||
@@ -154,7 +157,7 @@
|
||||
# 권장 진행 순서 (최단 경로)
|
||||
1. **0-1 키스토어 백업** (5분, 지금)
|
||||
2. **0-2 개인정보처리방침 + app-ads.txt 호스팅** (양 스토어 리스팅의 필수 입력값이라 먼저)
|
||||
3. **Claude에게 스크린샷 요청** (리스팅에 필요)
|
||||
3. **스크린샷 준비됨** ✅ (`docs/store/screenshots/`) — 리스팅에 바로 업로드
|
||||
4. **Apple B-1 빌드 업로드 시작** (처리에 시간 걸리니 먼저 걸어두기)
|
||||
5. Play A-1~A-7 + Apple B-2~B-4 문항 채우기 (대부분 위 표 복붙)
|
||||
6. Apple B-5 유료앱 계약 → 양쪽 **심사 제출**
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 143 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 215 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 276 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 164 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 308 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 401 KiB |
@@ -0,0 +1,253 @@
|
||||
// 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
|
||||
];
|
||||
|
||||
// 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');
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user