From 807e446b8b7ecf4e6f523810476a41a94918266b Mon Sep 17 00:00:00 2001 From: Sn-Kinos Date: Tue, 6 Oct 2026 01:32:25 +0900 Subject: [PATCH] =?UTF-8?q?=ED=95=98=EB=8B=A8=20=EB=82=B4=EB=B9=84?= =?UTF-8?q?=EA=B2=8C=EC=9D=B4=EC=85=98=20=EB=B0=94=20=EC=97=AC=EB=B0=B1=20?= =?UTF-8?q?=C2=B7=20=EC=9D=B8=EC=A6=9D=20=EC=B9=A9=20=C2=B7=20=EC=9C=84?= =?UTF-8?q?=EC=B9=98=20=EC=84=A0=ED=83=9D=20=EC=A7=80=EB=8F=84=20=C2=B7=20?= =?UTF-8?q?=ED=80=98=EC=8A=A4=ED=8A=B8=20=EC=83=81=EC=84=B8=20=EA=B0=92=20?= =?UTF-8?q?=EC=A0=95=EB=A0=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/lib/app.dart | 16 ++- app/lib/core/theme/app_theme.dart | 10 +- app/lib/features/auth/signup_screens.dart | 75 +++++++----- .../eclipse/eclipse_defend_screen.dart | 3 + .../features/quest/place_picker_screen.dart | 94 ++++++++++++--- .../features/quest/quest_create_screen.dart | 18 ++- .../features/quest/quest_detail_screen.dart | 114 +++++++++++------- app/test/chip_theme_test.dart | 29 +++++ 8 files changed, 263 insertions(+), 96 deletions(-) create mode 100644 app/test/chip_theme_test.dart diff --git a/app/lib/app.dart b/app/lib/app.dart index 63a107a..91e15cb 100644 --- a/app/lib/app.dart +++ b/app/lib/app.dart @@ -12,6 +12,7 @@ import 'core/push/push_messaging.dart'; import 'core/router/app_router.dart'; import 'features/home/home_screen.dart' show homeRefreshSignal; import 'core/theme/app_theme.dart'; +import 'core/theme/tokens.dart'; import 'features/auth/auth_repository.dart'; import 'features/campaign/campaign_detail_screen.dart'; import 'features/notification/notification_screen.dart'; @@ -241,8 +242,19 @@ class _FancakeAppState extends State with WidgetsBindingObserver { screenFor: _notificationScreen, child: MaterialApp.router( title: 'FANUZU', - builder: (context, child) => - AdaptivePlanetEffects(child: child ?? const SizedBox()), + // 하단 시스템 내비게이션 바(Android 15 edge-to-edge · iOS 홈 표시줄) 위로 모든 화면을 올린다. + // 본문이 `ListView(padding: …)` 처럼 하단 여백을 직접 정하면 안전 영역이 빠져 마지막 칸 · + // 하단 버튼이 바에 가렸다(10/6 실기기 — 약관 「이전 · 완료」 · 퀘스트 생성 「총 예치 · 수수료」 · + // 설정 「서비스 탈퇴」). 화면마다 고치지 않고 여기서 한 번에. 띠는 앱 바탕색이다. + builder: (context, child) => ColoredBox( + color: AppColors.cosmicVoid, + child: SafeArea( + top: false, + left: false, + right: false, + child: AdaptivePlanetEffects(child: child ?? const SizedBox()), + ), + ), debugShowCheckedModeBanner: false, scaffoldMessengerKey: _messengerKey, locale: _locale.locale, diff --git a/app/lib/core/theme/app_theme.dart b/app/lib/core/theme/app_theme.dart index af38961..220a990 100644 --- a/app/lib/core/theme/app_theme.dart +++ b/app/lib/core/theme/app_theme.dart @@ -114,7 +114,15 @@ ThemeData buildAppTheme() { shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(AppRadius.sm), ), - labelStyle: _style(AppType.caption, const Color(0xFFD5D7D9)), + // 선택(흰 바탕)이면 검은 글자 — 상태 색을 주지 않으면 선택 칩도 연회색 글자라 읽히지 않는다(10/6 실기기). + labelStyle: _style( + AppType.caption, + WidgetStateColor.resolveWith( + (s) => s.contains(WidgetState.selected) + ? Colors.black + : const Color(0xFFD5D7D9), + ), + ), secondaryLabelStyle: _style(AppType.caption, AppColors.onCosmicAccent), checkmarkColor: AppColors.onCosmicAccent, showCheckmark: false, diff --git a/app/lib/features/auth/signup_screens.dart b/app/lib/features/auth/signup_screens.dart index 356248a..e95672e 100644 --- a/app/lib/features/auth/signup_screens.dart +++ b/app/lib/features/auth/signup_screens.dart @@ -73,10 +73,57 @@ class _TermsScreenState extends State { void _toggle(_Term t) => setState(() => _agreed.contains(t) ? _agreed.remove(t) : _agreed.add(t)); + Widget _termsButtons(BuildContext context) { + final shape = RoundedRectangleBorder( + borderRadius: BorderRadius.circular(AppRadius.sm), + ); + return Row( + children: [ + Expanded( + child: FilledButton( + onPressed: widget.onBack ?? () => Navigator.of(context).pop(), + style: FilledButton.styleFrom( + minimumSize: const Size.fromHeight(56), + shape: shape, + side: const BorderSide(color: _stroke), + backgroundColor: Colors.black, + foregroundColor: AppColors.onCosmic, + ), + child: Text(context.l10n.commonPrev), + ), + ), + const SizedBox(width: 10), + Expanded( + child: FilledButton( + onPressed: _ready ? _done : null, + style: FilledButton.styleFrom( + minimumSize: const Size.fromHeight(56), + shape: shape, + side: const BorderSide(color: _stroke), + backgroundColor: AppColors.cosmicAccent, + foregroundColor: AppColors.onCosmicAccent, + disabledBackgroundColor: const Color(0xFF333333), + disabledForegroundColor: const Color(0xFF777777), + ), + child: Text(context.l10n.commonDone), + ), + ), + ], + ); + } + @override Widget build(BuildContext context) { return Scaffold( appBar: AppBar(title: Text(context.l10n.termsTitle)), + // Figma `179:24230` — 「이전 · 완료」는 목록 끝이 아니라 화면 하단 고정(56 높이 · 간격 10 · 아래 16). + // 시스템 내비게이션 바 위로 올린다(SafeArea) — 10/6 실기기에서 첫 탭이 바에 먹혔다. + bottomNavigationBar: SafeArea( + child: Padding( + padding: const EdgeInsets.all(AppSpace.s4), + child: _termsButtons(context), + ), + ), body: ListView( padding: const EdgeInsets.all(AppSpace.s4), children: [ @@ -137,34 +184,6 @@ class _TermsScreenState extends State { ), ), ], - const SizedBox(height: AppSpace.s6), - Row( - children: [ - Expanded( - child: FilledButton( - onPressed: widget.onBack ?? () => Navigator.of(context).pop(), - style: FilledButton.styleFrom( - backgroundColor: Colors.black, - foregroundColor: AppColors.onCosmic, - ), - child: Text(context.l10n.commonPrev), - ), - ), - const SizedBox(width: 10), - Expanded( - child: FilledButton( - onPressed: _ready ? _done : null, - style: FilledButton.styleFrom( - backgroundColor: AppColors.cosmicAccent, - foregroundColor: AppColors.onCosmicAccent, - disabledBackgroundColor: const Color(0xFF333333), - disabledForegroundColor: const Color(0xFF777777), - ), - child: Text(context.l10n.commonDone), - ), - ), - ], - ), ], ), ); diff --git a/app/lib/features/eclipse/eclipse_defend_screen.dart b/app/lib/features/eclipse/eclipse_defend_screen.dart index 0b684b9..354499c 100644 --- a/app/lib/features/eclipse/eclipse_defend_screen.dart +++ b/app/lib/features/eclipse/eclipse_defend_screen.dart @@ -420,6 +420,9 @@ class _EclipseDefendScreenState extends State fontWeight: FontWeight.w900, ), ), + // 사본 `373:445267` 「…행성 운석 충돌을 막으세요!」와 다르게 「이클립스를 막으세요!」다 — + // 발주사 상세정의 v1.6.1 3장 · 1주차 공식 검토 회신서 17항 「운석 충돌은 Figma 에 남은 임시 + // 카피, Eclipse 와 별도 이벤트로 구현하지 않고 Eclipse 문구로 일원화」(해석확인 영역A N-A05). TextSpan(text: context.l10n.eclipseShakeTimed), ], ), diff --git a/app/lib/features/quest/place_picker_screen.dart b/app/lib/features/quest/place_picker_screen.dart index 1e1b2d2..92ec688 100644 --- a/app/lib/features/quest/place_picker_screen.dart +++ b/app/lib/features/quest/place_picker_screen.dart @@ -1,3 +1,5 @@ +import 'dart:ui' as ui; + import 'package:flutter/material.dart'; import 'package:flutter_map/flutter_map.dart'; import 'package:geocoding/geocoding.dart'; @@ -62,11 +64,20 @@ class _PlacePickerScreenState extends State { Future _drop(LatLng point) async { final coords = '${point.latitude.toStringAsFixed(5)}, ${point.longitude.toStringAsFixed(5)}'; - setState(() => _selected = QuestPlace( - id: '', name: coords, lat: point.latitude, lng: point.longitude)); + setState( + () => _selected = QuestPlace( + id: '', + name: coords, + lat: point.latitude, + lng: point.longitude, + ), + ); var address = coords; try { - final marks = await Geocoding().placemarkFromCoordinates(point.latitude, point.longitude); + final marks = await Geocoding().placemarkFromCoordinates( + point.latitude, + point.longitude, + ); if (marks.isNotEmpty) { final line = placeAddress(marks.first); if (line.isNotEmpty) address = line; @@ -75,8 +86,14 @@ class _PlacePickerScreenState extends State { // 지오코더가 없거나(에뮬레이터 · 테스트 — 플랫폼 구현 없음) 실패 — 좌표 표시로 둔다. } if (!mounted || _selected?.lat != point.latitude) return; - setState(() => _selected = QuestPlace( - id: '', name: address, lat: point.latitude, lng: point.longitude)); + setState( + () => _selected = QuestPlace( + id: '', + name: address, + lat: point.latitude, + lng: point.longitude, + ), + ); } @override @@ -129,6 +146,8 @@ class _PlacePickerScreenState extends State { TileLayer( urlTemplate: 'https://tile.openstreetmap.org/{z}/{x}/{y}.png', userAgentPackageName: 'kr.co.fanuzu.app', + // Figma `371:356705` 은 어두운 지도다 — 같은 OSM 타일을 어둡게 칠한다(flutter_map 내장). + tileBuilder: darkModeTileBuilder, ), MarkerLayer( markers: [ @@ -138,14 +157,21 @@ class _PlacePickerScreenState extends State { point: LatLng(p.lat!, p.lng!), child: GestureDetector( onTap: () => _choose(p), - child: const Icon(Icons.place, color: AppColors.cosmicAccent), + child: const Icon( + Icons.place, + color: AppColors.cosmicAccent, + ), ), ), - if (pinned != null && pinned.lat != null && pinned.lng != null) + if (pinned != null && + pinned.lat != null && + pinned.lng != null) Marker( point: LatLng(pinned.lat!, pinned.lng!), + width: 38, + height: 44, alignment: Alignment.topCenter, - child: const Icon(Icons.location_pin, size: 40, color: Colors.redAccent), + child: const CustomPaint(painter: _PinPainter()), ), ], ), @@ -154,7 +180,7 @@ class _PlacePickerScreenState extends State { alignment: Alignment.bottomRight, child: Text( '© OpenStreetMap', - style: TextStyle(fontSize: 10, color: Colors.black54), + style: TextStyle(fontSize: 10, color: Colors.white54), ), ), ], @@ -181,7 +207,8 @@ class _PlacePickerScreenState extends State { // 디폴트 미선택 · 단수 선택. : RadioGroup( groupValue: pinned?.id, - onChanged: (v) => _choose(widget.places.firstWhere((p) => p.id == v)), + onChanged: (v) => + _choose(widget.places.firstWhere((p) => p.id == v)), child: ListView.builder( itemCount: _visible.length, itemBuilder: (context, i) { @@ -243,9 +270,46 @@ String placeAddress(Placemark m) { } final road = street.isNotEmpty ? street - : [t(m.thoroughfare), t(m.subThoroughfare)].where((s) => s.isNotEmpty).join(' '); - return [area, t(m.locality), t(m.subLocality), road] - .where((s) => s.isNotEmpty) - .toSet() - .join(' '); + : [ + t(m.thoroughfare), + t(m.subThoroughfare), + ].where((s) => s.isNotEmpty).join(' '); + return [ + area, + t(m.locality), + t(m.subLocality), + road, + ].where((s) => s.isNotEmpty).toSet().join(' '); +} + +/// Figma `372:356854` 핀 — 38×44 분홍(#FF7DDD) 물방울 · 흰 테두리 · 가운데 흰 점 12. +class _PinPainter extends CustomPainter { + const _PinPainter(); + + @override + void paint(Canvas canvas, Size size) { + final r = size.width / 2; + final drop = ui.Path.combine( + PathOperation.union, + ui.Path()..addOval(Rect.fromCircle(center: Offset(r, r), radius: r)), + ui.Path() + ..moveTo(r * 0.16, r * 1.5) + ..lineTo(size.width - r * 0.16, r * 1.5) + ..lineTo(r, size.height) + ..close(), + ); + canvas + ..drawPath(drop, Paint()..color = AppColors.cosmicAccent) + ..drawPath( + drop, + Paint() + ..color = Colors.white + ..style = PaintingStyle.stroke + ..strokeWidth = 1.5, + ) + ..drawCircle(Offset(r, r), 6, Paint()..color = Colors.white); + } + + @override + bool shouldRepaint(covariant CustomPainter oldDelegate) => false; } diff --git a/app/lib/features/quest/quest_create_screen.dart b/app/lib/features/quest/quest_create_screen.dart index df15987..b2ea8b2 100644 --- a/app/lib/features/quest/quest_create_screen.dart +++ b/app/lib/features/quest/quest_create_screen.dart @@ -193,6 +193,8 @@ class _QuestCreateScreenState extends State { context.l10n.questNoCreatable, style: const TextStyle(color: AppColors.onCosmicVariant), ), + // Figma `254:164081` 은 375 폭에서 3+1 줄(칩 88 · 114 · 111 · 간격 8 = 329)이다. 360 폭 기기(본문 + // 328)에서는 1 이 모자라 2+2 로 접힌다 — 칩 치수는 사본 그대로 두고 기기 폭대로 줄을 바꾼다. Wrap( spacing: 8, runSpacing: 8, @@ -309,17 +311,23 @@ class _QuestCreateScreenState extends State { Text(context.l10n.questExtraVerify, style: Theme.of(context).textTheme.bodySmall), const SizedBox(height: AppSpace.s2), Wrap( - spacing: AppSpace.s2, + spacing: 8, + runSpacing: 8, children: [ for (final (key, label) in [ ('photo', context.l10n.questVerifyPhoto), ('qr', context.l10n.questVerifyQr), ]) - FilterChip( - label: Text(label), + // 칩 모양은 Figma 카테고리 칩(`259:46062` 선택 = 흰 바탕 검은 글자) — 기본 FilterChip 은 + // 선택 시 흰 바탕에 연회색 글자라 읽히지 않았다(10/6 실기기). + _CategoryChip( + label: label, + icon: null, selected: _extraVerify.contains(key), - onSelected: (on) => setState( - () => on ? _extraVerify.add(key) : _extraVerify.remove(key), + onTap: () => setState( + () => _extraVerify.contains(key) + ? _extraVerify.remove(key) + : _extraVerify.add(key), ), ), ], diff --git a/app/lib/features/quest/quest_detail_screen.dart b/app/lib/features/quest/quest_detail_screen.dart index 99c44bc..f4e6c62 100644 --- a/app/lib/features/quest/quest_detail_screen.dart +++ b/app/lib/features/quest/quest_detail_screen.dart @@ -327,7 +327,10 @@ class _QuestDetailScreenState extends State { Align( alignment: Alignment.centerLeft, child: Container( - padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 4), + padding: const EdgeInsets.symmetric( + horizontal: 16, + vertical: 4, + ), decoration: BoxDecoration( color: Colors.black.withValues(alpha: 0.3), borderRadius: BorderRadius.circular(9999), @@ -335,7 +338,11 @@ class _QuestDetailScreenState extends State { child: Row( mainAxisSize: MainAxisSize.min, children: [ - const Icon(Icons.music_note_rounded, size: 18, color: AppColors.onCosmic), + const Icon( + Icons.music_note_rounded, + size: 18, + color: AppColors.onCosmic, + ), const SizedBox(width: 12), Flexible( child: Text( @@ -790,14 +797,15 @@ class _QuestDetailScreenState extends State { } if (!mounted) return; setState(() => _stage = QuestStage.inProgress); - final run = await Navigator.of(context, rootNavigator: true).push( - MaterialPageRoute( - builder: (_) => MusicShakePlayScreen( - setup: setup, - shakes: widget.repository.shakeEvents(), - ), - ), - ); + final run = await Navigator.of(context, rootNavigator: true) + .push( + MaterialPageRoute( + builder: (_) => MusicShakePlayScreen( + setup: setup, + shakes: widget.repository.shakeEvents(), + ), + ), + ); if (!mounted) return; if (run != null) widget.repository.recordShake(run); setState(() { @@ -864,9 +872,10 @@ class _QuestDetailScreenState extends State { Future _placeProof(List methods) async { String? qr; if (methods.contains('qr')) { - qr = await Navigator.of(context, rootNavigator: true).push( - MaterialPageRoute(builder: (_) => const QrScanScreen()), - ); + qr = await Navigator.of( + context, + rootNavigator: true, + ).push(MaterialPageRoute(builder: (_) => const QrScanScreen())); if (qr == null) return null; } Uint8List? photo; @@ -913,7 +922,8 @@ class _QuestDetailScreenState extends State { setState(() { _busy = false; // Music Shake 판정 미달(B-24 — 보상 0) — 판정표는 소진됐으니 다시 하게 시작 전으로. - if (detail.card.type == QuestType.musicShake) _stage = QuestStage.notStarted; + if (detail.card.type == QuestType.musicShake) + _stage = QuestStage.notStarted; }); _fail(error); // 타임어택 창 밖(E-QST-01) — 서버가 본 상태로 다시 읽어 마감 · 완료를 반영한다. @@ -996,12 +1006,14 @@ class _QuestDetailScreenState extends State { Future _watchAd(QuestReward reward) async { RewardedAdHandle? ad; BackendException? failure; - final loading = widget.repository.loadRewardedAd(reward).then( - (handle) => ad = handle, - onError: (Object error) => failure = error is BackendException - ? error - : BackendException('E-AD-01', context.l10n.adLoadFailed), - ); + final loading = widget.repository + .loadRewardedAd(reward) + .then( + (handle) => ad = handle, + onError: (Object error) => failure = error is BackendException + ? error + : BackendException('E-AD-01', context.l10n.adLoadFailed), + ); await _warp(QuestAdWarpScreen.before(until: loading)); final loaded = ad; if (!mounted) { @@ -1095,35 +1107,47 @@ class _Row extends StatelessWidget { color: AppColors.cosmicSurfaceContainer, borderRadius: BorderRadius.circular(24), ), + // Figma `264:48835` — 라벨은 왼쪽, 값(번개 포함)은 오른쪽 끝. 예전에는 라벨 Expanded 와 값 Flexible 이 + // 폭을 반씩 나눠 값이 가운데 놓였다(10/6 실기기). child: Row( children: [ - Expanded( - child: Text( - label, - style: const TextStyle( - fontSize: 14, - fontWeight: FontWeight.w500, - height: 20 / 14, - color: AppColors.onCosmicVariant, - ), + Text( + label, + style: const TextStyle( + fontSize: 14, + fontWeight: FontWeight.w500, + height: 20 / 14, + color: AppColors.onCosmicVariant, ), ), - if (bolt) ...[ - Image.asset('assets/figma/icon-bolt.png', width: 16, height: 16.7), - const SizedBox(width: 4), - ], - // 마감 줄처럼 값이 길면 줄을 바꾼다 — 좁은 화면에서 넘치지 않게. - Flexible( - child: Text( - value, - textAlign: TextAlign.end, - style: const TextStyle( - fontSize: 16, - fontWeight: FontWeight.w600, - height: 1.4, - letterSpacing: -0.4, - color: AppColors.onCosmic, - ), + const SizedBox(width: 12), + Expanded( + child: Row( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + if (bolt) ...[ + Image.asset( + 'assets/figma/icon-bolt.png', + width: 16, + height: 16.7, + ), + const SizedBox(width: 4), + ], + // 마감 줄처럼 값이 길면 줄을 바꾼다 — 좁은 화면에서 넘치지 않게. + Flexible( + child: Text( + value, + textAlign: TextAlign.end, + style: const TextStyle( + fontSize: 16, + fontWeight: FontWeight.w600, + height: 1.4, + letterSpacing: -0.4, + color: AppColors.onCosmic, + ), + ), + ), + ], ), ), ], diff --git a/app/test/chip_theme_test.dart b/app/test/chip_theme_test.dart new file mode 100644 index 0000000..8552973 --- /dev/null +++ b/app/test/chip_theme_test.dart @@ -0,0 +1,29 @@ +import 'package:fancake/core/theme/app_theme.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; + +/// 10/6 실기기 — 선택한 칩(흰 바탕)의 글자가 연회색이라 읽히지 않았다. 선택이면 검은 글자. +void main() { + Color? labelColor(WidgetTester t) => t + .widget( + find.ancestor(of: find.text('A'), matching: find.byType(DefaultTextStyle)).first, + ) + .style + .color; + + Future pump(WidgetTester t, bool selected) => t.pumpWidget( + MaterialApp( + theme: buildAppTheme(), + home: Scaffold( + body: FilterChip(label: const Text('A'), selected: selected, onSelected: (_) {}), + ), + ), + ); + + testWidgets('선택 칩은 검은 글자 · 미선택은 연회색', (t) async { + await pump(t, true); + expect(labelColor(t), Colors.black); + await pump(t, false); + expect(labelColor(t), const Color(0xFFD5D7D9)); + }); +}