화면 하단 여백 · 로그아웃 진행 · 스낵바 강조색 · TODAY'S QUEST 관리자 퀘스트 3개

This commit is contained in:
Sn-Kinos
2026-10-06 02:29:09 +09:00
parent 82662b9b9f
commit 64825e72b0
17 changed files with 104 additions and 67 deletions
+5 -14
View File
@@ -12,7 +12,6 @@ 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';
@@ -242,19 +241,11 @@ class _FancakeAppState extends State<FancakeApp> with WidgetsBindingObserver {
screenFor: _notificationScreen, screenFor: _notificationScreen,
child: MaterialApp.router( child: MaterialApp.router(
title: 'FANUZU', title: 'FANUZU',
// 하단 시스템 내비게이션 바(Android 15 edge-to-edge · iOS 홈 표시줄) 위로 모든 화면을 올린다. // 루트에서 하단 안전 영역을 잘라 내지 않는다 — 잘라 내면 바 높이만큼 cosmicVoid 띠가 생기고
// 본문이 `ListView(padding: …)` 처럼 하단 여백을 직접 정하면 안전 영역이 빠져 마지막 칸 · // 바텀시트가 화면 끝까지 내려오지 못한다(10/6 실기기 · Figma 는 하단까지 화면 배경).
// 하단 버튼이 바에 가렸다(10/6 실기기 — 약관 「이전 · 완료」 · 퀘스트 생성 「총 예치 · 수수료」 · // 하단 여백을 직접 정하는 화면이 `withBottomInset`(core/ui/bottom_inset.dart)으로 바 높이를 더한다.
// 설정 「서비스 탈퇴」). 화면마다 고치지 않고 여기서 한 번에. 띠는 앱 바탕색이다. builder: (context, child) =>
builder: (context, child) => ColoredBox( AdaptivePlanetEffects(child: child ?? const SizedBox()),
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,
+2
View File
@@ -188,6 +188,8 @@ ThemeData buildAppTheme() {
snackBarTheme: SnackBarThemeData( snackBarTheme: SnackBarThemeData(
backgroundColor: AppColors.cosmicSurfaceRaised, backgroundColor: AppColors.cosmicSurfaceRaised,
contentTextStyle: _style(AppType.bodyM, AppColors.onCosmic), contentTextStyle: _style(AppType.bodyM, AppColors.onCosmic),
// 「보기」 등 액션 글자 — 기본(inversePrimary)은 어두운 바탕에 묻힌다(10/6 실기기). 강조색 토큰.
actionTextColor: AppColors.cosmicAccent,
behavior: SnackBarBehavior.floating, behavior: SnackBarBehavior.floating,
shape: RoundedRectangleBorder( shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(AppRadius.md), borderRadius: BorderRadius.circular(AppRadius.md),
+9
View File
@@ -0,0 +1,9 @@
import 'package:flutter/widgets.dart';
/// [padding] 아래에 하단 시스템 바(Android 내비게이션 바 · iOS 홈 표시줄) 높이를 더한다.
///
/// 화면이 스크롤 여백을 직접 정하면(`ListView(padding: …)`) 안전 영역이 빠져 마지막 칸이 바에 가린다.
/// 앱 루트에서 한 번에 올리면 바 높이만큼 띠가 생기고 바텀시트가 화면 끝까지 내려오지 못해
/// (10/6 실기기 · Figma 는 하단까지 화면 배경), 여백을 직접 정하는 화면이 이걸로 더한다.
EdgeInsets withBottomInset(BuildContext context, EdgeInsets padding) =>
padding.copyWith(bottom: padding.bottom + MediaQuery.paddingOf(context).bottom);
@@ -397,6 +397,12 @@ final class KakaoSignInProvider implements SocialAuthProvider {
if (error.error == kakao.AuthErrorCause.accessDenied) { if (error.error == kakao.AuthErrorCause.accessDenied) {
return const AuthCancelled(); return const AuthCancelled();
} }
// 카카오톡 왕복 뒤 인가 코드 · 토큰 단계 실패(KOE · misconfigured 등). 원인을 로그로만 남긴다 —
// 10/6 릴리스 빌드 E-AUTH-03 은 이 분기로 들어와 아무 로그도 남지 않았다.
debugPrint('[KakaoSignInProvider] auth failed: ${error.error} ${error.errorDescription}');
return AuthFailed('E-AUTH-03', appL10n.authSocialFailed);
} on kakao.KakaoException catch (error) {
debugPrint('[KakaoSignInProvider] sdk error: $error');
return AuthFailed('E-AUTH-03', appL10n.authSocialFailed); return AuthFailed('E-AUTH-03', appL10n.authSocialFailed);
} on FirebaseFunctionsException catch (error) { } on FirebaseFunctionsException catch (error) {
final details = error.details; final details = error.details;
@@ -10,6 +10,7 @@ import '../home/home_screen.dart' show formatPop;
import 'campaign_data.dart'; import 'campaign_data.dart';
import 'widgets/campaign_period_sheet.dart'; import 'widgets/campaign_period_sheet.dart';
import 'widgets/campaign_style.dart'; import 'widgets/campaign_style.dart';
import '../../core/ui/bottom_inset.dart';
/// 새 캠페인 — 화면 대조 원장 F5 · Figma `287:16991`. /// 새 캠페인 — 화면 대조 원장 F5 · Figma `287:16991`.
/// ///
@@ -141,7 +142,7 @@ class _CampaignCreateScreenState extends State<CampaignCreateScreen> {
], ],
), ),
body: ListView( body: ListView(
padding: const EdgeInsets.only(bottom: AppSpace.s4), padding: withBottomInset(context, const EdgeInsets.only(bottom: AppSpace.s4)),
children: [ children: [
// 사진·영상 등록은 캠페인 문서에 필드가 없어 아직 연결하지 않는다(시안 자리만). // 사진·영상 등록은 캠페인 문서에 필드가 없어 아직 연결하지 않는다(시안 자리만).
Container( Container(
@@ -4,12 +4,14 @@ import 'package:cloud_firestore/cloud_firestore.dart'
import '../../core/data/backend.dart'; import '../../core/data/backend.dart';
import '../../core/data/invite_code.dart'; import '../../core/data/invite_code.dart';
import '../../core/data/server_clock.dart'; import '../../core/data/server_clock.dart';
import '../../core/ui/planet_25d.dart' show planetColors, planetColorsFrom, setPlanetColorMaster; import '../../core/ui/planet_25d.dart'
show planetColors, planetColorsFrom, setPlanetColorMaster;
import 'package:flutter/painting.dart' show Color; import 'package:flutter/painting.dart' show Color;
import 'home_data.dart'; import 'home_data.dart';
import '../inside/inside_repository_firestore.dart' show insideQuestionFrom; import '../inside/inside_repository_firestore.dart' show insideQuestionFrom;
import '../quest/quest_data.dart' show QuestType; import '../quest/quest_data.dart' show QuestType;
import '../quest/quest_repository_firestore.dart' show openTimeAttack, questTitle, questToday; import '../quest/quest_repository_firestore.dart'
show openTimeAttack, questTitle, questToday;
import '../../core/i18n/app_locale.dart'; import '../../core/i18n/app_locale.dart';
/// 홈의 실 서버 구현 — 화면 대조 원장 F1. /// 홈의 실 서버 구현 — 화면 대조 원장 F1.
@@ -117,23 +119,6 @@ class FirestoreHomeRepository implements HomeRepository {
.where('active', isEqualTo: true) .where('active', isEqualTo: true)
.limit(30) .limit(30)
.get(); .get();
// 긴급 돌발 타임어택(FR-QST-06) — 내 팬덤의 **열린 창**만. 위 목록과 따로 읽어 맨 앞에
// 둔다. 위 질의는 정렬이 없어 5 건 안에 든다는 보장이 없다. 색인 `firestore.indexes.json`.
// 질의는 단말 시계 기준 1시간 여유로 넓게 받고, 마감 판정은 아래에서 서버 시각으로 한다.
// ponytail: 단말 시계가 1시간 넘게 빠르면 열린 창을 놓친다. 그때는 여유를 늘린다.
final timeAttacksF = fandomId.isEmpty
? null
: db
.collection('quests')
.where('timeAttack.fandomId', isEqualTo: fandomId)
.where(
'timeAttack.activeTo',
isGreaterThan: Timestamp.fromDate(
DateTime.now().subtract(const Duration(hours: 1)),
),
)
.limit(10)
.get();
final progressF = db.collection('users/$uid/questProgress').get(); final progressF = db.collection('users/$uid/questProgress').get();
// 출석 보상은 서버가 `config/policy.attendPop` 으로 지급한다. 같은 곳을 읽는다. // 출석 보상은 서버가 `config/policy.attendPop` 으로 지급한다. 같은 곳을 읽는다.
final policyF = db.doc('config/policy').get(); final policyF = db.doc('config/policy').get();
@@ -199,24 +184,12 @@ class FirestoreHomeRepository implements HomeRepository {
final fandom = fandomF == null final fandom = fandomF == null
? const <String, dynamic>{} ? const <String, dynamic>{}
: ((await fandomF).data() ?? const <String, dynamic>{}); : ((await fandomF).data() ?? const <String, dynamic>{});
final timeAttacks = [ // 설명 프레임 `2097:16397` 그대로 — 관리자 퀘스트 최신순 최대 3개. 열린 타임어택 회차를
for (final doc in (await timeAttacksF)?.docs ?? const []) // 맨 앞에 끼우던 처리는 발주사 자료에 근거가 없어 뺐다(회차는 Push · 퀘스트 목록으로 안내).
if (doc.data()['active'] != false) doc, final quests = adminQuestsLatest(
]; (await questsF).docs,
if (timeAttacks.isNotEmpty) await ServerClock.ensure(_backend); (d) => d.data(),
final open = [ ).take(3).toList();
for (final d in timeAttacks)
if (openTimeAttack(d.data()) != null) d,
];
final openIds = {for (final d in open) d.id};
final quests = [
...open,
// 회차가 열린 Quest 는 위에서 맨 앞에 넣었다. 두 번 넣지 않는다.
...adminQuestsLatest([
for (final d in (await questsF).docs)
if (!openIds.contains(d.id)) d,
], (d) => d.data()),
].take(3).toList();
final progress = (await progressF).docs; final progress = (await progressF).docs;
final briefings = (await briefingsF).docs; final briefings = (await briefingsF).docs;
final blocked = {for (final d in (await blocksF).docs) d.id}; final blocked = {for (final d in (await blocksF).docs) d.id};
@@ -330,7 +303,11 @@ class FirestoreHomeRepository implements HomeRepository {
/// 이번 주 내가 [fandomId] 행성 성장에 보탠 Growth POP — 원장과 같은 트랜잭션에서 쓰는 /// 이번 주 내가 [fandomId] 행성 성장에 보탠 Growth POP — 원장과 같은 트랜잭션에서 쓰는
/// `users/{uid}/growthEvents`(역분개는 음수 행)의 [since] 이후 합. 읽지 못하면 null. /// `users/{uid}/growthEvents`(역분개는 음수 행)의 [since] 이후 합. 읽지 못하면 null.
// ponytail: 한 주치 성장 행을 전부 읽어 합한다. 한 사용자가 주에 수천 건을 넘기면 서버 집계 필드로 옮긴다. // ponytail: 한 주치 성장 행을 전부 읽어 합한다. 한 사용자가 주에 수천 건을 넘기면 서버 집계 필드로 옮긴다.
Future<num?> _myWeekGrowth(String uid, String fandomId, DateTime since) async { Future<num?> _myWeekGrowth(
String uid,
String fandomId,
DateTime since,
) async {
try { try {
final q = await _backend.db final q = await _backend.db
.collection('users/$uid/growthEvents') .collection('users/$uid/growthEvents')
@@ -340,7 +317,8 @@ class FirestoreHomeRepository implements HomeRepository {
for (final d in q.docs) { for (final d in q.docs) {
final data = d.data(); final data = d.data();
final amount = data['amount']; final amount = data['amount'];
if (data['fandomIdAtPayout'] == fandomId && amount is num) sum += amount; if (data['fandomIdAtPayout'] == fandomId && amount is num)
sum += amount;
} }
return sum; return sum;
} catch (_) { } catch (_) {
@@ -429,7 +407,8 @@ class FirestoreHomeRepository implements HomeRepository {
title: questTitle(quest), title: questTitle(quest),
type: QuestType.parse(quest.strOr('type')), type: QuestType.parse(quest.strOr('type')),
category: QuestType.parse(quest.strOr('type')).label, category: QuestType.parse(quest.strOr('type')).label,
rewardPop: openTimeAttack(quest)?.rewardPop ?? rewardPop:
openTimeAttack(quest)?.rewardPop ??
QuestType.parse(quest.strOr('type')).rewardFrom(quest, policy), QuestType.parse(quest.strOr('type')).rewardFrom(quest, policy),
state: done state: done
? QuestState.done ? QuestState.done
+2 -1
View File
@@ -3,6 +3,7 @@ import 'package:flutter/material.dart';
import '../../core/i18n/app_locale.dart'; import '../../core/i18n/app_locale.dart';
import '../../core/theme/tokens.dart'; import '../../core/theme/tokens.dart';
import '../auth/auth.dart'; import '../auth/auth.dart';
import '../../core/ui/bottom_inset.dart';
/// SNS 연동 — 화면 대조 원장 F6. /// SNS 연동 — 화면 대조 원장 F6.
/// ///
@@ -50,7 +51,7 @@ class _AccountLinkScreenState extends State<AccountLinkScreen> {
return Scaffold( return Scaffold(
appBar: AppBar(title: Text(context.l10n.linkTitle(_name))), appBar: AppBar(title: Text(context.l10n.linkTitle(_name))),
body: ListView( body: ListView(
padding: const EdgeInsets.all(AppSpace.s4), padding: withBottomInset(context, const EdgeInsets.all(AppSpace.s4)),
children: [ children: [
Container( Container(
padding: const EdgeInsets.all(AppSpace.s4), padding: const EdgeInsets.all(AppSpace.s4),
+2 -1
View File
@@ -4,6 +4,7 @@ import '../../core/i18n/app_locale.dart';
import '../../core/theme/tokens.dart'; import '../../core/theme/tokens.dart';
import 'my_data.dart'; import 'my_data.dart';
import 'widgets/my_cards.dart'; import 'widgets/my_cards.dart';
import '../../core/ui/bottom_inset.dart';
/// 「2026.04.30」 — 시안 공지 날짜 표기. /// 「2026.04.30」 — 시안 공지 날짜 표기.
String _date(DateTime at) => String _date(DateTime at) =>
@@ -116,7 +117,7 @@ class NoticeDetailScreen extends StatelessWidget {
Widget build(BuildContext context) => Scaffold( Widget build(BuildContext context) => Scaffold(
appBar: AppBar(title: Text(context.l10n.noticeDetailTitle)), appBar: AppBar(title: Text(context.l10n.noticeDetailTitle)),
body: ListView( body: ListView(
padding: const EdgeInsets.all(16), padding: withBottomInset(context, const EdgeInsets.all(16)),
children: [ children: [
_NoticeHead(notice: notice), _NoticeHead(notice: notice),
const SizedBox(height: 12), const SizedBox(height: 12),
+2 -1
View File
@@ -5,6 +5,7 @@ import 'my_data.dart';
import 'photo_picker.dart'; import 'photo_picker.dart';
import 'widgets/my_cards.dart'; import 'widgets/my_cards.dart';
import '../../core/i18n/app_locale.dart'; import '../../core/i18n/app_locale.dart';
import '../../core/ui/bottom_inset.dart';
/// 닉네임 수정 — 화면 대조 원장 F6 · 시안 `374:16029`(MY 연필에서 진입). /// 닉네임 수정 — 화면 대조 원장 F6 · 시안 `374:16029`(MY 연필에서 진입).
/// ///
@@ -68,7 +69,7 @@ class _ProfileEditScreenState extends State<ProfileEditScreen> {
onDone: _changed && !_saving ? _save : null, onDone: _changed && !_saving ? _save : null,
), ),
body: ListView( body: ListView(
padding: const EdgeInsets.all(16), padding: withBottomInset(context, const EdgeInsets.all(16)),
children: [ children: [
// 시안 — 카드 #19191c · 모서리 16 · 높이 58 · 글자 18/500. // 시안 — 카드 #19191c · 모서리 16 · 높이 58 · 글자 18/500.
Container( Container(
+9 -2
View File
@@ -19,6 +19,7 @@ import 'pop_history_screen.dart';
import 'push_settings_screen.dart'; import 'push_settings_screen.dart';
import 'support_screens.dart'; import 'support_screens.dart';
import 'widgets/my_cards.dart'; import 'widgets/my_cards.dart';
import '../../core/ui/bottom_inset.dart';
/// 설정 — 화면 대조 원장 F6 · Figma `354:222008`. /// 설정 — 화면 대조 원장 F6 · Figma `354:222008`.
/// ///
@@ -93,7 +94,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
return Scaffold( return Scaffold(
appBar: AppBar(title: Text(l10n.settingsTitle)), appBar: AppBar(title: Text(l10n.settingsTitle)),
body: ListView( body: ListView(
padding: const EdgeInsets.fromLTRB(16, 16, 16, 0), padding: withBottomInset(context, const EdgeInsets.fromLTRB(16, 16, 16, 0)),
children: [ children: [
MyGroup( MyGroup(
title: l10n.settingsAccount, title: l10n.settingsAccount,
@@ -220,8 +221,14 @@ class _SettingsScreenState extends State<SettingsScreen> {
action: l10n.settingsLogout, action: l10n.settingsLogout,
// 푸시 정리를 **먼저** 한다. 보안 규칙이 `users/{uid}` 쓰기를 // 푸시 정리를 **먼저** 한다. 보안 규칙이 `users/{uid}` 쓰기를
// `isSelf(uid)` 로 막아, 로그아웃 뒤에는 이 기기 토큰을 지울 수 없다. // `isSelf(uid)` 로 막아, 로그아웃 뒤에는 이 기기 토큰을 지울 수 없다.
// 정리가 실패해도(토픽 해제 · 토큰 쓰기 네트워크 오류) 로그아웃은 한다 — 예외가
// 그대로 올라가면 signOut 이 돌지 않아 첫 탭이 무시된 것처럼 보인다(10/6 실기기 1회).
onConfirm: () async { onConfirm: () async {
await widget.push?.clearOnSignOut(); try {
await widget.push?.clearOnSignOut();
} on Object catch (error) {
debugPrint('[Settings] push cleanup failed: ${error.runtimeType}');
}
await widget.auth?.signOut(); await widget.auth?.signOut();
}, },
), ),
+3 -2
View File
@@ -5,6 +5,7 @@ import '../../core/ui/relative_time.dart';
import 'my_data.dart'; import 'my_data.dart';
import 'widgets/my_cards.dart'; import 'widgets/my_cards.dart';
import '../../core/i18n/app_locale.dart'; import '../../core/i18n/app_locale.dart';
import '../../core/ui/bottom_inset.dart';
/// 1:1 문의 목록 — 화면 대조 원장 F6 · Figma `373:634916` 「1:1 문의 접수」. /// 1:1 문의 목록 — 화면 대조 원장 F6 · Figma `373:634916` 「1:1 문의 접수」.
/// ///
@@ -205,7 +206,7 @@ class InquiryDetailScreen extends StatelessWidget {
], ],
), ),
body: ListView( body: ListView(
padding: const EdgeInsets.all(AppSpace.s4), padding: withBottomInset(context, const EdgeInsets.all(AppSpace.s4)),
children: [ children: [
Text(inquiry.title, style: t.titleMedium), Text(inquiry.title, style: t.titleMedium),
const SizedBox(height: AppSpace.s2), const SizedBox(height: AppSpace.s2),
@@ -296,7 +297,7 @@ class _InquiryComposeScreenState extends State<InquiryComposeScreen> {
], ],
), ),
body: ListView( body: ListView(
padding: const EdgeInsets.all(AppSpace.s4), padding: withBottomInset(context, const EdgeInsets.all(AppSpace.s4)),
children: [ children: [
TextField( TextField(
controller: _title, controller: _title,
+3 -2
View File
@@ -8,6 +8,7 @@ import '../planet/planet_history_screen.dart';
import 'my_data.dart'; import 'my_data.dart';
import 'widgets/my_cards.dart'; import 'widgets/my_cards.dart';
import '../../core/i18n/app_locale.dart'; import '../../core/i18n/app_locale.dart';
import '../../core/ui/bottom_inset.dart';
/// 티어 정보 — 화면 대조 원장 F6 · Figma `373:633256`. /// 티어 정보 — 화면 대조 원장 F6 · Figma `373:633256`.
/// ///
@@ -61,7 +62,7 @@ class TierInfoScreen extends StatelessWidget {
return Scaffold( return Scaffold(
appBar: AppBar(title: Text(context.l10n.tierInfoTitle)), appBar: AppBar(title: Text(context.l10n.tierInfoTitle)),
body: ListView( body: ListView(
padding: const EdgeInsets.fromLTRB(20, 20, 20, 0), padding: withBottomInset(context, const EdgeInsets.fromLTRB(20, 20, 20, 0)),
children: [ children: [
Text.rich( Text.rich(
TextSpan( TextSpan(
@@ -142,7 +143,7 @@ class TierDetailScreen extends StatelessWidget {
return Scaffold( return Scaffold(
appBar: AppBar(title: const Text('TIER')), appBar: AppBar(title: const Text('TIER')),
body: ListView( body: ListView(
padding: const EdgeInsets.all(16), padding: withBottomInset(context, const EdgeInsets.all(16)),
children: [ children: [
for (final (i, planet) in list.indexed) ...[ for (final (i, planet) in list.indexed) ...[
if (i > 0) const SizedBox(height: 16), if (i > 0) const SizedBox(height: 16),
@@ -1,6 +1,7 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'planet_data.dart'; import 'planet_data.dart';
import '../../core/i18n/app_locale.dart'; import '../../core/i18n/app_locale.dart';
import '../../core/ui/bottom_inset.dart';
class PlanetHistoryScreen extends StatefulWidget { class PlanetHistoryScreen extends StatefulWidget {
const PlanetHistoryScreen({ const PlanetHistoryScreen({
@@ -57,7 +58,7 @@ class _PlanetHistoryScreenState extends State<PlanetHistoryScreen> {
Widget build(BuildContext context) => Scaffold( Widget build(BuildContext context) => Scaffold(
appBar: AppBar(title: Text(context.l10n.historyTitle)), appBar: AppBar(title: Text(context.l10n.historyTitle)),
body: ListView( body: ListView(
padding: const EdgeInsets.all(16), padding: withBottomInset(context, const EdgeInsets.all(16)),
children: [ children: [
Text(widget.fandomName, style: Theme.of(context).textTheme.titleLarge), Text(widget.fandomName, style: Theme.of(context).textTheme.titleLarge),
const SizedBox(height: 16), const SizedBox(height: 16),
@@ -257,6 +257,10 @@ class _PlacePickerScreenState extends State<PlacePickerScreen> {
/// 지오코더 결과 → 한 줄 주소. 한국 주소는 도로명이 `thoroughfare` 에 오지 않아(번지만 `subThoroughfare`) /// 지오코더 결과 → 한 줄 주소. 한국 주소는 도로명이 `thoroughfare` 에 오지 않아(번지만 `subThoroughfare`)
/// 칸을 이어 붙이면 「서울특별시 마포구 34」가 된다(10/6 실기기). 도로명이 든 `street` 를 쓴다 — /// 칸을 이어 붙이면 「서울특별시 마포구 34」가 된다(10/6 실기기). 도로명이 든 `street` 를 쓴다 —
/// Android 는 주소 전체 줄(나라 이름 포함), iOS 는 「도로명 번지」만 준다. /// Android 는 주소 전체 줄(나라 이름 포함), iOS 는 「도로명 번지」만 준다.
///
/// 지오코더가 지번 줄(「마포구 서교동 353-5」)만 주면 지번 그대로 둔다 — Figma 퀘스트 생성 「위치 선택」
/// 예시가 지번이다(`548:19930` · `372:357949` 등 「서울특별시 동작구 동작동 산22-11」). 도로명으로 바꾸는
/// 역지오코딩은 기준자료에 없다(2026-10-06 재확인).
String placeAddress(Placemark m) { String placeAddress(Placemark m) {
String t(String? v) => v?.trim() ?? ''; String t(String? v) => v?.trim() ?? '';
final street = t(m.street); final street = t(m.street);
@@ -10,6 +10,7 @@ import 'place_picker_screen.dart';
import 'quest_data.dart'; import 'quest_data.dart';
import 'widgets/quest_card_tile.dart'; import 'widgets/quest_card_tile.dart';
import '../../core/i18n/app_locale.dart'; import '../../core/i18n/app_locale.dart';
import '../../core/ui/bottom_inset.dart';
/// 퀘스트 생성 — 화면 대조 원장 F4 · Figma `254:164081`. /// 퀘스트 생성 — 화면 대조 원장 F4 · Figma `254:164081`.
/// ///
@@ -178,7 +179,7 @@ class _QuestCreateScreenState extends State<QuestCreateScreen> {
], ],
), ),
body: ListView( body: ListView(
padding: const EdgeInsets.all(AppSpace.s4), padding: withBottomInset(context, const EdgeInsets.all(AppSpace.s4)),
children: [ children: [
// 명세 — 입력 상태에 따라 실시간 반영. // 명세 — 입력 상태에 따라 실시간 반영.
QuestCardTile.preview(card: _preview), QuestCardTile.preview(card: _preview),
@@ -8,6 +8,7 @@ import 'quest_data.dart';
import 'quest_detail_screen.dart'; import 'quest_detail_screen.dart';
import 'widgets/quest_card_tile.dart'; import 'widgets/quest_card_tile.dart';
import '../../core/i18n/app_locale.dart'; import '../../core/i18n/app_locale.dart';
import '../../core/ui/bottom_inset.dart';
/// 퀘스트 목록 — 화면 대조 원장 F4. /// 퀘스트 목록 — 화면 대조 원장 F4.
/// ///
@@ -339,7 +340,7 @@ class _QuestListScreenState extends State<QuestListScreen> {
controller: _scroll, controller: _scroll,
physics: const AlwaysScrollableScrollPhysics(), physics: const AlwaysScrollableScrollPhysics(),
// 아래 여백은 「새 퀘스트」 버튼(36)이 마지막 카드를 가리지 않게 둔다. // 아래 여백은 「새 퀘스트」 버튼(36)이 마지막 카드를 가리지 않게 둔다.
padding: const EdgeInsets.fromLTRB(16, 16, 16, 16 + 36 + 16), padding: withBottomInset(context, const EdgeInsets.fromLTRB(16, 16, 16, 16 + 36 + 16)),
children: _body(), children: _body(),
), ),
), ),
+30
View File
@@ -19,6 +19,7 @@ import 'package:fancake/features/my/tier_screens.dart';
import 'package:fancake/features/my/widgets/profile_card.dart'; import 'package:fancake/features/my/widgets/profile_card.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import 'package:fancake/core/push/push_messaging.dart';
import 'package:flutter_test/flutter_test.dart'; import 'package:flutter_test/flutter_test.dart';
import 'package:share_plus/share_plus.dart'; import 'package:share_plus/share_plus.dart';
@@ -1009,6 +1010,26 @@ void main() {
/// ///
/// 이 배선이 없던 동안 모달은 「09-10 회차에 연결합니다」 안내만 띄우고 세션을 /// 이 배선이 없던 동안 모달은 「09-10 회차에 연결합니다」 안내만 띄우고 세션을
/// 그대로 두었다. 화면만 보면 로그아웃된 것처럼 보이지 않아 결함이 드러나지 않는다. /// 그대로 두었다. 화면만 보면 로그아웃된 것처럼 보이지 않아 결함이 드러나지 않는다.
testWidgets('푸시 정리가 실패해도 첫 확인에 로그아웃된다 (10/6 실기기)', (tester) async {
final session = _LogoutSession(
const AuthAccount(uid: 'u1', provider: SocialProvider.google),
);
final auth = AuthRepository(providers: const [], session: session, releaseMode: true);
await pumpWide(
tester,
SettingsScreen(repository: SampleMyRepository(), auth: auth, push: _FailingPush()),
);
await tester.pumpAndSettle();
await tester.scrollUntilVisible(find.text('로그아웃'), 220, scrollable: find.byType(Scrollable).first);
await tester.tap(find.text('로그아웃'));
await tester.pumpAndSettle();
await tester.tap(find.widgetWithText(FilledButton, '로그아웃'));
await tester.pumpAndSettle();
expect(session.signOutCalls, 1);
auth.dispose();
await session.close();
});
testWidgets('로그아웃을 확인하면 세션이 끊긴다 (F6 · 554:22903)', (tester) async { testWidgets('로그아웃을 확인하면 세션이 끊긴다 (F6 · 554:22903)', (tester) async {
final session = _LogoutSession( final session = _LogoutSession(
const AuthAccount(uid: 'u1', provider: SocialProvider.google), const AuthAccount(uid: 'u1', provider: SocialProvider.google),
@@ -1044,6 +1065,15 @@ void main() {
}); });
} }
/// 푸시 정리가 실패하는 경계 — 토픽 해제 · 토큰 쓰기가 네트워크 오류로 던진다.
class _FailingPush implements PushMessaging {
@override
Future<void> clearOnSignOut() async => throw Exception('offline');
@override
dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation);
}
/// 로그아웃 호출을 세는 최소 세션. 실제 Firebase 를 붙이지 않는다. /// 로그아웃 호출을 세는 최소 세션. 실제 Firebase 를 붙이지 않는다.
final class _LogoutSession implements AuthSession { final class _LogoutSession implements AuthSession {
_LogoutSession(this._current); _LogoutSession(this._current);