import 'package:cloud_firestore/cloud_firestore.dart'; import '../../core/data/backend.dart'; import 'inside_data.dart'; /// INSIDE 목록의 실 서버 구현 — 화면 대조 원장 F3. /// /// 정렬 셋(`최신순` · `인기순` · `POP순`)이 Figma 에서는 별도 화면이지만 여기서는 /// orderBy 필드만 다른 같은 질의다. 세 화면을 따로 두면 페이지네이션 커서도 셋이 /// 되고, 한쪽만 고치는 사고가 난다. /// /// 커서는 `startAfterDocument` 로 잡는다. `limit` 을 커서보다 먼저 걸면 둘째 장이 /// 항상 비어 돌아온다 — 라운지에서 한 번 겪은 결함이라 순서를 지킨다. class FirestoreInsideRepository implements InsideRepository { FirestoreInsideRepository(this._backend); final Backend _backend; /// 사용자 문서(소속 팬덤 · 표시명). 라우터는 이 값을 모르므로 한 번 읽어 들고 /// 있는다. 탭을 옮길 때마다 다시 읽으면 왕복이 그대로 쌓인다. Map? _user; Future> _me() async => _user ??= (await _backend.db.doc('users/${_backend.requireUid}').get()).data() ?? const {}; Future _resolveFandomId() async => (await _me()).strOr('fandomId'); DocumentSnapshot>? _cursor; InsideSort? _cursorSort; static const int pageSize = 5; Query> _query(InsideSort sort, String fandomId) { Query> q = _backend.db.collection('questions'); if (fandomId.isNotEmpty) { q = q.where('fandomId', isEqualTo: fandomId); } return q.orderBy(sort.field, descending: true); } @override Future load({InsideSort sort = InsideSort.latest}) async { final fandomId = await _resolveFandomId(); final snap = await _query(sort, fandomId).limit(pageSize).get(); _cursor = snap.docs.isEmpty ? null : snap.docs.last; _cursorSort = sort; return InsideSnapshot( questions: [for (final doc in snap.docs) _question(doc)], sort: sort, // 작성 버튼은 팬덤에 가입한 사용자에게만 보인다(명세 2.70-3). canWrite: fandomId.isNotEmpty, ); } @override Future> more({ required InsideSort sort, int limit = pageSize, }) async { // 정렬이 바뀌면 앞 장의 커서는 다른 순서의 위치라 의미가 없다. 처음부터 읽는다. if (_cursorSort != sort) _cursor = null; var query = _query(sort, await _resolveFandomId()); final cursor = _cursor; if (cursor != null) query = query.startAfterDocument(cursor); final snap = await query.limit(limit).get(); if (snap.docs.isNotEmpty) { _cursor = snap.docs.last; _cursorSort = sort; } return [for (final doc in snap.docs) _question(doc)]; } InsideQuestion _question(QueryDocumentSnapshot> doc) { final data = doc.data(); return InsideQuestion( id: doc.id, title: data.strOr('title'), body: data.strOr('body'), authorName: publicName(data['authorName']), createdAt: data.dateOr('createdAt', DateTime.now()), bountyPop: data.intOr('bountyPop'), answerCount: data.intOr('answerCount'), viewCount: data.intOr('viewCount'), // 질문 문서에 아직 없는 필드다(fn-question-create 가 쓰지 않음). 없으면 0 · 빈 목록. likeCount: data.intOr('likeCount'), imageUrls: [ for (final u in (data['imageUrls'] as List?) ?? const []) '$u', ], isMine: data.strOr('authorUid') == _backend.uid, isSolved: data.strOr('status') == 'accepted', refund: InsideRefund.fromStatus(data.strOr('status')), ); } /// 팬덤과 상관없이 내가 쓴 질문 전부(`authorUid` 등호 하나라 복합 색인이 필요 없다). // ponytail: 정렬은 앱에서 한다. 한 사람의 질문이 수백 건을 넘으면 orderBy + 복합 색인 + 페이지로. @override Future> mine() async { final snap = await _backend.db .collection('questions') .where('authorUid', isEqualTo: _backend.requireUid) .get(); return [for (final doc in snap.docs) _question(doc)] ..sort((a, b) => b.createdAt.compareTo(a.createdAt)); } @override Future balance() async { final wallet = (await _backend.db .doc('users/${_backend.requireUid}/wallet/main') .get()) .data() ?? const {}; return wallet.intOr('activityBalance') + wallet.intOr('supportBalance'); } @override Future minBounty() async { final policy = await _backend.db.doc('config/policy').get(); final value = (policy.data() ?? const {}).intOr('qnaMinBountyPop'); return value > 0 ? value : 100; } /// 실패는 [BackendException] 으로 그대로 올린다 — 화면이 성공처럼 그리지 않게. @override Future create({ required String title, required String body, required int bountyPop, required String clientSeq, }) async { final result = await _backend.call('fnQuestionCreate', { 'title': title, 'body': body, 'bountyPop': bountyPop, 'clientSeq': clientSeq, }); return result.strOr('questionId'); } @override Future> answers(String questionId) async { final snap = await _backend.db .collection('questions/$questionId/answers') .orderBy('createdAt', descending: true) .get(); return [ for (final doc in snap.docs) if (doc.data().strOr('visibility', 'visible') == 'visible') InsideAnswer( id: doc.id, authorName: publicName(doc.data()['authorName']), body: doc.data().strOr('body'), createdAt: doc.data().dateOr('createdAt', DateTime.now()), isMine: doc.data().strOr('authorUid') == _backend.uid, isAccepted: doc.data().boolOr('accepted'), ), ]; } /// 표시명 = 공개 닉네임(`publicName`, 없으면 「팬」). 공급자 실명(`displayName`)은 /// 쓰지 않는다(M-7). 규칙 `authorNameIsPublic` 이 같은 계산으로 대조한다. @override Future answer(String questionId, String body) async { final me = await _me(); await _backend.db.collection('questions/$questionId/answers').add({ 'authorUid': _backend.requireUid, 'authorName': storedPublicName(me['nickname']), 'body': body, 'accepted': false, 'visibility': 'visible', 'createdAt': FieldValue.serverTimestamp(), }); } @override Future accept(String questionId, String answerId) => _backend.call( 'fnAnswerAccept', {'questionId': questionId, 'answerId': answerId}, ); }