화면 하단 여백 · 로그아웃 진행 · 스낵바 강조색 · TODAY'S QUEST 관리자 퀘스트 3개
This commit is contained in:
@@ -397,6 +397,12 @@ final class KakaoSignInProvider implements SocialAuthProvider {
|
||||
if (error.error == kakao.AuthErrorCause.accessDenied) {
|
||||
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);
|
||||
} on FirebaseFunctionsException catch (error) {
|
||||
final details = error.details;
|
||||
|
||||
@@ -10,6 +10,7 @@ import '../home/home_screen.dart' show formatPop;
|
||||
import 'campaign_data.dart';
|
||||
import 'widgets/campaign_period_sheet.dart';
|
||||
import 'widgets/campaign_style.dart';
|
||||
import '../../core/ui/bottom_inset.dart';
|
||||
|
||||
/// 새 캠페인 — 화면 대조 원장 F5 · Figma `287:16991`.
|
||||
///
|
||||
@@ -141,7 +142,7 @@ class _CampaignCreateScreenState extends State<CampaignCreateScreen> {
|
||||
],
|
||||
),
|
||||
body: ListView(
|
||||
padding: const EdgeInsets.only(bottom: AppSpace.s4),
|
||||
padding: withBottomInset(context, const EdgeInsets.only(bottom: AppSpace.s4)),
|
||||
children: [
|
||||
// 사진·영상 등록은 캠페인 문서에 필드가 없어 아직 연결하지 않는다(시안 자리만).
|
||||
Container(
|
||||
|
||||
@@ -4,12 +4,14 @@ import 'package:cloud_firestore/cloud_firestore.dart'
|
||||
import '../../core/data/backend.dart';
|
||||
import '../../core/data/invite_code.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 'home_data.dart';
|
||||
import '../inside/inside_repository_firestore.dart' show insideQuestionFrom;
|
||||
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';
|
||||
|
||||
/// 홈의 실 서버 구현 — 화면 대조 원장 F1.
|
||||
@@ -117,23 +119,6 @@ class FirestoreHomeRepository implements HomeRepository {
|
||||
.where('active', isEqualTo: true)
|
||||
.limit(30)
|
||||
.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();
|
||||
// 출석 보상은 서버가 `config/policy.attendPop` 으로 지급한다. 같은 곳을 읽는다.
|
||||
final policyF = db.doc('config/policy').get();
|
||||
@@ -199,24 +184,12 @@ class FirestoreHomeRepository implements HomeRepository {
|
||||
final fandom = fandomF == null
|
||||
? const <String, dynamic>{}
|
||||
: ((await fandomF).data() ?? const <String, dynamic>{});
|
||||
final timeAttacks = [
|
||||
for (final doc in (await timeAttacksF)?.docs ?? const [])
|
||||
if (doc.data()['active'] != false) doc,
|
||||
];
|
||||
if (timeAttacks.isNotEmpty) await ServerClock.ensure(_backend);
|
||||
final open = [
|
||||
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();
|
||||
// 설명 프레임 `2097:16397` 그대로 — 관리자 퀘스트 최신순 최대 3개. 열린 타임어택 회차를
|
||||
// 맨 앞에 끼우던 처리는 발주사 자료에 근거가 없어 뺐다(회차는 Push · 퀘스트 목록으로 안내).
|
||||
final quests = adminQuestsLatest(
|
||||
(await questsF).docs,
|
||||
(d) => d.data(),
|
||||
).take(3).toList();
|
||||
final progress = (await progressF).docs;
|
||||
final briefings = (await briefingsF).docs;
|
||||
final blocked = {for (final d in (await blocksF).docs) d.id};
|
||||
@@ -330,7 +303,11 @@ class FirestoreHomeRepository implements HomeRepository {
|
||||
/// 이번 주 내가 [fandomId] 행성 성장에 보탠 Growth POP — 원장과 같은 트랜잭션에서 쓰는
|
||||
/// `users/{uid}/growthEvents`(역분개는 음수 행)의 [since] 이후 합. 읽지 못하면 null.
|
||||
// ponytail: 한 주치 성장 행을 전부 읽어 합한다. 한 사용자가 주에 수천 건을 넘기면 서버 집계 필드로 옮긴다.
|
||||
Future<num?> _myWeekGrowth(String uid, String fandomId, DateTime since) async {
|
||||
Future<num?> _myWeekGrowth(
|
||||
String uid,
|
||||
String fandomId,
|
||||
DateTime since,
|
||||
) async {
|
||||
try {
|
||||
final q = await _backend.db
|
||||
.collection('users/$uid/growthEvents')
|
||||
@@ -340,7 +317,8 @@ class FirestoreHomeRepository implements HomeRepository {
|
||||
for (final d in q.docs) {
|
||||
final data = d.data();
|
||||
final amount = data['amount'];
|
||||
if (data['fandomIdAtPayout'] == fandomId && amount is num) sum += amount;
|
||||
if (data['fandomIdAtPayout'] == fandomId && amount is num)
|
||||
sum += amount;
|
||||
}
|
||||
return sum;
|
||||
} catch (_) {
|
||||
@@ -429,7 +407,8 @@ class FirestoreHomeRepository implements HomeRepository {
|
||||
title: questTitle(quest),
|
||||
type: QuestType.parse(quest.strOr('type')),
|
||||
category: QuestType.parse(quest.strOr('type')).label,
|
||||
rewardPop: openTimeAttack(quest)?.rewardPop ??
|
||||
rewardPop:
|
||||
openTimeAttack(quest)?.rewardPop ??
|
||||
QuestType.parse(quest.strOr('type')).rewardFrom(quest, policy),
|
||||
state: done
|
||||
? QuestState.done
|
||||
|
||||
@@ -3,6 +3,7 @@ import 'package:flutter/material.dart';
|
||||
import '../../core/i18n/app_locale.dart';
|
||||
import '../../core/theme/tokens.dart';
|
||||
import '../auth/auth.dart';
|
||||
import '../../core/ui/bottom_inset.dart';
|
||||
|
||||
/// SNS 연동 — 화면 대조 원장 F6.
|
||||
///
|
||||
@@ -50,7 +51,7 @@ class _AccountLinkScreenState extends State<AccountLinkScreen> {
|
||||
return Scaffold(
|
||||
appBar: AppBar(title: Text(context.l10n.linkTitle(_name))),
|
||||
body: ListView(
|
||||
padding: const EdgeInsets.all(AppSpace.s4),
|
||||
padding: withBottomInset(context, const EdgeInsets.all(AppSpace.s4)),
|
||||
children: [
|
||||
Container(
|
||||
padding: const EdgeInsets.all(AppSpace.s4),
|
||||
|
||||
@@ -4,6 +4,7 @@ import '../../core/i18n/app_locale.dart';
|
||||
import '../../core/theme/tokens.dart';
|
||||
import 'my_data.dart';
|
||||
import 'widgets/my_cards.dart';
|
||||
import '../../core/ui/bottom_inset.dart';
|
||||
|
||||
/// 「2026.04.30」 — 시안 공지 날짜 표기.
|
||||
String _date(DateTime at) =>
|
||||
@@ -116,7 +117,7 @@ class NoticeDetailScreen extends StatelessWidget {
|
||||
Widget build(BuildContext context) => Scaffold(
|
||||
appBar: AppBar(title: Text(context.l10n.noticeDetailTitle)),
|
||||
body: ListView(
|
||||
padding: const EdgeInsets.all(16),
|
||||
padding: withBottomInset(context, const EdgeInsets.all(16)),
|
||||
children: [
|
||||
_NoticeHead(notice: notice),
|
||||
const SizedBox(height: 12),
|
||||
|
||||
@@ -5,6 +5,7 @@ import 'my_data.dart';
|
||||
import 'photo_picker.dart';
|
||||
import 'widgets/my_cards.dart';
|
||||
import '../../core/i18n/app_locale.dart';
|
||||
import '../../core/ui/bottom_inset.dart';
|
||||
|
||||
/// 닉네임 수정 — 화면 대조 원장 F6 · 시안 `374:16029`(MY 연필에서 진입).
|
||||
///
|
||||
@@ -68,7 +69,7 @@ class _ProfileEditScreenState extends State<ProfileEditScreen> {
|
||||
onDone: _changed && !_saving ? _save : null,
|
||||
),
|
||||
body: ListView(
|
||||
padding: const EdgeInsets.all(16),
|
||||
padding: withBottomInset(context, const EdgeInsets.all(16)),
|
||||
children: [
|
||||
// 시안 — 카드 #19191c · 모서리 16 · 높이 58 · 글자 18/500.
|
||||
Container(
|
||||
|
||||
@@ -19,6 +19,7 @@ import 'pop_history_screen.dart';
|
||||
import 'push_settings_screen.dart';
|
||||
import 'support_screens.dart';
|
||||
import 'widgets/my_cards.dart';
|
||||
import '../../core/ui/bottom_inset.dart';
|
||||
|
||||
/// 설정 — 화면 대조 원장 F6 · Figma `354:222008`.
|
||||
///
|
||||
@@ -93,7 +94,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||
return Scaffold(
|
||||
appBar: AppBar(title: Text(l10n.settingsTitle)),
|
||||
body: ListView(
|
||||
padding: const EdgeInsets.fromLTRB(16, 16, 16, 0),
|
||||
padding: withBottomInset(context, const EdgeInsets.fromLTRB(16, 16, 16, 0)),
|
||||
children: [
|
||||
MyGroup(
|
||||
title: l10n.settingsAccount,
|
||||
@@ -220,8 +221,14 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||
action: l10n.settingsLogout,
|
||||
// 푸시 정리를 **먼저** 한다. 보안 규칙이 `users/{uid}` 쓰기를
|
||||
// `isSelf(uid)` 로 막아, 로그아웃 뒤에는 이 기기 토큰을 지울 수 없다.
|
||||
// 정리가 실패해도(토픽 해제 · 토큰 쓰기 네트워크 오류) 로그아웃은 한다 — 예외가
|
||||
// 그대로 올라가면 signOut 이 돌지 않아 첫 탭이 무시된 것처럼 보인다(10/6 실기기 1회).
|
||||
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();
|
||||
},
|
||||
),
|
||||
|
||||
@@ -5,6 +5,7 @@ import '../../core/ui/relative_time.dart';
|
||||
import 'my_data.dart';
|
||||
import 'widgets/my_cards.dart';
|
||||
import '../../core/i18n/app_locale.dart';
|
||||
import '../../core/ui/bottom_inset.dart';
|
||||
|
||||
/// 1:1 문의 목록 — 화면 대조 원장 F6 · Figma `373:634916` 「1:1 문의 접수」.
|
||||
///
|
||||
@@ -205,7 +206,7 @@ class InquiryDetailScreen extends StatelessWidget {
|
||||
],
|
||||
),
|
||||
body: ListView(
|
||||
padding: const EdgeInsets.all(AppSpace.s4),
|
||||
padding: withBottomInset(context, const EdgeInsets.all(AppSpace.s4)),
|
||||
children: [
|
||||
Text(inquiry.title, style: t.titleMedium),
|
||||
const SizedBox(height: AppSpace.s2),
|
||||
@@ -296,7 +297,7 @@ class _InquiryComposeScreenState extends State<InquiryComposeScreen> {
|
||||
],
|
||||
),
|
||||
body: ListView(
|
||||
padding: const EdgeInsets.all(AppSpace.s4),
|
||||
padding: withBottomInset(context, const EdgeInsets.all(AppSpace.s4)),
|
||||
children: [
|
||||
TextField(
|
||||
controller: _title,
|
||||
|
||||
@@ -8,6 +8,7 @@ import '../planet/planet_history_screen.dart';
|
||||
import 'my_data.dart';
|
||||
import 'widgets/my_cards.dart';
|
||||
import '../../core/i18n/app_locale.dart';
|
||||
import '../../core/ui/bottom_inset.dart';
|
||||
|
||||
/// 티어 정보 — 화면 대조 원장 F6 · Figma `373:633256`.
|
||||
///
|
||||
@@ -61,7 +62,7 @@ class TierInfoScreen extends StatelessWidget {
|
||||
return Scaffold(
|
||||
appBar: AppBar(title: Text(context.l10n.tierInfoTitle)),
|
||||
body: ListView(
|
||||
padding: const EdgeInsets.fromLTRB(20, 20, 20, 0),
|
||||
padding: withBottomInset(context, const EdgeInsets.fromLTRB(20, 20, 20, 0)),
|
||||
children: [
|
||||
Text.rich(
|
||||
TextSpan(
|
||||
@@ -142,7 +143,7 @@ class TierDetailScreen extends StatelessWidget {
|
||||
return Scaffold(
|
||||
appBar: AppBar(title: const Text('TIER')),
|
||||
body: ListView(
|
||||
padding: const EdgeInsets.all(16),
|
||||
padding: withBottomInset(context, const EdgeInsets.all(16)),
|
||||
children: [
|
||||
for (final (i, planet) in list.indexed) ...[
|
||||
if (i > 0) const SizedBox(height: 16),
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'planet_data.dart';
|
||||
import '../../core/i18n/app_locale.dart';
|
||||
import '../../core/ui/bottom_inset.dart';
|
||||
|
||||
class PlanetHistoryScreen extends StatefulWidget {
|
||||
const PlanetHistoryScreen({
|
||||
@@ -57,7 +58,7 @@ class _PlanetHistoryScreenState extends State<PlanetHistoryScreen> {
|
||||
Widget build(BuildContext context) => Scaffold(
|
||||
appBar: AppBar(title: Text(context.l10n.historyTitle)),
|
||||
body: ListView(
|
||||
padding: const EdgeInsets.all(16),
|
||||
padding: withBottomInset(context, const EdgeInsets.all(16)),
|
||||
children: [
|
||||
Text(widget.fandomName, style: Theme.of(context).textTheme.titleLarge),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
@@ -257,6 +257,10 @@ class _PlacePickerScreenState extends State<PlacePickerScreen> {
|
||||
/// 지오코더 결과 → 한 줄 주소. 한국 주소는 도로명이 `thoroughfare` 에 오지 않아(번지만 `subThoroughfare`)
|
||||
/// 칸을 이어 붙이면 「서울특별시 마포구 34」가 된다(10/6 실기기). 도로명이 든 `street` 를 쓴다 —
|
||||
/// Android 는 주소 전체 줄(나라 이름 포함), iOS 는 「도로명 번지」만 준다.
|
||||
///
|
||||
/// 지오코더가 지번 줄(「마포구 서교동 353-5」)만 주면 지번 그대로 둔다 — Figma 퀘스트 생성 「위치 선택」
|
||||
/// 예시가 지번이다(`548:19930` · `372:357949` 등 「서울특별시 동작구 동작동 산22-11」). 도로명으로 바꾸는
|
||||
/// 역지오코딩은 기준자료에 없다(2026-10-06 재확인).
|
||||
String placeAddress(Placemark m) {
|
||||
String t(String? v) => v?.trim() ?? '';
|
||||
final street = t(m.street);
|
||||
|
||||
@@ -10,6 +10,7 @@ import 'place_picker_screen.dart';
|
||||
import 'quest_data.dart';
|
||||
import 'widgets/quest_card_tile.dart';
|
||||
import '../../core/i18n/app_locale.dart';
|
||||
import '../../core/ui/bottom_inset.dart';
|
||||
|
||||
/// 퀘스트 생성 — 화면 대조 원장 F4 · Figma `254:164081`.
|
||||
///
|
||||
@@ -178,7 +179,7 @@ class _QuestCreateScreenState extends State<QuestCreateScreen> {
|
||||
],
|
||||
),
|
||||
body: ListView(
|
||||
padding: const EdgeInsets.all(AppSpace.s4),
|
||||
padding: withBottomInset(context, const EdgeInsets.all(AppSpace.s4)),
|
||||
children: [
|
||||
// 명세 — 입력 상태에 따라 실시간 반영.
|
||||
QuestCardTile.preview(card: _preview),
|
||||
|
||||
@@ -8,6 +8,7 @@ import 'quest_data.dart';
|
||||
import 'quest_detail_screen.dart';
|
||||
import 'widgets/quest_card_tile.dart';
|
||||
import '../../core/i18n/app_locale.dart';
|
||||
import '../../core/ui/bottom_inset.dart';
|
||||
|
||||
/// 퀘스트 목록 — 화면 대조 원장 F4.
|
||||
///
|
||||
@@ -339,7 +340,7 @@ class _QuestListScreenState extends State<QuestListScreen> {
|
||||
controller: _scroll,
|
||||
physics: const AlwaysScrollableScrollPhysics(),
|
||||
// 아래 여백은 「새 퀘스트」 버튼(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(),
|
||||
),
|
||||
),
|
||||
|
||||
Reference in New Issue
Block a user