diff --git a/app/firebase/firestore.rules b/app/firebase/firestore.rules index 376ef6d..089673d 100644 --- a/app/firebase/firestore.rules +++ b/app/firebase/firestore.rules @@ -328,6 +328,13 @@ service cloud.firestore { allow write: if false; } + // 검증 활동 표식(`fpi.ts` markCompletion). MY 「완료 퀘스트」가 본인 표식 수를 센다(count 집계). + // 표식은 Campaign·Quest 개설자격의 근거라 서버만 쓴다. 화면: S-M-HOME + match /users/{uid}/verifiedCompletions/{completionId} { + allow read: if isSelf(uid) || isViewer(); + allow write: if false; + } + // 약관 동의 이력. 개인정보 처리 근거의 입증 자료라 append-only 이고 클라이언트가 쓰지 못한다. // 본인은 자기 동의 이력을 볼 수 있어야 한다(열람권). 화면: S-A-TERMS · S-M-SETTINGS match /users/{uid}/consents/{consentId} { diff --git a/app/firebase/test/tests/firestore.rules.test.mjs b/app/firebase/test/tests/firestore.rules.test.mjs index 7ba0eb2..00b2d83 100644 --- a/app/firebase/test/tests/firestore.rules.test.mjs +++ b/app/firebase/test/tests/firestore.rules.test.mjs @@ -7,6 +7,7 @@ import { collectionGroup, deleteDoc, doc, + getCountFromServer, getDoc, getDocs, orderBy, @@ -267,6 +268,20 @@ describe('가입 목록 — users/{uid}/memberships (C-15)', () => { }); }); +describe('검증 활동 표식 — users/{uid}/verifiedCompletions (MY 완료 퀘스트)', () => { + beforeEach(async () => { + await seedDocuments(testEnvironment, { + 'users/alice/verifiedCompletions/q1_20261005': { questId: 'q1' }, + }); + }); + + test('본인만 세고 누구도 직접 쓰지 못한다', async () => { + await assertSucceeds(getCountFromServer(collection(dbFor('alice'), 'users/alice/verifiedCompletions'))); + await assertFails(getDocs(collection(dbFor('bob'), 'users/alice/verifiedCompletions'))); + await assertFails(setDoc(doc(dbFor('alice'), 'users/alice/verifiedCompletions/q2_20261005'), { questId: 'q2' })); + }); +}); + describe('G-02 — 약관 동의 이력 무결성 회귀', () => { beforeEach(async () => { await seedDocuments(testEnvironment, { diff --git a/app/lib/core/router/app_router.dart b/app/lib/core/router/app_router.dart index 78f3c69..80cd54b 100644 --- a/app/lib/core/router/app_router.dart +++ b/app/lib/core/router/app_router.dart @@ -180,6 +180,8 @@ GoRouter buildRouter(AuthRepository auth, {Repositories? repositories}) { auth: auth, campaigns: repos.campaign, insides: repos.inside, + translation: repos.translation, + galaxy: repos.galaxy, ), ), ], diff --git a/app/lib/features/my/my_data.dart b/app/lib/features/my/my_data.dart index 417f611..999b439 100644 --- a/app/lib/features/my/my_data.dart +++ b/app/lib/features/my/my_data.dart @@ -71,120 +71,115 @@ enum PopTxType { /// Functions 의 `ReasonCode`(`firebase/functions/src/ledger/ledger.ts`)와 짝이며, /// 모르는 코드는 원문을 그대로 내보이지 않고 중립 문구로 접는다 — 사용자 화면에 /// `QUEST_MUSIC_SHAKE` 같은 내부 식별자가 새면 고장으로 읽힌다. -({String title, String type}) popReasonLabel(String reasonCode) => - switch (reasonCode) { - 'QUEST_STEPS' => ( - title: appL10n.popReasonQuestSteps, - type: appL10n.popTypeQuest, - ), - 'QUEST_MUSIC_SHAKE' => ( - title: appL10n.popReasonQuestMusicShake, - type: appL10n.popTypeQuest, - ), - 'QUEST_PLACE' => ( - title: appL10n.popReasonQuestPlace, - type: appL10n.popTypeQuest, - ), - 'QUEST_ATTEND' => ( - title: appL10n.popReasonQuestAttend, - type: appL10n.popTypeQuest, - ), - 'QUEST_UGC' => ( - title: appL10n.popReasonQuestUgc, - type: appL10n.popTypeQuest, - ), - 'QUEST_FEE' => ( - title: appL10n.popReasonQuestFee, - type: appL10n.popTypeFee, - ), - 'QUEST_POOL_DEPOSIT' => ( - title: appL10n.popReasonQuestPoolDeposit, - type: appL10n.popTypeQuest, - ), - // 정상 종료 반환(C-09)과 공개 전 실패 환불은 사용자에게 같은 「반환」이다. - 'QUEST_POOL_RETURN' || 'QUEST_POOL_REFUND' => ( - title: appL10n.popReasonQuestPoolReturn, - type: appL10n.popTxRefund, - ), - 'QUEST_TIMEATTACK' => ( - title: appL10n.popReasonQuestTimeAttack, - type: appL10n.popTypeQuest, - ), - 'AD_REWARD' => ( - title: appL10n.popReasonAdReward, - type: appL10n.popTypeReward, - ), - 'HERO_DROP' => ( - title: appL10n.popReasonHeroDrop, - type: appL10n.popTypeReward, - ), - 'HERO_DROP_TRIGGER' => ( - title: appL10n.popReasonHeroDropTrigger, - type: appL10n.commonContribute, - ), - 'TRANSLATION' => ( - title: appL10n.popReasonTranslation, - type: appL10n.popTypeReward, - ), - 'IAP_PURCHASE' => ( - title: appL10n.popReasonIapPurchase, - type: appL10n.popTypePayment, - ), - 'FOCUS_DAILY' => ( - title: appL10n.popReasonFocusDaily, - type: appL10n.popTypeSubscription, - ), - 'PREREG_BONUS' => ( - title: appL10n.popReasonPreregBonus, - type: appL10n.popTypeReward, - ), - 'REFERRAL_REWARD' => ( - // 사본 POP 내역 `554:23742` 「친구 초대 · 레퍼럴」. - title: appL10n.popReasonReferral, - type: appL10n.popTypeReferral, - ), - 'ADMIN_GRANT' => ( - title: appL10n.popReasonAdminGrant, - type: appL10n.popTypeAdmin, - ), - 'ADMIN_CLAWBACK' => ( - title: appL10n.popReasonAdminClawback, - type: appL10n.popTypeAdmin, - ), - 'CAMPAIGN_FUND' => ( - title: appL10n.popReasonCampaignFund, - type: appL10n.commonContribute, - ), - 'CAMPAIGN_REFUND' => ( - title: appL10n.popReasonCampaignRefund, - type: appL10n.popTxRefund, - ), - 'CAMPAIGN_FEE_REFUND' => ( - title: appL10n.popReasonCampaignFeeRefund, - type: appL10n.popTxRefund, - ), - 'CAMPAIGN_FEE' => ( - title: appL10n.popReasonCampaignFee, - type: appL10n.popTypeFee, - ), - 'QNA_BOUNTY' => ( - title: appL10n.popReasonQnaBounty, - type: appL10n.commonContribute, - ), - 'QNA_REFUND' => ( - title: appL10n.popReasonQnaRefund, - type: appL10n.popTxRefund, - ), - 'QNA_REWARD' => ( - title: appL10n.popReasonQnaReward, - type: appL10n.popTypeReward, - ), - 'ECLIPSE_DEFENSE' => ( - title: appL10n.popReasonEclipseDefense, - type: appL10n.commonContribute, - ), - _ => (title: appL10n.popReasonOther, type: appL10n.commonEtc), - }; +({String title, String type}) popReasonLabel( + String reasonCode, +) => switch (reasonCode) { + 'QUEST_STEPS' => ( + title: appL10n.popReasonQuestSteps, + type: appL10n.popTypeQuest, + ), + 'QUEST_MUSIC_SHAKE' => ( + title: appL10n.popReasonQuestMusicShake, + type: appL10n.popTypeQuest, + ), + 'QUEST_PLACE' => ( + title: appL10n.popReasonQuestPlace, + type: appL10n.popTypeQuest, + ), + 'QUEST_ATTEND' => ( + title: appL10n.popReasonQuestAttend, + type: appL10n.popTypeQuest, + ), + 'QUEST_UGC' => (title: appL10n.popReasonQuestUgc, type: appL10n.popTypeQuest), + 'QUEST_FEE' => (title: appL10n.popReasonQuestFee, type: appL10n.popTypeFee), + 'QUEST_POOL_DEPOSIT' => ( + title: appL10n.popReasonQuestPoolDeposit, + type: appL10n.popTypeQuest, + ), + // 정상 종료 반환(C-09)과 공개 전 실패 환불은 사용자에게 같은 「반환」이다. + 'QUEST_POOL_RETURN' || 'QUEST_POOL_REFUND' => ( + title: appL10n.popReasonQuestPoolReturn, + type: appL10n.popTxRefund, + ), + 'QUEST_TIMEATTACK' => ( + title: appL10n.popReasonQuestTimeAttack, + type: appL10n.popTypeQuest, + ), + 'AD_REWARD' => ( + title: appL10n.popReasonAdReward, + type: appL10n.popTypeReward, + ), + 'HERO_DROP' => ( + title: appL10n.popReasonHeroDrop, + type: appL10n.popTypeReward, + ), + 'HERO_DROP_TRIGGER' => ( + title: appL10n.popReasonHeroDropTrigger, + type: appL10n.commonContribute, + ), + 'TRANSLATION' => ( + title: appL10n.popReasonTranslation, + type: appL10n.popTypeReward, + ), + 'IAP_PURCHASE' => ( + title: appL10n.popReasonIapPurchase, + type: appL10n.popTypePayment, + ), + 'FOCUS_DAILY' => ( + title: appL10n.popReasonFocusDaily, + type: appL10n.popTypeSubscription, + ), + 'PREREG_BONUS' => ( + title: appL10n.popReasonPreregBonus, + type: appL10n.popTypeReward, + ), + 'REFERRAL_REWARD' => ( + // 사본 POP 내역 `554:23742` 「친구 초대 · 레퍼럴」. + title: appL10n.popReasonReferral, + type: appL10n.popTypeReferral, + ), + 'ADMIN_GRANT' => ( + title: appL10n.popReasonAdminGrant, + type: appL10n.popTypeAdmin, + ), + 'ADMIN_CLAWBACK' => ( + title: appL10n.popReasonAdminClawback, + type: appL10n.popTypeAdmin, + ), + 'CAMPAIGN_FUND' => ( + title: appL10n.popReasonCampaignFund, + type: appL10n.commonContribute, + ), + 'CAMPAIGN_REFUND' => ( + title: appL10n.popReasonCampaignRefund, + type: appL10n.popTxRefund, + ), + 'CAMPAIGN_FEE_REFUND' => ( + title: appL10n.popReasonCampaignFeeRefund, + type: appL10n.popTxRefund, + ), + 'CAMPAIGN_FEE' => ( + title: appL10n.popReasonCampaignFee, + type: appL10n.popTypeFee, + ), + 'QNA_BOUNTY' => ( + title: appL10n.popReasonQnaBounty, + type: appL10n.commonContribute, + ), + 'QNA_REFUND' => ( + title: appL10n.popReasonQnaRefund, + type: appL10n.popTxRefund, + ), + 'QNA_REWARD' => ( + title: appL10n.popReasonQnaReward, + type: appL10n.popTypeReward, + ), + 'ECLIPSE_DEFENSE' => ( + title: appL10n.popReasonEclipseDefense, + type: appL10n.commonContribute, + ), + _ => (title: appL10n.popReasonOther, type: appL10n.commonEtc), +}; /// 레벨 칭호 — 정책 원장 **C-18 · B-66**. /// @@ -586,7 +581,11 @@ const int nicknameMaxLength = 20; /// 운영 사칭으로 읽힐 수 있는 이름 — 대소문자 무시 부분 일치로 막는다. /// `firestore.rules` 의 `nicknameOk` 가 같은 목록을 본다. const List reservedNicknameWords = [ - 'fanuzu', '운영팀', '관리자', 'admin', 'official', + 'fanuzu', + '운영팀', + '관리자', + 'admin', + 'official', ]; /// 닉네임 없는 작성자 표기([storedFanName] 「팬」)와 그 번역 — 정확히 일치하면 막는다. @@ -603,7 +602,8 @@ String normalizeNickname(String nickname) => nickname bool nicknameReserved(String nickname) { final key = normalizeNickname(nickname); - return reservedFanNames.contains(key) || reservedNicknameWords.any(key.contains); + return reservedFanNames.contains(key) || + reservedNicknameWords.any(key.contains); } /// 차단한 계정 한 건 — 설계서 `users/{uid}/blocks/{targetUid}`. diff --git a/app/lib/features/my/my_lists_screen.dart b/app/lib/features/my/my_lists_screen.dart index 9118676..5740d47 100644 --- a/app/lib/features/my/my_lists_screen.dart +++ b/app/lib/features/my/my_lists_screen.dart @@ -5,6 +5,7 @@ import '../campaign/campaign_detail_screen.dart'; import '../campaign/widgets/campaign_list_card.dart'; import '../inside/inside_data.dart'; import '../inside/inside_detail_screen.dart'; +import '../translation/translation_data.dart' show TranslationRepository; import '../inside/inside_screen.dart' show InsideQuestionCard; import 'widgets/my_cards.dart'; import '../../core/i18n/app_locale.dart'; @@ -38,6 +39,7 @@ class MyListsScreen extends StatefulWidget { required this.kind, this.campaigns, this.insides, + this.translation, }); final MyListKind kind; @@ -45,6 +47,9 @@ class MyListsScreen extends StatefulWidget { /// 내 인사이드의 원천. null 이면 표본(목 모드). 저장소 선택은 repositories.dart. final InsideRepository? insides; + /// 내 질문 상세의 번역 원천. null 이면 번역 기능을 끈다. + final TranslationRepository? translation; + /// 내 캠페인의 원천. null 이면 표본(목 모드). 저장소 선택은 repositories.dart. final CampaignRepository? campaigns; @@ -119,8 +124,9 @@ class _MyListsScreenState extends State { Widget _insideItems(BuildContext context, List mine) { final items = switch (_sort) { - _MySort.pop => [...mine] - ..sort((a, b) => b.bountyPop.compareTo(a.bountyPop)), + _MySort.pop => [ + ...mine, + ]..sort((a, b) => b.bountyPop.compareTo(a.bountyPop)), _MySort.solved => mine.where((q) => q.isSolved).toList(), _ => mine, }; @@ -144,7 +150,11 @@ class _MyListsScreenState extends State { question: items[i], onTap: () => Navigator.of(context, rootNavigator: true).push( MaterialPageRoute( - builder: (_) => InsideDetailScreen(question: items[i]), + builder: (_) => InsideDetailScreen( + question: items[i], + repository: widget.insides, + translation: widget.translation, + ), ), ), ), diff --git a/app/lib/features/my/my_repository_firestore.dart b/app/lib/features/my/my_repository_firestore.dart index d0dfe12..2d9d183 100644 --- a/app/lib/features/my/my_repository_firestore.dart +++ b/app/lib/features/my/my_repository_firestore.dart @@ -1,3 +1,5 @@ +import 'dart:math' as math; + import 'package:cloud_firestore/cloud_firestore.dart'; import 'package:firebase_storage/firebase_storage.dart'; @@ -14,7 +16,8 @@ import '../../core/i18n/app_locale.dart'; /// 쓰면 어느 것이 진짜인지 화면에서 알 수 없다. /// /// 읽는 곳 — **서버가 실제로 쓰는 필드**를 읽는다. -/// - 프로필 `users/{uid}` (nickname · photoUrl · originNo · steps · completedQuests) +/// - 프로필 `users/{uid}` (nickname · photoUrl · originNo · steps) +/// - 완료 퀘스트 `users/{uid}/verifiedCompletions` 표식 수(무효 제외) /// - 누적 기여 POP `users/{uid}.totalContributedPop`(`fn-pop-spend` 가 갱신, 설계서 §3.6) /// - 레벨 `users/{uid}.{userExp, userLevel}` · Passport `users/{uid}.passportTier` /// (C-18 · B-65). 필드가 없는 기존 사용자는 Lv.1 · SILVER 로 둔다. @@ -46,6 +49,18 @@ class FirestoreMyRepository extends SampleMyRepository { final userF = db.doc('users/$uid').get(); final walletF = db.doc('users/$uid/wallet/main').get(); + // 완료 퀘스트 = 검증 활동 표식 수 − 무효 표식 수. 서버 `verifiedQuestCountOf`(fpi.ts)와 같은 식. + // `users.completedQuests` 는 쓰는 서버 코드가 없어 늘 0 이었다. + final done = db.collection('users/$uid/verifiedCompletions'); + // 규칙 배포 전 서버에서는 이 읽기가 거부된다 — MY 전체를 떨어뜨리지 않고 0 으로 둔다. + final completedF = + Future.wait([ + done.count().get(), + done.where('voided', isEqualTo: true).count().get(), + ]).then( + (c) => math.max(0, (c[0].count ?? 0) - (c[1].count ?? 0)), + onError: (Object _) => 0, + ); final user = (await userF).data() ?? const {}; final wallet = (await walletF).data() ?? const {}; @@ -83,7 +98,7 @@ class FirestoreMyRepository extends SampleMyRepository { passportTier: PassportTier.parse(user.strOr('passportTier')), balancePop: wallet.intOr('activityBalance'), steps: user.intOr('steps'), - completedQuests: user.intOr('completedQuests'), + completedQuests: await completedF, planetTiers: tiers, photoUrl: photoUrl.isEmpty ? null : photoUrl, originNo: user.containsKey('originNo') ? user.intOr('originNo') : null, @@ -127,10 +142,9 @@ class FirestoreMyRepository extends SampleMyRepository { @override Future updateNickname(String nickname) async { final uid = _backend.requireUid; - await _backend.db.doc('users/$uid').set( - {'nickname': nickname}, - SetOptions(merge: true), - ); + await _backend.db.doc('users/$uid').set({ + 'nickname': nickname, + }, SetOptions(merge: true)); } /// POP 원장 — S-M-WALLET · 명세 `554:23732`. @@ -174,22 +188,24 @@ class FirestoreMyRepository extends SampleMyRepository { final reasonCode = data.strOr('reasonCode'); final label = popReasonLabel(reasonCode); final at = data.dateOr('createdAt', _epoch); - rows.add(_LedgerRow( - reasonCode: reasonCode, - // Zap 한 행이 몇 번분인가. `fn-eclipse-defend` 가 `count` 를 묶어 받으므로 - // 행 하나가 여러 번일 수 있다. 없는 문서는 한 번으로 본다. - count: data.intOr('count', 1).clamp(1, 1 << 30), - entry: PopLedgerEntry( - at: at, - title: label.title, - type: label.type, - kind: PopKind.parse(data.strOr('popType')), - txType: PopTxType.parse(data.strOr('type', 'grant')), - delta: data.intOr('amount'), - balanceAfter: data.intOr('balanceAfter'), - cursor: PopCursor(at: at, id: doc.id), + rows.add( + _LedgerRow( + reasonCode: reasonCode, + // Zap 한 행이 몇 번분인가. `fn-eclipse-defend` 가 `count` 를 묶어 받으므로 + // 행 하나가 여러 번일 수 있다. 없는 문서는 한 번으로 본다. + count: data.intOr('count', 1).clamp(1, 1 << 30), + entry: PopLedgerEntry( + at: at, + title: label.title, + type: label.type, + kind: PopKind.parse(data.strOr('popType')), + txType: PopTxType.parse(data.strOr('type', 'grant')), + delta: data.intOr('amount'), + balanceAfter: data.intOr('balanceAfter'), + cursor: PopCursor(at: at, id: doc.id), + ), ), - )); + ); } return _foldZap(rows); } @@ -262,7 +278,10 @@ class FirestoreMyRepository extends SampleMyRepository { PaymentEntry _payment(String id, Map data) => PaymentEntry( id: id, at: data.dateOr('purchasedAt', _epoch), - itemName: data.strOr('itemName', data.strOr('productId', appL10n.payItemFallback)), + itemName: data.strOr( + 'itemName', + data.strOr('productId', appL10n.payItemFallback), + ), price: data.intOr('priceAmount'), method: data.strOr('method', appL10n.payMethodInApp), state: paymentStateOf(data.strOr('state')), @@ -416,10 +435,9 @@ class FirestoreMyRepository extends SampleMyRepository { ); } final url = await ref.getDownloadURL(); - await _backend.db.doc('users/$uid').set( - {'photoUrl': url}, - SetOptions(merge: true), - ); + await _backend.db.doc('users/$uid').set({ + 'photoUrl': url, + }, SetOptions(merge: true)); return url; } } diff --git a/app/lib/features/my/my_screen.dart b/app/lib/features/my/my_screen.dart index 1fdeaf0..3bbbf20 100644 --- a/app/lib/features/my/my_screen.dart +++ b/app/lib/features/my/my_screen.dart @@ -9,7 +9,10 @@ import '../../core/ui/app_icons.dart'; import '../auth/auth.dart'; import '../auth/auth_repository.dart'; import '../campaign/campaign_data.dart' show CampaignRepository; +import '../galaxy/galaxy_data.dart'; +import '../galaxy/galaxy_home_screen.dart' show GalaxyHomeScreen; import '../inside/inside_data.dart' show InsideRepository; +import '../translation/translation_data.dart' show TranslationRepository; import 'my_data.dart'; import 'my_lists_screen.dart'; import 'profile_edit_screen.dart'; @@ -31,16 +34,24 @@ class MyScreen extends StatefulWidget { this.auth, this.campaigns, this.insides, + this.translation, + this.galaxy = const SampleGalaxyRepository(), }); final MyRepository repository; + /// 「행성/성운」 칸 — GALAXY 「내 행성」과 같은 가입 목록(`users/{uid}/memberships`)을 읽는다. + final GalaxyRepository galaxy; + /// 「내 캠페인」이 읽는 저장소. null 이면 표본. final CampaignRepository? campaigns; /// 「내 인사이드」가 읽는 저장소. null 이면 표본. final InsideRepository? insides; + /// 내 질문 상세의 번역 원천. + final TranslationRepository? translation; + /// 설정 > SNS 연동으로 그대로 내려간다. null 이면 설정 화면이 표본을 쓴다. final AccountLinker? linker; @@ -58,6 +69,7 @@ class MyScreen extends StatefulWidget { class _MyScreenState extends State { MyProfile? _profile; FocusStatus? _focus; + List _planets = const []; bool _claiming = false; @override @@ -70,10 +82,16 @@ class _MyScreenState extends State { final profile = await widget.repository.profile(); // 구독 조회 실패로 MY 전체가 멈추지 않게 한다 — 카드만 빠진다. final focus = await widget.repository.focus().catchError((_) => null); + // 행성 목록 실패도 칸만 비운다. + final planets = await widget.galaxy + .load() + .then((s) => s.memberships.take(3).toList()) + .catchError((_) => const []); if (mounted) { setState(() { _profile = profile; _focus = focus; + _planets = planets; }); } } @@ -214,11 +232,9 @@ class _MyScreenState extends State { children: [ Row( children: [ - for (final (i, img) in const [ - 'assets/figma/my-planet-1.png', - 'assets/figma/my-planet-2.png', - 'assets/figma/my-planet-3.png', - ].indexed) ...[ + // 시안 세 칸 — 가입하거나 만든 행성/성운(최신 가입순). 그림은 + // GALAXY 「내 행성」과 같은 기준(팬덤 stage)이고, 모자라면 빈 칸이다. + for (var i = 0; i < 3; i++) ...[ if (i > 0) const SizedBox(width: 8), Expanded( child: AspectRatio( @@ -227,12 +243,23 @@ class _MyScreenState extends State { // 칸 109 안에 행성 88 — 안쪽 10.5. padding: const EdgeInsets.all(10.5), decoration: BoxDecoration( - color: const Color(0xFF2C2C2C), + color: i < _planets.length + ? const Color(0xFF2C2C2C) + : const Color(0x802C2C2C), borderRadius: BorderRadius.circular( AppRadius.md, ), ), - child: Image.asset(img, fit: BoxFit.contain), + child: i < _planets.length + ? Image.asset( + GalaxyHomeScreen.cardImageOf( + _planets[i], + i, + ), + fit: BoxFit.contain, + semanticLabel: _planets[i].name, + ) + : null, ), ), ), @@ -259,6 +286,7 @@ class _MyScreenState extends State { MyListsScreen( kind: MyListKind.inside, insides: widget.insides, + translation: widget.translation, ), ), ), @@ -337,7 +365,9 @@ class _MyScreenState extends State { ), ); } catch (_) { - messenger.showSnackBar(SnackBar(content: Text(l10n.commonRequestFailed))); + messenger.showSnackBar( + SnackBar(content: Text(l10n.commonRequestFailed)), + ); } }, ), @@ -464,7 +494,11 @@ class _MyHeader extends StatelessWidget { /// `518:88748` 은 B-04 가 대체한 과거 안). 반영 화면이 `MY` 로 정해져 있어 통계 카드 아래에 둔다. /// 구독 중일 때만 보인다. class _FocusCard extends StatelessWidget { - const _FocusCard({required this.focus, required this.busy, required this.onClaim}); + const _FocusCard({ + required this.focus, + required this.busy, + required this.onClaim, + }); final FocusStatus focus; final bool busy; diff --git a/app/lib/features/my/payment_screens.dart b/app/lib/features/my/payment_screens.dart index bf85e15..3b9e240 100644 --- a/app/lib/features/my/payment_screens.dart +++ b/app/lib/features/my/payment_screens.dart @@ -40,10 +40,12 @@ class _PaymentHistoryScreenState extends State { await Store.instance?.restore(); } on Exception { if (!mounted) return; - ScaffoldMessenger.of(context).showSnackBar(SnackBar( - behavior: SnackBarBehavior.floating, - content: Text(context.l10n.storeConnectFailed), - )); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + behavior: SnackBarBehavior.floating, + content: Text(context.l10n.storeConnectFailed), + ), + ); return; } await _load(); @@ -63,7 +65,10 @@ class _PaymentHistoryScreenState extends State { // 구매 복원 — 재설치 · 기기 변경 뒤 Focus 구독을 되살린다. 스토어가 돌려준 // 구매는 서버가 거래 ID 로 멱등 검증하므로 두 번 지급되지 않는다. if (Store.instance != null) - TextButton(onPressed: _restore, child: Text(context.l10n.payRestore)), + TextButton( + onPressed: _restore, + child: Text(context.l10n.payRestore), + ), ], ), body: Column( @@ -219,7 +224,11 @@ class _PaymentDetailScreenState extends State { ), _Row(label: context.l10n.payMethod, value: p.method), _Row(label: context.l10n.payStatus, value: p.state.label), - _Row(label: context.l10n.payAmount, value: '₩ ${_won(p.price)}', amount: true), + _Row( + label: context.l10n.payAmount, + value: '₩ ${_won(p.price)}', + amount: true, + ), const SizedBox(height: 8), Text(context.l10n.payRefundInfo, style: _guide), const SizedBox(height: 6), @@ -328,51 +337,51 @@ class _RefundRequestScreenState extends State { ), // 디폴트 미선택 · 단수 선택. body: ListView( - children: [ - Container( - height: 32, - color: const Color(0xFF232325), - padding: const EdgeInsets.symmetric(horizontal: 16), - alignment: Alignment.centerLeft, - child: Text( - context.l10n.refundReason, - style: myText( - 12, - FontWeight.w600, - color: const Color(0xFF909091), - height: 1, + children: [ + Container( + height: 32, + color: const Color(0xFF232325), + padding: const EdgeInsets.symmetric(horizontal: 16), + alignment: Alignment.centerLeft, + child: Text( + context.l10n.refundReason, + style: myText( + 12, + FontWeight.w600, + color: const Color(0xFF909091), + height: 1, + ), + ), + ), + for (final reason in RefundRequestScreen.reasons) + GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: () => setState(() => _reason = reason), + child: Container( + height: 48, + margin: const EdgeInsets.symmetric(horizontal: 16), + decoration: const BoxDecoration( + border: Border(bottom: BorderSide(color: Color(0xFF313131))), + ), + child: Row( + children: [ + AppRadio(selected: _reason == reason), + const SizedBox(width: 8), + Text( + reason, + style: myText( + 16, + FontWeight.w600, + color: const Color(0xFFD1D1D2), + height: 1, + ), + ), + ], ), ), ), - for (final reason in RefundRequestScreen.reasons) - GestureDetector( - behavior: HitTestBehavior.opaque, - onTap: () => setState(() => _reason = reason), - child: Container( - height: 48, - margin: const EdgeInsets.symmetric(horizontal: 16), - decoration: const BoxDecoration( - border: Border(bottom: BorderSide(color: Color(0xFF313131))), - ), - child: Row( - children: [ - AppRadio(selected: _reason == reason), - const SizedBox(width: 8), - Text( - reason, - style: myText( - 16, - FontWeight.w600, - color: const Color(0xFFD1D1D2), - height: 1, - ), - ), - ], - ), - ), - ), - ], - ), + ], + ), ); /// 확인 — 시안 `506:14615` iOS 경고창 「정말 환불 신청을 할까요?」. @@ -397,9 +406,9 @@ class _RefundRequestScreenState extends State { // 서버·Admin 에는 기준 언어(ko) 사유를 남긴다 — 화면 언어에 따라 원장 값이 갈리지 않게. await widget.repository.requestRefund( widget.paymentId, - RefundRequestScreen.reasonsOf( - koL10n, - )[RefundRequestScreen.reasons.indexOf(_reason!)], + RefundRequestScreen.reasonsOf(koL10n)[RefundRequestScreen.reasons.indexOf( + _reason!, + )], ); if (mounted) Navigator.of(context).pop(_reason); } diff --git a/app/lib/features/my/pop_history_screen.dart b/app/lib/features/my/pop_history_screen.dart index a221927..85311fd 100644 --- a/app/lib/features/my/pop_history_screen.dart +++ b/app/lib/features/my/pop_history_screen.dart @@ -117,7 +117,10 @@ class _PopHistoryScreenState extends State { child: Padding( padding: const EdgeInsets.all(AppSpace.s6), child: Center( - child: Text(context.l10n.commonNoHistory, style: myText(14, FontWeight.w500)), + child: Text( + context.l10n.commonNoHistory, + style: myText(14, FontWeight.w500), + ), ), ), ); @@ -131,7 +134,10 @@ class _PopHistoryScreenState extends State { padding: EdgeInsets.all(AppSpace.s3), child: CircularProgressIndicator(), ) - : TextButton(onPressed: _more, child: Text(context.l10n.commonMore)), + : TextButton( + onPressed: _more, + child: Text(context.l10n.commonMore), + ), ), ); } diff --git a/app/lib/features/my/profile_edit_screen.dart b/app/lib/features/my/profile_edit_screen.dart index 70ec737..cc73355 100644 --- a/app/lib/features/my/profile_edit_screen.dart +++ b/app/lib/features/my/profile_edit_screen.dart @@ -136,7 +136,10 @@ PreferredSizeWidget myTextActionBar( }) => AppBar( automaticallyImplyLeading: false, leadingWidth: 72, - leading: _BarText(label: context.l10n.commonCancel, onTap: () => Navigator.of(context).pop()), + leading: _BarText( + label: context.l10n.commonCancel, + onTap: () => Navigator.of(context).pop(), + ), title: Text(title), actions: [_BarText(label: context.l10n.commonDone, onTap: onDone)], ); @@ -237,7 +240,10 @@ class _ProfilePhotoScreenState extends State { ), Padding( padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 20), - child: Text(context.l10n.albumItems, style: myText(16, FontWeight.w600, height: 1)), + child: Text( + context.l10n.albumItems, + style: myText(16, FontWeight.w600, height: 1), + ), ), // 시안 4×3 격자 — 카메라 · 앨범 선택기 다음 열 칸은 #000 빈 칸(사진 목록은 기기 선택기가 그린다). Wrap( diff --git a/app/lib/features/my/support_screens.dart b/app/lib/features/my/support_screens.dart index 1b022cc..5e1737d 100644 --- a/app/lib/features/my/support_screens.dart +++ b/app/lib/features/my/support_screens.dart @@ -51,7 +51,10 @@ class _InquiryScreenState extends State { appBar: AppBar(title: Text(context.l10n.inquiryListTitle)), body: items.isEmpty ? Center( - child: Text(context.l10n.inquiryEmpty, style: myText(14, FontWeight.w500)), + child: Text( + context.l10n.inquiryEmpty, + style: myText(14, FontWeight.w500), + ), ) : ListView.separated( padding: const EdgeInsets.all(16), @@ -347,9 +350,7 @@ class _InquiryComposeScreenState extends State { if (!mounted) return; _toast( context, - edit == null - ? context.l10n.inquirySubmitted - : context.l10n.inquiryEdited, + edit == null ? context.l10n.inquirySubmitted : context.l10n.inquiryEdited, ); Navigator.of(context).pop(true); } diff --git a/app/lib/features/planet/planet_detail_screen.dart b/app/lib/features/planet/planet_detail_screen.dart index 618553f..2e34b54 100644 --- a/app/lib/features/planet/planet_detail_screen.dart +++ b/app/lib/features/planet/planet_detail_screen.dart @@ -819,6 +819,30 @@ class _LoungeTabState extends State<_LoungeTab> { return false; }, child: _tabScroll('lounge', [ + // 글이 없을 때 — 시안에 빈 상태가 없어 댓글 빈 상태(`commentFirst`)와 같은 꼴에 + // 글쓰기 안내 문구(`loungeHint`)를 쓴다. + if (posts.isEmpty && !_loading) + SliverToBoxAdapter( + child: Padding( + padding: const EdgeInsets.symmetric(vertical: 48), + child: Column( + children: [ + Image.asset( + 'assets/figma/icon-message-circle.png', + width: 48, + height: 48, + color: const Color(0xFF4F4F4F), + ), + const SizedBox(height: AppSpace.s4), + Text( + context.l10n.loungeHint, + textAlign: TextAlign.center, + style: loungeText(12, AppColors.onCosmicVariant), + ), + ], + ), + ), + ), SliverPadding( padding: const EdgeInsets.all(AppSpace.s4), sliver: SliverList.separated( diff --git a/app/lib/features/quest/quest_detail_screen.dart b/app/lib/features/quest/quest_detail_screen.dart index e3d5e35..165e106 100644 --- a/app/lib/features/quest/quest_detail_screen.dart +++ b/app/lib/features/quest/quest_detail_screen.dart @@ -86,7 +86,8 @@ class _QuestDetailScreenState extends State { }); // 목록에서 「시작하기」를 눌러 진행중으로 들어온 경우다. 여기서 다시 켜지 않으면 // 상세를 열어도 실행률이 0 에 멈춘 채로 남는다. - if (_stage == QuestStage.inProgress && detail.card.type == QuestType.steps) { + if (_stage == QuestStage.inProgress && + detail.card.type == QuestType.steps) { _watch(detail.card); } } @@ -110,20 +111,36 @@ class _QuestDetailScreenState extends State { actions: [ PopupMenuButton( tooltip: context.l10n.commonMore, - icon: const ImageIcon(AppIcons.ellipsisVertical, size: 24, color: AppColors.onCosmic), + icon: const ImageIcon( + AppIcons.ellipsisVertical, + size: 24, + color: AppColors.onCosmic, + ), onSelected: (value) => value == 'edit' ? _edit(detail) : _report(card.title), itemBuilder: (_) => [ // 공개 후 수정은 생성자의 제목 · 설명뿐이다(C-07). if (stage == QuestStage.mine && ugc) - PopupMenuItem(value: 'edit', child: Text(context.l10n.questEdit)), - PopupMenuItem(value: 'report', child: Text(context.l10n.reportTitle)), + PopupMenuItem( + value: 'edit', + child: Text(context.l10n.questEdit), + ), + PopupMenuItem( + value: 'report', + child: Text(context.l10n.reportTitle), + ), ], ), ], ), body: ListView( - padding: const EdgeInsets.fromLTRB(16, 10, 16, 16), + // 하단 바가 없는 상태(내 퀘스트)에서도 시스템 내비게이션 바 아래로 깔리지 않게. + padding: EdgeInsets.fromLTRB( + 16, + 10, + 16, + 16 + MediaQuery.paddingOf(context).bottom, + ), children: [ // 명세 `518:88429` — 대표 이미지 · 카테고리 · 퀘스트 명 · 중앙 정렬. // 시안 `264:48835` — #2e2e2e 240 원 안에 200 이미지. @@ -178,13 +195,16 @@ class _QuestDetailScreenState extends State { // 명세 `518:88435` — 설정 목표 수치 · 실행한 목표 수치 · 설정 보상 POP. _Row( label: context.l10n.questGoal, - value: '${formatPop(_live ?? card.progress)} / ${formatPop(card.goal)}', + value: + '${formatPop(_live ?? card.progress)} / ${formatPop(card.goal)}', ), const SizedBox(height: 8), _Row( label: context.l10n.questReward, bolt: card.rewardPop != null, - value: card.rewardPop == null ? context.l10n.questPopGrant : '+${formatPop(card.rewardPop!)}', + value: card.rewardPop == null + ? context.l10n.questPopGrant + : '+${formatPop(card.rewardPop!)}', ), // 타임어택 — 남은 시간. 마감 판정은 서버 시각이 다시 한다(FR-QST-06). if (endsAt != null) ...[ @@ -193,14 +213,19 @@ class _QuestDetailScreenState extends State { endsAt: endsAt, builder: (context, left) => _Row( label: context.l10n.questTimeLeft, - value: left == null ? context.l10n.questTimeAttackClosed : formatLeft(left), + value: left == null + ? context.l10n.questTimeAttackClosed + : formatLeft(left), ), ), ], const SizedBox(height: 8), // 마감 순간은 서버값 그대로, 표시만 기기 시간대(C-06 · G-04). if (detail.endsAt != null) ...[ - _Row(label: context.l10n.questDeadline, value: _deadline(detail.endsAt!)), + _Row( + label: context.l10n.questDeadline, + value: _deadline(detail.endsAt!), + ), const SizedBox(height: 8), ], // 참여 전 간격 · 최대 횟수 안내(C-12). @@ -210,7 +235,10 @@ class _QuestDetailScreenState extends State { child: Text( detail.repeatHours == null ? context.l10n.questRepeatOnce - : context.l10n.questRepeatEvery('${detail.repeatHours}', '${detail.repeatMax}'), + : context.l10n.questRepeatEvery( + '${detail.repeatHours}', + '${detail.repeatMax}', + ), style: _body, ), ), @@ -218,9 +246,15 @@ class _QuestDetailScreenState extends State { padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 24), child: Row( children: [ - Image.asset('assets/figma/quest-check.png', width: 20, height: 20), + Image.asset( + 'assets/figma/quest-check.png', + width: 20, + height: 20, + ), const SizedBox(width: 8), - Expanded(child: Text(context.l10n.questParticipants, style: _strong)), + Expanded( + child: Text(context.l10n.questParticipants, style: _strong), + ), Text( card.capacity == null ? formatPop(card.participants) @@ -245,8 +279,14 @@ class _QuestDetailScreenState extends State { ? null : endsAt == null // 사용자 생성 Quest 는 서버 시각 기준 마감이 지나면 잠근다(C-06 · G-04). 판정은 서버가 다시 한다. - ? _button(detail, stage, blocked, - closed: detail.endsAt != null && !ServerClock.now().isBefore(detail.endsAt!)) + ? _button( + detail, + stage, + blocked, + closed: + detail.endsAt != null && + !ServerClock.now().isBefore(detail.endsAt!), + ) : TimeAttackCountdown( endsAt: endsAt, builder: (_, left) => @@ -266,50 +306,52 @@ class _QuestDetailScreenState extends State { final open = !closed || stage == QuestStage.rewarded; final submission = detail.submission; // 사람 검토형의 제출 상태(C-08 · G-03) — 검토 중 · 최종 반려는 다시 누를 수 없다. - final held = submission != null && (submission.pending || submission.finalRejected); + final held = + submission != null && (submission.pending || submission.finalRejected); return SafeArea( - child: Padding( - padding: const EdgeInsets.fromLTRB(16, 0, 16, 16), - child: FilledButton( - onPressed: stage.tappable && !_busy && blocked == null && open && !held - ? () => _act(detail, stage) - : null, - style: FilledButton.styleFrom( - minimumSize: const Size.fromHeight(56), - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(AppRadius.sm), - ), - backgroundColor: colors.bg, - foregroundColor: colors.fg, - disabledBackgroundColor: const Color(0xFF333333), - disabledForegroundColor: const Color(0xFF777777), - textStyle: const TextStyle( - fontFamily: AppFonts.body, - fontSize: 16, - fontWeight: FontWeight.w600, - height: 24 / 16, - ), - ), - child: Text( - blocked != null - ? context.l10n.commonComingSoon - : !open - ? context.l10n.questTimeAttackClosed - : _busy - ? context.l10n.commonChecking - : submission != null && submission.pending - ? context.l10n.questReviewButton - : submission != null && submission.rejected - ? (!submission.finalRejected - ? context.l10n.questResubmit - : context.l10n.questRejectedButton) - : detail.card.type.reviewed && stage == QuestStage.notStarted - ? context.l10n.questSubmit - : stage.buttonLabel, - ), - ), - ), - ); + child: Padding( + padding: const EdgeInsets.fromLTRB(16, 0, 16, 16), + child: FilledButton( + onPressed: + stage.tappable && !_busy && blocked == null && open && !held + ? () => _act(detail, stage) + : null, + style: FilledButton.styleFrom( + minimumSize: const Size.fromHeight(56), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(AppRadius.sm), + ), + backgroundColor: colors.bg, + foregroundColor: colors.fg, + disabledBackgroundColor: const Color(0xFF333333), + disabledForegroundColor: const Color(0xFF777777), + textStyle: const TextStyle( + fontFamily: AppFonts.body, + fontSize: 16, + fontWeight: FontWeight.w600, + height: 24 / 16, + ), + ), + child: Text( + blocked != null + ? context.l10n.commonComingSoon + : !open + ? context.l10n.questTimeAttackClosed + : _busy + ? context.l10n.commonChecking + : submission != null && submission.pending + ? context.l10n.questReviewButton + : submission != null && submission.rejected + ? (!submission.finalRejected + ? context.l10n.questResubmit + : context.l10n.questRejectedButton) + : detail.card.type.reviewed && stage == QuestStage.notStarted + ? context.l10n.questSubmit + : stage.buttonLabel, + ), + ), + ), + ); } static const _caption = TextStyle( @@ -331,7 +373,8 @@ class _QuestDetailScreenState extends State { /// 기기 시간대의 마감 시각과 남은 시간. String _deadline(DateTime endsAt) { final local = endsAt.toLocal(); - final at = '${local.year}.${_two(local.month)}.${_two(local.day)} ' + final at = + '${local.year}.${_two(local.month)}.${_two(local.day)} ' '${_two(local.hour)}:${_two(local.minute)}'; final left = endsAt.difference(ServerClock.now()); return left.isNegative @@ -352,16 +395,22 @@ class _QuestDetailScreenState extends State { : context.l10n.questResubmitNotAllowed, ], ]; - return [for (final line in lines) Padding( - padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 4), - child: Text(line, style: _body), - )]; + return [ + for (final line in lines) + Padding( + padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 4), + child: Text(line, style: _body), + ), + ]; } /// 생성자 검토 대기열 — 승인 · 반려. 시간이 지나도 자동 승인하지 않는다(C-08). List _reviewQueue(QuestDetail detail) => [ const SizedBox(height: 16), - Text(context.l10n.questReviewQueue('${detail.reviews.length}'), style: _strong), + Text( + context.l10n.questReviewQueue('${detail.reviews.length}'), + style: _strong, + ), for (final s in detail.reviews) ListTile( contentPadding: const EdgeInsets.symmetric(horizontal: 4), @@ -400,7 +449,9 @@ class _QuestDetailScreenState extends State { children: [ TextField( controller: input, - decoration: InputDecoration(labelText: context.l10n.questRejectReason), + decoration: InputDecoration( + labelText: context.l10n.questRejectReason, + ), ), CheckboxListTile( contentPadding: EdgeInsets.zero, @@ -416,12 +467,14 @@ class _QuestDetailScreenState extends State { input.dispose(); if (!ok || reason.isEmpty) return; } - await _run(() => widget.repository.review( - s.id, - approve: approve, - reason: reason, - resubmitAllowed: resubmit, - )); + await _run( + () => widget.repository.review( + s.id, + approve: approve, + reason: reason, + resubmitAllowed: resubmit, + ), + ); } /// 인증 제출 · 보완 제출(C-08 · G-01) — 링크 또는 내용 중 하나 이상. @@ -433,21 +486,25 @@ class _QuestDetailScreenState extends State { context.l10n.questSubmit, confirmLabel: context.l10n.commonSubmit, content: Column( - mainAxisSize: MainAxisSize.min, - children: [ - TextField( - controller: url, - keyboardType: TextInputType.url, - decoration: InputDecoration(labelText: context.l10n.questEvidenceUrl), + mainAxisSize: MainAxisSize.min, + children: [ + TextField( + controller: url, + keyboardType: TextInputType.url, + decoration: InputDecoration( + labelText: context.l10n.questEvidenceUrl, ), - TextField( - controller: text, - maxLines: 3, - minLines: 1, - decoration: InputDecoration(labelText: context.l10n.questEvidenceText), + ), + TextField( + controller: text, + maxLines: 3, + minLines: 1, + decoration: InputDecoration( + labelText: context.l10n.questEvidenceText, ), - ], - ), + ), + ], + ), ); final proofUrl = url.text.trim(); final proofText = text.text.trim(); @@ -455,7 +512,11 @@ class _QuestDetailScreenState extends State { text.dispose(); if (!ok || (proofUrl.isEmpty && proofText.isEmpty)) return; final done = await _run( - () => widget.repository.submit(detail.card.id, url: proofUrl, text: proofText), + () => widget.repository.submit( + detail.card.id, + url: proofUrl, + text: proofText, + ), ); if (done && mounted) _toast(context.l10n.questSubmitted); } @@ -469,32 +530,36 @@ class _QuestDetailScreenState extends State { context.l10n.questEdit, confirmLabel: context.l10n.commonEdit, content: Column( - mainAxisSize: MainAxisSize.min, - children: [ - Text(context.l10n.questEditNotice, style: _caption), - TextField( - controller: title, - decoration: InputDecoration(labelText: context.l10n.questName), + mainAxisSize: MainAxisSize.min, + children: [ + Text(context.l10n.questEditNotice, style: _caption), + TextField( + controller: title, + decoration: InputDecoration(labelText: context.l10n.questName), + ), + TextField( + controller: description, + maxLines: 3, + minLines: 1, + decoration: InputDecoration( + labelText: context.l10n.questDescriptionOptional, ), - TextField( - controller: description, - maxLines: 3, - minLines: 1, - decoration: InputDecoration(labelText: context.l10n.questDescriptionOptional), - ), - ], - ), + ), + ], + ), ); final newTitle = title.text.trim(); final newDescription = description.text.trim(); title.dispose(); description.dispose(); if (ok != true || newTitle.isEmpty) return; - await _run(() => widget.repository.edit( - detail.card.id, - title: newTitle, - description: newDescription, - )); + await _run( + () => widget.repository.edit( + detail.card.id, + title: newTitle, + description: newDescription, + ), + ); } /// 서버 호출 한 번 — 실패는 알리고, 성공하면 상세를 다시 읽는다. @@ -546,8 +611,10 @@ class _QuestDetailScreenState extends State { /// 「시작하기」 — 진행중으로 바꾸고 실측 수집을 켠다. Future _start(QuestDetail detail) async { - final next = - await widget.repository.advance(detail.card.id, QuestStage.inProgress); + final next = await widget.repository.advance( + detail.card.id, + QuestStage.inProgress, + ); if (!mounted) return; setState(() { _stage = next; @@ -562,27 +629,29 @@ class _QuestDetailScreenState extends State { /// 이 값은 화면 진행률과 서버에 보낼 증거로만 쓴다. void _watch(QuestCard card) { _walk?.cancel(); - _walk = widget.repository.progressStream(card).listen( - (value) { - if (!mounted) return; - setState(() { - _live = value; - if (card.goal > 0 && value >= card.goal) { - _stage = QuestStage.completed; - } - }); - }, - onError: (Object error) { - // 권한 거부·센서 부재. 진행 상태를 시작 전으로 되돌려야 사용자가 다시 - // 시도할 수 있고, 미동의 상태에서도 화면은 그대로 쓸 수 있다(B-27). - if (!mounted) return; - setState(() { - _stage = QuestStage.notStarted; - _live = null; - }); - _fail(error); - }, - ); + _walk = widget.repository + .progressStream(card) + .listen( + (value) { + if (!mounted) return; + setState(() { + _live = value; + if (card.goal > 0 && value >= card.goal) { + _stage = QuestStage.completed; + } + }); + }, + onError: (Object error) { + // 권한 거부·센서 부재. 진행 상태를 시작 전으로 되돌려야 사용자가 다시 + // 시도할 수 있고, 미동의 상태에서도 화면은 그대로 쓸 수 있다(B-27). + if (!mounted) return; + setState(() { + _stage = QuestStage.notStarted; + _live = null; + }); + _fail(error); + }, + ); } /// 명세 `518:88552` — *진행중 버튼 클릭 시 노출 · 취소 버튼 모달 종료 ·* @@ -596,8 +665,10 @@ class _QuestDetailScreenState extends State { ); if (!stop) return; _stopWatching(); - final next = - await widget.repository.advance(detail.card.id, QuestStage.notStarted); + final next = await widget.repository.advance( + detail.card.id, + QuestStage.notStarted, + ); if (mounted) { setState(() { _stage = next; @@ -651,7 +722,8 @@ class _QuestDetailScreenState extends State { // 기본 보상은 이미 원장에 있다. 광고는 그 위의 선택적 증분이다(원장 B-03 · C-122) — // 광고가 실패해도 기본 보상은 그대로다. final bonus = - await showQuestAdBonus(context, bonusPop: detail.card.adBonusPop) ?? false; + await showQuestAdBonus(context, bonusPop: detail.card.adBonusPop) ?? + false; if (!mounted) return; int? adPop; if (bonus) { @@ -674,8 +746,11 @@ class _QuestDetailScreenState extends State { await Navigator.of(context, rootNavigator: true).push( MaterialPageRoute( - builder: (_) => - QuestRewardScreen(detail: detail, reward: reward, adBonusPop: adPop), + builder: (_) => QuestRewardScreen( + detail: detail, + reward: reward, + adBonusPop: adPop, + ), ), ); if (!mounted) return; @@ -730,31 +805,37 @@ class _QuestDetailScreenState extends State { final permission = error is BackendException && error.code == 'E-PERM-01'; ScaffoldMessenger.of(context) ..hideCurrentSnackBar() - ..showSnackBar(SnackBar( - content: Text(message), - behavior: SnackBarBehavior.floating, - action: permission - ? SnackBarAction( - label: context.l10n.openSettings, - onPressed: openQuestPermissionSettings, - ) - : null, - )); + ..showSnackBar( + SnackBar( + content: Text(message), + behavior: SnackBarBehavior.floating, + action: permission + ? SnackBarAction( + label: context.l10n.openSettings, + onPressed: openQuestPermissionSettings, + ) + : null, + ), + ); } - Future _warp(QuestAdWarpScreen screen) => - Navigator.of(context, rootNavigator: true).push( - MaterialPageRoute(builder: (_) => screen), - ); + Future _warp(QuestAdWarpScreen screen) => Navigator.of( + context, + rootNavigator: true, + ).push(MaterialPageRoute(builder: (_) => screen)); Future _report(String title) async { - final reason = await Navigator.of(context, rootNavigator: true).push( - MaterialPageRoute( - builder: (_) => ReportScreen( - target: ModerationTarget(authorName: title, title: context.l10n.questNamed(title)), - ), - ), - ); + final reason = await Navigator.of(context, rootNavigator: true) + .push( + MaterialPageRoute( + builder: (_) => ReportScreen( + target: ModerationTarget( + authorName: title, + title: context.l10n.questNamed(title), + ), + ), + ), + ); if (reason != null && mounted) _toast(context.l10n.reportReceived); } diff --git a/app/lib/features/translation/translation_comments.dart b/app/lib/features/translation/translation_comments.dart index 617aa81..cf99ed4 100644 --- a/app/lib/features/translation/translation_comments.dart +++ b/app/lib/features/translation/translation_comments.dart @@ -385,6 +385,8 @@ class _TranslationCommentCardState extends State { child: OutlinedButton( onPressed: !_busy && e.myMark == null ? () => _mark(valid) : null, style: OutlinedButton.styleFrom( + // 테마의 minimumSize(폭 무한)를 끊는다 — Row 안 두 버튼이 나란히 보이게. + minimumSize: const Size(0, 36), visualDensity: VisualDensity.compact, foregroundColor: e.myMark == valid ? AppColors.cosmicAccent diff --git a/app/test/backend_repository_test.dart b/app/test/backend_repository_test.dart index ea72610..cdbf9ff 100644 --- a/app/test/backend_repository_test.dart +++ b/app/test/backend_repository_test.dart @@ -667,6 +667,16 @@ void main() { expect(profile.levelLabel, 'Lv.1 GLOW'); expect(profile.passportTier, PassportTier.silver); }); + + test('완료 퀘스트는 검증 활동 표식 수에서 무효 표식을 뺀 값이다', () async { + final db = await _seed(); + final col = db.collection('users/$_uid/verifiedCompletions'); + await col.doc('q1_20261004').set({'questId': 'q1'}); + await col.doc('q2_20261005').set({'questId': 'q2'}); + await col.doc('q3_20261005').set({'questId': 'q3', 'voided': true}); + final profile = await FirestoreMyRepository(_backend(db)).profile(); + expect(profile.completedQuests, 2); + }); }); group('FPI 공동 순위 (C-19)', () { diff --git a/app/test/f3_screens_test.dart b/app/test/f3_screens_test.dart index ec93c6e..a378a96 100644 --- a/app/test/f3_screens_test.dart +++ b/app/test/f3_screens_test.dart @@ -226,6 +226,17 @@ void main() { expect(find.byType(FloatingActionButton), findsOneWidget); }); + testWidgets('글이 하나도 없으면 빈 상태 안내를 띄운다', (tester) async { + await _pumpTall(tester, const PlanetDetailScreen( + repository: _NoPosts(), + eclipse: NoEclipseRepository(), + )); + await tester.tap(find.text('팬덤 라운지')); + await tester.pumpAndSettle(); + expect(find.text('팬덤 라운지에 자유롭게 글을 작성해 보세요.'), findsOneWidget); + expect(find.byType(FloatingActionButton), findsOneWidget); + }); + testWidgets('스크롤하면 히어로가 접히고 탭은 위에 남는다 — 발주사 지적(글이 보이게)', (tester) async { tester.view.physicalSize = const Size(750, 1624); tester.view.devicePixelRatio = 2; @@ -583,3 +594,22 @@ void _fpiRankingTests() { }); }); } + +/// 라운지 글이 0건인 행성. +class _NoPosts extends SamplePlanetRepository { + const _NoPosts(); + + @override + Future load({required bool nebula}) async { + final d = await super.load(nebula: nebula); + return PlanetDetail( + fandomId: d.fandomId, name: d.name, isNebula: d.isNebula, + totalFpi: d.totalFpi, scores: d.scores, members: d.members, + posts: const [], + ); + } + + @override + Future> morePosts({required int offset, int limit = 5}) async => + const []; +} diff --git a/app/test/f6_screens_test.dart b/app/test/f6_screens_test.dart index 38b3db5..73d856c 100644 --- a/app/test/f6_screens_test.dart +++ b/app/test/f6_screens_test.dart @@ -2,6 +2,7 @@ import 'dart:async'; import 'dart:convert'; import 'package:fancake/features/auth/auth.dart'; +import 'package:fancake/features/galaxy/galaxy_data.dart'; import 'package:fancake/features/inside/inside_data.dart'; import 'package:fancake/features/auth/auth_repository.dart'; import 'package:fancake/features/my/my_data.dart'; @@ -176,6 +177,24 @@ void main() { expect(find.text('37'), findsOneWidget); }); + testWidgets('행성/성운 칸은 가입 목록을 팬덤 stage 그림으로 그린다', (tester) async { + await pumpWide( + tester, + MyScreen( + repository: SampleMyRepository(), + galaxy: _OneNebula(), + ), + ); + await tester.pump(); + final images = tester + .widgetList(find.byType(Image)) + .map((i) => (i.image as AssetImage).assetName) + .toList(); + expect(images, contains('assets/figma/nebula-card.png')); + expect(images.where((a) => a.startsWith('assets/figma/my-planet-')), isEmpty); + expect(find.bySemanticsLabel('새 성운'), findsOneWidget); + }); + testWidgets('프로필을 누르면 카드 오버레이가 뜬다', (tester) async { await pumpWide(tester, MyScreen(repository: SampleMyRepository())); await tester.pump(); @@ -1061,3 +1080,18 @@ class _MyInsides extends SampleInsideRepository { ), ]; } + +/// 가입 행성이 성운(Stage0) 하나뿐인 계정. +class _OneNebula extends SampleGalaxyRepository { + @override + Future load() async => const GalaxySnapshot( + planets: [], + myPop: 0, + memberships: [ + PlanetNode( + id: 'n1', name: '새 성운', rank: 0, fpi: 0, members: 1, online: 0, + isMine: true, stage: 0, + ), + ], + ); +} diff --git a/app/test/translation_test.dart b/app/test/translation_test.dart index 030e7df..0ba0eb7 100644 --- a/app/test/translation_test.dart +++ b/app/test/translation_test.dart @@ -11,6 +11,7 @@ import 'package:fancake/features/planet/planet_data.dart'; import 'package:fancake/features/translation/translation_comments.dart'; import 'package:firebase_auth_mocks/firebase_auth_mocks.dart'; import 'package:flutter/material.dart'; +import 'package:fancake/core/theme/app_theme.dart'; import 'package:flutter_localizations/flutter_localizations.dart'; import 'package:flutter_test/flutter_test.dart'; @@ -40,6 +41,8 @@ void main() { addTearDown(tester.view.reset); await tester.pumpWidget( MaterialApp( + // 실제 앱 테마 — 테마의 버튼 최소 크기가 마킹 버튼을 가린 적이 있다(10/5 실기기). + theme: buildAppTheme(), locale: const Locale('ko'), supportedLocales: appLocales, localizationsDelegates: const [ @@ -228,6 +231,15 @@ void main() { await pump(tester, repo); // 작성자 이름이 비어 있으면 「팬」. expect(find.text('팬'), findsOneWidget); + // 두 버튼이 같은 줄에 보이는 크기로 있어야 누를 수 있다. + final validBox = tester.getRect( + find.widgetWithText(OutlinedButton, '유효 0'), + ); + final invalidBox = tester.getRect( + find.widgetWithText(OutlinedButton, '무효 0'), + ); + expect(invalidBox.width, greaterThan(0)); + expect(validBox.top, invalidBox.top); await tester.tap(find.text('무효 0')); await tester.pumpAndSettle(); expect(repo.marks, 1);