import 'dart:async'; import 'package:cloud_firestore/cloud_firestore.dart'; import '../../core/data/backend.dart'; /// 참여형 번역 — 요구사항 FR-I18N-02 · 정책 원장 B-63 · 별첨 1 ⑧. /// /// 데이터 관계(정책 원장 B-63): 번역 등록 → 「번역함」 표시 → 다른 멤버 유효/무효 마킹 → /// 24시간 pending → `fn-translation-settle` 확정 → 일반 POP 5. /// /// 화면에서는 댓글 영역의 「번역 댓글」이다(Figma `201:50554` · `540:19136` · 해석 확인 /// 요청서 i18n-참여형번역 2.1). 하단 입력의 지구본으로 쓰면 글 본문의 번역, 댓글을 골라 /// 쓰면 그 댓글의 번역이다(🟡 Q-TR-07 권고 A안). /// /// 저장 위치는 `translationEntries/{id}`(등록은 `fn-translation-submit` 만) 와 /// `translationEntries/{id}/markings/{uid}`(`valid` bool · 쓰기는 `fn-translation-mark` 만). /// 번역이 붙는 콘텐츠는 [targetPath](Firestore 문서 경로)로, 그 콘텐츠가 속한 글은 /// [translationRootOf] 로 가리킨다. class TranslationEntry { const TranslationEntry({ required this.id, required this.authorUid, required this.body, required this.locale, this.targetPath = '', this.authorName = '', this.createdAt, this.validCount = 0, this.invalidCount = 0, this.myMark, this.status = 'pending', }); final String id; final String authorUid; final String body; /// 번역한 원문 — 글 본문(`posts/{id}`) 또는 댓글(`posts/{id}/comments/{cid}`). final String targetPath; final String authorName; final DateTime? createdAt; /// `ko` · `en` · `ja` · `zh-CN` · `es`. final String locale; /// 작성자 본인 마킹은 세지 않은 수(fn-translation-settle 과 같은 셈). final int validCount; final int invalidCount; /// 내가 남긴 마킹. null 이면 아직 안 했다. final bool? myMark; /// `pending` 일 때만 마킹을 받는다 — 정산이 끝나면(`confirmed` · `excluded`) 버튼을 숨긴다. final String status; bool get markable => status == 'pending'; /// 마킹이 성공한 직후의 모습 — 스트림이 다시 오기 전에 화면이 먼저 바꾼다. TranslationEntry marked(bool valid) => TranslationEntry( id: id, authorUid: authorUid, body: body, locale: locale, targetPath: targetPath, authorName: authorName, createdAt: createdAt, validCount: validCount + (valid ? 1 : 0), invalidCount: invalidCount + (valid ? 0 : 1), myMark: valid, status: status, ); } /// 번역 댓글을 여는 화면 — 🟡 Q-TR-06 권고 B안(세 곳 모두). 회신이 A안이면 /// [TranslationSurface.lounge] 를, C안이면 캠페인까지 여기서 뺀다. enum TranslationSurface { inside, campaignBoard, lounge } const Set translationSurfaces = { TranslationSurface.inside, TranslationSurface.campaignBoard, TranslationSurface.lounge, }; /// 콘텐츠 경로가 속한 글 — `posts/p1/comments/c1` → `posts/p1`. 서버 `rootPath` 와 같은 셈. String translationRootOf(String targetPath) => targetPath.split('/').take(2).join('/'); /// 번역 댓글 한 페이지 — 「더 보기」마다 이만큼 늘린다. const int translationPageSize = 50; /// 화면이 잡는 번역 실패. 문구는 화면이 [kind] 로 고른다. enum TranslationFailure { /// E-TRN-01 — 본인 번역 마킹(정책 설계서 §P-13). selfMark, /// E-TRN-05 — 같은 번역에 두 번째 마킹. alreadyMarked, /// E-TRN-05 — 같은 콘텐츠·언어에 두 번째 등록. duplicate, /// E-TRN-02 — 하루 등록 한도 초과. dailyCap, /// E-TRN-03 — 번역할 수 없는(없거나 허용 밖) 콘텐츠. invalidTarget, /// E-TRN-04 — 팬덤 멤버가 아니거나 가입 24시간 미만. notEligible, other, } /// 서버 카탈로그 코드 → 화면 실패 종류. [forMark] 면 E-TRN-05 를 중복 마킹으로 읽는다. TranslationFailure translationFailureOf(String code, {required bool forMark}) => switch (code) { 'E-TRN-01' => TranslationFailure.selfMark, 'E-TRN-02' => TranslationFailure.dailyCap, 'E-TRN-03' => TranslationFailure.invalidTarget, 'E-TRN-04' => TranslationFailure.notEligible, 'E-TRN-05' => forMark ? TranslationFailure.alreadyMarked : TranslationFailure.duplicate, _ => TranslationFailure.other, }; class TranslationException implements Exception { const TranslationException(this.kind); final TranslationFailure kind; @override String toString() => 'TranslationException($kind)'; } abstract class TranslationRepository { /// 로그인 사용자. 자기 마킹 차단(E-TRN-01)을 화면이 먼저 판정하는 데 쓴다. String? get uid; /// [rootPath] 글과 그 댓글에 달린 번역 댓글(모든 언어 · 최신순) 최대 [limit] 건. /// 더 보려면 [limit] 을 늘려 다시 구독한다. Stream> watch( String rootPath, { int limit = translationPageSize, }); /// 번역 등록. 같은 콘텐츠·언어 재등록은 [TranslationFailure.duplicate]. Future submit(String targetPath, String locale, String body); /// 유효/무효 마킹. 본인 번역은 [TranslationFailure.selfMark], 두 번째는 [TranslationFailure.alreadyMarked]. Future mark(TranslationEntry entry, {required bool valid}); } class FirestoreTranslationRepository implements TranslationRepository { FirestoreTranslationRepository(this._backend); final Backend _backend; @override String? get uid => _backend.uid; @override Stream> watch( String rootPath, { int limit = translationPageSize, }) => _backend.db .collection('translationEntries') .where('rootPath', isEqualTo: rootPath) .orderBy('createdAt', descending: true) .limit(limit) .snapshots() .asyncMap((snap) => Future.wait(snap.docs.map(_entry))); Future _entry( QueryDocumentSnapshot> doc, ) async { final data = doc.data(); final author = data.strOr('authorUid'); // 수는 번역 문서에 있다(fn-translation-mark 가 같은 트랜잭션에서 올린다). 내 마킹만 한 건 // 읽는다 — 규칙이 남의 마킹 목록을 막는다. final me = uid; bool? mine; if (me != null && me != author) { final v = (await doc.reference.collection('markings').doc(me).get()) .data()?['valid']; if (v is bool) mine = v; } return TranslationEntry( id: doc.id, authorUid: author, body: data.strOr('body'), locale: data.strOr('locale'), targetPath: data.strOr('targetPath'), authorName: publicName(data['authorName']), createdAt: data.dateOr('createdAt', DateTime.now()), validCount: data.intOr('validCount'), invalidCount: data.intOr('invalidCount'), myMark: mine, status: data.strOr('status', 'pending'), ); } @override Future submit(String targetPath, String locale, String body) async { try { await _backend.call('fnTranslationSubmit', { 'targetPath': targetPath, 'locale': locale, 'body': body, }); } on BackendException catch (e) { throw TranslationException(translationFailureOf(e.code, forMark: false)); } } @override Future mark(TranslationEntry entry, {required bool valid}) async { final me = _backend.requireUid; if (entry.authorUid == me) { throw const TranslationException(TranslationFailure.selfMark); } if (entry.myMark != null) { throw const TranslationException(TranslationFailure.alreadyMarked); } // 마킹은 서버 경유다 — 팬덤 멤버 · 가입 24시간 판정(E-TRN-04)을 규칙으로는 못 한다. try { await _backend.call('fnTranslationMark', { 'entryId': entry.id, 'valid': valid, }); } on BackendException catch (e) { throw TranslationException(translationFailureOf(e.code, forMark: true)); } } } /// 목 모드 · 위젯 테스트용. 서버 판정(중복 등록 · 자기 마킹 · 중복 마킹)을 같은 규칙으로 흉내 낸다. class SampleTranslationRepository implements TranslationRepository { SampleTranslationRepository({this.uid = 'me', List? seed}) : _rows = [...?seed]; @override final String? uid; final List _rows; final _changes = StreamController.broadcast(); List _list(String rootPath, int limit) => [ for (final r in _rows.reversed) if (translationRootOf(r.targetPath) == rootPath) TranslationEntry( id: r.id, authorUid: r.authorUid, body: r.body, locale: r.locale, targetPath: r.targetPath, authorName: r.authorName, createdAt: r.createdAt, validCount: r.marks.entries .where((e) => e.key != r.authorUid && e.value) .length, invalidCount: r.marks.entries .where((e) => e.key != r.authorUid && !e.value) .length, myMark: r.marks[uid], ), ].take(limit).toList(); @override Stream> watch( String rootPath, { int limit = translationPageSize, }) => Stream.multi((out) { out.add(_list(rootPath, limit)); final sub = _changes.stream.listen((_) => out.add(_list(rootPath, limit))); out.onCancel = sub.cancel; }); @override Future submit(String targetPath, String locale, String body) async { if (_rows.any( (r) => r.targetPath == targetPath && r.locale == locale && r.authorUid == uid, )) { throw const TranslationException(TranslationFailure.duplicate); } _rows.add( SampleTranslation( id: 's${_rows.length + 1}', targetPath: targetPath, locale: locale, authorUid: uid ?? '', body: body.trim(), ), ); _changes.add(null); } @override Future mark(TranslationEntry entry, {required bool valid}) async { if (entry.authorUid == uid) { throw const TranslationException(TranslationFailure.selfMark); } final row = _rows.firstWhere((r) => r.id == entry.id); if (row.marks.containsKey(uid)) { throw const TranslationException(TranslationFailure.alreadyMarked); } row.marks[uid ?? ''] = valid; _changes.add(null); } } class SampleTranslation { SampleTranslation({ required this.id, required this.targetPath, required this.locale, required this.authorUid, required this.body, this.authorName = '', DateTime? createdAt, }) : createdAt = createdAt ?? DateTime.now(); final String id; final String targetPath; final String locale; final String authorUid; final String body; final String authorName; final DateTime createdAt; final Map marks = {}; }