INSIDE · STATION · 참여형 번역 · 알림 · MY 화면
This commit is contained in:
@@ -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),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user