퀘스트 제목 다국어 · HOME 다시 읽기 · 내 퀘스트 검토 중·반려됨 구분
This commit is contained in:
@@ -10,6 +10,7 @@ import 'core/i18n/app_locale.dart';
|
||||
import 'core/ui/adaptive_planet_effects.dart';
|
||||
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 'features/auth/auth_repository.dart';
|
||||
import 'features/campaign/campaign_detail_screen.dart';
|
||||
@@ -188,6 +189,8 @@ class _FancakeAppState extends State<FancakeApp> with WidgetsBindingObserver {
|
||||
/// 포그라운드 수신 — 시스템 알림이 뜨지 않으므로 앱이 직접 보여 준다.
|
||||
/// 탭하면 알림을 눌렀을 때와 같은 곳으로 간다.
|
||||
void _showInApp(PushNotice notice) {
|
||||
// 타임어택 발동 · 퀘스트 완료 등 — HOME 카드가 바로 따라오게 다시 읽힌다.
|
||||
homeRefreshSignal.value++;
|
||||
if (!notice.hasText) return;
|
||||
_messengerKey.currentState
|
||||
?..hideCurrentSnackBar()
|
||||
|
||||
@@ -169,6 +169,23 @@ extension SnapshotReader on Map<String, dynamic> {
|
||||
return value is String && value.isNotEmpty ? value : fallback;
|
||||
}
|
||||
|
||||
/// 다국어 필드 — 문자열이거나 언어별 지도(`{ko: …, en: …}`, 운영 Quest 제목 등).
|
||||
/// 지도면 지금 앱 언어(`zh-CN` · `zh`) → ko → en → 아무 값 순으로 고른다.
|
||||
String textOr(String key, [String fallback = '']) {
|
||||
final value = this[key];
|
||||
if (value is! Map) return strOr(key, fallback);
|
||||
final map = Map<String, dynamic>.from(value);
|
||||
final tag = appL10n.localeName.replaceAll('_', '-');
|
||||
for (final k in [tag, tag.split('-').first, 'ko', 'en']) {
|
||||
final v = map.strOr(k);
|
||||
if (v.isNotEmpty) return v;
|
||||
}
|
||||
return map.values.whereType<String>().firstWhere(
|
||||
(v) => v.isNotEmpty,
|
||||
orElse: () => fallback,
|
||||
);
|
||||
}
|
||||
|
||||
bool boolOr(String key, [bool fallback = false]) {
|
||||
final value = this[key];
|
||||
return value is bool ? value : fallback;
|
||||
|
||||
@@ -98,6 +98,7 @@ GoRouter buildRouter(AuthRepository auth, {Repositories? repositories}) {
|
||||
final linker = repos.linker(auth.providers);
|
||||
return GoRouter(
|
||||
initialLocation: appTabs.first.path,
|
||||
observers: [HomeRefreshObserver()],
|
||||
refreshListenable: auth,
|
||||
redirect: (context, state) {
|
||||
final at = state.matchedLocation;
|
||||
@@ -151,6 +152,7 @@ GoRouter buildRouter(AuthRepository auth, {Repositories? repositories}) {
|
||||
builder: (_, _, shell) => AppShell(shell: shell, flags: repos.flags),
|
||||
branches: [
|
||||
StatefulShellBranch(
|
||||
observers: [HomeRefreshObserver()],
|
||||
routes: [
|
||||
GoRoute(
|
||||
path: appTabs[0].path,
|
||||
@@ -259,11 +261,15 @@ class AppShell extends StatelessWidget {
|
||||
child: NavigationBar(
|
||||
// 숨긴 탭에 머무는 동안(딥링크 등)에는 선택 표시를 첫 탭으로 둔다.
|
||||
selectedIndex: selected < 0 ? 0 : selected,
|
||||
onDestinationSelected: (i) => shell.goBranch(
|
||||
branches[i],
|
||||
// initialLocation: true 를 주면 이미 열려 있는 탭을 다시 누를 때 루트로 되돌아간다.
|
||||
initialLocation: branches[i] == shell.currentIndex,
|
||||
),
|
||||
onDestinationSelected: (i) {
|
||||
// 다른 탭에서 HOME 으로 돌아오면 다시 읽는다(HOME 은 탭 전환으로 다시 만들어지지 않는다).
|
||||
if (branches[i] == 0 && shell.currentIndex != 0) homeRefreshSignal.value++;
|
||||
shell.goBranch(
|
||||
branches[i],
|
||||
// initialLocation: true 를 주면 이미 열려 있는 탭을 다시 누를 때 루트로 되돌아간다.
|
||||
initialLocation: branches[i] == shell.currentIndex,
|
||||
);
|
||||
},
|
||||
destinations: [
|
||||
for (final tab in tabs)
|
||||
NavigationDestination(
|
||||
|
||||
@@ -9,7 +9,7 @@ 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, questToday;
|
||||
import '../quest/quest_repository_firestore.dart' show openTimeAttack, questTitle, questToday;
|
||||
import '../../core/i18n/app_locale.dart';
|
||||
|
||||
/// 홈의 실 서버 구현 — 화면 대조 원장 F1.
|
||||
@@ -426,7 +426,7 @@ class FirestoreHomeRepository implements HomeRepository {
|
||||
);
|
||||
final started = count > 0;
|
||||
return QuestSummary(
|
||||
title: quest.strOr('title'),
|
||||
title: questTitle(quest),
|
||||
type: QuestType.parse(quest.strOr('type')),
|
||||
category: QuestType.parse(quest.strOr('type')).label,
|
||||
rewardPop: openTimeAttack(quest)?.rewardPop ??
|
||||
|
||||
@@ -57,6 +57,19 @@ import 'widgets/briefing_share.dart';
|
||||
///
|
||||
/// 값은 [HomeRepository] 가 준다. 지금은 [SampleHomeRepository] 라 서버 없이 뜨고,
|
||||
/// 5주차 Firestore 연동 때 구현체만 바꾼다.
|
||||
/// HOME 을 다시 읽으라는 신호 — 포그라운드 Push 수신 · 위에 띄운 화면이 닫힘 · HOME 탭 복귀.
|
||||
/// HOME 은 진입 때 한 번 읽어서 타임어택 발동 Push · 후원 뒤에도 카드가 낡아 있었다(10/6 실기기).
|
||||
/// ponytail: 어느 화면이 닫혀도 HOME 을 한 번 다시 읽는다. 읽기 비용이 문제면 HOME 이 보일 때만.
|
||||
final ValueNotifier<int> homeRefreshSignal = ValueNotifier(0);
|
||||
|
||||
/// 페이지(시트 · 대화상자 제외)가 닫히면 [homeRefreshSignal] 을 울린다. 루트 · HOME 탭 내비게이터에 하나씩.
|
||||
class HomeRefreshObserver extends NavigatorObserver {
|
||||
@override
|
||||
void didPop(Route<dynamic> route, Route<dynamic>? previousRoute) {
|
||||
if (route is PageRoute) homeRefreshSignal.value++;
|
||||
}
|
||||
}
|
||||
|
||||
class HomeScreen extends StatefulWidget {
|
||||
const HomeScreen({
|
||||
super.key,
|
||||
@@ -97,7 +110,7 @@ class HomeScreen extends StatefulWidget {
|
||||
State<HomeScreen> createState() => _HomeScreenState();
|
||||
}
|
||||
|
||||
class _HomeScreenState extends State<HomeScreen> {
|
||||
class _HomeScreenState extends State<HomeScreen> with WidgetsBindingObserver {
|
||||
late Future<HomeSnapshot> _snapshot;
|
||||
Future<GalaxySnapshot>? _galaxy;
|
||||
ZapController? _zap;
|
||||
@@ -113,13 +126,22 @@ class _HomeScreenState extends State<HomeScreen> {
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
WidgetsBinding.instance.removeObserver(this);
|
||||
homeRefreshSignal.removeListener(_refresh);
|
||||
_zap?.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
void didChangeAppLifecycleState(AppLifecycleState state) {
|
||||
if (state == AppLifecycleState.resumed) _refresh();
|
||||
}
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
WidgetsBinding.instance.addObserver(this);
|
||||
homeRefreshSignal.addListener(_refresh);
|
||||
_snapshot = widget.repository.load();
|
||||
if (widget.galaxyRepository case final repository?) {
|
||||
_galaxy = repository.load();
|
||||
@@ -156,6 +178,7 @@ class _HomeScreenState extends State<HomeScreen> {
|
||||
}
|
||||
|
||||
Future<void> _refresh() async {
|
||||
if (!mounted) return;
|
||||
final next = widget.repository.load();
|
||||
setState(() => _snapshot = next);
|
||||
await next;
|
||||
@@ -176,7 +199,8 @@ class _HomeScreenState extends State<HomeScreen> {
|
||||
body: FutureBuilder<HomeSnapshot>(
|
||||
future: _snapshot,
|
||||
builder: (context, snap) {
|
||||
if (snap.connectionState != ConnectionState.done) {
|
||||
// 다시 읽는 동안은 앞 데이터를 그대로 둔다 — 화면이 스피너로 깜빡이지 않게.
|
||||
if (snap.connectionState != ConnectionState.done && !snap.hasData) {
|
||||
return const Center(child: CircularProgressIndicator());
|
||||
}
|
||||
if (snap.hasError) {
|
||||
|
||||
@@ -332,12 +332,17 @@ class QuestCard {
|
||||
this.adMultiplier = 2,
|
||||
this.ugcAdPop = 10,
|
||||
this.endsAt,
|
||||
this.publishState = '',
|
||||
});
|
||||
|
||||
final String id;
|
||||
final String title;
|
||||
final String categoryName;
|
||||
|
||||
/// 내가 만든 Quest 의 공개 상태(`quests.status`) — `review` · `published` · `rejected` 등.
|
||||
/// 「내 퀘스트」 카드가 검수 중 · 반려를 공개된 것과 구분해 보인다(10/6 실기기 4-2).
|
||||
final String publishState;
|
||||
|
||||
/// 검증 경로를 고르는 유형. 표본 데이터는 지정하지 않아 [QuestType.unknown] 이다.
|
||||
final QuestType type;
|
||||
final QuestAuthor author;
|
||||
@@ -393,6 +398,7 @@ class QuestCard {
|
||||
adMultiplier: adMultiplier,
|
||||
ugcAdPop: ugcAdPop,
|
||||
endsAt: endsAt,
|
||||
publishState: publishState,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -178,7 +178,7 @@ class FirestoreQuestRepository implements QuestRepository {
|
||||
|
||||
return QuestCard(
|
||||
id: id,
|
||||
title: _title(quest),
|
||||
title: questTitle(quest),
|
||||
categoryName: type.label,
|
||||
type: type,
|
||||
author: authorUid.isEmpty ? QuestAuthor.admin : QuestAuthor.user,
|
||||
@@ -203,22 +203,11 @@ class FirestoreQuestRepository implements QuestRepository {
|
||||
ugcAdPop: policy.intOr('adRewardPop', 10),
|
||||
// 열린 TIME ATTACK 회차의 마감. 있으면 카드 · 상세가 남은 시간을 세고 참여는 회차로 간다.
|
||||
endsAt: openTimeAttack(quest)?.endsAt,
|
||||
publishState: authorUid == uid ? quest.strOr('status') : '',
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
/// 다국어 제목 — 설계서 §2.8 은 `title`(다국어)이라고만 적었다.
|
||||
/// 문자열과 지도(map) 둘 다 들어올 수 있어 둘 다 받는다.
|
||||
String _title(Map<String, dynamic> quest) {
|
||||
final raw = quest['title'];
|
||||
if (raw is String) return raw;
|
||||
if (raw is Map) {
|
||||
final map = Map<String, dynamic>.from(raw);
|
||||
return map.strOr('ko', map.strOr('en'));
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
Future<Map<String, dynamic>> _policy() async {
|
||||
final snap = await _backend.db.doc('config/policy').get();
|
||||
return snap.data() ?? const <String, dynamic>{};
|
||||
@@ -635,7 +624,7 @@ class FirestoreQuestRepository implements QuestRepository {
|
||||
}
|
||||
return MusicShakeSetup(
|
||||
videoId: videoId,
|
||||
title: link.strOr('title', _title(quest)),
|
||||
title: link.strOr('title', questTitle(quest)),
|
||||
bpm: bpm.toDouble(),
|
||||
segments: segments,
|
||||
windows: ShakeWindows.from(await _policy()),
|
||||
@@ -792,7 +781,14 @@ List<int> _stepTiers(Map<String, dynamic> policy) {
|
||||
/// 열린 TIME ATTACK 회차 — 원 Quest 문서의 `timeAttack`(10/3 통합회신: 별도 유형이 아니라
|
||||
/// 기존 Quest 에 붙는 제한시간 모드). 창(`activeFrom` ~ `activeTo`, 서버 시각) 밖이면 null.
|
||||
/// 화면 표시용이다 — 참여 수락은 서버(`fn-quest-timeattack`)가 서버 시각으로 다시 판정한다.
|
||||
({String roundId, DateTime endsAt, int rewardPop})? openTimeAttack(
|
||||
/// 카드 · 상세에 보일 Quest 제목. 열린 TIME ATTACK 회차면 회차 제목(`timeAttack.title`)이 먼저다.
|
||||
/// 운영 Quest 제목은 언어별 지도라 [SnapshotReader.textOr] 로 읽는다(설계서 §2.8 `title` 다국어).
|
||||
String questTitle(Map<String, dynamic> quest) {
|
||||
final round = openTimeAttack(quest)?.title ?? '';
|
||||
return round.isNotEmpty ? round : quest.textOr('title');
|
||||
}
|
||||
|
||||
({String roundId, DateTime endsAt, int rewardPop, String title})? openTimeAttack(
|
||||
Map<String, dynamic> quest,
|
||||
) {
|
||||
final raw = quest['timeAttack'];
|
||||
@@ -807,5 +803,6 @@ List<int> _stepTiers(Map<String, dynamic> policy) {
|
||||
roundId: round.strOr('roundId'),
|
||||
endsAt: to.toDate(),
|
||||
rewardPop: round.intOr('rewardPop'),
|
||||
title: round.textOr('title'),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -232,7 +232,14 @@ class QuestCardTile extends StatelessWidget {
|
||||
height: 1,
|
||||
),
|
||||
),
|
||||
child: Text(card.stage.buttonLabel),
|
||||
// 내가 만든 Quest 가 검수 중 · 반려면 「자세히」 자리에 상태를 적는다(누르면 상세).
|
||||
child: Text(switch (card) {
|
||||
QuestCard(stage: QuestStage.mine, publishState: 'review') =>
|
||||
context.l10n.questReviewButton,
|
||||
QuestCard(stage: QuestStage.mine, publishState: 'rejected') =>
|
||||
context.l10n.questRejectedButton,
|
||||
_ => card.stage.buttonLabel,
|
||||
}),
|
||||
),
|
||||
],
|
||||
],
|
||||
|
||||
Reference in New Issue
Block a user