528 lines
17 KiB
Dart
528 lines
17 KiB
Dart
import 'package:flutter/material.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 showDialog<bool>(
|
|
context: context,
|
|
builder: (dialog) => AlertDialog(
|
|
content: Text(
|
|
context.l10n.insideAcceptConfirm('${question.bountyPop}'),
|
|
),
|
|
actions: [
|
|
TextButton(
|
|
onPressed: () => Navigator.of(dialog).pop(false),
|
|
child: Text(context.l10n.commonCancel),
|
|
),
|
|
TextButton(
|
|
onPressed: () => Navigator.of(dialog).pop(true),
|
|
child: Text(context.l10n.insideAccept),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
if (ok != true || _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)),
|
|
);
|
|
}
|