번역 평가 버튼 · 내 퀘스트 검토 버튼 · MY 질문 상세 · MY 완료 퀘스트 수 · 행성/성운 칸 · 라운지 빈 상태 수정
This commit is contained in:
@@ -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, {
|
||||
|
||||
@@ -180,6 +180,8 @@ GoRouter buildRouter(AuthRepository auth, {Repositories? repositories}) {
|
||||
auth: auth,
|
||||
campaigns: repos.campaign,
|
||||
insides: repos.inside,
|
||||
translation: repos.translation,
|
||||
galaxy: repos.galaxy,
|
||||
),
|
||||
),
|
||||
],
|
||||
|
||||
@@ -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}`.
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)', () {
|
||||
|
||||
@@ -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 [];
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user