번역 평가 버튼 · 내 퀘스트 검토 버튼 · 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;
}
// 검증 활동 표식(`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} {
@@ -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, {
+2
View File
@@ -180,6 +180,8 @@ GoRouter buildRouter(AuthRepository auth, {Repositories? repositories}) {
auth: auth,
campaigns: repos.campaign,
insides: repos.inside,
translation: repos.translation,
galaxy: repos.galaxy,
),
),
],
+12 -12
View File
@@ -71,8 +71,9 @@ enum PopTxType {
/// Functions 의 `ReasonCode`(`firebase/functions/src/ledger/ledger.ts`)와 짝이며,
/// 모르는 코드는 원문을 그대로 내보이지 않고 중립 문구로 접는다 — 사용자 화면에
/// `QUEST_MUSIC_SHAKE` 같은 내부 식별자가 새면 고장으로 읽힌다.
({String title, String type}) popReasonLabel(String reasonCode) =>
switch (reasonCode) {
({String title, String type}) popReasonLabel(
String reasonCode,
) => switch (reasonCode) {
'QUEST_STEPS' => (
title: appL10n.popReasonQuestSteps,
type: appL10n.popTypeQuest,
@@ -89,14 +90,8 @@ enum PopTxType {
title: appL10n.popReasonQuestAttend,
type: appL10n.popTypeQuest,
),
'QUEST_UGC' => (
title: appL10n.popReasonQuestUgc,
type: appL10n.popTypeQuest,
),
'QUEST_FEE' => (
title: appL10n.popReasonQuestFee,
type: appL10n.popTypeFee,
),
'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,
@@ -586,7 +581,11 @@ const int nicknameMaxLength = 20;
/// 운영 사칭으로 읽힐 수 있는 이름 — 대소문자 무시 부분 일치로 막는다.
/// `firestore.rules` 의 `nicknameOk` 가 같은 목록을 본다.
const List<String> 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}`.
+13 -3
View File
@@ -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<MyListsScreen> {
Widget _insideItems(BuildContext context, List<InsideQuestion> 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<MyListsScreen> {
question: items[i],
onTap: () => Navigator.of(context, rootNavigator: true).push(
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: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 <String, dynamic>{};
final wallet = (await walletF).data() ?? const <String, dynamic>{};
@@ -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<void> 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,7 +188,8 @@ class FirestoreMyRepository extends SampleMyRepository {
final reasonCode = data.strOr('reasonCode');
final label = popReasonLabel(reasonCode);
final at = data.dateOr('createdAt', _epoch);
rows.add(_LedgerRow(
rows.add(
_LedgerRow(
reasonCode: reasonCode,
// Zap 한 행이 몇 번분인가. `fn-eclipse-defend` 가 `count` 를 묶어 받으므로
// 행 하나가 여러 번일 수 있다. 없는 문서는 한 번으로 본다.
@@ -189,7 +204,8 @@ class FirestoreMyRepository extends SampleMyRepository {
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<String, dynamic> 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;
}
}
+43 -9
View File
@@ -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<MyScreen> {
MyProfile? _profile;
FocusStatus? _focus;
List<PlanetNode> _planets = const [];
bool _claiming = false;
@override
@@ -70,10 +82,16 @@ class _MyScreenState extends State<MyScreen> {
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 <PlanetNode>[]);
if (mounted) {
setState(() {
_profile = profile;
_focus = focus;
_planets = planets;
});
}
}
@@ -214,11 +232,9 @@ class _MyScreenState extends State<MyScreen> {
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<MyScreen> {
// 칸 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<MyScreen> {
MyListsScreen(
kind: MyListKind.inside,
insides: widget.insides,
translation: widget.translation,
),
),
),
@@ -337,7 +365,9 @@ class _MyScreenState extends State<MyScreen> {
),
);
} 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;
+16 -7
View File
@@ -40,10 +40,12 @@ class _PaymentHistoryScreenState extends State<PaymentHistoryScreen> {
await Store.instance?.restore();
} on Exception {
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(SnackBar(
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
behavior: SnackBarBehavior.floating,
content: Text(context.l10n.storeConnectFailed),
));
),
);
return;
}
await _load();
@@ -63,7 +65,10 @@ class _PaymentHistoryScreenState extends State<PaymentHistoryScreen> {
// 구매 복원 — 재설치 · 기기 변경 뒤 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<PaymentDetailScreen> {
),
_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),
@@ -397,9 +406,9 @@ class _RefundRequestScreenState extends State<RefundRequestScreen> {
// 서버·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);
}
+8 -2
View File
@@ -117,7 +117,10 @@ class _PopHistoryScreenState extends State<PopHistoryScreen> {
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<PopHistoryScreen> {
padding: EdgeInsets.all(AppSpace.s3),
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(
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<ProfilePhotoScreen> {
),
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(
+5 -4
View File
@@ -51,7 +51,10 @@ class _InquiryScreenState extends State<InquiryScreen> {
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<InquiryComposeScreen> {
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);
}
@@ -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(
+126 -45
View File
@@ -86,7 +86,8 @@ class _QuestDetailScreenState extends State<QuestDetailScreen> {
});
// 목록에서 「시작하기」를 눌러 진행중으로 들어온 경우다. 여기서 다시 켜지 않으면
// 상세를 열어도 실행률이 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<QuestDetailScreen> {
actions: [
PopupMenuButton<String>(
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<QuestDetailScreen> {
// 명세 `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<QuestDetailScreen> {
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<QuestDetailScreen> {
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<QuestDetailScreen> {
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<QuestDetailScreen> {
? 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,12 +306,14 @@ class _QuestDetailScreenState extends State<QuestDetailScreen> {
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
onPressed:
stage.tappable && !_busy && blocked == null && open && !held
? () => _act(detail, stage)
: null,
style: FilledButton.styleFrom(
@@ -331,7 +373,8 @@ class _QuestDetailScreenState extends State<QuestDetailScreen> {
/// 기기 시간대의 마감 시각과 남은 시간.
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<QuestDetailScreen> {
: context.l10n.questResubmitNotAllowed,
],
];
return [for (final line in lines) Padding(
return [
for (final line in lines)
Padding(
padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 4),
child: Text(line, style: _body),
)];
),
];
}
/// 생성자 검토 대기열 — 승인 · 반려. 시간이 지나도 자동 승인하지 않는다(C-08).
List<Widget> _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<QuestDetailScreen> {
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<QuestDetailScreen> {
input.dispose();
if (!ok || reason.isEmpty) return;
}
await _run(() => widget.repository.review(
await _run(
() => widget.repository.review(
s.id,
approve: approve,
reason: reason,
resubmitAllowed: resubmit,
));
),
);
}
/// 인증 제출 · 보완 제출(C-08 · G-01) — 링크 또는 내용 중 하나 이상.
@@ -438,13 +491,17 @@ class _QuestDetailScreenState extends State<QuestDetailScreen> {
TextField(
controller: url,
keyboardType: TextInputType.url,
decoration: InputDecoration(labelText: context.l10n.questEvidenceUrl),
decoration: InputDecoration(
labelText: context.l10n.questEvidenceUrl,
),
),
TextField(
controller: text,
maxLines: 3,
minLines: 1,
decoration: InputDecoration(labelText: context.l10n.questEvidenceText),
decoration: InputDecoration(
labelText: context.l10n.questEvidenceText,
),
),
],
),
@@ -455,7 +512,11 @@ class _QuestDetailScreenState extends State<QuestDetailScreen> {
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);
}
@@ -480,7 +541,9 @@ class _QuestDetailScreenState extends State<QuestDetailScreen> {
controller: description,
maxLines: 3,
minLines: 1,
decoration: InputDecoration(labelText: context.l10n.questDescriptionOptional),
decoration: InputDecoration(
labelText: context.l10n.questDescriptionOptional,
),
),
],
),
@@ -490,11 +553,13 @@ class _QuestDetailScreenState extends State<QuestDetailScreen> {
title.dispose();
description.dispose();
if (ok != true || newTitle.isEmpty) return;
await _run(() => widget.repository.edit(
await _run(
() => widget.repository.edit(
detail.card.id,
title: newTitle,
description: newDescription,
));
),
);
}
/// 서버 호출 한 번 — 실패는 알리고, 성공하면 상세를 다시 읽는다.
@@ -546,8 +611,10 @@ class _QuestDetailScreenState extends State<QuestDetailScreen> {
/// 「시작하기」 — 진행중으로 바꾸고 실측 수집을 켠다.
Future<void> _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,7 +629,9 @@ class _QuestDetailScreenState extends State<QuestDetailScreen> {
/// 이 값은 화면 진행률과 서버에 보낼 증거로만 쓴다.
void _watch(QuestCard card) {
_walk?.cancel();
_walk = widget.repository.progressStream(card).listen(
_walk = widget.repository
.progressStream(card)
.listen(
(value) {
if (!mounted) return;
setState(() {
@@ -596,8 +665,10 @@ class _QuestDetailScreenState extends State<QuestDetailScreen> {
);
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<QuestDetailScreen> {
// 기본 보상은 이미 원장에 있다. 광고는 그 위의 선택적 증분이다(원장 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<QuestDetailScreen> {
await Navigator.of(context, rootNavigator: true).push<bool>(
MaterialPageRoute(
builder: (_) =>
QuestRewardScreen(detail: detail, reward: reward, adBonusPop: adPop),
builder: (_) => QuestRewardScreen(
detail: detail,
reward: reward,
adBonusPop: adPop,
),
),
);
if (!mounted) return;
@@ -730,7 +805,8 @@ class _QuestDetailScreenState extends State<QuestDetailScreen> {
final permission = error is BackendException && error.code == 'E-PERM-01';
ScaffoldMessenger.of(context)
..hideCurrentSnackBar()
..showSnackBar(SnackBar(
..showSnackBar(
SnackBar(
content: Text(message),
behavior: SnackBarBehavior.floating,
action: permission
@@ -739,19 +815,24 @@ class _QuestDetailScreenState extends State<QuestDetailScreen> {
onPressed: openQuestPermissionSettings,
)
: null,
));
),
);
}
Future<void> _warp(QuestAdWarpScreen screen) =>
Navigator.of(context, rootNavigator: true).push<void>(
MaterialPageRoute(builder: (_) => screen),
);
Future<void> _warp(QuestAdWarpScreen screen) => Navigator.of(
context,
rootNavigator: true,
).push<void>(MaterialPageRoute(builder: (_) => screen));
Future<void> _report(String title) async {
final reason = await Navigator.of(context, rootNavigator: true).push<String>(
final reason = await Navigator.of(context, rootNavigator: true)
.push<String>(
MaterialPageRoute(
builder: (_) => ReportScreen(
target: ModerationTarget(authorName: title, title: context.l10n.questNamed(title)),
target: ModerationTarget(
authorName: title,
title: context.l10n.questNamed(title),
),
),
),
);
@@ -385,6 +385,8 @@ class _TranslationCommentCardState extends State<TranslationCommentCard> {
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
+10
View File
@@ -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)', () {
+30
View File
@@ -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<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 '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<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 {
await pumpWide(tester, MyScreen(repository: SampleMyRepository()));
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: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);