import 'package:flutter/material.dart'; import '../../core/ui/app_dialog.dart'; import '../../core/i18n/app_locale.dart'; import '../../core/theme/tokens.dart'; import '../../core/ui/app_icons.dart'; import '../planet/moderation.dart'; import '../translation/translation_data.dart'; import '../translation/translation_comments.dart'; import 'inside_data.dart'; import 'inside_ui.dart'; /// 인사이드 상세 — 화면 대조 원장 F9. /// /// Figma 노드 /// - `207:53130` Inside_인사이드 상세(본인) /// - `201:49678` Inside/인사이드 상세(타인) /// - `201:51871` 신고하기 · `207:52152` 차단하기 /// /// 본인 글과 타인 글이 Figma 에서 별도 화면인 이유는 더보기 동작이 갈리기 때문이다 — /// 본인은 수정·삭제, 타인은 신고·차단. 화면 골격은 같으므로 하나로 두고 동작만 나눈다. /// /// 댓글(답변)은 [repository] 가 있으면 `answers` 하위 컬렉션에서 읽고, 없으면 [answers] /// 를 그대로 그린다(화면 대조 · 시연). 질문자는 남의 답변 하나를 채택할 수 있고 /// (명세 2.64 · `fn-answer-accept`), 채택은 되돌릴 수 없다. class InsideDetailScreen extends StatefulWidget { const InsideDetailScreen({ super.key, required this.question, this.answers = const [], this.repository, this.translation, }); final InsideQuestion question; final List answers; final InsideRepository? repository; /// 참여형 번역(FR-I18N-02 · B-63). 있으면 댓글 입력의 지구본으로 번역 댓글을 쓰고, /// 번역 댓글을 댓글 목록에 지구본 표시와 함께 그린다. final TranslationRepository? translation; @override State createState() => _InsideDetailScreenState(); } class _InsideDetailScreenState extends State { late InsideQuestion question = widget.question; late List answers = widget.answers; bool _busy = false; /// 번역할 댓글(대댓글 버튼으로 고른다). 없으면 번역 댓글은 질문 본문의 번역이다. TranslationReplyTarget? _replyTo; TranslationRepository? get _translation => translationSurfaces.contains(TranslationSurface.inside) ? widget.translation : null; String get _root => 'questions/${question.id}'; @override void initState() { super.initState(); _reload(); } Future _reload() async { final repo = widget.repository; if (repo == null) return; try { final next = await repo.answers(question.id); if (mounted) setState(() => answers = next); } catch (error) { if (mounted) _notice(insideErrorMessage(error)); } } void _notice(String message) => ScaffoldMessenger.of(context) ..hideCurrentSnackBar() ..showSnackBar(SnackBar(content: Text(message))); /// 답변 등록. 저장이 끝난 뒤에만 목록을 다시 읽는다 — 실패를 성공처럼 그리지 않게. Future _send(String body) async { final repo = widget.repository; if (repo == null) { _notice(context.l10n.insideAnswerLater); return false; } try { await repo.answer(question.id, body); } catch (error) { if (mounted) _notice(insideErrorMessage(error)); return false; } await _reload(); return true; } /// 채택 가능한 답변 — 본인 질문 · 아직 열림 · 남의 답변. bool _canAccept(InsideAnswer a) => widget.repository != null && question.isMine && question.isOpen && !a.isMine && a.id.isNotEmpty; Future _accept(InsideAnswer a) async { final ok = await showConfirmDialog( context, context.l10n.insideAcceptConfirm('${question.bountyPop}'), confirmLabel: context.l10n.insideAccept, ); if (!ok || _busy || !mounted) return; setState(() => _busy = true); try { await widget.repository!.accept(question.id, a.id); if (mounted) setState(() => question = question.solvedCopy()); } catch (error) { if (mounted) _notice(insideErrorMessage(error)); } finally { if (mounted) setState(() => _busy = false); } await _reload(); } @override Widget build(BuildContext context) { return Scaffold( appBar: AppBar( actions: [ IconButton( tooltip: context.l10n.insideMore, icon: const ImageIcon(AppIcons.ellipsisVertical, size: 24), onPressed: () => _showMore(context), ), const SizedBox(width: AppSpace.s1), ], ), body: SafeArea( top: false, child: Column( children: [ Expanded( child: ListView( padding: EdgeInsets.zero, children: [ _Post(question: question), // 시안은 본문과 댓글 사이를 검정 8 로 끊는다. const ColoredBox( color: Colors.black, child: SizedBox( height: AppSpace.s2, width: double.infinity, ), ), Padding( padding: const EdgeInsets.all(AppSpace.s4), child: switch (_translation) { final t? => TranslationThreads( repository: t, rootPath: _root, builder: (_, byTarget) => _comments(t, byTarget), ), null => _comments(null, const {}), }, ), ], ), ), CommentInputBar( hint: context.l10n.insideAnswerHint, onComment: _send, translation: _translation, rootPath: _root, replyTo: _replyTo, onClearReply: () => setState(() => _replyTo = null), ), ], ), ), ); } /// 댓글 목록 — 댓글마다 그 댓글의 번역 댓글을 대댓글 자리에, 질문 본문의 번역 댓글은 /// 댓글 뒤에 붙인다(시안 `201:50554`). Widget _comments( TranslationRepository? t, Map> byTarget, ) { final ownTranslations = byTarget[_root] ?? const []; if (answers.isEmpty && ownTranslations.isEmpty) return const _NoAnswer(); const divider = Divider(height: 33, thickness: 1, color: InsideColors.rule); return Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ for (final (i, a) in answers.indexed) ...[ if (i > 0) divider, _AnswerThread( answer: a, onAccept: _canAccept(a) ? () => _accept(a) : null, onTranslate: t != null && a.id.isNotEmpty ? () => setState( () => _replyTo = ( path: '$_root/answers/${a.id}', name: a.authorName, ), ) : null, ), for (final e in byTarget['$_root/answers/${a.id}'] ?? const []) ...[ const SizedBox(height: AppSpace.s5), TranslationCommentCard(entry: e, repository: t!, reply: true), ], ], for (final (i, e) in ownTranslations.indexed) ...[ if (answers.isNotEmpty || i > 0) divider, TranslationCommentCard(entry: e, repository: t!), ], ], ); } /// 명세대로 본인 글과 타인 글의 선택지를 갈라 준다. void _showMore(BuildContext context) { showModalBottomSheet( context: context, builder: (sheet) => SafeArea( child: Column( mainAxisSize: MainAxisSize.min, children: question.isMine ? [ ListTile( title: Text(context.l10n.commonEditAction), onTap: () => Navigator.of(sheet).pop(), ), ListTile( title: Text(context.l10n.commonDeleteAction), onTap: () => Navigator.of(sheet).pop(), ), ] : [ ListTile( title: Text(context.l10n.reportTitle), onTap: () { Navigator.of(sheet).pop(); Navigator.of(context).push( MaterialPageRoute( builder: (_) => ReportScreen( target: ModerationTarget( authorName: question.authorName, title: question.title, ), ), ), ); }, ), ListTile( title: Text(context.l10n.commonBlockAction), onTap: () { Navigator.of(sheet).pop(); confirmBlock(context, question.authorName); }, ), ], ), ), ); } } /// 상세 시각 — 시안 `2026.04.27 09:00`. String insideStamp(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)}'; } class _Post extends StatelessWidget { const _Post({required this.question}); final InsideQuestion question; @override Widget build(BuildContext context) => Padding( padding: const EdgeInsets.all(AppSpace.s4), child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ InsideAuthor( name: question.authorName, meta: insideStamp(question.createdAt), ), if (question.bountyPop > 0) InsideBountyChip(question: question), ], ), if (question.refund != InsideRefund.none) ...[ const SizedBox(height: AppSpace.s2), Text( question.refund.notice, style: insideText(12, color: AppColors.onCosmicMuted), ), ], const SizedBox(height: AppSpace.s3), Text(question.title, style: insideText(22)), const SizedBox(height: AppSpace.s3), Text( question.body, style: insideText(16, color: AppColors.onCosmicVariant), ), for (final (i, url) in question.imageUrls.indexed) ...[ SizedBox(height: i == 0 ? AppSpace.s5 : AppSpace.s3), ClipRRect( borderRadius: BorderRadius.circular(AppRadius.sm), child: AspectRatio( aspectRatio: 1, child: Image(image: insideImage(url), fit: BoxFit.cover), ), ), ], const SizedBox(height: AppSpace.s4), InsideStats( question: question, size: 18, textSize: 14, color: InsideColors.chipLabel, ), ], ), ); } class _NoAnswer extends StatelessWidget { const _NoAnswer(); @override Widget build(BuildContext context) => Padding( padding: const EdgeInsets.symmetric(vertical: AppSpace.s4), child: Column( children: [ const ImageIcon( AppIcons.messageCircle, size: 48, color: InsideColors.stat, ), const SizedBox(height: AppSpace.s4), Text( context.l10n.insideFirstAnswer, style: insideText(12, color: AppColors.onCosmicVariant), ), ], ), ); } /// 댓글 하나와 그 대댓글 — 댓글과 대댓글 사이 24. class _AnswerThread extends StatelessWidget { const _AnswerThread({required this.answer, this.onAccept, this.onTranslate}); final InsideAnswer answer; final VoidCallback? onAccept; final VoidCallback? onTranslate; @override Widget build(BuildContext context) => Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ _Answer( answer: answer, reply: false, onAccept: onAccept, onTranslate: onTranslate, ), for (final r in answer.replies) ...[ const SizedBox(height: AppSpace.s5), Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ const ImageIcon( AppIcons.cornerDownRight, size: 20, color: AppColors.onCosmicMuted, ), const SizedBox(width: AppSpace.s3), Expanded( child: Container( padding: const EdgeInsets.all(6), decoration: BoxDecoration( // 본인 댓글은 분홍 10%, 나머지는 #1a1a1c. color: r.isMine ? AppColors.cosmicAccent.withValues(alpha: 0.1) : AppColors.cosmicSurfaceContainer, borderRadius: BorderRadius.circular(2), ), child: _Answer(answer: r, reply: true), ), ), ], ), ], ], ); } class _Answer extends StatelessWidget { const _Answer({ required this.answer, required this.reply, this.onAccept, this.onTranslate, }); final InsideAnswer answer; final bool reply; /// 대댓글 버튼 — 이 댓글을 골라 번역 댓글을 단다(명세 「댓글 선택 UI 변경 후 키보드 노출」). final VoidCallback? onTranslate; /// 「채택하기」 — 질문자에게만, 채택 전에만 붙는다(명세 2.64-3). final VoidCallback? onAccept; @override Widget build(BuildContext context) => Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ InsideAuthor(name: answer.authorName), const Spacer(), if (answer.isAccepted) _AcceptChip( label: context.l10n.insideAcceptDone, color: AppColors.cosmicBlue, ) else if (onAccept != null) GestureDetector( onTap: onAccept, child: _AcceptChip( label: context.l10n.insideAcceptAction, color: AppColors.onCosmic, ), ), if (answer.isAccepted || onAccept != null) const SizedBox(width: AppSpace.s2), // 댓글 동작 칸 — #1a1a1a · 아이콘 16 · 사이 12. 대댓글에는 답글이 없다. Container( color: InsideColors.rule, padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 5), child: Row( children: [ const ImageIcon( AppIcons.heart, size: 16, color: InsideColors.stat, ), if (!reply) ...[ const SizedBox(width: AppSpace.s3), Semantics( button: onTranslate != null, label: onTranslate == null ? null : context.l10n.translationWrite, child: GestureDetector( onTap: onTranslate, child: const ImageIcon( AppIcons.messageCircle, size: 16, color: InsideColors.stat, ), ), ), ], const SizedBox(width: AppSpace.s3), const ImageIcon( AppIcons.ellipsisVertical, size: 16, color: InsideColors.stat, ), ], ), ), ], ), const SizedBox(height: AppSpace.s3), Text(answer.body, style: insideText(14)), const SizedBox(height: AppSpace.s3), Text( insideStamp(answer.createdAt), style: insideText(10, color: AppColors.onCosmicMuted), ), ], ); } /// 채택 칩 — 「채택하기」 버튼과 「채택 완료」 표시가 같은 모양을 쓴다. class _AcceptChip extends StatelessWidget { const _AcceptChip({required this.label, required this.color}); final String label; final Color color; @override Widget build(BuildContext context) => Container( padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), decoration: BoxDecoration( border: Border.all(color: color), borderRadius: BorderRadius.circular(2), ), child: Text(label, style: insideText(12, color: color, height: 1)), ); }