Files
patbond-flutter/lib/features/post/post_detail_page.dart
T
lixi f873acf9a3
CI / flutter-gates (push) Failing after 20s
新增:帖子详情页真实数据整页替换——四态/真九宫格大图/评论区/关注双态,Feed 导航与互动全接线,demo 详情退役(T3-15/16)
- 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>
2026-09-09 13:39:10 +08:00

987 lines
32 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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,
),
),
),
],
),
),
),
],
),
);
}
}