Files
fanuzu/app/lib/features/planet/lounge_post_screens.dart

792 lines
26 KiB
Dart

import 'package:firebase_core/firebase_core.dart' show FirebaseException;
import 'package:flutter/material.dart';
import 'package:share_plus/share_plus.dart';
import '../../core/data/backend.dart' show BackendException;
import '../../core/theme/tokens.dart';
import '../../core/ui/app_icons.dart';
import '../../core/ui/relative_time.dart';
import '../campaign/campaign_share.dart' show campaignShareText;
import '../my/my_data.dart' show PickedPhoto, PhotoRejected;
import '../my/photo_picker.dart';
import '../translation/translation_comments.dart';
import '../translation/translation_data.dart';
import 'moderation.dart';
import 'planet_data.dart';
import '../../core/i18n/app_locale.dart';
/// 글 상세 · 글 쓰기 — 화면 대조 원장 F3.
///
/// Figma 노드
/// - `244:22509` Galaxy/행성 상세/글 상세 · `373:439127` 홈/…/글 상세
/// - `244:21746` Galaxy/행성 상세_팬덤 라운지/글 쓰기 · `373:437905` 홈/…/글 쓰기
/// - 신고·차단(`252:162674` · `252:163306` · `251:51469`)은 [ReportScreen] 공용
/// 글 쓰기가 돌려주는 값.
class PostDraft {
const PostDraft({
required this.title,
required this.body,
this.photos = const [],
});
final String title;
final String body;
/// 첨부 사진. 1차 검증([checkLoungePhotos])을 지난 것만 담긴다.
final List<PickedPhoto> photos;
}
/// 라운지 쓰기 실패를 사용자 문구로. 사유가 있는 실패는 그 문구를, 규칙 거부는
/// 권한 안내를, 나머지는 재시도 안내를 띄운다.
String loungeError(Object error) => switch (error) {
PhotoRejected(:final message) => message,
BackendException(:final message) => message,
FirebaseException(code: 'permission-denied') => appL10n.loungeMembersOnly,
_ => appL10n.loungeFailed,
};
/// 라운지 글 공유 — 시스템 공유 시트로 본문 문구를 보낸다.
Future<void> shareLoungePost(LoungePost post) => SharePlus.instance.share(
ShareParams(
text: campaignShareText(
title: loungePostText(post),
topic: appL10n.loungeTitle,
),
),
);
void _toast(BuildContext context, String message) =>
ScaffoldMessenger.of(context)
..hideCurrentSnackBar()
..showSnackBar(SnackBar(content: Text(message)));
/// 글 한 편의 본문. 설계서 §2.6 `posts` 에는 제목이 없어(서버 글은 [LoungePost.title]
/// 이 빈 값) Figma 도 본문 한 덩어리만 둔다. 표본 글처럼 제목이 있으면 첫 줄로 붙인다.
String loungePostText(LoungePost p) =>
[p.title, p.body].where((s) => s.isNotEmpty).join('\n');
/// Figma 글 문구 공통 — 42dot Sans 500 · 행간 130% · 자간 -2.5%.
TextStyle loungeText(double size, Color color) => TextStyle(
fontFamily: AppFonts.body,
fontSize: size,
fontWeight: FontWeight.w500,
height: 1.3,
letterSpacing: size * -0.025,
color: color,
);
/// 에셋 경로 또는 URL.
ImageProvider loungeImage(String src) =>
src.startsWith('http') ? NetworkImage(src) : AssetImage(src);
/// 작성자 줄 — 아바타 26 · 간격 9 · 닉네임 ABeeZee 12 / 시각 10 #767676.
class LoungeAuthorRow extends StatelessWidget {
const LoungeAuthorRow({super.key, required this.post, this.trailing});
final LoungePost post;
final Widget? trailing;
@override
Widget build(BuildContext context) => Row(
children: [
CircleAvatar(
radius: 13,
backgroundColor: const Color(0xFFD9D9D9),
backgroundImage: switch (post.authorPhoto) {
final p? => loungeImage(p),
null => null,
},
),
const SizedBox(width: 9),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
post.authorName,
style: loungeText(12, AppColors.onCosmic).copyWith(
fontFamily: AppFonts.display,
fontWeight: FontWeight.w400,
),
),
Text(
relativeTime(post.createdAt),
style: loungeText(10, AppColors.onCosmicMuted),
),
],
),
),
?trailing,
],
);
}
/// 좋아요 · 댓글 수. 수가 0 이면 아이콘만 둔다(Figma 글 상세 첫 화면 `244:22509`).
class LoungeCounts extends StatelessWidget {
const LoungeCounts({
super.key,
required this.post,
required this.icon,
required this.color,
this.onLike,
});
final LoungePost post;
final double icon;
final Color color;
/// 하트를 누르면 좋아요를 켜고 끈다. null 이면 수만 보인다.
final VoidCallback? onLike;
@override
Widget build(BuildContext context) {
Widget item(String asset, int n, [Color? tint]) => Row(
mainAxisSize: MainAxisSize.min,
children: [
Image.asset(asset, width: icon, height: icon, color: tint ?? color),
if (n > 0) ...[
const SizedBox(width: 4),
Text(
'$n',
style: loungeText(icon <= 14 ? 12 : 14, color).copyWith(height: 1),
),
],
],
);
final heart = item(
'assets/figma/icon-heart.png',
post.likes,
post.liked ? AppColors.cosmicAccent : null,
);
return Row(
children: [
if (onLike == null)
heart
else
Semantics(
button: true,
label: post.liked ? context.l10n.likeCancel : context.l10n.like,
child: InkResponse(onTap: onLike, radius: 18, child: heart),
),
const SizedBox(width: 12),
item('assets/figma/icon-message-circle.png', post.comments),
],
);
}
}
/// 글 상세. 좋아요 · 댓글 · 공유 · 신고 · 차단 · 내 글 수정 · 삭제가 여기서 일어난다.
///
/// 바뀐 글은 [onChanged] 로 목록에 알린다 — 목록이 같은 글을 다시 읽지 않아도
/// 수가 맞는다. `null` 을 보내면 글이 목록에서 빠진다(삭제 · 차단).
class LoungePostScreen extends StatefulWidget {
const LoungePostScreen({
super.key,
required this.post,
this.repository = const SamplePlanetRepository(),
this.onChanged,
this.translation,
});
final LoungePost post;
final PlanetRepository repository;
final ValueChanged<LoungePost?>? onChanged;
/// 참여형 번역 — 없으면 앱 루트의 [TranslationScope] 를 쓴다.
final TranslationRepository? translation;
@override
State<LoungePostScreen> createState() => _LoungePostScreenState();
}
class _LoungePostScreenState extends State<LoungePostScreen> {
late LoungePost _post = widget.post;
List<LoungeComment>? _comments;
/// 번역할 댓글. 없으면 번역 댓글은 글 본문의 번역이다(🟡 Q-TR-07 권고 A안).
TranslationReplyTarget? _replyTo;
TranslationRepository? get _translation =>
translationSurfaces.contains(TranslationSurface.lounge)
? widget.translation ?? TranslationScope.maybeOf(context)
: null;
String get _root => 'posts/${_post.id}';
@override
void initState() {
super.initState();
_loadComments();
}
Future<void> _loadComments() async {
try {
final list = await widget.repository.comments(_post.id);
if (mounted) setState(() => _comments = list);
} catch (e) {
if (mounted) {
setState(() => _comments = const []);
_toast(context, loungeError(e));
}
}
}
void _update(LoungePost post) {
setState(() => _post = post);
widget.onChanged?.call(post);
}
Future<void> _run(Future<void> Function() action) async {
try {
await action();
} catch (e) {
if (mounted) _toast(context, loungeError(e));
}
}
Future<void> _like() =>
_run(() async => _update(await widget.repository.toggleLike(_post)));
Future<bool> _send(String body) async {
try {
final c = await widget.repository.addComment(_post.id, body);
if (!mounted) return true;
setState(() => _comments = [...?_comments, c]);
_update(_post.copyWith(comments: _post.comments + 1));
return true;
} catch (e) {
if (mounted) _toast(context, loungeError(e));
return false;
}
}
Future<void> _deleteComment(LoungeComment c) async {
if (!await confirmOverlay(context, context.l10n.commentDeleteConfirm))
return;
await _run(() async {
await widget.repository.deleteComment(_post.id, c.id);
setState(() => _comments = [...?_comments]..remove(c));
_update(
_post.copyWith(comments: _post.comments > 0 ? _post.comments - 1 : 0),
);
});
}
@override
Widget build(BuildContext context) {
final post = _post;
final comments = _comments;
final translation = _translation;
return Scaffold(
appBar: AppBar(
actions: [
IconButton(
tooltip: context.l10n.commonShare,
onPressed: () => _run(() => shareLoungePost(post)),
icon: const ImageIcon(AppIcons.share, size: 24),
),
PopupMenuButton<String>(
tooltip: context.l10n.commonMore,
icon: const ImageIcon(AppIcons.ellipsisVertical, size: 24),
onSelected: (v) => _moderate(context, v),
itemBuilder: (_) => post.isMine
? [
PopupMenuItem(
value: 'edit',
child: Text(context.l10n.commonEditAction),
),
PopupMenuItem(
value: 'delete',
child: Text(context.l10n.commonDeleteAction),
),
]
: [
PopupMenuItem(
value: 'report',
child: Text(context.l10n.reportTitle),
),
PopupMenuItem(
value: 'block',
child: Text(context.l10n.commonBlockAction),
),
],
),
],
),
body: Column(
children: [
Expanded(
// 시안 `Frame 4241` — 검정 바탕 위에 글 · 댓글 두 판을 8 간격으로 쌓는다.
child: ColoredBox(
color: Colors.black,
child: ListView(
padding: EdgeInsets.zero,
children: [
ColoredBox(
color: AppColors.cosmicVoid,
child: Padding(
padding: const EdgeInsets.all(AppSpace.s4),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
LoungeAuthorRow(post: post),
const SizedBox(height: 24),
Text(
loungePostText(post),
style: loungeText(16, AppColors.onCosmic),
),
for (final (i, src) in post.images.indexed) ...[
const SizedBox(height: 12),
AspectRatio(
aspectRatio: 1,
child: ClipRRect(
borderRadius: BorderRadius.circular(6),
child: Stack(
fit: StackFit.expand,
children: [
Image(
image: loungeImage(src),
fit: BoxFit.cover,
),
// 시안 `244:22509` — 영상 길이 「00:36」 15/500 흰색, 오른쪽 아래 10.
if (i == 0 && post.videoDuration != null)
Positioned(
right: 10,
bottom: 10,
child: Text(
_mmss(post.videoDuration!),
style: loungeText(
15,
AppColors.onCosmic,
).copyWith(height: 1),
),
),
],
),
),
),
],
const SizedBox(height: AppSpace.s4),
LoungeCounts(
post: post,
icon: 18,
color: const Color(0xFFD5D7D9),
onLike: _like,
),
],
),
),
),
const SizedBox(height: 8),
if (comments != null)
switch (translation) {
final t? => TranslationThreads(
repository: t,
rootPath: _root,
builder: (_, byTarget) =>
_commentList(comments, t, byTarget),
),
null => _commentList(comments, null, const {}),
},
],
),
),
),
CommentInputBar(
hint: context.l10n.commentHint,
onComment: _send,
// 규칙의 댓글 상한과 같은 수.
maxLength: 1000,
translation: translation,
rootPath: _root,
replyTo: _replyTo,
onClearReply: () => setState(() => _replyTo = null),
),
],
),
);
}
/// 댓글 판 — 댓글마다 그 댓글의 번역 댓글을 대댓글 자리에, 글 본문의 번역 댓글은
/// 댓글 뒤에 붙인다. 둘 다 없으면 시안의 빈 상태.
Widget _commentList(
List<LoungeComment> comments,
TranslationRepository? t,
Map<String, List<TranslationEntry>> byTarget,
) {
final ownTranslations = byTarget[_root] ?? const [];
if (comments.isEmpty && ownTranslations.isEmpty) {
return ColoredBox(
color: AppColors.cosmicVoid,
child: Padding(
padding: const EdgeInsets.all(32),
child: Column(
children: [
Image.asset(
'assets/figma/icon-message-circle.png',
width: 48,
height: 48,
color: const Color(0xFF4F4F4F),
),
const SizedBox(height: AppSpace.s4),
Text(
context.l10n.commentFirst,
style: loungeText(12, AppColors.onCosmicVariant),
),
],
),
),
);
}
const gap = EdgeInsets.fromLTRB(16, 0, 16, 12);
return ColoredBox(
color: AppColors.cosmicVoid,
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
for (final c in comments) ...[
_CommentRow(
comment: c,
onDelete: c.isMine ? () => _deleteComment(c) : null,
onTranslate: t == null
? null
: () => setState(
() => _replyTo = (
path: '$_root/comments/${c.id}',
name: c.authorName,
),
),
),
for (final e in byTarget['$_root/comments/${c.id}'] ?? const [])
Padding(
padding: gap,
child: TranslationCommentCard(
entry: e,
repository: t!,
reply: true,
),
),
],
for (final e in ownTranslations)
Padding(
padding: gap,
child: TranslationCommentCard(entry: e, repository: t!),
),
],
),
);
}
Future<void> _moderate(BuildContext context, String action) async {
final post = _post;
switch (action) {
case 'edit':
final updated = await Navigator.of(context, rootNavigator: true)
.push<LoungePost>(
MaterialPageRoute(
builder: (_) => LoungeComposeScreen(
initialBody: post.body,
allowPhotos: false,
onSubmit: (d) => widget.repository.updatePost(post, d.body),
),
),
);
if (updated != null && mounted) _update(updated);
case 'delete':
if (!await confirmOverlay(context, context.l10n.postDeleteConfirm))
return;
await _run(() async {
await widget.repository.deletePost(post);
widget.onChanged?.call(null);
if (mounted) Navigator.pop(this.context);
});
case 'report':
final reason = await Navigator.of(context, rootNavigator: true)
.push<String>(
MaterialPageRoute(
builder: (_) => ReportScreen(
target: ModerationTarget(
authorName: post.authorName,
title: loungePostText(post),
),
),
),
);
if (reason == null) return;
await _run(() async {
await widget.repository.reportPost(post, reason);
if (mounted) _toast(this.context, context.l10n.reportReceived);
});
case 'block':
if (!await confirmBlock(context, post.authorName)) return;
await _run(() async {
await widget.repository.blockAuthor(post);
widget.onChanged?.call(null);
// 명세 — 차단 처리 후 목록으로 돌아간다.
if (mounted) Navigator.pop(this.context);
});
}
}
}
/// 댓글 한 줄 — 닉네임 · 작성 시각 · 본문. 내 댓글에만 삭제가 붙는다.
/// [onTranslate] 는 이 댓글을 골라 번역 댓글을 다는 지구본(시안 라운지 변형 `252:163306`).
class _CommentRow extends StatelessWidget {
const _CommentRow({required this.comment, this.onDelete, this.onTranslate});
final LoungeComment comment;
final VoidCallback? onDelete;
final VoidCallback? onTranslate;
@override
Widget build(BuildContext context) => Padding(
padding: const EdgeInsets.fromLTRB(16, 12, 8, 12),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'${comment.authorName} · ${relativeTime(comment.createdAt)}',
style: loungeText(12, AppColors.onCosmicMuted),
),
const SizedBox(height: 4),
Text(comment.body, style: loungeText(14, AppColors.onCosmic)),
],
),
),
if (onTranslate != null)
IconButton(
tooltip: context.l10n.translationWrite,
visualDensity: VisualDensity.compact,
onPressed: onTranslate,
icon: const ImageIcon(
AppIcons.globe,
size: 16,
color: AppColors.onCosmicMuted,
),
),
if (onDelete != null)
TextButton(
onPressed: onDelete,
style: TextButton.styleFrom(
foregroundColor: AppColors.onCosmicMuted,
),
child: Text(context.l10n.commonDelete),
),
],
),
);
}
/// 글 쓰기 — 시안 `244:21746`. 제목 칸 없이 본문 한 칸(설계서 §2.6 posts 에 제목 없음).
///
/// [onSubmit] 이 저장을 맡는다. 실패하면 이 화면에 남아 사유를 띄우므로 쓴 글이
/// 날아가지 않는다. 성공하면 [onSubmit] 이 돌려준 글을 들고 닫힌다.
class LoungeComposeScreen extends StatefulWidget {
const LoungeComposeScreen({
super.key,
this.initialBody = '',
this.allowPhotos = true,
this.picker = pickPhotoWithPlugin,
this.onSubmit,
});
final String initialBody;
/// 수정 화면은 사진을 바꾸지 않는다 — 본문만 고친다(규칙상 `imageUrls` 도
/// 고칠 수 있으나 업로드 · 삭제 동선이 한 벌 더 필요해진다).
final bool allowPhotos;
final PhotoPicker picker;
final Future<LoungePost> Function(PostDraft draft)? onSubmit;
@override
State<LoungeComposeScreen> createState() => _LoungeComposeScreenState();
}
class _LoungeComposeScreenState extends State<LoungeComposeScreen> {
late final _body = TextEditingController(text: widget.initialBody);
final _photos = <PickedPhoto>[];
bool _saving = false;
bool get _ready => _body.text.trim().isNotEmpty && !_saving;
@override
void initState() {
super.initState();
_body.addListener(() => setState(() {}));
}
@override
void dispose() {
_body.dispose();
super.dispose();
}
/// 사진 한 장을 골라 1차 검증(B-44 — 5 장 · 10MB · 이미지 형식)을 지난 것만 붙인다.
Future<void> _pick(PhotoSource source) async {
try {
final photo = await widget.picker(source);
if (photo == null) return;
checkLoungePhotos([..._photos, photo]);
setState(() => _photos.add(photo));
} catch (e) {
if (mounted) _toast(context, loungeError(e));
}
}
Future<void> _submit() async {
final draft = PostDraft(
title: '',
body: _body.text.trim(),
photos: List.unmodifiable(_photos),
);
final submit = widget.onSubmit;
if (submit == null) {
Navigator.pop(context);
return;
}
setState(() => _saving = true);
try {
final post = await submit(draft);
if (mounted) Navigator.pop(context, post);
} catch (e) {
if (!mounted) return;
setState(() => _saving = false);
_toast(context, loungeError(e));
}
}
@override
Widget build(BuildContext context) => Scaffold(
appBar: AppBar(
leading: const CloseButton(),
title: Text(
widget.initialBody.isEmpty
? context.l10n.postWrite
: context.l10n.postEdit,
),
actions: [
TextButton(
onPressed: _ready ? _submit : null,
style: TextButton.styleFrom(
foregroundColor: AppColors.onCosmic,
disabledForegroundColor: const Color(0xFF474747),
textStyle: loungeText(
14,
AppColors.onCosmic,
).copyWith(fontWeight: FontWeight.w600, height: 1),
minimumSize: const Size(56, 56),
),
child: Text(context.l10n.commonDone),
),
],
),
body: SafeArea(
child: Column(
children: [
if (_saving) const LinearProgressIndicator(),
Expanded(
child: Padding(
padding: const EdgeInsets.fromLTRB(16, 22, 16, 16),
child: TextField(
controller: _body,
// 규칙의 글 상한과 같은 수.
maxLength: 5000,
buildCounter: _noCounter,
maxLines: null,
expands: true,
textAlignVertical: TextAlignVertical.top,
style: loungeText(15, AppColors.onCosmic),
decoration: InputDecoration(
hintText: context.l10n.loungeHint,
hintStyle: loungeText(
15,
const Color(0xFF6B6B6B),
).copyWith(height: 1),
border: InputBorder.none,
enabledBorder: InputBorder.none,
focusedBorder: InputBorder.none,
filled: false,
isDense: true,
contentPadding: EdgeInsets.zero,
),
),
),
),
// 고른 사진 — 누르면 뺀다.
if (_photos.isNotEmpty)
SizedBox(
height: 64,
child: ListView.separated(
scrollDirection: Axis.horizontal,
padding: const EdgeInsets.symmetric(horizontal: 16),
itemCount: _photos.length,
separatorBuilder: (_, _) => const SizedBox(width: 8),
itemBuilder: (_, i) => GestureDetector(
onTap: () => setState(() => _photos.removeAt(i)),
child: ClipRRect(
borderRadius: BorderRadius.circular(6),
child: Image.memory(
_photos[i].bytes,
width: 64,
height: 64,
fit: BoxFit.cover,
semanticLabel: context.l10n.photoRemove('${i + 1}'),
),
),
),
),
),
// 사진 첨부 — 시안 하단 도구 줄(카메라 · 앨범 28, 간격 20).
if (widget.allowPhotos)
Padding(
padding: const EdgeInsets.fromLTRB(16, 8, 16, 8),
child: Row(
children: [
for (final (asset, label, source) in [
(
'assets/figma/icon-compose-camera.png',
context.l10n.camera,
PhotoSource.camera,
),
(
'assets/figma/icon-image.png',
context.l10n.album,
PhotoSource.gallery,
),
]) ...[
InkResponse(
radius: 20,
onTap: _saving ? null : () => _pick(source),
child: Image.asset(
asset,
width: 28,
height: 28,
semanticLabel: label,
),
),
const SizedBox(width: 20),
],
],
),
),
],
),
),
);
}
/// 글자 수 표시는 시안에 없다 — 상한만 건다.
Widget? _noCounter(
BuildContext context, {
required int currentLength,
required bool isFocused,
required int? maxLength,
}) => null;
String _mmss(Duration d) =>
'${d.inMinutes.toString().padLeft(2, '0')}:${(d.inSeconds % 60).toString().padLeft(2, '0')}';