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 createState() => _PostDetailPageState(); } enum _DetailPhase { loading, error, ready } class _PostDetailPageState extends State { final _commentController = TextEditingController(); final _commentFocus = FocusNode(); _DetailPhase _phase = _DetailPhase.loading; ApiException? _loadError; /// 评论列表(页面级状态按页自建,03 号评估 §2 纪律;DESC 新评在前)。 _DetailPhase _commentsPhase = _DetailPhase.loading; ApiException? _commentsError; List _comments = const []; String? _commentsCursor; bool _commentsHasMore = false; LoadMorePhase _commentsLoadMore = LoadMorePhase.idle; bool _sending = false; final Set _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 _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 _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 _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 _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 _deleteComment(PostComment comment) async { final confirmed = await showDialog( 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 _toggleFollow(String authorId) async { final target = !_following; if (!target) { final confirmed = await showDialog( 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 media, int initialIndex) { Navigator.of(context).push( MaterialPageRoute( 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( 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 _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 _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 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, ), ), ), ], ), ), ), ], ), ); } }