퀘스트 목록 페이지 판정 · 다시 읽기 · 캠페인 응원 수를 고유 후원자 기준으로
This commit is contained in:
@@ -1155,6 +1155,8 @@ export async function fundCampaign(input: {
|
|||||||
const fandomId = campaign.get('fandomId');
|
const fandomId = campaign.get('fandomId');
|
||||||
let goal = 0;
|
let goal = 0;
|
||||||
let raised = 0;
|
let raised = 0;
|
||||||
|
// 처음 후원하는가 — 「응원 중」(donorCount)은 고유 후원자 수다. 후원 건수가 아니다(2026-10-06 실기기).
|
||||||
|
let firstBacker = false;
|
||||||
|
|
||||||
const result = await spendPop({
|
const result = await spendPop({
|
||||||
uid,
|
uid,
|
||||||
@@ -1178,6 +1180,8 @@ export async function fundCampaign(input: {
|
|||||||
goal = (snap.get('targetPop') as number | undefined)
|
goal = (snap.get('targetPop') as number | undefined)
|
||||||
?? (snap.get('goalPop') as number | undefined) ?? 0;
|
?? (snap.get('goalPop') as number | undefined) ?? 0;
|
||||||
raised = (snap.get('raisedPop') as number | undefined) ?? 0;
|
raised = (snap.get('raisedPop') as number | undefined) ?? 0;
|
||||||
|
// 후원자 표식(backers/{uid})이 없으면 첫 후원이다. 읽기는 쓰기보다 먼저 — 같은 트랜잭션.
|
||||||
|
firstBacker = !(await tx.get(campaignRef.collection('backers').doc(uid))).exists;
|
||||||
// 잔여분으로 깎아 받지 않고 거부한다(B-34 「잔여 목표액까지만 후원 가능」).
|
// 잔여분으로 깎아 받지 않고 거부한다(B-34 「잔여 목표액까지만 후원 가능」).
|
||||||
if (goal > 0 && raised + amount > goal) {
|
if (goal > 0 && raised + amount > goal) {
|
||||||
fail(ERR.campaignOverGoal, '남은 모금 목표보다 큰 금액입니다.');
|
fail(ERR.campaignOverGoal, '남은 모금 목표보다 큰 금액입니다.');
|
||||||
@@ -1204,7 +1208,7 @@ export async function fundCampaign(input: {
|
|||||||
}, { merge: true });
|
}, { merge: true });
|
||||||
tx.set(campaignRef, {
|
tx.set(campaignRef, {
|
||||||
raisedPop: FieldValue.increment(amount),
|
raisedPop: FieldValue.increment(amount),
|
||||||
donorCount: FieldValue.increment(1),
|
...(firstBacker ? { donorCount: FieldValue.increment(1) } : {}),
|
||||||
updatedAt: FieldValue.serverTimestamp(),
|
updatedAt: FieldValue.serverTimestamp(),
|
||||||
}, { merge: true });
|
}, { merge: true });
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -315,6 +315,15 @@ describe('정산 분기', () => {
|
|||||||
expect(short.get('refundDueAt').toMillis() - Date.now()).toBeGreaterThan(71 * 3600_000);
|
expect(short.get('refundDueAt').toMillis() - Date.now()).toBeGreaterThan(71 * 3600_000);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('「응원 중」(donorCount)은 고유 후원자 수다 — 같은 사람의 두 번째 후원은 늘리지 않는다', async () => {
|
||||||
|
await fundedCampaign('cmp-donors', 1_000, [[A, 100], [B, 50]]);
|
||||||
|
await db().doc('campaigns/cmp-donors').update({ endsAt: Timestamp.fromMillis(Date.now() + 60_000) });
|
||||||
|
await fundCampaign({ uid: A, campaignId: 'cmp-donors', clientSeq: '2', amount: 30 });
|
||||||
|
const snap = await db().doc('campaigns/cmp-donors').get();
|
||||||
|
expect(snap.get('donorCount')).toBe(2);
|
||||||
|
expect(snap.get('raisedPop')).toBe(180);
|
||||||
|
});
|
||||||
|
|
||||||
it('마감이 지난 캠페인은 정산 전이라도 후원을 받지 않는다', async () => {
|
it('마감이 지난 캠페인은 정산 전이라도 후원을 받지 않는다', async () => {
|
||||||
await fundedCampaign('cmp-late', 1_000, [[A, 100]]);
|
await fundedCampaign('cmp-late', 1_000, [[A, 100]]);
|
||||||
expect(await code(() => fundCampaign({ uid: A, campaignId: 'cmp-late', clientSeq: '2', amount: 50 })))
|
expect(await code(() => fundCampaign({ uid: A, campaignId: 'cmp-late', clientSeq: '2', amount: 50 })))
|
||||||
|
|||||||
@@ -618,7 +618,8 @@ class _CampaignDetailScreenState extends State<CampaignDetailScreen> {
|
|||||||
Row(
|
Row(
|
||||||
children: [
|
children: [
|
||||||
_stat(
|
_stat(
|
||||||
formatPop(card.supporters + (_spent > 0 ? 1 : 0)),
|
// 서버 donorCount(고유 후원자 수)를 그대로 쓴다. 후원 뒤 상세를 다시 읽으므로 화면이 더하지 않는다.
|
||||||
|
formatPop(card.supporters),
|
||||||
context.l10n.campaignSupporting,
|
context.l10n.campaignSupporting,
|
||||||
),
|
),
|
||||||
const SizedBox(width: 6),
|
const SizedBox(width: 6),
|
||||||
|
|||||||
@@ -561,6 +561,9 @@ class QuestDraft {
|
|||||||
|
|
||||||
abstract interface class QuestRepository {
|
abstract interface class QuestRepository {
|
||||||
/// 명세 — *퀘스트 생성 최신순 소팅(최초 10개 노출, 스크롤 시 10개씩 로드)*.
|
/// 명세 — *퀘스트 생성 최신순 소팅(최초 10개 노출, 스크롤 시 10개씩 로드)*.
|
||||||
|
///
|
||||||
|
/// [limit] 장보다 적게 돌려주면 더 읽을 문서가 없다는 뜻이다 — 화면은 이것으로 끝을 판정한다.
|
||||||
|
/// 구현은 걸러져 모자란 만큼 다음 페이지를 이어 읽어야 한다.
|
||||||
Future<List<QuestCard>> load({int limit = 10});
|
Future<List<QuestCard>> load({int limit = 10});
|
||||||
|
|
||||||
Future<List<QuestCard>> more({required int offset, int limit = 10});
|
Future<List<QuestCard>> more({required int offset, int limit = 10});
|
||||||
|
|||||||
@@ -85,11 +85,20 @@ class _QuestListScreenState extends State<QuestListScreen> {
|
|||||||
setState(() {
|
setState(() {
|
||||||
_cards = cards;
|
_cards = cards;
|
||||||
_loading = false;
|
_loading = false;
|
||||||
|
// 저장소는 걸러져 모자란 만큼 이어 읽고, 10장보다 적으면 문서가 끝난 것이다(QuestRepository.load).
|
||||||
_end = cards.length < 10;
|
_end = cards.length < 10;
|
||||||
if (pop != null) _pop = pop;
|
if (pop != null) _pop = pop;
|
||||||
});
|
});
|
||||||
|
_fill();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 보이는 카드가 화면을 못 채우면(탭으로 걸러져 비었을 때 포함) 스크롤 없이도 다음 페이지를 읽는다.
|
||||||
|
/// 스크롤 리스너만 두면 걸러진 탭에서 다음 페이지로 갈 방법이 없다.
|
||||||
|
void _fill() => WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
|
if (!mounted || _end || _more || _loading) return;
|
||||||
|
if (!_scroll.hasClients || _scroll.position.maxScrollExtent <= 240) _loadMore();
|
||||||
|
});
|
||||||
|
|
||||||
Future<void> _loadMore() async {
|
Future<void> _loadMore() async {
|
||||||
if (_more || _end) return;
|
if (_more || _end) return;
|
||||||
setState(() => _more = true);
|
setState(() => _more = true);
|
||||||
@@ -98,8 +107,9 @@ class _QuestListScreenState extends State<QuestListScreen> {
|
|||||||
setState(() {
|
setState(() {
|
||||||
_cards = [..._cards, ...next];
|
_cards = [..._cards, ...next];
|
||||||
_more = false;
|
_more = false;
|
||||||
_end = next.isEmpty;
|
_end = next.length < 10;
|
||||||
});
|
});
|
||||||
|
_fill();
|
||||||
}
|
}
|
||||||
|
|
||||||
List<QuestCard> get _visible {
|
List<QuestCard> get _visible {
|
||||||
@@ -295,25 +305,44 @@ class _QuestListScreenState extends State<QuestListScreen> {
|
|||||||
preferredSize: const Size.fromHeight(44),
|
preferredSize: const Size.fromHeight(44),
|
||||||
child: QuestTabStrip(
|
child: QuestTabStrip(
|
||||||
selected: _tab,
|
selected: _tab,
|
||||||
onSelect: (tab) => setState(() => _tab = tab),
|
// 탭을 바꿀 때마다 다시 읽는다 — 「내 퀘스트」 검토 중 → 반려됨이 재진입 없이 바뀌게(2026-10-06 실기기).
|
||||||
|
onSelect: (tab) {
|
||||||
|
setState(() => _tab = tab);
|
||||||
|
_load();
|
||||||
|
},
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
body: _loading
|
body: _loading
|
||||||
? const Center(child: CircularProgressIndicator())
|
? const Center(child: CircularProgressIndicator())
|
||||||
: _visible.isEmpty
|
// 당겨서 새로고침 — 상태(검토 중 · 반려됨)와 헤더 보유 POP 을 다시 읽는다.
|
||||||
? Center(
|
: RefreshIndicator(
|
||||||
|
onRefresh: _load,
|
||||||
|
child: _visible.isEmpty
|
||||||
|
? LayoutBuilder(
|
||||||
|
builder: (context, box) => ListView(
|
||||||
|
physics: const AlwaysScrollableScrollPhysics(),
|
||||||
|
children: [
|
||||||
|
SizedBox(
|
||||||
|
height: box.maxHeight,
|
||||||
|
child: Center(
|
||||||
child: Text(
|
child: Text(
|
||||||
context.l10n.questTabEmpty,
|
context.l10n.questTabEmpty,
|
||||||
style: Theme.of(context).textTheme.bodyMedium,
|
style: Theme.of(context).textTheme.bodyMedium,
|
||||||
),
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
)
|
)
|
||||||
: ListView(
|
: ListView(
|
||||||
controller: _scroll,
|
controller: _scroll,
|
||||||
|
physics: const AlwaysScrollableScrollPhysics(),
|
||||||
// 아래 여백은 「새 퀘스트」 버튼(36)이 마지막 카드를 가리지 않게 둔다.
|
// 아래 여백은 「새 퀘스트」 버튼(36)이 마지막 카드를 가리지 않게 둔다.
|
||||||
padding: const EdgeInsets.fromLTRB(16, 16, 16, 16 + 36 + 16),
|
padding: const EdgeInsets.fromLTRB(16, 16, 16, 16 + 36 + 16),
|
||||||
children: _body(),
|
children: _body(),
|
||||||
),
|
),
|
||||||
|
),
|
||||||
floatingActionButtonLocation: FloatingActionButtonLocation.centerFloat,
|
floatingActionButtonLocation: FloatingActionButtonLocation.centerFloat,
|
||||||
floatingActionButton: Material(
|
floatingActionButton: Material(
|
||||||
color: const Color(0xFF19191C),
|
color: const Color(0xFF19191C),
|
||||||
|
|||||||
@@ -60,6 +60,7 @@ class FirestoreQuestRepository implements QuestRepository {
|
|||||||
@override
|
@override
|
||||||
Future<List<QuestCard>> load({int limit = 10}) async {
|
Future<List<QuestCard>> load({int limit = 10}) async {
|
||||||
_cursor = null;
|
_cursor = null;
|
||||||
|
_exhausted = false;
|
||||||
return _page(limit);
|
return _page(limit);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -67,19 +68,37 @@ class FirestoreQuestRepository implements QuestRepository {
|
|||||||
Future<List<QuestCard>> more({required int offset, int limit = 10}) =>
|
Future<List<QuestCard>> more({required int offset, int limit = 10}) =>
|
||||||
_page(limit);
|
_page(limit);
|
||||||
|
|
||||||
|
/// 원본 문서를 끝까지 읽었는가. 끝은 **걸러내기 전 문서 수**로 판정한다 — 비활성 ·
|
||||||
|
/// 마감 문서가 한 페이지를 채워도 뒤 문서가 사라지지 않게(2026-10-06 실기기: 11번째 이후 누락).
|
||||||
|
bool _exhausted = false;
|
||||||
|
|
||||||
|
/// 보이는 카드가 [limit] 장 모일 때까지(또는 문서가 끝날 때까지) 페이지를 이어 읽는다.
|
||||||
|
/// 그래서 [limit] 장보다 적게 돌려주면 문서가 끝났다는 뜻이다(`QuestRepository` 계약).
|
||||||
Future<List<QuestCard>> _page(int limit) async {
|
Future<List<QuestCard>> _page(int limit) async {
|
||||||
|
final cards = <QuestCard>[];
|
||||||
|
while (cards.length < limit && !_exhausted) {
|
||||||
|
cards.addAll(await _batch(limit));
|
||||||
|
}
|
||||||
|
return cards;
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<List<QuestCard>> _batch(int limit) async {
|
||||||
final uid = _backend.requireUid;
|
final uid = _backend.requireUid;
|
||||||
final db = _backend.db;
|
final db = _backend.db;
|
||||||
|
|
||||||
// 정렬하지 않는다. 설계서 §2.8 의 `quests` 에 정렬 키가 없고, Admin 이 쓰는
|
// 정렬하지 않는다. 설계서 §2.8 의 `quests` 에 정렬 키가 없고, Admin 이 쓰는
|
||||||
// 문서에는 `createdAt` 이 없다(`fn-admin-write` 는 `updatedAt` 만 찍는다).
|
// 문서에는 `createdAt` 이 없다(`fn-admin-write` 는 `updatedAt` 만 찍는다).
|
||||||
// `orderBy('createdAt')` 로 두면 그 문서들이 목록에서 조용히 사라진다.
|
// `orderBy('createdAt')` 로 두면 그 문서들이 목록에서 조용히 사라진다.
|
||||||
var query = db.collection('quests').limit(limit);
|
// 커서는 기본 순서(문서 ID)를 따른다. 커서를 limit 보다 먼저 건다 — 서버는 순서를 가리지 않지만
|
||||||
|
// 테스트용 fake 는 적은 순서대로 적용한다.
|
||||||
|
Query<Map<String, dynamic>> query = db.collection('quests');
|
||||||
final cursor = _cursor;
|
final cursor = _cursor;
|
||||||
if (cursor != null) query = query.startAfterDocument(cursor);
|
if (cursor != null) query = query.startAfterDocument(cursor);
|
||||||
|
query = query.limit(limit);
|
||||||
|
|
||||||
final snap = await query.get();
|
final snap = await query.get();
|
||||||
if (snap.docs.isNotEmpty) _cursor = snap.docs.last;
|
if (snap.docs.isNotEmpty) _cursor = snap.docs.last;
|
||||||
|
if (snap.docs.length < limit) _exhausted = true;
|
||||||
|
|
||||||
// 타임어택 창은 서버 시각이다. 회차가 붙은 Quest 가 있을 때만 서버 시계를 잰다(세션당 1회).
|
// 타임어택 창은 서버 시각이다. 회차가 붙은 Quest 가 있을 때만 서버 시계를 잰다(세션당 1회).
|
||||||
if (snap.docs.any((d) => d.data()['timeAttack'] is Map)) {
|
if (snap.docs.any((d) => d.data()['timeAttack'] is Map)) {
|
||||||
|
|||||||
@@ -132,6 +132,31 @@ void main() {
|
|||||||
expect(find.text('12,290'), findsNothing);
|
expect(find.text('12,290'), findsNothing);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
testWidgets('첫 10장이 모두 걸러져도 11번째 이후 퀘스트가 보인다 (2026-10-06 실기기)', (tester) async {
|
||||||
|
await pumpWide(tester, QuestListScreen(repository: _PagedRepo()));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
expect(find.text('열한 번째 퀘스트'), findsOneWidget);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('탭 전환 · 당겨서 새로고침이 목록을 다시 읽는다 (내 퀘스트 상태 · 2026-10-06 실기기)', (tester) async {
|
||||||
|
final repo = _PagedRepo();
|
||||||
|
await pumpWide(tester, QuestListScreen(repository: repo));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
final before = repo.loads;
|
||||||
|
await tester.tap(
|
||||||
|
find.descendant(of: find.byType(QuestTabStrip), matching: find.text(QuestTab.mine.label)),
|
||||||
|
);
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
expect(repo.loads, before + 1);
|
||||||
|
await tester.tap(
|
||||||
|
find.descendant(of: find.byType(QuestTabStrip), matching: find.text(QuestTab.all.label)),
|
||||||
|
);
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
await tester.fling(find.text('열한 번째 퀘스트'), const Offset(0, 400), 1000);
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
expect(repo.loads, before + 3);
|
||||||
|
});
|
||||||
|
|
||||||
testWidgets('목록 조회가 실패해도 로딩이 끝난다', (tester) async {
|
testWidgets('목록 조회가 실패해도 로딩이 끝난다', (tester) async {
|
||||||
await pumpWide(tester, QuestListScreen(repository: _FailingList()));
|
await pumpWide(tester, QuestListScreen(repository: _FailingList()));
|
||||||
await tester.pumpAndSettle();
|
await tester.pumpAndSettle();
|
||||||
@@ -513,6 +538,34 @@ class _BalanceRepo extends SampleQuestRepository {
|
|||||||
Future<int?> popBalance() async => 12260;
|
Future<int?> popBalance() async => 12260;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 첫 페이지 10장이 모두 감춰지는(보상완료 관리자) 표본 — 11번째가 다음 페이지에 있다.
|
||||||
|
class _PagedRepo extends SampleQuestRepository {
|
||||||
|
var loads = 0;
|
||||||
|
|
||||||
|
static QuestCard _hidden(int i) => QuestCard(
|
||||||
|
id: 'h$i', title: '숨김 $i', categoryName: '출석', type: QuestType.attend,
|
||||||
|
author: QuestAuthor.admin, stage: QuestStage.rewarded, goal: 1, progress: 1,
|
||||||
|
rewardPop: 10, participants: 0,
|
||||||
|
);
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<List<QuestCard>> load({int limit = 10}) async {
|
||||||
|
loads += 1;
|
||||||
|
return [for (var i = 0; i < 10; i++) _hidden(i)];
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<List<QuestCard>> more({required int offset, int limit = 10}) async => offset >= 11
|
||||||
|
? const []
|
||||||
|
: const [
|
||||||
|
QuestCard(
|
||||||
|
id: 'u11', title: '열한 번째 퀘스트', categoryName: '건강', type: QuestType.steps,
|
||||||
|
author: QuestAuthor.user, stage: QuestStage.notStarted, goal: 1, progress: 0,
|
||||||
|
rewardPop: 10, participants: 0,
|
||||||
|
),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
/// 목록이 실패하는 표본 — 로딩이 끝나지 않으면 탭 전체가 무한 로딩이다.
|
/// 목록이 실패하는 표본 — 로딩이 끝나지 않으면 탭 전체가 무한 로딩이다.
|
||||||
class _FailingList extends SampleQuestRepository {
|
class _FailingList extends SampleQuestRepository {
|
||||||
@override
|
@override
|
||||||
|
|||||||
@@ -179,6 +179,34 @@ service cloud.firestore {
|
|||||||
expect(cards.map((c) => c.id), contains('attend'));
|
expect(cards.map((c) => c.id), contains('attend'));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('비활성 문서가 첫 페이지를 채워도 11번째 이후 퀘스트가 보인다 (2026-10-06 실기기)', () async {
|
||||||
|
final db = FakeFirebaseFirestore();
|
||||||
|
// 문서 ID 순으로 앞 10건이 비활성(daily-*) — 걸러낸 뒤 개수로 끝을 판정하면 뒤가 사라진다.
|
||||||
|
for (var i = 0; i < 10; i++) {
|
||||||
|
await db.doc('quests/a-daily-$i').set({'type': 'attend', 'title': 'd$i', 'active': false});
|
||||||
|
}
|
||||||
|
await db.doc('quests/z-music').set({'type': 'music_shake', 'title': '박자 맞춰 흔들기'});
|
||||||
|
final repo = FirestoreQuestRepository(_RecordingBackend(db));
|
||||||
|
final first = await repo.load();
|
||||||
|
expect(first.map((c) => c.id), contains('z-music'));
|
||||||
|
// 10장보다 적으면 끝 — 다음 페이지는 비어 있다.
|
||||||
|
expect(first.length, lessThan(10));
|
||||||
|
expect(await repo.more(offset: first.length), isEmpty);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('보이는 퀘스트가 10건을 넘으면 다음 페이지에서 11번째부터 온다', () async {
|
||||||
|
final db = FakeFirebaseFirestore();
|
||||||
|
for (var i = 0; i < 12; i++) {
|
||||||
|
await db.doc('quests/q${i.toString().padLeft(2, '0')}').set({'type': 'attend', 'title': 'q$i'});
|
||||||
|
}
|
||||||
|
final repo = FirestoreQuestRepository(_RecordingBackend(db));
|
||||||
|
final first = await repo.load();
|
||||||
|
expect(first, hasLength(10));
|
||||||
|
final next = await repo.more(offset: first.length);
|
||||||
|
expect(next, hasLength(2));
|
||||||
|
expect({...first, ...next}.map((c) => c.id).toSet(), hasLength(12));
|
||||||
|
});
|
||||||
|
|
||||||
test('내가 만든 퀘스트는 내 퀘스트 상태로 온다', () async {
|
test('내가 만든 퀘스트는 내 퀘스트 상태로 온다', () async {
|
||||||
final db = await _seed();
|
final db = await _seed();
|
||||||
await db.doc('quests/mine').set({
|
await db.doc('quests/mine').set({
|
||||||
|
|||||||
Reference in New Issue
Block a user