import 'dart:async'; import 'package:flutter/material.dart'; import 'package:patbond_flutter/core/network/api_exception.dart'; import 'package:patbond_flutter/core/theme/app_theme.dart'; import 'package:patbond_flutter/core/widgets/inline_error_banner.dart'; import 'package:patbond_flutter/core/widgets/post_media_grid.dart'; import 'package:patbond_flutter/features/community/community_controller.dart'; import 'package:patbond_flutter/features/community/community_display.dart'; import 'package:patbond_flutter/features/community/community_exceptions.dart'; import 'package:patbond_flutter/features/community/community_models.dart'; import 'package:patbond_flutter/features/community/community_repository.dart'; import 'package:patbond_flutter/features/community/media_uploader.dart'; import 'package:patbond_flutter/features/community/post_analytics.dart'; import 'package:uuid/uuid.dart'; /// 发布页(P3,05 号规范 §2.3;T3-17 真实数据整页落地)。 /// /// 结构:媒体选择区([PostMediaEditGrid] 组装 [MediaUploader])→ 正文 /// → 类目(general / help)→ 位置占位;AppBar 三件套「取消 / 发布动态 / /// 发布」,另置「存草稿」。 /// /// 两条提交路径(15 号后端语义 §2.4): /// /// - **直接发布** = `createPost(status: draft)` 建草稿 → `PATCH /// {status: published}` 迁移发布。两步而非「一步建 published」是为了让 /// 「发布失败但草稿已保存」成为事实而不是话术:迁移这一步失败时草稿 /// 已在服务端,用户内容不会丢。 /// - **存草稿退出** = 同一个 `createPost(status: draft)`(或对已有草稿 /// `PATCH`),随后离页。 /// /// 幂等纪律:建草稿的 `Idempotency-Key` 由**本页持有**——网络失败重试 /// 沿用同键(服务端命中首帖,不重复建帖);表单一经改动即换新键 /// (避免「同键异 payload」的 40905 常态化)。 /// /// 草稿管理最小实现:进页拉取「我的草稿」最新一条并恢复(05 §2.3 的 /// 「已恢复上次草稿」提示条),完整草稿列表页留待(26 号报告 §7)。 class PostComposePage extends StatefulWidget { const PostComposePage({ required this.controller, super.key, this.entryPoint = PostEntryPoint.createTab, this.analytics, this.uploaderFactory, this.now, }); /// Tab 级单例(app.dart 装配):发布成功后由调用方触发 Feed 刷新。 final CommunityController controller; /// 入口归因(`post_create_started.entryPoint`)。 final PostEntryPoint entryPoint; /// post 域埋点(发布漏斗五事件 + 媒体三段,媒体段经 [MediaUploader])。 final PostAnalytics? analytics; /// [MediaUploader] 构造口(测试 / 桌面实测替换选图与压缩层)。 final MediaUploaderFactory? uploaderFactory; /// 时钟注入口(durationMs 断言用;缺省取当前时间)。 final DateTime Function()? now; @override State createState() => _PostComposePageState(); } class _PostComposePageState extends State { static const _maxContentLength = 1000; final _contentController = TextEditingController(); final _uuid = const Uuid(); late final MediaUploader _uploader; PostCategory _category = PostCategory.general; /// 服务端草稿标识与乐观锁版本(建草稿成功或恢复草稿后非空)。 String? _draftPostId; int? _draftVersion; /// 恢复来的草稿既有媒体(uploader 只持本地选图,服务端媒体只读呈现)。 List _draftMedia = const []; /// 「已恢复上次草稿」提示条可见性。 bool _restoredBannerVisible = false; /// 草稿恢复期间的输入监听抑制(恢复不是「首次输入」,不发 started)。 bool _restoring = false; /// 本页是否基于先前保存/恢复的草稿发布(`fromDraft` 口径)。 bool _fromDraft = false; /// 上一次同步到服务端的 ready assetId 签名(media 三态判定: /// 与当前一致即 PATCH 缺席不动,不一致才整组替换)。 String? _syncedMediaSignature; /// 建草稿幂等键(同键重放;表单改动即置 null 换新键)。 String? _idempotencyKey; bool _publishing = false; bool _savingDraft = false; /// 发布失败横幅(页内停留供对照,不用 SnackBar)。 String? _publishErrorMessage; /// 「草稿已保存」的伴随提示(发布失败时告知内容未丢)。 bool _draftPreservedHint = false; /// 「已保存草稿 ✓」提示(保存动作后显示)。 bool _draftSavedHint = false; /// 首次输入已上报 started。 bool _started = false; DateTime? _startedAt; /// 本页发布尝试序号(attemptSeq,从 1 起)。 int _publishAttemptSeq = 0; CommunityController get _controller => widget.controller; DateTime _nowValue() => (widget.now ?? DateTime.now)(); @override void initState() { super.initState(); _uploader = (widget.uploaderFactory ?? _defaultUploaderFactory)( _controller.repository, widget.analytics, ); _uploader.addListener(_onUploaderChanged); _contentController.addListener(_onContentChanged); unawaited(_restoreLatestDraft()); } static MediaUploader _defaultUploaderFactory( CommunityRepository repository, PostAnalytics? analytics, ) => MediaUploader(repository: repository, analytics: analytics); @override void dispose() { _uploader.removeListener(_onUploaderChanged); // 在途上传作废(未 confirm 的 asset 弃引用,服务端超时清理兜底)。 _uploader.reset(); _uploader.dispose(); _contentController.dispose(); super.dispose(); } // ---- 输入与状态 ---- String get _content => _contentController.text.trim(); bool get _isEmptyForm => _content.isEmpty && _uploader.isEmpty && _draftMedia.isEmpty; /// 发布 gating(05 §2.2/§2.3 + 后端 content 必填):正文非空、 /// 在场媒体全部 ready、无在途提交。 bool get _canPublish => _content.isNotEmpty && (_uploader.isEmpty || _uploader.allReady) && !_publishing && !_savingDraft; void _onContentChanged() { if (_restoring) return; _markDirty(); _reportStartedOnce(); setState(() {}); } void _onUploaderChanged() { if (_uploader.items.isNotEmpty) _reportStartedOnce(); _markDirty(); setState(() {}); } /// 表单一经改动即弃用旧幂等键(下次提交换新键,杜绝 40905 常态化)。 void _markDirty() { _idempotencyKey = null; _draftSavedHint = false; } void _reportStartedOnce() { if (_started) return; if (_content.isEmpty && _uploader.isEmpty) return; _started = true; _startedAt = _nowValue(); widget.analytics?.postCreateStarted(entryPoint: widget.entryPoint); } // ---- 草稿恢复(最小实现:最新一条)---- Future _restoreLatestDraft() async { try { final page = await _controller.repository.listMyPosts( limit: 1, status: PostStatus.draft, ); if (!mounted || page.items.isEmpty) return; final draft = page.items.first; _restoring = true; _contentController.text = draft.content; _restoring = false; setState(() { _draftPostId = draft.id; _draftVersion = draft.version; _draftMedia = draft.media; // ai_creation(M4 预留读侧值)不在发布页可选集内,回落 general。 _category = draft.category == PostCategory.aiCreation ? PostCategory.general : draft.category; _restoredBannerVisible = true; _fromDraft = true; _syncedMediaSignature = _mediaSignature(); }); } on ApiException { // 草稿恢复失败静默降级为「新建」,不阻塞发布(不打扰)。 } } void _clearRestoredDraft() { _restoring = true; _contentController.clear(); _restoring = false; setState(() { _draftMedia = const []; _restoredBannerVisible = false; _idempotencyKey = null; }); } // ---- 媒体 ---- Future _pickImages() async { await _uploader.pickAndAdd(); if (!mounted) return; if (_uploader.remainingSlots <= 0) { _showSnackBar('最多可选 ${_uploader.maxImages} 张图片'); } } String _mediaSignature() => _uploader.items .where((item) => item.isReady) .map((item) => item.assetId) .join(','); /// 当前选图的挂接请求(全 ready 才可取;封面取首张)。 List? _mediaAttachOrNull() => _uploader.isEmpty ? null : _uploader.buildAttachRequests(); // ---- 发布(建草稿 → 迁移发布)---- Future _publish() async { if (!_canPublish) return; FocusScope.of(context).unfocus(); _publishAttemptSeq += 1; setState(() { _publishing = true; _publishErrorMessage = null; _draftPreservedHint = false; }); final signature = _mediaSignature(); final fromDraft = _fromDraft && _draftPostId != null; try { if (_draftPostId == null) { final key = _idempotencyKey ??= _uuid.v4(); final draft = await _controller.repository.createPost( CreatePostRequest( content: _content, category: _category, status: PostStatus.draft, media: _mediaAttachOrNull(), ), idempotencyKey: key, ); _draftPostId = draft.id; _draftVersion = draft.version; _syncedMediaSignature = signature; } await _patchPublish(signature); if (!mounted) return; widget.analytics?.postPublishSucceeded( durationMs: _elapsedSinceStart(), mediaCount: _uploader.items.length + _keptDraftMediaCount(signature), // 话题(TopicChip / 话题选择 sheet)无契约端点,M3 恒 0。 topicCount: 0, textLength: _content.length, fromDraft: fromDraft, ); _uploader.reset(); Navigator.of(context).pop(true); } on ApiException catch (error) { if (!mounted) return; _handlePublishError(error); } finally { if (mounted) setState(() => _publishing = false); } } /// PATCH 迁移发布;乐观锁过期(40902,别处改过草稿)自动刷新 version /// 重提一次。已发布帖重复提交为幂等 no-op(15 号 §2.4),弱网重放安全。 Future _patchPublish(String signature) async { final media = signature == _syncedMediaSignature ? null // 缺席不动(服务端媒体与本地选图一致) : _mediaAttachOrNull() ?? const []; UpdatePostRequest request(int version) => UpdatePostRequest( version: version, content: _content, category: _category, publish: true, media: media, ); try { await _controller.repository.updatePost( _draftPostId!, request(_draftVersion!), ); } on PostVersionConflictException { final latest = await _controller.repository.getPost(_draftPostId!); _draftVersion = latest.version; await _controller.repository.updatePost( _draftPostId!, request(latest.version), ); } _syncedMediaSignature = signature; } /// 恢复草稿的服务端既有媒体在本次发布中被保留的张数(mediaCount 口径)。 int _keptDraftMediaCount(String signature) => signature == _syncedMediaSignature && _uploader.isEmpty ? _draftMedia.length : 0; int _elapsedSinceStart() { final startedAt = _startedAt; if (startedAt == null) return 0; return _nowValue().difference(startedAt).inMilliseconds; } void _handlePublishError(ApiException error) { final reason = postPublishFailureReasonOf(error); if (reason != null) { widget.analytics?.postPublishFailed( reason: reason, attemptSeq: _publishAttemptSeq, errorCode: error is ApiBusinessException ? error.code : null, ); } if (error is IdempotencyMismatchException) { // 同键异 payload:弃用旧键,下次提交换新键即可成功。 _idempotencyKey = null; } if (error is PostNotFoundException) { // 草稿在别处被删:解除关联,重试走全新建草稿。 _draftPostId = null; _draftVersion = null; _fromDraft = false; } setState(() { _publishErrorMessage = postPublishErrorMessage(error); // 草稿已在服务端 → 明确告知内容未丢(本单核心提示语义)。 _draftPreservedHint = _draftPostId != null; }); } // ---- 存草稿 ---- Future _saveDraft(DraftSaveTrigger trigger) async { if (_isEmptyForm) return true; if (_content.isEmpty) { _showSnackBar('请先写点什么再保存草稿'); return false; } if (_uploader.hasBusyItem) { _showSnackBar('图片还在上传中,请稍候再保存草稿'); return false; } if (_uploader.hasFailure) { _showSnackBar('有图片上传失败,请重试或删除后再保存草稿'); return false; } setState(() { _savingDraft = true; _publishErrorMessage = null; }); final signature = _mediaSignature(); try { if (_draftPostId == null) { final key = _idempotencyKey ??= _uuid.v4(); final draft = await _controller.repository.createPost( CreatePostRequest( content: _content, category: _category, status: PostStatus.draft, media: _mediaAttachOrNull(), ), idempotencyKey: key, ); _draftPostId = draft.id; _draftVersion = draft.version; } else { final updated = await _controller.repository.updatePost( _draftPostId!, UpdatePostRequest( version: _draftVersion!, content: _content, category: _category, media: signature == _syncedMediaSignature ? null : _mediaAttachOrNull() ?? const [], ), ); _draftVersion = updated.version; } _syncedMediaSignature = signature; _fromDraft = true; widget.analytics?.postDraftSaved( trigger: trigger, mediaCount: _uploader.items.length + _keptDraftMediaCount(signature), ); if (mounted) setState(() => _draftSavedHint = true); return true; } on ApiException catch (error) { if (mounted) _showSnackBar(draftSaveErrorMessage(error)); return false; } finally { if (mounted) setState(() => _savingDraft = false); } } /// 「不保留」:已落服务端的草稿一并软删(`post_deleted` 触点)。 Future _discardDraft() async { final draftId = _draftPostId; if (draftId == null) return; try { await _controller.repository.deletePost(draftId); widget.analytics?.postDeleted(); } on ApiException { // 删除失败不拦住离页(草稿留在服务端,下次进页可恢复)。 } } // ---- 离页 ---- Future _onCancel() async { if (_publishing || _savingDraft) return; if (_isEmptyForm) { Navigator.of(context).pop(false); return; } final choice = await showDialog<_ExitChoice>( context: context, builder: (context) => AlertDialog( title: const Text('保留草稿?'), content: const Text('保留后下次进入发布页可继续编辑。'), actions: [ TextButton( onPressed: () => Navigator.pop(context, _ExitChoice.keepEditing), child: const Text('继续编辑'), ), TextButton( onPressed: () => Navigator.pop(context, _ExitChoice.discard), child: const Text('不保留'), ), FilledButton( onPressed: () => Navigator.pop(context, _ExitChoice.keep), child: const Text('保留'), ), ], ), ); if (!mounted || choice == null || choice == _ExitChoice.keepEditing) return; if (choice == _ExitChoice.discard) { await _discardDraft(); if (!mounted) return; Navigator.of(context).pop(false); return; } final saved = await _saveDraft(DraftSaveTrigger.onExit); if (!mounted || !saved) return; Navigator.of(context).pop(false); } void _showSnackBar(String message) { ScaffoldMessenger.of( context, ).showSnackBar(SnackBar(content: Text(message))); } // ---- 渲染 ---- @override Widget build(BuildContext context) { final theme = Theme.of(context); return PopScope( canPop: _isEmptyForm && !_publishing && !_savingDraft, onPopInvokedWithResult: (didPop, _) { if (!didPop) unawaited(_onCancel()); }, child: Scaffold( appBar: AppBar( leadingWidth: 76, leading: Center( child: TextButton( onPressed: _publishing ? null : () => unawaited(_onCancel()), style: TextButton.styleFrom(foregroundColor: AppColors.ink), child: const Text('取消'), ), ), title: const Text('发布动态'), actions: [ TextButton( onPressed: _publishing || _savingDraft ? null : () => unawaited(_saveDraft(DraftSaveTrigger.manual)), child: const Text('存草稿'), ), const SizedBox(width: 4), FilledButton( style: FilledButton.styleFrom( minimumSize: const Size(0, 40), padding: const EdgeInsets.symmetric(horizontal: 20), ), onPressed: _canPublish ? () => unawaited(_publish()) : null, child: _publishing ? const SizedBox.square( dimension: 18, child: CircularProgressIndicator( strokeWidth: 2, color: Colors.white, ), ) : const Text('发布'), ), const SizedBox(width: 12), ], ), body: ListView( padding: const EdgeInsets.fromLTRB(16, 12, 16, 28), children: [ if (_restoredBannerVisible) ...[ _RestoredDraftBanner(onClear: _clearRestoredDraft), const SizedBox(height: 12), ], if (_publishErrorMessage != null) ...[ InlineErrorBanner(message: _publishErrorMessage!), if (_draftPreservedHint) ...[ const SizedBox(height: 6), const Text( '草稿已保存,可稍后继续发布', style: TextStyle(fontSize: 12, color: AppColors.inkSoft), ), ], const SizedBox(height: 12), ], if (_draftSavedHint) ...[ const Text( '已保存草稿 ✓', style: TextStyle(fontSize: 12, color: AppColors.inkSoft), ), const SizedBox(height: 12), ], ..._mediaSection(), if (_uploader.hasBusyItem) ...[ const SizedBox(height: 8), _UploadSummaryBar( progress: _uploader.overallProgress, readyCount: _uploader.readyCount, total: _uploader.items.length, ), ], const SizedBox(height: 16), TextField( controller: _contentController, minLines: 6, maxLines: null, maxLength: _maxContentLength, keyboardType: TextInputType.multiline, decoration: const InputDecoration( hintText: '分享毛孩子的日常,或向宠友求助…', alignLabelWithHint: true, ), ), const SizedBox(height: 12), Text('分类', style: theme.textTheme.bodySmall), const SizedBox(height: 7), Wrap( spacing: 8, children: [ for (final entry in const [ (PostCategory.general, '日常分享'), (PostCategory.help, '求助'), ]) ChoiceChip( label: Text(entry.$2), selected: _category == entry.$1, onSelected: (_) { _markDirty(); setState(() => _category = entry.$1); }, ), ], ), const SizedBox(height: 12), ListTile( contentPadding: EdgeInsets.zero, leading: const Icon(Icons.location_on_outlined), title: const Text('添加位置(选填)'), trailing: const Icon(Icons.chevron_right), onTap: () => _showSnackBar('位置功能即将上线'), ), ], ), ), ); } List _mediaSection() { final showDraftMedia = _uploader.isEmpty && _draftMedia.isNotEmpty; return [ PostMediaEditGrid( items: _uploader.items, canAdd: _uploader.remainingSlots > 0, onAdd: _uploader.isPicking ? null : () => unawaited(_pickImages()), onRemove: _uploader.remove, onRetry: _uploader.retry, ), if (showDraftMedia) ...[ const SizedBox(height: 8), Text( '草稿已含 ${_draftMedia.length} 张图片(发布时保留;重新选图将整组替换)', style: const TextStyle(fontSize: 12, color: AppColors.inkSoft), ), ], ]; } } enum _ExitChoice { keep, discard, keepEditing } /// 「已恢复上次草稿」提示条(05 §2.3:surfaceTint 底、圆角 sm12)。 class _RestoredDraftBanner extends StatelessWidget { const _RestoredDraftBanner({required this.onClear}); final VoidCallback onClear; @override Widget build(BuildContext context) { return Container( padding: const EdgeInsets.fromLTRB(12, 4, 4, 4), decoration: const BoxDecoration( color: AppColors.surfaceTint, borderRadius: BorderRadius.all(Radius.circular(AppRadius.sm)), ), child: Row( children: [ const Expanded( child: Text( '已恢复上次草稿', style: TextStyle(fontSize: 12, color: AppColors.primaryDark), ), ), TextButton(onPressed: onClear, child: const Text('清空')), ], ), ); } } /// 页级上传汇总条(05 §3.3 末段:线性进度 + 「正在上传 n/N」)。 class _UploadSummaryBar extends StatelessWidget { const _UploadSummaryBar({ required this.progress, required this.readyCount, required this.total, }); final double progress; final int readyCount; final int total; @override Widget build(BuildContext context) { return Row( children: [ Text( '正在上传 $readyCount/$total', style: const TextStyle(fontSize: 12, color: AppColors.inkSoft), ), const SizedBox(width: 10), Expanded( child: LinearProgressIndicator( value: progress, minHeight: 4, color: AppColors.primaryStrong, backgroundColor: AppColors.surfaceTint, ), ), ], ); } }