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

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 '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<FancakeApp> 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,
+9 -1
View File
@@ -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,
+47 -28
View File
@@ -73,10 +73,57 @@ class _TermsScreenState extends State<TermsScreen> {
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<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,
),
),
// 사본 `373:445267` 「…행성 운석 충돌을 막으세요!」와 다르게 「이클립스를 막으세요!」다 —
// 발주사 상세정의 v1.6.1 3장 · 1주차 공식 검토 회신서 17항 「운석 충돌은 Figma 에 남은 임시
// 카피, Eclipse 와 별도 이벤트로 구현하지 않고 Eclipse 문구로 일원화」(해석확인 영역A N-A05).
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_map/flutter_map.dart';
import 'package:geocoding/geocoding.dart';
@@ -62,11 +64,20 @@ class _PlacePickerScreenState extends State<PlacePickerScreen> {
Future<void> _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<PlacePickerScreen> {
// 지오코더가 없거나(에뮬레이터 · 테스트 — 플랫폼 구현 없음) 실패 — 좌표 표시로 둔다.
}
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<PlacePickerScreen> {
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<PlacePickerScreen> {
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<PlacePickerScreen> {
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<PlacePickerScreen> {
// 디폴트 미선택 · 단수 선택.
: RadioGroup<String>(
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;
}
@@ -193,6 +193,8 @@ class _QuestCreateScreenState extends State<QuestCreateScreen> {
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<QuestCreateScreen> {
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),
),
),
],
+69 -45
View File
@@ -327,7 +327,10 @@ class _QuestDetailScreenState extends State<QuestDetailScreen> {
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<QuestDetailScreen> {
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<QuestDetailScreen> {
}
if (!mounted) return;
setState(() => _stage = QuestStage.inProgress);
final run = await Navigator.of(context, rootNavigator: true).push<ShakeJudge>(
MaterialPageRoute(
builder: (_) => MusicShakePlayScreen(
setup: setup,
shakes: widget.repository.shakeEvents(),
),
),
);
final run = await Navigator.of(context, rootNavigator: true)
.push<ShakeJudge>(
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<QuestDetailScreen> {
Future<PlaceProof?> _placeProof(List<String> methods) async {
String? qr;
if (methods.contains('qr')) {
qr = await Navigator.of(context, rootNavigator: true).push<String>(
MaterialPageRoute(builder: (_) => const QrScanScreen()),
);
qr = await Navigator.of(
context,
rootNavigator: true,
).push<String>(MaterialPageRoute(builder: (_) => const QrScanScreen()));
if (qr == null) return null;
}
Uint8List? photo;
@@ -913,7 +922,8 @@ class _QuestDetailScreenState extends State<QuestDetailScreen> {
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<QuestDetailScreen> {
Future<String?> _watchAd(QuestReward reward) async {
RewardedAdHandle? ad;
BackendException? failure;
final loading = widget.repository.loadRewardedAd(reward).then<void>(
(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<void>(
(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,
),
),
),
],
),
),
],
+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));
});
}