416 lines
14 KiB
Dart
416 lines
14 KiB
Dart
@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<MyProfile> 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<List<PopLedgerEntry>> 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<List<NoticeItem>> 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<List<InquiryItem>> 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<List<PaymentEntry>> 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<PaymentEntry> 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<List<CampaignCardData>> 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<SocialAuthProvider> providers) =>
|
|
SampleAccountLinker();
|
|
}
|
|
|
|
typedef Build = Future<Widget> 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<void> Function(WidgetTester t)? after;
|
|
}
|
|
|
|
Future<void> _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>[
|
|
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<void> _loadFonts() async {
|
|
final manifest =
|
|
json.decode(await rootBundle.loadString('FontManifest.json')) as List;
|
|
for (final font in manifest.cast<Map<String, dynamic>>()) {
|
|
final loader = FontLoader(font['family'] as String);
|
|
for (final f in (font['fonts'] as List).cast<Map<String, dynamic>>()) {
|
|
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<void> _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<NavigatorState>();
|
|
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<void>(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);
|
|
});
|
|
}
|
|
}
|