792 lines
26 KiB
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')}';
|