186 lines
6.7 KiB
Dart
186 lines
6.7 KiB
Dart
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<String, dynamic>? _user;
|
|
|
|
Future<Map<String, dynamic>> _me() async => _user ??=
|
|
(await _backend.db.doc('users/${_backend.requireUid}').get()).data() ??
|
|
const {};
|
|
|
|
Future<String> _resolveFandomId() async => (await _me()).strOr('fandomId');
|
|
|
|
DocumentSnapshot<Map<String, dynamic>>? _cursor;
|
|
InsideSort? _cursorSort;
|
|
|
|
static const int pageSize = 5;
|
|
|
|
Query<Map<String, dynamic>> _query(InsideSort sort, String fandomId) {
|
|
Query<Map<String, dynamic>> q = _backend.db.collection('questions');
|
|
if (fandomId.isNotEmpty) {
|
|
q = q.where('fandomId', isEqualTo: fandomId);
|
|
}
|
|
return q.orderBy(sort.field, descending: true);
|
|
}
|
|
|
|
@override
|
|
Future<InsideSnapshot> 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<List<InsideQuestion>> 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<Map<String, dynamic>> 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<List<InsideQuestion>> 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<int> 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<int> 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<String> 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<List<InsideAnswer>> 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<void> 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<void> accept(String questionId, String answerId) => _backend.call(
|
|
'fnAnswerAccept',
|
|
{'questionId': questionId, 'answerId': answerId},
|
|
);
|
|
}
|