하단 내비게이션 바 여백 · 인증 칩 · 위치 선택 지도 · 퀘스트 상세 값 정렬

This commit is contained in:
Sn-Kinos
2026-10-06 01:32:25 +09:00
parent d441170dd7
commit 807e446b8b
8 changed files with 263 additions and 96 deletions
+14 -2
View File
@@ -12,6 +12,7 @@ import 'core/push/push_messaging.dart';
import 'core/router/app_router.dart'; import 'core/router/app_router.dart';
import 'features/home/home_screen.dart' show homeRefreshSignal; import 'features/home/home_screen.dart' show homeRefreshSignal;
import 'core/theme/app_theme.dart'; import 'core/theme/app_theme.dart';
import 'core/theme/tokens.dart';
import 'features/auth/auth_repository.dart'; import 'features/auth/auth_repository.dart';
import 'features/campaign/campaign_detail_screen.dart'; import 'features/campaign/campaign_detail_screen.dart';
import 'features/notification/notification_screen.dart'; import 'features/notification/notification_screen.dart';
@@ -241,8 +242,19 @@ class _FancakeAppState extends State<FancakeApp> with WidgetsBindingObserver {
screenFor: _notificationScreen, screenFor: _notificationScreen,
child: MaterialApp.router( child: MaterialApp.router(
title: 'FANUZU', title: 'FANUZU',
builder: (context, child) => // 하단 시스템 내비게이션 바(Android 15 edge-to-edge · iOS 홈 표시줄) 위로 모든 화면을 올린다.
AdaptivePlanetEffects(child: child ?? const SizedBox()), // 본문이 `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, debugShowCheckedModeBanner: false,
scaffoldMessengerKey: _messengerKey, scaffoldMessengerKey: _messengerKey,
locale: _locale.locale, locale: _locale.locale,
+9 -1
View File
@@ -114,7 +114,15 @@ ThemeData buildAppTheme() {
shape: RoundedRectangleBorder( shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(AppRadius.sm), 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), secondaryLabelStyle: _style(AppType.caption, AppColors.onCosmicAccent),
checkmarkColor: AppColors.onCosmicAccent, checkmarkColor: AppColors.onCosmicAccent,
showCheckmark: false, showCheckmark: false,
+47 -28
View File
@@ -73,10 +73,57 @@ class _TermsScreenState extends State<TermsScreen> {
void _toggle(_Term t) => void _toggle(_Term t) =>
setState(() => _agreed.contains(t) ? _agreed.remove(t) : _agreed.add(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 @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Scaffold( return Scaffold(
appBar: AppBar(title: Text(context.l10n.termsTitle)), 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( body: ListView(
padding: const EdgeInsets.all(AppSpace.s4), padding: const EdgeInsets.all(AppSpace.s4),
children: [ children: [
@@ -137,34 +184,6 @@ class _TermsScreenState extends State<TermsScreen> {
), ),
), ),
], ],
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),
),
),
],
),
], ],
), ),
); );
@@ -420,6 +420,9 @@ class _EclipseDefendScreenState extends State<EclipseDefendScreen>
fontWeight: FontWeight.w900, fontWeight: FontWeight.w900,
), ),
), ),
// 사본 `373:445267` 「…행성 운석 충돌을 막으세요!」와 다르게 「이클립스를 막으세요!」다 —
// 발주사 상세정의 v1.6.1 3장 · 1주차 공식 검토 회신서 17항 「운석 충돌은 Figma 에 남은 임시
// 카피, Eclipse 와 별도 이벤트로 구현하지 않고 Eclipse 문구로 일원화」(해석확인 영역A N-A05).
TextSpan(text: context.l10n.eclipseShakeTimed), TextSpan(text: context.l10n.eclipseShakeTimed),
], ],
), ),
+79 -15
View File
@@ -1,3 +1,5 @@
import 'dart:ui' as ui;
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_map/flutter_map.dart'; import 'package:flutter_map/flutter_map.dart';
import 'package:geocoding/geocoding.dart'; import 'package:geocoding/geocoding.dart';
@@ -62,11 +64,20 @@ class _PlacePickerScreenState extends State<PlacePickerScreen> {
Future<void> _drop(LatLng point) async { Future<void> _drop(LatLng point) async {
final coords = final coords =
'${point.latitude.toStringAsFixed(5)}, ${point.longitude.toStringAsFixed(5)}'; '${point.latitude.toStringAsFixed(5)}, ${point.longitude.toStringAsFixed(5)}';
setState(() => _selected = QuestPlace( setState(
id: '', name: coords, lat: point.latitude, lng: point.longitude)); () => _selected = QuestPlace(
id: '',
name: coords,
lat: point.latitude,
lng: point.longitude,
),
);
var address = coords; var address = coords;
try { try {
final marks = await Geocoding().placemarkFromCoordinates(point.latitude, point.longitude); final marks = await Geocoding().placemarkFromCoordinates(
point.latitude,
point.longitude,
);
if (marks.isNotEmpty) { if (marks.isNotEmpty) {
final line = placeAddress(marks.first); final line = placeAddress(marks.first);
if (line.isNotEmpty) address = line; if (line.isNotEmpty) address = line;
@@ -75,8 +86,14 @@ class _PlacePickerScreenState extends State<PlacePickerScreen> {
// 지오코더가 없거나(에뮬레이터 · 테스트 — 플랫폼 구현 없음) 실패 — 좌표 표시로 둔다. // 지오코더가 없거나(에뮬레이터 · 테스트 — 플랫폼 구현 없음) 실패 — 좌표 표시로 둔다.
} }
if (!mounted || _selected?.lat != point.latitude) return; if (!mounted || _selected?.lat != point.latitude) return;
setState(() => _selected = QuestPlace( setState(
id: '', name: address, lat: point.latitude, lng: point.longitude)); () => _selected = QuestPlace(
id: '',
name: address,
lat: point.latitude,
lng: point.longitude,
),
);
} }
@override @override
@@ -129,6 +146,8 @@ class _PlacePickerScreenState extends State<PlacePickerScreen> {
TileLayer( TileLayer(
urlTemplate: 'https://tile.openstreetmap.org/{z}/{x}/{y}.png', urlTemplate: 'https://tile.openstreetmap.org/{z}/{x}/{y}.png',
userAgentPackageName: 'kr.co.fanuzu.app', userAgentPackageName: 'kr.co.fanuzu.app',
// Figma `371:356705` 은 어두운 지도다 — 같은 OSM 타일을 어둡게 칠한다(flutter_map 내장).
tileBuilder: darkModeTileBuilder,
), ),
MarkerLayer( MarkerLayer(
markers: [ markers: [
@@ -138,14 +157,21 @@ class _PlacePickerScreenState extends State<PlacePickerScreen> {
point: LatLng(p.lat!, p.lng!), point: LatLng(p.lat!, p.lng!),
child: GestureDetector( child: GestureDetector(
onTap: () => _choose(p), 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( Marker(
point: LatLng(pinned.lat!, pinned.lng!), point: LatLng(pinned.lat!, pinned.lng!),
width: 38,
height: 44,
alignment: Alignment.topCenter, 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<PlacePickerScreen> {
alignment: Alignment.bottomRight, alignment: Alignment.bottomRight,
child: Text( child: Text(
'© OpenStreetMap', '© OpenStreetMap',
style: TextStyle(fontSize: 10, color: Colors.black54), style: TextStyle(fontSize: 10, color: Colors.white54),
), ),
), ),
], ],
@@ -181,7 +207,8 @@ class _PlacePickerScreenState extends State<PlacePickerScreen> {
// 디폴트 미선택 · 단수 선택. // 디폴트 미선택 · 단수 선택.
: RadioGroup<String>( : RadioGroup<String>(
groupValue: pinned?.id, 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( child: ListView.builder(
itemCount: _visible.length, itemCount: _visible.length,
itemBuilder: (context, i) { itemBuilder: (context, i) {
@@ -243,9 +270,46 @@ String placeAddress(Placemark m) {
} }
final road = street.isNotEmpty final road = street.isNotEmpty
? street ? street
: [t(m.thoroughfare), t(m.subThoroughfare)].where((s) => s.isNotEmpty).join(' '); : [
return [area, t(m.locality), t(m.subLocality), road] t(m.thoroughfare),
.where((s) => s.isNotEmpty) t(m.subThoroughfare),
.toSet() ].where((s) => s.isNotEmpty).join(' ');
.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;
} }
@@ -193,6 +193,8 @@ class _QuestCreateScreenState extends State<QuestCreateScreen> {
context.l10n.questNoCreatable, context.l10n.questNoCreatable,
style: const TextStyle(color: AppColors.onCosmicVariant), style: const TextStyle(color: AppColors.onCosmicVariant),
), ),
// Figma `254:164081` 은 375 폭에서 3+1 줄(칩 88 · 114 · 111 · 간격 8 = 329)이다. 360 폭 기기(본문
// 328)에서는 1 이 모자라 2+2 로 접힌다 — 칩 치수는 사본 그대로 두고 기기 폭대로 줄을 바꾼다.
Wrap( Wrap(
spacing: 8, spacing: 8,
runSpacing: 8, runSpacing: 8,
@@ -309,17 +311,23 @@ class _QuestCreateScreenState extends State<QuestCreateScreen> {
Text(context.l10n.questExtraVerify, style: Theme.of(context).textTheme.bodySmall), Text(context.l10n.questExtraVerify, style: Theme.of(context).textTheme.bodySmall),
const SizedBox(height: AppSpace.s2), const SizedBox(height: AppSpace.s2),
Wrap( Wrap(
spacing: AppSpace.s2, spacing: 8,
runSpacing: 8,
children: [ children: [
for (final (key, label) in [ for (final (key, label) in [
('photo', context.l10n.questVerifyPhoto), ('photo', context.l10n.questVerifyPhoto),
('qr', context.l10n.questVerifyQr), ('qr', context.l10n.questVerifyQr),
]) ])
FilterChip( // 칩 모양은 Figma 카테고리 칩(`259:46062` 선택 = 흰 바탕 검은 글자) — 기본 FilterChip 은
label: Text(label), // 선택 시 흰 바탕에 연회색 글자라 읽히지 않았다(10/6 실기기).
_CategoryChip(
label: label,
icon: null,
selected: _extraVerify.contains(key), selected: _extraVerify.contains(key),
onSelected: (on) => setState( onTap: () => setState(
() => on ? _extraVerify.add(key) : _extraVerify.remove(key), () => _extraVerify.contains(key)
? _extraVerify.remove(key)
: _extraVerify.add(key),
), ),
), ),
], ],
+69 -45
View File
@@ -327,7 +327,10 @@ class _QuestDetailScreenState extends State<QuestDetailScreen> {
Align( Align(
alignment: Alignment.centerLeft, alignment: Alignment.centerLeft,
child: Container( child: Container(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 4), padding: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 4,
),
decoration: BoxDecoration( decoration: BoxDecoration(
color: Colors.black.withValues(alpha: 0.3), color: Colors.black.withValues(alpha: 0.3),
borderRadius: BorderRadius.circular(9999), borderRadius: BorderRadius.circular(9999),
@@ -335,7 +338,11 @@ class _QuestDetailScreenState extends State<QuestDetailScreen> {
child: Row( child: Row(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ 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), const SizedBox(width: 12),
Flexible( Flexible(
child: Text( child: Text(
@@ -790,14 +797,15 @@ class _QuestDetailScreenState extends State<QuestDetailScreen> {
} }
if (!mounted) return; if (!mounted) return;
setState(() => _stage = QuestStage.inProgress); setState(() => _stage = QuestStage.inProgress);
final run = await Navigator.of(context, rootNavigator: true).push<ShakeJudge>( final run = await Navigator.of(context, rootNavigator: true)
MaterialPageRoute( .push<ShakeJudge>(
builder: (_) => MusicShakePlayScreen( MaterialPageRoute(
setup: setup, builder: (_) => MusicShakePlayScreen(
shakes: widget.repository.shakeEvents(), setup: setup,
), shakes: widget.repository.shakeEvents(),
), ),
); ),
);
if (!mounted) return; if (!mounted) return;
if (run != null) widget.repository.recordShake(run); if (run != null) widget.repository.recordShake(run);
setState(() { setState(() {
@@ -864,9 +872,10 @@ class _QuestDetailScreenState extends State<QuestDetailScreen> {
Future<PlaceProof?> _placeProof(List<String> methods) async { Future<PlaceProof?> _placeProof(List<String> methods) async {
String? qr; String? qr;
if (methods.contains('qr')) { if (methods.contains('qr')) {
qr = await Navigator.of(context, rootNavigator: true).push<String>( qr = await Navigator.of(
MaterialPageRoute(builder: (_) => const QrScanScreen()), context,
); rootNavigator: true,
).push<String>(MaterialPageRoute(builder: (_) => const QrScanScreen()));
if (qr == null) return null; if (qr == null) return null;
} }
Uint8List? photo; Uint8List? photo;
@@ -913,7 +922,8 @@ class _QuestDetailScreenState extends State<QuestDetailScreen> {
setState(() { setState(() {
_busy = false; _busy = false;
// Music Shake 판정 미달(B-24 — 보상 0) — 판정표는 소진됐으니 다시 하게 시작 전으로. // 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); _fail(error);
// 타임어택 창 밖(E-QST-01) — 서버가 본 상태로 다시 읽어 마감 · 완료를 반영한다. // 타임어택 창 밖(E-QST-01) — 서버가 본 상태로 다시 읽어 마감 · 완료를 반영한다.
@@ -996,12 +1006,14 @@ class _QuestDetailScreenState extends State<QuestDetailScreen> {
Future<String?> _watchAd(QuestReward reward) async { Future<String?> _watchAd(QuestReward reward) async {
RewardedAdHandle? ad; RewardedAdHandle? ad;
BackendException? failure; BackendException? failure;
final loading = widget.repository.loadRewardedAd(reward).then<void>( final loading = widget.repository
(handle) => ad = handle, .loadRewardedAd(reward)
onError: (Object error) => failure = error is BackendException .then<void>(
? error (handle) => ad = handle,
: BackendException('E-AD-01', context.l10n.adLoadFailed), onError: (Object error) => failure = error is BackendException
); ? error
: BackendException('E-AD-01', context.l10n.adLoadFailed),
);
await _warp(QuestAdWarpScreen.before(until: loading)); await _warp(QuestAdWarpScreen.before(until: loading));
final loaded = ad; final loaded = ad;
if (!mounted) { if (!mounted) {
@@ -1095,35 +1107,47 @@ class _Row extends StatelessWidget {
color: AppColors.cosmicSurfaceContainer, color: AppColors.cosmicSurfaceContainer,
borderRadius: BorderRadius.circular(24), borderRadius: BorderRadius.circular(24),
), ),
// Figma `264:48835` — 라벨은 왼쪽, 값(번개 포함)은 오른쪽 끝. 예전에는 라벨 Expanded 와 값 Flexible 이
// 폭을 반씩 나눠 값이 가운데 놓였다(10/6 실기기).
child: Row( child: Row(
children: [ children: [
Expanded( Text(
child: Text( label,
label, style: const TextStyle(
style: const TextStyle( fontSize: 14,
fontSize: 14, fontWeight: FontWeight.w500,
fontWeight: FontWeight.w500, height: 20 / 14,
height: 20 / 14, color: AppColors.onCosmicVariant,
color: AppColors.onCosmicVariant,
),
), ),
), ),
if (bolt) ...[ const SizedBox(width: 12),
Image.asset('assets/figma/icon-bolt.png', width: 16, height: 16.7), Expanded(
const SizedBox(width: 4), child: Row(
], mainAxisAlignment: MainAxisAlignment.end,
// 마감 줄처럼 값이 길면 줄을 바꾼다 — 좁은 화면에서 넘치지 않게. children: [
Flexible( if (bolt) ...[
child: Text( Image.asset(
value, 'assets/figma/icon-bolt.png',
textAlign: TextAlign.end, width: 16,
style: const TextStyle( height: 16.7,
fontSize: 16, ),
fontWeight: FontWeight.w600, const SizedBox(width: 4),
height: 1.4, ],
letterSpacing: -0.4, // 마감 줄처럼 값이 길면 줄을 바꾼다 — 좁은 화면에서 넘치지 않게.
color: AppColors.onCosmic, Flexible(
), child: Text(
value,
textAlign: TextAlign.end,
style: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.w600,
height: 1.4,
letterSpacing: -0.4,
color: AppColors.onCosmic,
),
),
),
],
), ),
), ),
], ],
+29
View File
@@ -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<DefaultTextStyle>(
find.ancestor(of: find.text('A'), matching: find.byType(DefaultTextStyle)).first,
)
.style
.color;
Future<void> 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));
});
}