INSIDE · STATION · 참여형 번역 · 알림 · MY 화면

This commit is contained in:
Sn-Kinos
2026-09-29 17:06:48 +09:00
parent 18afc9cb98
commit 3aca678072
31 changed files with 10046 additions and 25 deletions
@@ -0,0 +1,570 @@
import 'package:flutter/material.dart';
import '../../core/data/backend.dart' show publicName;
import '../../core/i18n/app_locale.dart';
import '../../core/theme/tokens.dart';
import '../../core/ui/app_icons.dart';
import 'translation_data.dart';
/// 참여형 번역 — 「번역 댓글」 UI (FR-I18N-02 · 원장 B-63 · 해석 확인 요청서 i18n-참여형번역 2.1).
///
/// Figma
/// - `540:19136` · `527:21198` 댓글 입력 영역 — 「번역 댓글 작성 버튼(지구본) · 클릭 시 키보드
/// 노출 · 입력 필드 색 변경」 → [CommentInputBar]
/// - `201:50554` 번역 댓글 카드 — 대댓글 자리 · 지구본 표시 · 분홍 바탕 → [TranslationCommentCard]
/// - 유효/무효 버튼은 Figma 카드에 없으나 마킹을 하려면 필요한 요소라 번역 댓글 카드에만 둔다
/// (공통 기준 5항).
///
/// 쓰는 화면: INSIDE 상세 · 캠페인 응원 게시판 · 팬덤 라운지 글 상세([translationSurfaces]).
/// 트리 아래 화면(캠페인 상세 · 라운지 글 상세)이 번역 저장소를 찾는 통로. 앱 루트에 한 번 둔다.
class TranslationScope extends InheritedWidget {
const TranslationScope({
super.key,
required this.repository,
required super.child,
});
final TranslationRepository repository;
static TranslationRepository? maybeOf(BuildContext context) => context
.dependOnInheritedWidgetOfExactType<TranslationScope>()
?.repository;
@override
bool updateShouldNotify(TranslationScope old) => old.repository != repository;
}
/// 번역 댓글이 번역하는 댓글 — 입력 영역 위에 「○○ 님 댓글 번역」으로 뜬다.
typedef TranslationReplyTarget = ({String path, String name});
String _failure(AppLocalizations l10n, TranslationFailure kind) =>
switch (kind) {
TranslationFailure.selfMark => l10n.translationSelfMarkBlocked,
TranslationFailure.alreadyMarked => l10n.translationAlreadyMarked,
TranslationFailure.duplicate => l10n.translationDuplicate,
TranslationFailure.dailyCap => l10n.translationDailyCap,
TranslationFailure.invalidTarget => l10n.translationInvalidTarget,
TranslationFailure.notEligible => l10n.translationNotEligible,
TranslationFailure.other => l10n.translationFailed,
};
/// 등록 · 마킹을 돌리고 결과를 스낵바로 알린다. 성공하면 true.
Future<bool> _run(
BuildContext context,
Future<void> Function() action,
String done,
) async {
final l10n = context.l10n;
final messenger = ScaffoldMessenger.maybeOf(context);
var message = done;
var ok = true;
try {
await action();
} on TranslationException catch (e) {
message = _failure(l10n, e.kind);
ok = false;
} catch (_) {
message = l10n.translationFailed;
ok = false;
}
messenger
?..hideCurrentSnackBar()
..showSnackBar(
SnackBar(content: Text(message), behavior: SnackBarBehavior.floating),
);
return ok;
}
TextStyle _text(double size, [Color color = AppColors.onCosmic]) => TextStyle(
fontFamily: AppFonts.body,
fontSize: size,
fontWeight: FontWeight.w500,
height: 1.3,
letterSpacing: -0.025 * size,
color: color,
);
/// 하단 댓글 입력 — 시안 높이 60 · 지구본 24 · #222225 알약 · 보내기 20.
///
/// 지구본을 누르면 번역 댓글 모드(입력칸 분홍)로 바뀌어 [rootPath] 글 본문의 번역을 쓴다.
/// [replyTo] 가 있으면 그 댓글의 번역이다. [onComment] 가 없으면 번역 댓글만 쓴다.
/// [translation] 이 없으면 지구본을 숨기고 일반 댓글 입력만 남는다.
class CommentInputBar extends StatefulWidget {
const CommentInputBar({
super.key,
required this.hint,
this.onComment,
this.translation,
this.rootPath,
this.replyTo,
this.onClearReply,
this.maxLength,
});
final String hint;
/// 일반 댓글 저장. 성공하면 true — 입력칸을 비운다.
final Future<bool> Function(String body)? onComment;
final TranslationRepository? translation;
final String? rootPath;
final TranslationReplyTarget? replyTo;
final VoidCallback? onClearReply;
/// 일반 댓글 글자 수 상한(규칙과 같은 수). 번역 댓글은 서버 상한 2,000자.
final int? maxLength;
@override
State<CommentInputBar> createState() => _CommentInputBarState();
}
class _CommentInputBarState extends State<CommentInputBar> {
final _controller = TextEditingController();
final _focus = FocusNode();
bool _translateMode = false;
bool _sending = false;
bool get _canTranslate =>
widget.translation != null && widget.rootPath != null;
bool get _translating =>
_canTranslate &&
(_translateMode || widget.replyTo != null || widget.onComment == null);
@override
void didUpdateWidget(CommentInputBar old) {
super.didUpdateWidget(old);
// 댓글을 고르면 키보드를 올린다(명세 「대댓글 버튼 — 댓글 선택 UI 변경 후 키보드 노출」).
if (widget.replyTo != null && widget.replyTo != old.replyTo) {
_focus.requestFocus();
}
}
@override
void dispose() {
_controller.dispose();
_focus.dispose();
super.dispose();
}
void _toggle() {
if (_translating && widget.replyTo != null) widget.onClearReply?.call();
setState(() => _translateMode = !_translating);
if (_translateMode) _focus.requestFocus();
}
Future<void> _send() async {
final body = _controller.text.trim();
if (body.isEmpty || _sending) return;
final onComment = widget.onComment;
if (!_translating && onComment == null) return;
setState(() => _sending = true);
final bool ok;
if (_translating) {
// 번역 언어 = 지금 앱 언어.
final tag = localeTag(
resolveAppLocale([Localizations.localeOf(context)]),
);
final target = widget.replyTo?.path ?? widget.rootPath!;
ok = await _run(
context,
() => widget.translation!.submit(target, tag, body),
context.l10n.translationSubmitted,
);
if (ok) {
_translateMode = false;
widget.onClearReply?.call();
}
} else {
ok = await onComment!(body);
}
if (!mounted) return;
if (ok) _controller.clear();
setState(() => _sending = false);
}
@override
Widget build(BuildContext context) {
final l10n = context.l10n;
final translating = _translating;
final reply = widget.replyTo;
final style = _text(14);
return ColoredBox(
color: AppColors.cosmicVoid,
child: SafeArea(
top: false,
child: Padding(
padding: const EdgeInsets.all(10),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
if (reply != null && translating)
Padding(
padding: const EdgeInsets.only(bottom: 6),
child: Row(
children: [
const ImageIcon(
AppIcons.cornerDownRight,
size: 16,
color: AppColors.onCosmicMuted,
),
const SizedBox(width: 6),
Expanded(
child: Text(
l10n.translationReplyTo(reply.name),
style: _text(12, AppColors.onCosmicVariant),
overflow: TextOverflow.ellipsis,
),
),
IconButton(
tooltip: l10n.commonCancel,
visualDensity: VisualDensity.compact,
onPressed: widget.onClearReply,
icon: const ImageIcon(
AppIcons.x,
size: 16,
color: AppColors.onCosmicMuted,
),
),
],
),
),
Row(
children: [
if (_canTranslate) ...[
Semantics(
button: true,
selected: translating,
label: l10n.translationWrite,
child: GestureDetector(
onTap: widget.onComment == null ? null : _toggle,
child: ImageIcon(
AppIcons.globe,
size: 24,
color: translating
? AppColors.cosmicAccent
: AppColors.onCosmic,
),
),
),
const SizedBox(width: 10),
],
Expanded(
child: AnimatedContainer(
duration: const Duration(milliseconds: 150),
height: 40,
padding: const EdgeInsets.only(left: 16, right: 6),
decoration: ShapeDecoration(
// 명세 「번역 댓글 작성 버튼 — 입력 필드 색 변경」.
color: translating
? Color.alphaBlend(
AppColors.cosmicAccent.withValues(alpha: 0.18),
const Color(0xFF222225),
)
: const Color(0xFF222225),
shape: const StadiumBorder(),
),
child: Row(
children: [
Expanded(
child: TextField(
controller: _controller,
focusNode: _focus,
style: style,
maxLength: translating ? 2000 : widget.maxLength,
buildCounter: _noCounter,
onSubmitted: (_) => _send(),
cursorColor: AppColors.cosmicAccent,
decoration: InputDecoration(
isCollapsed: true,
filled: false,
border: InputBorder.none,
enabledBorder: InputBorder.none,
focusedBorder: InputBorder.none,
hintText: translating
? l10n.translationHint
: widget.hint,
hintStyle: style.copyWith(
color: const Color(0xFF6B6B6B),
),
),
),
),
IconButton(
tooltip: l10n.commonSend,
onPressed: _sending ? null : _send,
icon: const Image(
image: AppIcons.send,
width: 20,
height: 20,
),
),
],
),
),
),
],
),
],
),
),
),
);
}
}
Widget? _noCounter(
BuildContext context, {
required int currentLength,
required bool isFocused,
required int? maxLength,
}) => null;
/// 번역 댓글 카드 — 시안 `201:50554`(분홍 10% 바탕 · 동작 칸의 지구본).
///
/// 「번역함」 표시와 번역 언어를 달고, 다른 멤버가 유효/무효를 한 번 표시한다.
/// 본인 번역도 버튼은 눌린다 — 저장소가 selfMark(E-TRN-01)로 거절하고 그 이유를 띄운다.
/// [reply] 면 댓글 아래 대댓글 자리(↳)에 붙는다.
class TranslationCommentCard extends StatefulWidget {
const TranslationCommentCard({
super.key,
required this.entry,
required this.repository,
this.reply = false,
});
final TranslationEntry entry;
final TranslationRepository repository;
final bool reply;
@override
State<TranslationCommentCard> createState() => _TranslationCommentCardState();
}
class _TranslationCommentCardState extends State<TranslationCommentCard> {
bool _busy = false;
/// 방금 성공한 마킹. 서버 수가 스트림으로 다시 올 때까지 이 모습을 보여 준다.
TranslationEntry? _marked;
TranslationEntry get _entry {
final marked = _marked;
return marked != null &&
marked.id == widget.entry.id &&
widget.entry.myMark == null
? marked
: widget.entry;
}
Future<void> _mark(bool valid) async {
final entry = _entry;
setState(() => _busy = true);
final ok = await _run(
context,
() => widget.repository.mark(entry, valid: valid),
context.l10n.translationMarked,
);
if (!mounted) return;
setState(() {
_busy = false;
if (ok) _marked = entry.marked(valid);
});
}
@override
Widget build(BuildContext context) {
final l10n = context.l10n;
final e = _entry;
final own = e.authorUid == widget.repository.uid;
final canMark = !_busy && !own && e.myMark == null;
final lang = parseLocaleTag(e.locale);
Widget button(bool valid, int count, String label) => Semantics(
button: true,
enabled: canMark,
child: OutlinedButton(
onPressed: !_busy && e.myMark == null ? () => _mark(valid) : null,
style: OutlinedButton.styleFrom(
visualDensity: VisualDensity.compact,
foregroundColor: e.myMark == valid
? AppColors.cosmicAccent
: canMark
? AppColors.onCosmic
: AppColors.onCosmicMuted,
),
child: Text('$label $count'),
),
);
final at = e.createdAt;
final card = Container(
padding: const EdgeInsets.all(10),
decoration: BoxDecoration(
color: AppColors.cosmicAccent.withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(2),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Expanded(
child: Text(
publicName(e.authorName),
style: _text(12),
overflow: TextOverflow.ellipsis,
),
),
// 동작 칸의 지구본 — 번역 댓글 표시(시안 `201:50554`).
Container(
color: const Color(0xFF1A1A1A),
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 5),
child: const ImageIcon(
AppIcons.globe,
size: 16,
color: AppColors.onCosmicVariant,
),
),
],
),
const SizedBox(height: AppSpace.s2),
Row(
children: [
TranslationMark(label: l10n.translationMark),
const SizedBox(width: 6),
Text(
lang == null ? e.locale : languageName(lang),
style: _text(11, AppColors.onCosmicMuted),
),
],
),
const SizedBox(height: AppSpace.s2),
Text(e.body, style: _text(14)),
if (at != null) ...[
const SizedBox(height: AppSpace.s2),
Text(_stamp(at), style: _text(10, AppColors.onCosmicMuted)),
],
// 정산이 끝난 번역(확정 · 제외)은 더 마킹하지 않는다.
if (e.markable) ...[
const SizedBox(height: AppSpace.s2),
Row(
children: [
button(true, e.validCount, l10n.translationValid),
const SizedBox(width: AppSpace.s2),
button(false, e.invalidCount, l10n.translationInvalid),
],
),
],
],
),
);
if (!widget.reply) return card;
return Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const ImageIcon(
AppIcons.cornerDownRight,
size: 20,
color: AppColors.onCosmicMuted,
),
const SizedBox(width: AppSpace.s3),
Expanded(child: card),
],
);
}
}
/// 시안 댓글 시각 `2026.04.27 09:00`.
String _stamp(DateTime at) {
String two(int v) => v.toString().padLeft(2, '0');
return '${at.year}.${two(at.month)}.${two(at.day)} ${two(at.hour)}:${two(at.minute)}';
}
/// 「번역함」 표시 — 사용자가 등록한 번역임을 원문과 구분한다(FR-I18N-02).
class TranslationMark extends StatelessWidget {
const TranslationMark({super.key, required this.label});
final String label;
@override
Widget build(BuildContext context) => Container(
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
decoration: BoxDecoration(
border: Border.all(color: AppColors.cosmicAccent),
borderRadius: BorderRadius.circular(AppRadius.xs),
),
child: Text(
label,
style: Theme.of(
context,
).textTheme.labelSmall?.copyWith(color: AppColors.cosmicAccent),
),
);
}
/// [rootPath] 글에 달린 번역 댓글을 원문별로 묶어 [builder] 에 넘긴다.
/// 키는 원문 경로 — 글 본문이면 [rootPath], 댓글이면 그 댓글 경로.
class TranslationThreads extends StatefulWidget {
const TranslationThreads({
super.key,
required this.repository,
required this.rootPath,
required this.builder,
});
final TranslationRepository repository;
final String rootPath;
final Widget Function(
BuildContext context,
Map<String, List<TranslationEntry>> byTarget,
)
builder;
@override
State<TranslationThreads> createState() => _TranslationThreadsState();
}
class _TranslationThreadsState extends State<TranslationThreads> {
int _limit = translationPageSize;
late Stream<List<TranslationEntry>> _stream = _watch();
Stream<List<TranslationEntry>> _watch() =>
widget.repository.watch(widget.rootPath, limit: _limit);
@override
void didUpdateWidget(TranslationThreads old) {
super.didUpdateWidget(old);
if (old.rootPath != widget.rootPath ||
old.repository != widget.repository) {
_limit = translationPageSize;
_stream = _watch();
}
}
@override
Widget build(BuildContext context) => StreamBuilder<List<TranslationEntry>>(
stream: _stream,
builder: (context, snap) {
final rows = snap.data ?? const <TranslationEntry>[];
final byTarget = <String, List<TranslationEntry>>{};
for (final e in rows) {
(byTarget[e.targetPath] ??= []).add(e);
}
final list = widget.builder(context, byTarget);
// 한 페이지가 꽉 찼으면 더 있을 수 있다 — 한 페이지 더 늘려 다시 구독한다(최신순).
if (rows.length < _limit) return list;
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
list,
TextButton(
onPressed: () => setState(() {
_limit += translationPageSize;
_stream = _watch();
}),
child: Text(context.l10n.commonMore),
),
],
);
},
);
}
@@ -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 = {};
}