INSIDE · STATION · 참여형 번역 · 알림 · MY 화면
This commit is contained in:
@@ -0,0 +1,334 @@
|
||||
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<TranslationSurface> 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<List<TranslationEntry>> watch(
|
||||
String rootPath, {
|
||||
int limit = translationPageSize,
|
||||
});
|
||||
|
||||
/// 번역 등록. 같은 콘텐츠·언어 재등록은 [TranslationFailure.duplicate].
|
||||
Future<void> submit(String targetPath, String locale, String body);
|
||||
|
||||
/// 유효/무효 마킹. 본인 번역은 [TranslationFailure.selfMark], 두 번째는 [TranslationFailure.alreadyMarked].
|
||||
Future<void> mark(TranslationEntry entry, {required bool valid});
|
||||
}
|
||||
|
||||
class FirestoreTranslationRepository implements TranslationRepository {
|
||||
FirestoreTranslationRepository(this._backend);
|
||||
|
||||
final Backend _backend;
|
||||
|
||||
@override
|
||||
String? get uid => _backend.uid;
|
||||
|
||||
@override
|
||||
Stream<List<TranslationEntry>> 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<TranslationEntry> _entry(
|
||||
QueryDocumentSnapshot<Map<String, dynamic>> 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<void> 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<void> 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<SampleTranslation>? seed})
|
||||
: _rows = [...?seed];
|
||||
|
||||
@override
|
||||
final String? uid;
|
||||
|
||||
final List<SampleTranslation> _rows;
|
||||
final _changes = StreamController<void>.broadcast();
|
||||
|
||||
List<TranslationEntry> _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<List<TranslationEntry>> 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<void> 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<void> 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<String, bool> marks = {};
|
||||
}
|
||||
Reference in New Issue
Block a user