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