@Tags(['design']) library; import 'dart:convert'; import 'dart:io'; import 'dart:ui' as ui; import 'package:fancake/core/data/repositories.dart'; import 'package:fancake/core/router/app_router.dart'; import 'package:fancake/core/theme/app_theme.dart'; import 'package:fancake/features/auth/auth.dart'; import 'package:fancake/features/auth/auth_repository.dart'; import 'package:fancake/features/campaign/campaign_data.dart'; import 'package:fancake/features/my/my_data.dart'; import 'package:fancake/features/my/my_lists_screen.dart'; import 'package:fancake/features/my/notice_screens.dart'; import 'package:fancake/features/my/payment_screens.dart'; import 'package:fancake/features/my/pop_history_screen.dart'; import 'package:fancake/features/my/profile_edit_screen.dart'; import 'package:fancake/features/my/push_settings_screen.dart'; import 'package:fancake/features/my/settings_screen.dart'; import 'package:fancake/features/my/support_screens.dart'; import 'package:fancake/features/my/tier_screens.dart'; import 'package:flutter/material.dart'; import 'package:flutter/rendering.dart'; import 'package:flutter/services.dart'; import 'package:flutter_test/flutter_test.dart'; /// 그룹 D(F6 MY) 디자인 대조 렌더 — 표본을 Figma 사본 문구에 맞춘다. /// /// 실행: `DESIGN_MATCH_OUT=<폴더> fvm flutter test --tags design test/design_match/d_test.dart` /// 렌더 방식은 공유 하네스(design_match_test.dart)와 같고, 저장소만 [_FigmaMy] 로 바꾼다. const _width = 375.0; const _padding = FakeViewPadding(top: 48 * 2, bottom: 34 * 2); /// Figma 사본의 표본 문구(BTS_Hong · hon******@gmail.com …). 숫자는 원장 체계(C-18)로 담는다. class _FigmaMy extends SampleMyRepository { @override Future profile() async => const MyProfile( nickname: 'BTS_Hong', email: 'hon******@gmail.com', contributedPop: 350, userExp: 100, userLevel: 1, balancePop: 12540, steps: 1000, completedQuests: 3, planetTiers: [ PlanetTier(fandomName: 'BTS', contributedPop: 100, isPrimary: true), PlanetTier(fandomName: 'NewJeans', contributedPop: 100), PlanetTier(fandomName: 'IVE', contributedPop: 100), PlanetTier(fandomName: 'aespa', contributedPop: 100), PlanetTier(fandomName: 'SEVENTEEN', contributedPop: 100), PlanetTier(fandomName: 'LE SSERAFIM', contributedPop: 100), ], ); @override Future> popHistory({ int limit = 20, PopCursor? after, }) async => after != null ? const [] : [ for (final (d, title, type, delta, balance) in const [ (5, 'BTS', '행성 기여', -100, 12640), (4, '만보기', '퀘스트 생성', -750, 13390), (4, '좋아요', '퀘스트 완료', 150, 13240), (3, '강남역 초대형 LED 광고', '캠페인 기여', -300, 13540), (2, 'BTS_RM', '성운 기여', -100, 13640), (1, 'BTS 컴백 알려주세요!', '인사이드 생성', -100, 13640), (0, '친구 초대', '레퍼럴', 150, 13640), (-1, '여기 화면 어떻게 하나요?', '인사이드 채택', 150, 13640), ]) PopLedgerEntry( at: DateTime(2026, 5, d, 12), title: title, type: type, kind: PopKind.general, txType: delta < 0 ? PopTxType.spend : PopTxType.grant, delta: delta, balanceAfter: balance, cursor: PopCursor(at: DateTime(2026, 5, d, 12), id: title), ), ]; @override Future> notices() async => [ for (final (i, (title, m, d)) in const [ ('[공지] 2026년 하반기 업데이트 관련 안내', 4, 30), ('[공지] 2026년 하반기 업데이트 관련 안내', 4, 30), ('서비스 첫 출시 관련 수정 안내', 4, 30), ('새로운 기능 추가 및 성능 개선', 5, 10), ('봄맞이 할인 이벤트 안내', 5, 5), ('서버 정기 점검 안내', 5, 15), ('이용약관 변경 안내', 5, 20), ('신규 사용자 가이드 배포', 5, 25), ('휴대폰 앱 업데이트 권장', 5, 30), ('긴급 보안 패치 적용', 6, 1), ].indexed) NoticeItem( id: 'n$i', title: title, // 시안 본문의 「팬케이크」는 사용자 노출 브랜드 규칙으로 「팬우주」. body: '안녕하세요. 팬우주 운영진 입니다.\n2026년 하반기를 앞두고 아래의 업데이트 관련 내용을 전달드리려고 합니다!\n\n아래의 내용들은 이미지와 함께 첨부했으니 확인해주시기 바랍니다.\n\n감사합니다.', at: DateTime(2026, m, d), ), ]; @override Future> myInquiries() async => [ for (final (i, s) in const [ InquiryStatus.answered, InquiryStatus.inProgress, InquiryStatus.pending, ].indexed) InquiryItem( id: 'q$i', title: i == 0 ? '구매 상품 환불 관련 문의 접수' : '서비스 이용 관련 문의 접수', body: '문의 내용', at: DateTime(2026, 4, 30), status: s, ), ]; @override Future> payments() async => [ for (final (i, (m, price)) in const [ (5, 3900), (6, 4500), (7, 5200), (8, 6100), (9, 2800), (10, 4750), (11, 3600), (12, 5000), // 시안 「13.05 · 14.05 · 15.05」 는 달력에 없는 날짜다. 달을 넘겨 01~03.05 로 그린다. (13, 6450), (14, 4900), (15, 3750), ].indexed) PaymentEntry( id: 'IAP2405110${i + 1}', at: DateTime(2026, m, 5), itemName: '아이템${i + 1}', price: price, method: 'iOS', state: PaymentState.paid, ), ]; @override Future payment(String id) async => (await payments()).firstWhere((p) => p.id == id); @override Future<({String current, bool latest})> appVersion() async => (current: '0.4.0', latest: true); } /// 시안 `370:229665` 의 내 캠페인 한 장. class _FigmaCampaign extends SampleCampaignRepository { @override Future> load({ CampaignSort sort = CampaignSort.latest, int limit = 10, }) async => [ CampaignCardData( id: 'c-mine', title: 'BTS 인스타 광고 캠페인', body: 'BTS 인스타 광고 캠페인 진행하실 아미분들!!!\n매년 찾아오는 인스타 광고에서 저희 방탄을 위해 화력을 보여주세요!!', authorName: 'BTS_Hong', createdAt: DateTime.now(), status: CampaignStatus.funding, goalPop: 30000, raisedPop: 0, supporters: 0, likes: 0, comments: 0, views: 0, shares: 0, mine: true, ), ]; } class _Repos extends Repositories { _Repos() : super(live: false); @override late final MyRepository my = _FigmaMy(); @override late final CampaignRepository campaign = _FigmaCampaign(); @override AccountLinker linker(List providers) => SampleAccountLinker(); } typedef Build = Future Function(Repositories r); class Shot { const Shot(this.id, {this.build, this.tab, this.h = 812, this.after}); final String id; final Build? build; final String? tab; final double h; final Future Function(WidgetTester t)? after; } Future _settle(WidgetTester t) async { for (var i = 0; i < 6; i++) { await t.pump(const Duration(milliseconds: 300)); } } Widget _settings(Repositories r) => SettingsScreen(repository: r.my, linker: r.linker(const [])); final shots = [ Shot('F6-01', tab: '/my'), Shot( 'F6-02', build: (r) async => const MyListsScreen(kind: MyListKind.inside), ), Shot( 'F6-03', build: (r) async => MyListsScreen(kind: MyListKind.campaign, campaigns: r.campaign), ), Shot('F6-04', h: 1344, build: (r) async => _settings(r)), // F6-05~07 은 설정 위에 뜨는 OS 제공자 시트다. 시트 자체는 OS 가 그리므로(계약 제외) // 아래 깔린 설정 화면만 대조한다. Shot('F6-05', build: (r) async => _settings(r)), Shot('F6-06', build: (r) async => _settings(r)), Shot('F6-07', build: (r) async => _settings(r)), Shot('F6-08', h: 959, build: (r) async => PopHistoryScreen(repository: r.my)), Shot( 'F6-10', h: 1276, build: (r) async => NoticeListScreen(repository: r.my), ), Shot( 'F6-11', build: (r) async => NoticeDetailScreen(notice: (await r.my.notices()).first), ), Shot('F6-12', build: (r) async => PaymentHistoryScreen(repository: r.my)), Shot( 'F6-13', build: (r) async => PaymentDetailScreen(paymentId: 'IAP24051101', repository: r.my), ), Shot( 'F6-14', build: (r) async => RefundRequestScreen(paymentId: 'IAP24051101', repository: r.my), ), Shot( 'F6-15', build: (r) async => TierDetailScreen(tiers: (await r.my.profile()).planetTiers), ), Shot('F6-16', build: (r) async => const TierInfoScreen()), // 시안은 앨범에서 사진을 고른 뒤(완료 활성) 상태다. Shot( 'F6-17', build: (r) async { final bytes = (await rootBundle.load( 'assets/figma/avatar-sample.png', )).buffer.asUint8List(); return ProfilePhotoScreen( pickPhoto: (_) async => PickedPhoto( bytes: bytes, contentType: 'image/png', name: 'avatar-sample.png', ), ); }, after: (t) async { await t.tap(find.bySemanticsLabel('앨범에서 고르기')); await _settle(t); }, ), // 시안은 닉네임을 바꾼 뒤(완료 활성) 상태다. Shot( 'F6-18', build: (r) async => ProfileEditScreen(nickname: 'BTS', repository: r.my), after: (t) async { await t.enterText(find.byType(TextField), 'BTS_Hong'); FocusManager.instance.primaryFocus?.unfocus(); await _settle(t); }, ), Shot('F6-19', build: (r) async => InquiryScreen(repository: r.my)), // 시안 기본 상태는 기기 알림이 꺼진 쪽이다(켜진 쪽은 변형 `357:223611`). Shot( 'F6-20', build: (r) async => PushSettingsScreen(repository: r.my, deviceAllowed: false), ), ]; Future _loadFonts() async { final manifest = json.decode(await rootBundle.loadString('FontManifest.json')) as List; for (final font in manifest.cast>()) { final loader = FontLoader(font['family'] as String); for (final f in (font['fonts'] as List).cast>()) { loader.addFont(rootBundle.load(Uri.decodeFull(f['asset'] as String))); } await loader.load(); } await (FontLoader( 'Roboto', )..addFont(rootBundle.load('assets/fonts/42dotSans[wght].ttf'))).load(); } Future _precacheImages(WidgetTester t) => t.runAsync(() async { for (final e in find.byType(Image).evaluate().toList()) { await precacheImage((e.widget as Image).image, e); } }); void main() { final out = Platform.environment['DESIGN_MATCH_OUT']; if (out == null) return; setUpAll(() async { Directory(out).createSync(recursive: true); await _loadFonts(); }); for (final s in shots) { testWidgets(s.id, (t) async { t.view.devicePixelRatio = 2; t.view.physicalSize = Size(_width * 2, s.h * 2); t.view.padding = _padding; t.view.viewPadding = _padding; addTearDown(t.view.reset); final repos = _Repos(); final boundary = GlobalKey(); final navigator = GlobalKey(); Widget? pushed; Widget app; if (s.tab != null) { final router = buildRouter( AuthRepository()..signInWithMockSession(), repositories: repos, ); router.go(s.tab!); app = MaterialApp.router( theme: buildAppTheme(), debugShowCheckedModeBanner: false, routerConfig: router, ); } else { pushed = (await t.runAsync(() => s.build!(repos)))!; app = MaterialApp( theme: buildAppTheme(), debugShowCheckedModeBanner: false, navigatorKey: navigator, home: const SizedBox(), ); } await t.pumpWidget(RepaintBoundary(key: boundary, child: app)); if (pushed != null) { final screen = pushed; navigator.currentState!.push( MaterialPageRoute(builder: (_) => screen), ); } await _settle(t); await s.after?.call(t); await _precacheImages(t); await _settle(t); await t.runAsync(() async { final image = await (boundary.currentContext!.findRenderObject()! as RenderRepaintBoundary) .toImage(pixelRatio: 2); final png = await image.toByteData(format: ui.ImageByteFormat.png); await File('$out/${s.id}.png').writeAsBytes(png!.buffer.asUint8List()); image.dispose(); }); final texts = [ for (final e in find.byType(RichText).evaluate()) if (e.renderObject case final RenderParagraph p when p.attached && p.text.toPlainText().trim().isNotEmpty) { 'text': p.text.toPlainText(), 'size': p.text.style?.fontSize, 'rect': [ for (final v in [ p.localToGlobal(Offset.zero).dx, p.localToGlobal(Offset.zero).dy, p.size.width, p.size.height, ]) v.roundToDouble(), ], }, ]; File( '$out/${s.id}.texts.json', ).writeAsStringSync(const JsonEncoder.withIndent(' ').convert(texts)); expect(t.takeException(), isNull); }); } }