퀘스트 목록 페이지 판정 · 다시 읽기 · 캠페인 응원 수를 고유 후원자 기준으로
This commit is contained in:
@@ -561,6 +561,9 @@ class QuestDraft {
|
||||
|
||||
abstract interface class QuestRepository {
|
||||
/// 명세 — *퀘스트 생성 최신순 소팅(최초 10개 노출, 스크롤 시 10개씩 로드)*.
|
||||
///
|
||||
/// [limit] 장보다 적게 돌려주면 더 읽을 문서가 없다는 뜻이다 — 화면은 이것으로 끝을 판정한다.
|
||||
/// 구현은 걸러져 모자란 만큼 다음 페이지를 이어 읽어야 한다.
|
||||
Future<List<QuestCard>> load({int limit = 10});
|
||||
|
||||
Future<List<QuestCard>> more({required int offset, int limit = 10});
|
||||
|
||||
@@ -85,11 +85,20 @@ class _QuestListScreenState extends State<QuestListScreen> {
|
||||
setState(() {
|
||||
_cards = cards;
|
||||
_loading = false;
|
||||
// 저장소는 걸러져 모자란 만큼 이어 읽고, 10장보다 적으면 문서가 끝난 것이다(QuestRepository.load).
|
||||
_end = cards.length < 10;
|
||||
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 {
|
||||
if (_more || _end) return;
|
||||
setState(() => _more = true);
|
||||
@@ -98,8 +107,9 @@ class _QuestListScreenState extends State<QuestListScreen> {
|
||||
setState(() {
|
||||
_cards = [..._cards, ...next];
|
||||
_more = false;
|
||||
_end = next.isEmpty;
|
||||
_end = next.length < 10;
|
||||
});
|
||||
_fill();
|
||||
}
|
||||
|
||||
List<QuestCard> get _visible {
|
||||
@@ -295,25 +305,44 @@ class _QuestListScreenState extends State<QuestListScreen> {
|
||||
preferredSize: const Size.fromHeight(44),
|
||||
child: QuestTabStrip(
|
||||
selected: _tab,
|
||||
onSelect: (tab) => setState(() => _tab = tab),
|
||||
// 탭을 바꿀 때마다 다시 읽는다 — 「내 퀘스트」 검토 중 → 반려됨이 재진입 없이 바뀌게(2026-10-06 실기기).
|
||||
onSelect: (tab) {
|
||||
setState(() => _tab = tab);
|
||||
_load();
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
body: _loading
|
||||
? const Center(child: CircularProgressIndicator())
|
||||
: _visible.isEmpty
|
||||
? Center(
|
||||
child: Text(
|
||||
context.l10n.questTabEmpty,
|
||||
style: Theme.of(context).textTheme.bodyMedium,
|
||||
),
|
||||
)
|
||||
: ListView(
|
||||
controller: _scroll,
|
||||
// 아래 여백은 「새 퀘스트」 버튼(36)이 마지막 카드를 가리지 않게 둔다.
|
||||
padding: const EdgeInsets.fromLTRB(16, 16, 16, 16 + 36 + 16),
|
||||
children: _body(),
|
||||
),
|
||||
// 당겨서 새로고침 — 상태(검토 중 · 반려됨)와 헤더 보유 POP 을 다시 읽는다.
|
||||
: RefreshIndicator(
|
||||
onRefresh: _load,
|
||||
child: _visible.isEmpty
|
||||
? LayoutBuilder(
|
||||
builder: (context, box) => ListView(
|
||||
physics: const AlwaysScrollableScrollPhysics(),
|
||||
children: [
|
||||
SizedBox(
|
||||
height: box.maxHeight,
|
||||
child: Center(
|
||||
child: Text(
|
||||
context.l10n.questTabEmpty,
|
||||
style: Theme.of(context).textTheme.bodyMedium,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
)
|
||||
: ListView(
|
||||
controller: _scroll,
|
||||
physics: const AlwaysScrollableScrollPhysics(),
|
||||
// 아래 여백은 「새 퀘스트」 버튼(36)이 마지막 카드를 가리지 않게 둔다.
|
||||
padding: const EdgeInsets.fromLTRB(16, 16, 16, 16 + 36 + 16),
|
||||
children: _body(),
|
||||
),
|
||||
),
|
||||
floatingActionButtonLocation: FloatingActionButtonLocation.centerFloat,
|
||||
floatingActionButton: Material(
|
||||
color: const Color(0xFF19191C),
|
||||
|
||||
@@ -60,6 +60,7 @@ class FirestoreQuestRepository implements QuestRepository {
|
||||
@override
|
||||
Future<List<QuestCard>> load({int limit = 10}) async {
|
||||
_cursor = null;
|
||||
_exhausted = false;
|
||||
return _page(limit);
|
||||
}
|
||||
|
||||
@@ -67,19 +68,37 @@ class FirestoreQuestRepository implements QuestRepository {
|
||||
Future<List<QuestCard>> more({required int offset, int limit = 10}) =>
|
||||
_page(limit);
|
||||
|
||||
/// 원본 문서를 끝까지 읽었는가. 끝은 **걸러내기 전 문서 수**로 판정한다 — 비활성 ·
|
||||
/// 마감 문서가 한 페이지를 채워도 뒤 문서가 사라지지 않게(2026-10-06 실기기: 11번째 이후 누락).
|
||||
bool _exhausted = false;
|
||||
|
||||
/// 보이는 카드가 [limit] 장 모일 때까지(또는 문서가 끝날 때까지) 페이지를 이어 읽는다.
|
||||
/// 그래서 [limit] 장보다 적게 돌려주면 문서가 끝났다는 뜻이다(`QuestRepository` 계약).
|
||||
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 db = _backend.db;
|
||||
|
||||
// 정렬하지 않는다. 설계서 §2.8 의 `quests` 에 정렬 키가 없고, Admin 이 쓰는
|
||||
// 문서에는 `createdAt` 이 없다(`fn-admin-write` 는 `updatedAt` 만 찍는다).
|
||||
// `orderBy('createdAt')` 로 두면 그 문서들이 목록에서 조용히 사라진다.
|
||||
var query = db.collection('quests').limit(limit);
|
||||
// 커서는 기본 순서(문서 ID)를 따른다. 커서를 limit 보다 먼저 건다 — 서버는 순서를 가리지 않지만
|
||||
// 테스트용 fake 는 적은 순서대로 적용한다.
|
||||
Query<Map<String, dynamic>> query = db.collection('quests');
|
||||
final cursor = _cursor;
|
||||
if (cursor != null) query = query.startAfterDocument(cursor);
|
||||
query = query.limit(limit);
|
||||
|
||||
final snap = await query.get();
|
||||
if (snap.docs.isNotEmpty) _cursor = snap.docs.last;
|
||||
if (snap.docs.length < limit) _exhausted = true;
|
||||
|
||||
// 타임어택 창은 서버 시각이다. 회차가 붙은 Quest 가 있을 때만 서버 시계를 잰다(세션당 1회).
|
||||
if (snap.docs.any((d) => d.data()['timeAttack'] is Map)) {
|
||||
|
||||
Reference in New Issue
Block a user