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() ?.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 _run( BuildContext context, Future 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 Function(String body)? onComment; final TranslationRepository? translation; final String? rootPath; final TranslationReplyTarget? replyTo; final VoidCallback? onClearReply; /// 일반 댓글 글자 수 상한(규칙과 같은 수). 번역 댓글은 서버 상한 2,000자. final int? maxLength; @override State createState() => _CommentInputBarState(); } class _CommentInputBarState extends State { 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 _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 createState() => _TranslationCommentCardState(); } class _TranslationCommentCardState extends State { 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 _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( // 테마의 minimumSize(폭 무한)를 끊는다 — Row 안 두 버튼이 나란히 보이게. minimumSize: const Size(0, 36), 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> byTarget, ) builder; @override State createState() => _TranslationThreadsState(); } class _TranslationThreadsState extends State { int _limit = translationPageSize; late Stream> _stream = _watch(); Stream> _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>( stream: _stream, builder: (context, snap) { final rows = snap.data ?? const []; final byTarget = >{}; 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), ), ], ); }, ); }