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 photos; } /// 라운지 쓰기 실패를 사용자 문구로. 사유가 있는 실패는 그 문구를, 규칙 거부는 /// 권한 안내를, 나머지는 재시도 안내를 띄운다. String loungeError(Object error) => switch (error) { PhotoRejected(:final message) => message, BackendException(:final message) => message, FirebaseException(code: 'permission-denied') => appL10n.loungeMembersOnly, _ => appL10n.loungeFailed, }; /// 라운지 글 공유 — 시스템 공유 시트로 본문 문구를 보낸다. Future 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? onChanged; /// 참여형 번역 — 없으면 앱 루트의 [TranslationScope] 를 쓴다. final TranslationRepository? translation; @override State createState() => _LoungePostScreenState(); } class _LoungePostScreenState extends State { late LoungePost _post = widget.post; List? _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 _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 _run(Future Function() action) async { try { await action(); } catch (e) { if (mounted) _toast(context, loungeError(e)); } } Future _like() => _run(() async => _update(await widget.repository.toggleLike(_post))); Future _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 _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( 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 comments, TranslationRepository? t, Map> 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 _moderate(BuildContext context, String action) async { final post = _post; switch (action) { case 'edit': final updated = await Navigator.of(context, rootNavigator: true) .push( 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( 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 Function(PostDraft draft)? onSubmit; @override State createState() => _LoungeComposeScreenState(); } class _LoungeComposeScreenState extends State { late final _body = TextEditingController(text: widget.initialBody); final _photos = []; 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 _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 _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')}';