Files
fanuzu/app/lib/features/inside/inside_detail_screen.dart

517 lines
16 KiB
Dart

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<InsideAnswer> answers;
final InsideRepository? repository;
/// 참여형 번역(FR-I18N-02 · B-63). 있으면 댓글 입력의 지구본으로 번역 댓글을 쓰고,
/// 번역 댓글을 댓글 목록에 지구본 표시와 함께 그린다.
final TranslationRepository? translation;
@override
State<InsideDetailScreen> createState() => _InsideDetailScreenState();
}
class _InsideDetailScreenState extends State<InsideDetailScreen> {
late InsideQuestion question = widget.question;
late List<InsideAnswer> 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<void> _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<bool> _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<void> _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<String, List<TranslationEntry>> 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<void>(
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<String>(
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)),
);
}