From 83d9c1e8d4975bb198d11a692bb12afe2341117a Mon Sep 17 00:00:00 2001 From: Sn-Kinos Date: Tue, 6 Oct 2026 01:22:28 +0900 Subject: [PATCH] =?UTF-8?q?=ED=80=98=EC=8A=A4=ED=8A=B8=20=EC=A0=9C?= =?UTF-8?q?=EB=AA=A9=20=EB=8B=A4=EA=B5=AD=EC=96=B4=20=C2=B7=20HOME=20?= =?UTF-8?q?=EB=8B=A4=EC=8B=9C=20=EC=9D=BD=EA=B8=B0=20=C2=B7=20=EB=82=B4=20?= =?UTF-8?q?=ED=80=98=EC=8A=A4=ED=8A=B8=20=EA=B2=80=ED=86=A0=20=EC=A4=91?= =?UTF-8?q?=C2=B7=EB=B0=98=EB=A0=A4=EB=90=A8=20=EA=B5=AC=EB=B6=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/lib/app.dart | 3 ++ app/lib/core/data/backend.dart | 17 +++++++++ app/lib/core/router/app_router.dart | 16 +++++--- .../home/home_repository_firestore.dart | 4 +- app/lib/features/home/home_screen.dart | 28 +++++++++++++- app/lib/features/quest/quest_data.dart | 6 +++ .../quest/quest_repository_firestore.dart | 27 ++++++-------- .../quest/widgets/quest_card_tile.dart | 9 ++++- app/test/quest_card_publish_state_test.dart | 37 +++++++++++++++++++ app/test/quest_today_test.dart | 18 ++++++++- 10 files changed, 139 insertions(+), 26 deletions(-) create mode 100644 app/test/quest_card_publish_state_test.dart diff --git a/app/lib/app.dart b/app/lib/app.dart index 1419f68..63a107a 100644 --- a/app/lib/app.dart +++ b/app/lib/app.dart @@ -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 with WidgetsBindingObserver { /// 포그라운드 수신 — 시스템 알림이 뜨지 않으므로 앱이 직접 보여 준다. /// 탭하면 알림을 눌렀을 때와 같은 곳으로 간다. void _showInApp(PushNotice notice) { + // 타임어택 발동 · 퀘스트 완료 등 — HOME 카드가 바로 따라오게 다시 읽힌다. + homeRefreshSignal.value++; if (!notice.hasText) return; _messengerKey.currentState ?..hideCurrentSnackBar() diff --git a/app/lib/core/data/backend.dart b/app/lib/core/data/backend.dart index a53c829..f6a9006 100644 --- a/app/lib/core/data/backend.dart +++ b/app/lib/core/data/backend.dart @@ -169,6 +169,23 @@ extension SnapshotReader on Map { 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.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().firstWhere( + (v) => v.isNotEmpty, + orElse: () => fallback, + ); + } + bool boolOr(String key, [bool fallback = false]) { final value = this[key]; return value is bool ? value : fallback; diff --git a/app/lib/core/router/app_router.dart b/app/lib/core/router/app_router.dart index e785f23..e34c291 100644 --- a/app/lib/core/router/app_router.dart +++ b/app/lib/core/router/app_router.dart @@ -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( diff --git a/app/lib/features/home/home_repository_firestore.dart b/app/lib/features/home/home_repository_firestore.dart index e328d1a..5ee255a 100644 --- a/app/lib/features/home/home_repository_firestore.dart +++ b/app/lib/features/home/home_repository_firestore.dart @@ -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 ?? diff --git a/app/lib/features/home/home_screen.dart b/app/lib/features/home/home_screen.dart index bf35775..66887a8 100644 --- a/app/lib/features/home/home_screen.dart +++ b/app/lib/features/home/home_screen.dart @@ -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 homeRefreshSignal = ValueNotifier(0); + +/// 페이지(시트 · 대화상자 제외)가 닫히면 [homeRefreshSignal] 을 울린다. 루트 · HOME 탭 내비게이터에 하나씩. +class HomeRefreshObserver extends NavigatorObserver { + @override + void didPop(Route route, Route? previousRoute) { + if (route is PageRoute) homeRefreshSignal.value++; + } +} + class HomeScreen extends StatefulWidget { const HomeScreen({ super.key, @@ -97,7 +110,7 @@ class HomeScreen extends StatefulWidget { State createState() => _HomeScreenState(); } -class _HomeScreenState extends State { +class _HomeScreenState extends State with WidgetsBindingObserver { late Future _snapshot; Future? _galaxy; ZapController? _zap; @@ -113,13 +126,22 @@ class _HomeScreenState extends State { @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 { } Future _refresh() async { + if (!mounted) return; final next = widget.repository.load(); setState(() => _snapshot = next); await next; @@ -176,7 +199,8 @@ class _HomeScreenState extends State { body: FutureBuilder( 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) { diff --git a/app/lib/features/quest/quest_data.dart b/app/lib/features/quest/quest_data.dart index 00430db..28b4072 100644 --- a/app/lib/features/quest/quest_data.dart +++ b/app/lib/features/quest/quest_data.dart @@ -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, ); } diff --git a/app/lib/features/quest/quest_repository_firestore.dart b/app/lib/features/quest/quest_repository_firestore.dart index 088d88b..dfa99d7 100644 --- a/app/lib/features/quest/quest_repository_firestore.dart +++ b/app/lib/features/quest/quest_repository_firestore.dart @@ -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 quest) { - final raw = quest['title']; - if (raw is String) return raw; - if (raw is Map) { - final map = Map.from(raw); - return map.strOr('ko', map.strOr('en')); - } - return ''; - } - Future> _policy() async { final snap = await _backend.db.doc('config/policy').get(); return snap.data() ?? const {}; @@ -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 _stepTiers(Map 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 quest) { + final round = openTimeAttack(quest)?.title ?? ''; + return round.isNotEmpty ? round : quest.textOr('title'); +} + +({String roundId, DateTime endsAt, int rewardPop, String title})? openTimeAttack( Map quest, ) { final raw = quest['timeAttack']; @@ -807,5 +803,6 @@ List _stepTiers(Map policy) { roundId: round.strOr('roundId'), endsAt: to.toDate(), rewardPop: round.intOr('rewardPop'), + title: round.textOr('title'), ); } diff --git a/app/lib/features/quest/widgets/quest_card_tile.dart b/app/lib/features/quest/widgets/quest_card_tile.dart index 6a30268..a2d5bc7 100644 --- a/app/lib/features/quest/widgets/quest_card_tile.dart +++ b/app/lib/features/quest/widgets/quest_card_tile.dart @@ -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, + }), ), ], ], diff --git a/app/test/quest_card_publish_state_test.dart b/app/test/quest_card_publish_state_test.dart new file mode 100644 index 0000000..74e6ca2 --- /dev/null +++ b/app/test/quest_card_publish_state_test.dart @@ -0,0 +1,37 @@ +import 'package:fancake/features/quest/quest_data.dart'; +import 'package:fancake/features/quest/widgets/quest_card_tile.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; + +/// 10/6 실기기 4-2 — 「내 퀘스트」 카드가 검수 중 · 반려를 공개된 것과 구분한다. +void main() { + QuestCard mine(String state) => QuestCard( + id: 'q', + title: '걷기', + categoryName: '건강', + author: QuestAuthor.user, + stage: QuestStage.mine, + goal: 1000, + progress: 0, + rewardPop: 10, + participants: 0, + publishState: state, + ); + + Future pump(WidgetTester t, QuestCard card) => t.pumpWidget( + MaterialApp( + home: Scaffold( + body: QuestCardTile(card: card, onOpen: () {}, onAction: () {}), + ), + ), + ); + + testWidgets('검수 중 · 반려 · 공개', (t) async { + await pump(t, mine('review')); + expect(find.text('검토 중'), findsOneWidget); + await pump(t, mine('rejected')); + expect(find.text('반려됨'), findsOneWidget); + await pump(t, mine('published')); + expect(find.text(QuestStage.mine.buttonLabel), findsOneWidget); + }); +} diff --git a/app/test/quest_today_test.dart b/app/test/quest_today_test.dart index d951fb8..d918646 100644 --- a/app/test/quest_today_test.dart +++ b/app/test/quest_today_test.dart @@ -1,5 +1,6 @@ import 'package:fancake/features/quest/quest_data.dart'; -import 'package:fancake/features/quest/quest_repository_firestore.dart' show questToday; +import 'package:cloud_firestore/cloud_firestore.dart' show Timestamp; +import 'package:fancake/features/quest/quest_repository_firestore.dart' show questTitle, questToday; import 'package:fancake/features/quest/widgets/quest_card_tile.dart' show questImage; import 'package:flutter_test/flutter_test.dart'; @@ -50,4 +51,19 @@ void main() { ); expect(questImage(card), 'assets/figma/quest-img-bolt.png'); }); + + // 10/6 실기기 — 운영 Quest 제목은 언어별 지도라 HOME 카드 제목이 비었다. 타임어택은 회차 제목이 먼저다. + test('Quest 제목 — 문자열 · 언어별 지도(앱 언어 → ko) · 열린 타임어택 회차 제목', () { + expect(questTitle({'title': '만보기'}), '만보기'); + expect(questTitle({'title': {'en': 'Stream', 'ko': '스트리밍'}}), '스트리밍'); + expect(questTitle({'title': {'en': 'Stream'}}), 'Stream'); + final round = { + 'roundId': 'r1', + 'title': '긴급 돌발', + 'activeFrom': Timestamp.fromDate(DateTime.now().subtract(const Duration(minutes: 5))), + 'activeTo': Timestamp.fromDate(DateTime.now().add(const Duration(minutes: 5))), + }; + expect(questTitle({'title': {'ko': '스트리밍'}, 'timeAttack': round}), '긴급 돌발'); + expect(questTitle({'title': {'ko': '스트리밍'}, 'timeAttack': {...round, 'title': ''}}), '스트리밍'); + }); }