번역 평가 버튼 · 내 퀘스트 검토 버튼 · MY 질문 상세 · MY 완료 퀘스트 수 · 행성/성운 칸 · 라운지 빈 상태 수정

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