f873acf9a3
CI / flutter-gates (push) Failing after 20s
- post_detail_page 整页重写:内存副本先渲染 + getPost 拉新、四态 (loading/error+重试/ready/40403 提示后返回 Feed 并刷新);媒体全量 ——单图原比例(高度钳制 [0.75w,1.33w])、多图真九宫格(D11 轮播 弃用,本单裁定)+ InteractiveViewer 全屏大图(03 号拍板 E) - 评论区:游标列表触底补页 + 底部输入条创建(幂等键在数据层)+ 仅本人评论渲染删除入口(40301/40404 服务端兜底);成败埋点对 (durationMs 取输入会话起点、attemptSeq 会话内递增);CommentTile 升共享组件(@ 回复前缀呈现、降级作者「宠友」占位) - 关注双态钮(tonal +关注 / Outlined 已关注 + 取关确认)乐观翻转、 回滚零动画 + SnackBar,user_followed/unfollowed(post_detail) 挂接; 本人帖不渲染 - Feed 接通:整卡与评论钮导航详情(T3-14 占位提示移除)、点赞/收藏 接共享 ToggleSync(详情与卡片同一实例跨页一致)、toggleError 一次性 SnackBar 消费 - 主壳/装配:openPost 按 postId push;create demo 流不再导航已退役的 demo 详情(发布页真实化留 T3-17);AppState 在 post_detail/Feed 面 消费清零 - compose 冒烟(env 门控默认跳过):真链路发帖/点赞幂等/收藏/评论/ 删评一轮 + 断网点赞回滚(生产 ApiClient 异常链) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
987 lines
32 KiB
Dart
987 lines
32 KiB
Dart
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/comment_tile.dart';
|
||
import 'package:patbond_flutter/core/widgets/inline_error_banner.dart';
|
||
import 'package:patbond_flutter/core/widgets/like_button.dart';
|
||
import 'package:patbond_flutter/core/widgets/pet_avatar.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_interaction_analytics.dart';
|
||
import 'package:patbond_flutter/features/community/community_models.dart';
|
||
import 'package:patbond_flutter/widgets/common.dart';
|
||
|
||
/// 帖子详情页(T3-15 整页替换,demo 数据层退役):媒体全量渲染
|
||
/// (真九宫格 + 大图预览)+ 作者卡(关注双态)+ 正文 + 操作行
|
||
/// (ToggleSync 接线,Feed 卡片与详情共享同一 [CommunityController]
|
||
/// 实例,互动状态跨页一致)+ 评论区(游标列表 / 输入框创建 /
|
||
/// 仅本人评论可删)。
|
||
///
|
||
/// 形态取舍:
|
||
/// - 多图媒体区按本单裁定走**真九宫格**([PostMediaGrid] 全量形态,
|
||
/// 05 号 D11 的轮播方案弃用);点格进全屏大图浏览
|
||
/// (03 号拍板 E:内置 InteractiveViewer,体验不达再升级 photo_view)。
|
||
/// - 40403(不存在/已删/防枚举合并)→ SnackBar 提示后返回 Feed 并
|
||
/// 触发刷新(列表剔除失效帖)。
|
||
/// - 评论 @ 回复的**发起** UI 留待(数据层 replyToUserId 已支持),
|
||
/// 响应中的 replyToUser 照常呈现。
|
||
class PostDetailPage extends StatefulWidget {
|
||
const PostDetailPage({
|
||
required this.controller,
|
||
required this.postId,
|
||
super.key,
|
||
this.currentUserId,
|
||
this.analytics,
|
||
this.now,
|
||
});
|
||
|
||
/// Tab 级单例(app.dart 装配);详情副本与 Feed 卡片互动字段同源。
|
||
final CommunityController controller;
|
||
|
||
final String postId;
|
||
|
||
/// 当前登录用户 id(评论删除入口与关注钮自见性的 UI 判定;
|
||
/// 服务端仍是权限的唯一裁决者)。
|
||
final String? currentUserId;
|
||
|
||
/// 互动域埋点(评论成败对 + 关注对;点赞/收藏对经 controller 上报)。
|
||
final CommunityInteractionAnalytics? analytics;
|
||
|
||
/// 相对时间的参考时钟(测试注入;缺省取当前时间)。
|
||
final DateTime? now;
|
||
|
||
@override
|
||
State<PostDetailPage> createState() => _PostDetailPageState();
|
||
}
|
||
|
||
enum _DetailPhase { loading, error, ready }
|
||
|
||
class _PostDetailPageState extends State<PostDetailPage> {
|
||
final _commentController = TextEditingController();
|
||
final _commentFocus = FocusNode();
|
||
|
||
_DetailPhase _phase = _DetailPhase.loading;
|
||
ApiException? _loadError;
|
||
|
||
/// 评论列表(页面级状态按页自建,03 号评估 §2 纪律;DESC 新评在前)。
|
||
_DetailPhase _commentsPhase = _DetailPhase.loading;
|
||
ApiException? _commentsError;
|
||
List<PostComment> _comments = const [];
|
||
String? _commentsCursor;
|
||
bool _commentsHasMore = false;
|
||
LoadMorePhase _commentsLoadMore = LoadMorePhase.idle;
|
||
|
||
bool _sending = false;
|
||
final Set<String> _deletingIds = {};
|
||
|
||
/// 输入会话起点(首个字符输入时记;comment_create_succeeded 的
|
||
/// durationMs 口径,评论不设 started 事件)。
|
||
DateTime? _composeStartedAt;
|
||
|
||
/// 本次输入会话内的提交序号(comment_create_failed.attemptSeq,
|
||
/// 从 1 起;成功或清空输入后重置)。
|
||
int _attemptSeq = 0;
|
||
|
||
/// 关注状态(页面级;作者为本人或加载失败时不渲染关注钮)。
|
||
FollowStats? _followStats;
|
||
bool _following = false;
|
||
|
||
/// 40403 返回 Feed 的单次守卫(详情与评论路径都可能触发)。
|
||
bool _leaving = false;
|
||
|
||
CommunityController get _controller => widget.controller;
|
||
|
||
Post? get _post => _controller.cachedPost(widget.postId);
|
||
|
||
@override
|
||
void initState() {
|
||
super.initState();
|
||
_commentController.addListener(_onComposeChanged);
|
||
if (_post != null) {
|
||
_phase = _DetailPhase.ready;
|
||
_ensureFollowStats(_post!);
|
||
}
|
||
unawaited(_loadPost());
|
||
unawaited(_loadComments());
|
||
}
|
||
|
||
@override
|
||
void dispose() {
|
||
_commentController.dispose();
|
||
_commentFocus.dispose();
|
||
super.dispose();
|
||
}
|
||
|
||
// ---- 详情加载 ----
|
||
|
||
Future<void> _loadPost() async {
|
||
if (_post == null && _phase != _DetailPhase.loading) {
|
||
setState(() {
|
||
_phase = _DetailPhase.loading;
|
||
_loadError = null;
|
||
});
|
||
}
|
||
try {
|
||
final post = await _controller.getPost(widget.postId);
|
||
if (!mounted) return;
|
||
setState(() => _phase = _DetailPhase.ready);
|
||
_ensureFollowStats(post);
|
||
} on PostNotFoundException {
|
||
_handleGone();
|
||
} on ApiException catch (error) {
|
||
if (!mounted) return;
|
||
// 有内存副本则保留展示(后台刷新失败不打断阅读);无副本收敛 error 态。
|
||
if (_post == null) {
|
||
setState(() {
|
||
_phase = _DetailPhase.error;
|
||
_loadError = error;
|
||
});
|
||
}
|
||
}
|
||
}
|
||
|
||
/// 40403:帖子不存在/已删/不可见——提示后返回 Feed 并刷新
|
||
/// (整体替换剔除失效帖)。
|
||
void _handleGone() {
|
||
if (_leaving || !mounted) return;
|
||
_leaving = true;
|
||
ScaffoldMessenger.of(
|
||
context,
|
||
).showSnackBar(const SnackBar(content: Text('帖子不存在或已被删除')));
|
||
unawaited(_controller.refresh());
|
||
Navigator.of(context).pop();
|
||
}
|
||
|
||
// ---- 评论列表 ----
|
||
|
||
Future<void> _loadComments() async {
|
||
setState(() {
|
||
_commentsPhase = _DetailPhase.loading;
|
||
_commentsError = null;
|
||
});
|
||
try {
|
||
final page = await _controller.repository.listComments(widget.postId);
|
||
if (!mounted) return;
|
||
setState(() {
|
||
_comments = page.items;
|
||
_commentsCursor = page.nextCursor;
|
||
_commentsHasMore = page.hasMore;
|
||
_commentsPhase = _DetailPhase.ready;
|
||
_commentsLoadMore = LoadMorePhase.idle;
|
||
});
|
||
} on PostNotFoundException {
|
||
_handleGone();
|
||
} on ApiException catch (error) {
|
||
if (!mounted) return;
|
||
setState(() {
|
||
_commentsPhase = _DetailPhase.error;
|
||
_commentsError = error;
|
||
});
|
||
}
|
||
}
|
||
|
||
Future<void> _loadMoreComments() async {
|
||
if (_commentsPhase != _DetailPhase.ready ||
|
||
!_commentsHasMore ||
|
||
_commentsLoadMore == LoadMorePhase.loading) {
|
||
return;
|
||
}
|
||
setState(() => _commentsLoadMore = LoadMorePhase.loading);
|
||
try {
|
||
final page = await _controller.repository.listComments(
|
||
widget.postId,
|
||
cursor: _commentsCursor,
|
||
);
|
||
if (!mounted) return;
|
||
setState(() {
|
||
_comments = [..._comments, ...page.items];
|
||
_commentsCursor = page.nextCursor;
|
||
_commentsHasMore = page.hasMore;
|
||
_commentsLoadMore = LoadMorePhase.idle;
|
||
});
|
||
} on ApiException {
|
||
if (!mounted) return;
|
||
setState(() => _commentsLoadMore = LoadMorePhase.error);
|
||
}
|
||
}
|
||
|
||
bool _onScrollNotification(ScrollNotification notification) {
|
||
if (notification is ScrollUpdateNotification ||
|
||
notification is ScrollEndNotification) {
|
||
// 触底翻页(余量 400 提前预取);失败态只走显式重试(Feed 同款守卫)。
|
||
if (notification.metrics.extentAfter < 400 &&
|
||
_commentsLoadMore == LoadMorePhase.idle) {
|
||
unawaited(_loadMoreComments());
|
||
}
|
||
}
|
||
return false;
|
||
}
|
||
|
||
// ---- 评论创建 / 删除 ----
|
||
|
||
void _onComposeChanged() {
|
||
final text = _commentController.text;
|
||
if (text.isEmpty) {
|
||
// 清空 = 输入会话结束(durationMs 起点与 attemptSeq 一并重置)。
|
||
_composeStartedAt = null;
|
||
_attemptSeq = 0;
|
||
} else {
|
||
_composeStartedAt ??= DateTime.now();
|
||
}
|
||
setState(() {}); // 发送钮可用态跟随文本。
|
||
}
|
||
|
||
Future<void> _submitComment() async {
|
||
final text = _commentController.text.trim();
|
||
if (text.isEmpty || _sending) return;
|
||
final startedAt = _composeStartedAt ?? DateTime.now();
|
||
_attemptSeq += 1;
|
||
setState(() => _sending = true);
|
||
try {
|
||
final comment = await _controller.repository.createComment(
|
||
widget.postId,
|
||
CreateCommentRequest(content: text),
|
||
);
|
||
if (!mounted) return;
|
||
widget.analytics?.commentCreateSucceeded(
|
||
durationMs: DateTime.now().difference(startedAt).inMilliseconds,
|
||
isReply: false,
|
||
textLength: text.length,
|
||
);
|
||
setState(() {
|
||
_comments = [comment, ..._comments];
|
||
_sending = false;
|
||
});
|
||
_controller.adjustCommentCount(widget.postId, 1);
|
||
_commentController.clear();
|
||
_commentFocus.unfocus();
|
||
} on ApiException catch (error) {
|
||
if (!mounted) return;
|
||
final reason = commentCreateFailureReasonOf(error);
|
||
if (reason != null) {
|
||
widget.analytics?.commentCreateFailed(
|
||
reason: reason,
|
||
attemptSeq: _attemptSeq,
|
||
errorCode: error is ApiBusinessException ? error.code : null,
|
||
);
|
||
}
|
||
setState(() => _sending = false);
|
||
if (error is PostNotFoundException) {
|
||
_handleGone();
|
||
return;
|
||
}
|
||
ScaffoldMessenger.of(
|
||
context,
|
||
).showSnackBar(SnackBar(content: Text(_commentErrorMessage(error))));
|
||
}
|
||
}
|
||
|
||
String _commentErrorMessage(ApiException error) => switch (error) {
|
||
ApiNetworkException _ => '网络异常,请检查网络后重试',
|
||
ApiRateLimitException _ => '请求过于频繁,请稍后再试',
|
||
ApiBusinessException(code: ApiCodes.paramError) => '评论内容不合规,请修改后重试',
|
||
_ => '评论发送失败,请稍后重试',
|
||
};
|
||
|
||
Future<void> _deleteComment(PostComment comment) async {
|
||
final confirmed = await showDialog<bool>(
|
||
context: context,
|
||
builder: (context) => AlertDialog(
|
||
title: const Text('删除这条评论?'),
|
||
actions: [
|
||
TextButton(
|
||
onPressed: () => Navigator.pop(context, false),
|
||
child: const Text('取消'),
|
||
),
|
||
FilledButton(
|
||
onPressed: () => Navigator.pop(context, true),
|
||
child: const Text('删除'),
|
||
),
|
||
],
|
||
),
|
||
);
|
||
if (confirmed != true || !mounted) return;
|
||
setState(() => _deletingIds.add(comment.id));
|
||
try {
|
||
await _controller.repository.deleteComment(comment.id);
|
||
if (!mounted) return;
|
||
_removeCommentLocally(comment.id);
|
||
} on CommentNotFoundException {
|
||
// 已在别处删除:本地同步剔除(终态一致,下次 getPost 对齐权威计数)。
|
||
if (!mounted) return;
|
||
_removeCommentLocally(comment.id);
|
||
} on ApiException catch (error) {
|
||
if (!mounted) return;
|
||
setState(() => _deletingIds.remove(comment.id));
|
||
final message = switch (error) {
|
||
PostAccessDeniedException _ => '没有权限删除这条评论',
|
||
ApiNetworkException _ => '网络异常,请检查网络后重试',
|
||
_ => '删除失败,请稍后重试',
|
||
};
|
||
ScaffoldMessenger.of(
|
||
context,
|
||
).showSnackBar(SnackBar(content: Text(message)));
|
||
}
|
||
}
|
||
|
||
void _removeCommentLocally(String commentId) {
|
||
setState(() {
|
||
_deletingIds.remove(commentId);
|
||
_comments = _comments.where((c) => c.id != commentId).toList();
|
||
});
|
||
_controller.adjustCommentCount(widget.postId, -1);
|
||
}
|
||
|
||
// ---- 关注 ----
|
||
|
||
void _ensureFollowStats(Post post) {
|
||
if (_followStats != null) return;
|
||
final authorId = post.author.userId;
|
||
if (authorId == widget.currentUserId) return; // 本人帖不渲染关注钮。
|
||
unawaited(() async {
|
||
try {
|
||
final stats = await _controller.repository.getFollowStats(authorId);
|
||
if (!mounted) return;
|
||
setState(() {
|
||
_followStats = stats;
|
||
_following = stats.followedByMe;
|
||
});
|
||
} on ApiException {
|
||
// 关注状态拉取失败:不渲染关注钮(不阻塞正文阅读)。
|
||
}
|
||
}());
|
||
}
|
||
|
||
Future<void> _toggleFollow(String authorId) async {
|
||
final target = !_following;
|
||
if (!target) {
|
||
final confirmed = await showDialog<bool>(
|
||
context: context,
|
||
builder: (context) => AlertDialog(
|
||
title: const Text('不再关注 TA?'),
|
||
actions: [
|
||
TextButton(
|
||
onPressed: () => Navigator.pop(context, false),
|
||
child: const Text('取消'),
|
||
),
|
||
FilledButton(
|
||
onPressed: () => Navigator.pop(context, true),
|
||
child: const Text('不再关注'),
|
||
),
|
||
],
|
||
),
|
||
);
|
||
if (confirmed != true || !mounted) return;
|
||
}
|
||
setState(() => _following = target); // 乐观翻转(§4.5)。
|
||
try {
|
||
final state = target
|
||
? await _controller.repository.followUser(authorId)
|
||
: await _controller.repository.unfollowUser(authorId);
|
||
if (!mounted) return;
|
||
target
|
||
? widget.analytics?.userFollowed(source: InteractionSource.postDetail)
|
||
: widget.analytics?.userUnfollowed(
|
||
source: InteractionSource.postDetail,
|
||
);
|
||
setState(() => _following = state.following); // 权威终态对账。
|
||
} on ApiException {
|
||
if (!mounted) return;
|
||
// 回滚直接跳变 + SnackBar(§4.5,无过渡动画)。
|
||
setState(() => _following = !target);
|
||
ScaffoldMessenger.of(
|
||
context,
|
||
).showSnackBar(const SnackBar(content: Text('操作失败,请重试')));
|
||
}
|
||
}
|
||
|
||
// ---- 大图浏览 ----
|
||
|
||
void _openGallery(List<PostMediaItem> media, int initialIndex) {
|
||
Navigator.of(context).push(
|
||
MaterialPageRoute<void>(
|
||
fullscreenDialog: true,
|
||
builder: (context) => _MediaGalleryPage(
|
||
urls: [for (final item in media) item.url],
|
||
initialIndex: initialIndex,
|
||
),
|
||
),
|
||
);
|
||
}
|
||
|
||
// ---- 渲染 ----
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
return ListenableBuilder(
|
||
listenable: _controller,
|
||
builder: (context, _) {
|
||
// 点赞/收藏对账失败的一次性 SnackBar(§3.5 回滚提示)。
|
||
if (_controller.toggleError != null) {
|
||
_controller.clearToggleError();
|
||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||
if (!mounted) return;
|
||
ScaffoldMessenger.of(
|
||
context,
|
||
).showSnackBar(const SnackBar(content: Text('操作失败,请重试')));
|
||
});
|
||
}
|
||
final post = _post;
|
||
return Scaffold(
|
||
appBar: AppBar(
|
||
title: const Text(
|
||
'社区动态',
|
||
style: TextStyle(fontSize: 16, fontWeight: FontWeight.w800),
|
||
),
|
||
centerTitle: true,
|
||
actions: [
|
||
IconButton(
|
||
tooltip: '分享',
|
||
onPressed: () => ScaffoldMessenger.of(
|
||
context,
|
||
).showSnackBar(const SnackBar(content: Text('分享功能即将上线'))),
|
||
icon: const Icon(Icons.ios_share_outlined),
|
||
),
|
||
],
|
||
),
|
||
body: post != null ? _content(post) : _placeholder(),
|
||
bottomSheet: post != null ? _commentInputBar() : null,
|
||
);
|
||
},
|
||
);
|
||
}
|
||
|
||
/// 无内存副本时的 loading / error 态。
|
||
Widget _placeholder() {
|
||
if (_phase == _DetailPhase.error) {
|
||
return Padding(
|
||
padding: const EdgeInsets.all(24),
|
||
child: Column(
|
||
children: [
|
||
InlineErrorBanner(message: feedLoadErrorMessage(_loadError)),
|
||
const SizedBox(height: 16),
|
||
FilledButton(onPressed: _loadPost, child: const Text('重试')),
|
||
],
|
||
),
|
||
);
|
||
}
|
||
return const Center(child: CircularProgressIndicator());
|
||
}
|
||
|
||
Widget _content(Post post) {
|
||
return NotificationListener<ScrollNotification>(
|
||
onNotification: _onScrollNotification,
|
||
child: ListView(
|
||
padding: const EdgeInsets.only(bottom: 100),
|
||
children: [
|
||
_mediaSection(post),
|
||
Padding(
|
||
padding: const EdgeInsets.all(16),
|
||
child: Column(
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
children: [
|
||
_authorCard(post),
|
||
const SizedBox(height: 14),
|
||
_bodyCard(post),
|
||
const SizedBox(height: 14),
|
||
_actionRow(post),
|
||
const SizedBox(height: 22),
|
||
Text(
|
||
'评论 (${post.commentCount})',
|
||
style: Theme.of(context).textTheme.titleLarge,
|
||
),
|
||
const SizedBox(height: 12),
|
||
..._commentsSection(),
|
||
],
|
||
),
|
||
),
|
||
],
|
||
),
|
||
);
|
||
}
|
||
|
||
/// 媒体全量:单图原比例(高度钳制 [0.75w, 1.33w]);多图真九宫格
|
||
///(PostMediaGrid 全量形态,列数 2/4→2 列、3/5–9→3 列 + 超 9 折叠)。
|
||
Widget _mediaSection(Post post) {
|
||
final media = [...post.media]
|
||
..sort((a, b) => a.position.compareTo(b.position));
|
||
if (media.isEmpty) return const SizedBox.shrink();
|
||
if (media.length == 1) {
|
||
final item = media.first;
|
||
var ratio = 4 / 3;
|
||
final width = item.widthPx;
|
||
final height = item.heightPx;
|
||
if (width != null && height != null && width > 0 && height > 0) {
|
||
// 高度钳制 [宽×0.75, 宽×1.33](05 §2.2)→ 宽高比落 [1/1.33, 1/0.75]。
|
||
ratio = (width / height).clamp(1 / 1.33, 1 / 0.75);
|
||
}
|
||
return GestureDetector(
|
||
onTap: () => _openGallery(media, 0),
|
||
child: AspectRatio(
|
||
aspectRatio: ratio,
|
||
child: RemoteImage(url: item.url),
|
||
),
|
||
);
|
||
}
|
||
return Padding(
|
||
padding: const EdgeInsets.fromLTRB(16, 4, 16, 0),
|
||
child: PostMediaGrid(
|
||
urls: [for (final item in media) item.url],
|
||
onCellTap: (index) => _openGallery(media, index),
|
||
),
|
||
);
|
||
}
|
||
|
||
Widget _authorCard(Post post) {
|
||
final author = post.author;
|
||
final isSelf = author.userId == widget.currentUserId;
|
||
return SectionCard(
|
||
child: Row(
|
||
children: [
|
||
PetAvatar(size: PetAvatarSize.md, url: author.avatarUrl),
|
||
const SizedBox(width: 12),
|
||
Expanded(
|
||
child: Column(
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
children: [
|
||
Text(
|
||
authorDisplayName(author),
|
||
maxLines: 1,
|
||
overflow: TextOverflow.ellipsis,
|
||
style: Theme.of(context).textTheme.titleMedium,
|
||
),
|
||
const SizedBox(height: 2),
|
||
Text(
|
||
feedRelativeTime(
|
||
post.publishedAt ?? post.createdAt,
|
||
now: widget.now,
|
||
),
|
||
style: const TextStyle(
|
||
color: AppColors.inkSoft,
|
||
fontSize: 12,
|
||
),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
if (!isSelf && _followStats != null) _followButton(author.userId),
|
||
],
|
||
),
|
||
);
|
||
}
|
||
|
||
/// 关注双态(05 §2.2):未关注 tonal「+ 关注」;已关注 Outlined
|
||
/// 「已关注」+ 取关确认。
|
||
Widget _followButton(String authorId) {
|
||
if (_following) {
|
||
return OutlinedButton(
|
||
onPressed: () => _toggleFollow(authorId),
|
||
style: OutlinedButton.styleFrom(
|
||
foregroundColor: AppColors.inkSoft,
|
||
side: const BorderSide(color: AppColors.border),
|
||
),
|
||
child: const Text('已关注'),
|
||
);
|
||
}
|
||
return FilledButton.tonal(
|
||
onPressed: () => _toggleFollow(authorId),
|
||
child: const Text('+ 关注'),
|
||
);
|
||
}
|
||
|
||
Widget _bodyCard(Post post) {
|
||
return SectionCard(
|
||
child: Column(
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
children: [
|
||
if (post.title != null) ...[
|
||
Text(post.title!, style: Theme.of(context).textTheme.titleMedium),
|
||
const SizedBox(height: 8),
|
||
],
|
||
if (post.category == PostCategory.help) ...[
|
||
const TagPill('求助', color: AppColors.accent),
|
||
const SizedBox(height: 8),
|
||
],
|
||
Text(
|
||
post.content,
|
||
style: const TextStyle(
|
||
color: AppColors.ink,
|
||
fontSize: 14,
|
||
height: 1.6,
|
||
),
|
||
),
|
||
const SizedBox(height: 12),
|
||
Text(
|
||
'发布于 ${feedRelativeTime(post.publishedAt ?? post.createdAt, now: widget.now)}',
|
||
style: const TextStyle(color: AppColors.inkSoft, fontSize: 12),
|
||
),
|
||
],
|
||
),
|
||
);
|
||
}
|
||
|
||
/// 操作行:与 Feed 卡片同一套组件、同一 ToggleSync 实例(卡片外裸排,
|
||
/// demo 的 FilledButton.tonalIcon 形态弃用)。
|
||
Widget _actionRow(Post post) {
|
||
return Row(
|
||
children: [
|
||
LikeButton(
|
||
variant: LikeButtonVariant.like,
|
||
active: post.likedByMe,
|
||
count: post.likeCount,
|
||
onPressed: () => _controller.toggleLike(
|
||
widget.postId,
|
||
source: InteractionSource.postDetail,
|
||
),
|
||
semanticLabel: '点赞',
|
||
),
|
||
const SizedBox(width: 4),
|
||
// 评论锚点:聚焦底部输入框(唤起键盘直接开写)。
|
||
Semantics(
|
||
label: '评论',
|
||
button: true,
|
||
child: InkWell(
|
||
onTap: _commentFocus.requestFocus,
|
||
borderRadius: BorderRadius.circular(AppRadius.pill),
|
||
child: ConstrainedBox(
|
||
constraints: const BoxConstraints(minWidth: 44, minHeight: 44),
|
||
child: Padding(
|
||
padding: const EdgeInsets.symmetric(horizontal: 8),
|
||
child: Row(
|
||
mainAxisSize: MainAxisSize.min,
|
||
children: [
|
||
const Icon(
|
||
Icons.chat_bubble_outline,
|
||
size: 20,
|
||
color: AppColors.inkSoft,
|
||
),
|
||
const SizedBox(width: 4),
|
||
Text(
|
||
'${post.commentCount}',
|
||
style: const TextStyle(
|
||
color: AppColors.inkSoft,
|
||
fontSize: 13,
|
||
fontWeight: FontWeight.w600,
|
||
),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
),
|
||
),
|
||
),
|
||
const SizedBox(width: 4),
|
||
LikeButton(
|
||
variant: LikeButtonVariant.bookmark,
|
||
active: post.bookmarkedByMe,
|
||
count: post.bookmarkCount,
|
||
onPressed: () => _controller.toggleBookmark(
|
||
widget.postId,
|
||
source: InteractionSource.postDetail,
|
||
),
|
||
semanticLabel: '收藏',
|
||
),
|
||
],
|
||
);
|
||
}
|
||
|
||
List<Widget> _commentsSection() {
|
||
switch (_commentsPhase) {
|
||
case _DetailPhase.loading:
|
||
return const [
|
||
_CommentSkeleton(),
|
||
SizedBox(height: 12),
|
||
_CommentSkeleton(),
|
||
];
|
||
case _DetailPhase.error:
|
||
return [
|
||
Center(
|
||
child: Column(
|
||
children: [
|
||
Text(
|
||
feedLoadErrorMessage(_commentsError),
|
||
style: const TextStyle(
|
||
color: AppColors.inkSoft,
|
||
fontSize: 12,
|
||
),
|
||
),
|
||
TextButton(
|
||
onPressed: _loadComments,
|
||
child: const Text('加载失败,点此重试'),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
];
|
||
case _DetailPhase.ready:
|
||
if (_comments.isEmpty) {
|
||
return const [
|
||
Padding(
|
||
padding: EdgeInsets.symmetric(vertical: 32),
|
||
child: Column(
|
||
children: [
|
||
// 纯装饰图标,muted 合法(DEBT-2 口径)。
|
||
Icon(
|
||
Icons.chat_bubble_outline,
|
||
size: 36,
|
||
color: AppColors.muted,
|
||
),
|
||
SizedBox(height: 8),
|
||
Text(
|
||
'还没有评论,来抢沙发',
|
||
style: TextStyle(color: AppColors.inkSoft, fontSize: 12),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
];
|
||
}
|
||
return [
|
||
for (final comment in _comments)
|
||
Padding(
|
||
padding: const EdgeInsets.only(bottom: 12),
|
||
child: CommentTile(
|
||
comment: comment,
|
||
now: widget.now,
|
||
deleting: _deletingIds.contains(comment.id),
|
||
// 仅本人评论渲染删除入口(服务端 40301/40404 仍兜底)。
|
||
onDelete:
|
||
widget.currentUserId != null &&
|
||
comment.author.userId == widget.currentUserId
|
||
? () => _deleteComment(comment)
|
||
: null,
|
||
),
|
||
),
|
||
..._commentsTail(),
|
||
];
|
||
}
|
||
}
|
||
|
||
List<Widget> _commentsTail() {
|
||
switch (_commentsLoadMore) {
|
||
case LoadMorePhase.loading:
|
||
return const [
|
||
Padding(
|
||
padding: EdgeInsets.symmetric(vertical: 12),
|
||
child: Center(
|
||
child: SizedBox(
|
||
width: 24,
|
||
height: 24,
|
||
child: CircularProgressIndicator(
|
||
strokeWidth: 2.5,
|
||
color: AppColors.primary,
|
||
),
|
||
),
|
||
),
|
||
),
|
||
];
|
||
case LoadMorePhase.error:
|
||
return [
|
||
Center(
|
||
child: TextButton(
|
||
onPressed: _loadMoreComments,
|
||
child: const Text('加载失败,点此重试'),
|
||
),
|
||
),
|
||
];
|
||
case LoadMorePhase.idle:
|
||
return const [];
|
||
}
|
||
}
|
||
|
||
/// 底部固定输入条(05 §2.2):surface 底 + 顶部 border 1px 分隔线 +
|
||
/// isDense 输入框 + filled 发送钮(空文本禁用;发送中 18 转圈锁尺寸)。
|
||
Widget _commentInputBar() {
|
||
final canSend = _commentController.text.trim().isNotEmpty && !_sending;
|
||
return Container(
|
||
decoration: const BoxDecoration(
|
||
color: AppColors.surface,
|
||
border: Border(top: BorderSide(color: AppColors.border)),
|
||
),
|
||
padding: const EdgeInsets.fromLTRB(12, 10, 12, 10),
|
||
child: SafeArea(
|
||
top: false,
|
||
child: Row(
|
||
children: [
|
||
Expanded(
|
||
child: TextField(
|
||
controller: _commentController,
|
||
focusNode: _commentFocus,
|
||
textInputAction: TextInputAction.send,
|
||
onSubmitted: (_) => _submitComment(),
|
||
decoration: const InputDecoration(
|
||
hintText: '写下你的评论…',
|
||
isDense: true,
|
||
),
|
||
),
|
||
),
|
||
const SizedBox(width: 8),
|
||
IconButton.filled(
|
||
tooltip: '发送评论',
|
||
onPressed: canSend ? _submitComment : null,
|
||
icon: _sending
|
||
? const SizedBox(
|
||
width: 18,
|
||
height: 18,
|
||
child: CircularProgressIndicator(
|
||
strokeWidth: 2,
|
||
color: Colors.white,
|
||
),
|
||
)
|
||
: const Icon(Icons.send_rounded),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
);
|
||
}
|
||
}
|
||
|
||
/// 评论区首载骨架(05 §3.7:32 圆 + 圆角 16 矩形块高 72,装饰性占位)。
|
||
class _CommentSkeleton extends StatelessWidget {
|
||
const _CommentSkeleton();
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
return Row(
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
children: [
|
||
Container(
|
||
width: 32,
|
||
height: 32,
|
||
decoration: const BoxDecoration(
|
||
shape: BoxShape.circle,
|
||
color: AppColors.surfaceTint,
|
||
),
|
||
),
|
||
const SizedBox(width: 10),
|
||
Expanded(
|
||
child: Container(
|
||
height: 72,
|
||
decoration: BoxDecoration(
|
||
color: AppColors.surfaceTint,
|
||
borderRadius: BorderRadius.circular(16),
|
||
),
|
||
),
|
||
),
|
||
],
|
||
);
|
||
}
|
||
}
|
||
|
||
/// 全屏大图浏览(05 §2.2:黑底、双击缩放;03 号拍板 E 选内置
|
||
/// InteractiveViewer,零依赖)。横滑翻页 + 右上「n/N」ink 胶囊 + 关闭钮;
|
||
/// 下滑关闭与 InteractiveViewer 平移手势冲突,留待手势方案升级
|
||
/// (photo_view 复评条件:体验不达标)。
|
||
class _MediaGalleryPage extends StatefulWidget {
|
||
const _MediaGalleryPage({required this.urls, required this.initialIndex});
|
||
|
||
final List<String> urls;
|
||
final int initialIndex;
|
||
|
||
@override
|
||
State<_MediaGalleryPage> createState() => _MediaGalleryPageState();
|
||
}
|
||
|
||
class _MediaGalleryPageState extends State<_MediaGalleryPage> {
|
||
late final PageController _pageController = PageController(
|
||
initialPage: widget.initialIndex,
|
||
);
|
||
late int _index = widget.initialIndex;
|
||
final _viewerController = TransformationController();
|
||
|
||
@override
|
||
void dispose() {
|
||
_pageController.dispose();
|
||
_viewerController.dispose();
|
||
super.dispose();
|
||
}
|
||
|
||
void _toggleZoom(TapDownDetails details) {
|
||
if (_viewerController.value != Matrix4.identity()) {
|
||
_viewerController.value = Matrix4.identity();
|
||
return;
|
||
}
|
||
final position = details.localPosition;
|
||
// 双击点为中心放大 2.5 倍。
|
||
_viewerController.value = Matrix4.identity()
|
||
..translateByDouble(-position.dx * 1.5, -position.dy * 1.5, 0, 1)
|
||
..scaleByDouble(2.5, 2.5, 1, 1);
|
||
}
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
return Scaffold(
|
||
backgroundColor: Colors.black,
|
||
body: Stack(
|
||
children: [
|
||
PageView.builder(
|
||
controller: _pageController,
|
||
itemCount: widget.urls.length,
|
||
onPageChanged: (index) {
|
||
_viewerController.value = Matrix4.identity();
|
||
setState(() => _index = index);
|
||
},
|
||
itemBuilder: (context, index) {
|
||
return GestureDetector(
|
||
onDoubleTapDown: index == _index ? _toggleZoom : null,
|
||
onDoubleTap: () {}, // 消费事件,保留 onDoubleTapDown 的坐标。
|
||
child: InteractiveViewer(
|
||
transformationController: index == _index
|
||
? _viewerController
|
||
: null,
|
||
maxScale: 4,
|
||
child: Center(
|
||
child: RemoteImage(
|
||
url: widget.urls[index],
|
||
fit: BoxFit.contain,
|
||
),
|
||
),
|
||
),
|
||
);
|
||
},
|
||
),
|
||
SafeArea(
|
||
child: Padding(
|
||
padding: const EdgeInsets.all(12),
|
||
child: Row(
|
||
children: [
|
||
IconButton(
|
||
tooltip: '关闭',
|
||
onPressed: () => Navigator.of(context).pop(),
|
||
icon: const Icon(Icons.close, color: Colors.white),
|
||
),
|
||
const Spacer(),
|
||
if (widget.urls.length > 1)
|
||
Container(
|
||
padding: const EdgeInsets.symmetric(
|
||
horizontal: 10,
|
||
vertical: 4,
|
||
),
|
||
decoration: BoxDecoration(
|
||
color: AppColors.ink,
|
||
borderRadius: BorderRadius.circular(AppRadius.pill),
|
||
),
|
||
child: Text(
|
||
'${_index + 1}/${widget.urls.length}',
|
||
style: const TextStyle(
|
||
color: Colors.white,
|
||
fontSize: 12,
|
||
fontWeight: FontWeight.w700,
|
||
),
|
||
),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
),
|
||
],
|
||
),
|
||
);
|
||
}
|
||
}
|