화면 하단 여백 · 로그아웃 진행 · 스낵바 강조색 · TODAY'S QUEST 관리자 퀘스트 3개
This commit is contained in:
+5
-14
@@ -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,
|
||||||
|
|||||||
@@ -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),
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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),
|
||||||
|
|||||||
@@ -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),
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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();
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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(),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
Reference in New Issue
Block a user