import 'package:cached_network_image/cached_network_image.dart'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_screenutil/flutter_screenutil.dart'; import 'package:go_router/go_router.dart'; import 'package:new_learn/core/components/image_preview_viewer.dart'; import 'package:new_learn/core/components/user_circle_avatar.dart'; import 'package:new_learn/core/models/user_model.dart'; import 'package:new_learn/core/providers/user_provider.dart'; import 'package:new_learn/core/utils/img_url.dart'; import 'package:new_learn/core/api/class/api.dart'; import 'package:new_learn/core/api/class/model.dart'; import 'package:new_learn/features/class/widgets/class_update_widgets.dart'; /// ============================================================ /// 【ClassUpdateView】「班级动态」子页 —— 对标 Vue classUpdate.vue /// /// 一、Vue 源结构: /// 班名标题 → OnlineStatus 在线同学条 → 动态列表 /// 列表项 SubjectCard 内按 type 分发:1=攻克任务 / 2、3=文字笔记, /// 再叠九宫格相册 + LikeAndComment 点赞评论栏。 /// /// 二、数据对接(已接通): /// - 班名 → currentUser(gradeLevel + graduationYear + className) /// - 在线同学 → getClassUsers /// - 动态列表 → getUserClassPost(游标分页) /// - 点赞/评论 → addUserLike / addUserComment(乐观更新,失败回滚) /// /// 三、为什么列表不用 CommonPagedList: /// 点赞/评论需要对单条动态做原位更新(不重建整个列表、不丢滚动位置), /// CommonPagedList 把 PagingController/列表藏死在内部拿不到,无法改单条; /// 故这里用 ListView + 手动游标分页,list 由本 State 直接持有,改单条即 setState。 /// /// 四、换算规则:rpx → /2 后接 .w/.h/.sp/.r /// ============================================================ class ClassUpdateView extends ConsumerStatefulWidget { const ClassUpdateView({super.key}); @override ConsumerState createState() => _ClassUpdateViewState(); } class _ClassUpdateViewState extends ConsumerState { static const Color _titleColor = Color(0xFF1D2B47); static const Color _primary = Color(0xFF3D7DF5); static const int _pageSize = 10; final ScrollController _scrollController = ScrollController(); final List _posts = []; int _pageIndex = 1; int _prevPageId = 0; bool _loading = true; // 首屏加载中 bool _loadingMore = false; bool _hasMore = true; bool _failed = false; int _onlineCount = 0; List _avatarUrls = []; @override void initState() { super.initState(); _scrollController.addListener(_onScroll); _loadFirstPage(); _fetchClassUsers(); } @override void dispose() { _scrollController.dispose(); super.dispose(); } void _onScroll() { if (_scrollController.position.pixels >= _scrollController.position.maxScrollExtent - 80) { _loadMore(); } } /// 班名:gradeLevel + graduationYear + "届" + 班级(1班 → (1)班) String _buildClassName(CurrentUserExtension? user) { if (user == null) return ''; final className = user.className.trim().replaceAllMapped( RegExp(r'\d+'), (m) => '(${m.group(0)})', ); return '${user.gradeLevel}${user.graduationYear}届$className'; } Future _fetchClassUsers() async { try { final users = await ClassApi.instance.getClassUsers(); final online = users.where((u) => u.isOnline).toList(); final offline = users.where((u) => !u.isOnline).toList(); final ordered = [...online, ...offline]; if (!mounted) return; setState(() { _onlineCount = online.length; _avatarUrls = ordered.map((u) => _avatarUrl(u.avatar)).toList(); }); } catch (_) { // 在线状态拉取失败静默处理,不影响动态列表 } } Future _loadFirstPage() async { setState(() { _loading = true; _failed = false; }); try { final res = await ClassApi.instance.getUserClassPost( pageIndex: 1, pageSize: _pageSize, prevPageId: 0, ); if (!mounted) return; setState(() { _posts ..clear() ..addAll(res.data); _pageIndex = 1; _prevPageId = res.data.isNotEmpty ? res.data.last.id : 0; // 优先用总条数判断是否还有更多;total 不可用时退回"本页满页才继续" _hasMore = res.total > 0 ? res.data.length < res.total : res.data.length >= _pageSize; _loading = false; }); } catch (_) { if (!mounted) return; setState(() { _loading = false; _failed = true; }); } } Future _loadMore() async { if (_loadingMore || !_hasMore || _loading || _failed) return; setState(() => _loadingMore = true); try { final res = await ClassApi.instance.getUserClassPost( pageIndex: _pageIndex + 1, pageSize: _pageSize, prevPageId: _prevPageId, ); if (!mounted) return; setState(() { _posts.addAll(res.data); _pageIndex += 1; if (res.data.isNotEmpty) _prevPageId = res.data.last.id; // 累计长度 vs 总条数,即使每页都返回满页也能在到底后正确收尾 _hasMore = res.total > 0 ? _posts.length < res.total : res.data.length >= _pageSize; _loadingMore = false; }); } catch (_) { if (!mounted) return; setState(() => _loadingMore = false); } } Future _toggleLike(int index) async { final post = _posts[index]; final liked = !post.userLikeStatus; final newCount = post.likeCount + (liked ? 1 : -1); _replaceAt( index, post.copyWith( userLikeStatus: liked, likeCount: newCount < 0 ? 0 : newCount, ), ); try { await ClassApi.instance.addUserLike( userPostId: post.id, likeStatus: liked, ); } catch (_) { if (!mounted) return; _replaceAt(index, post); } } Future _submitComment(int index, String content) async { final text = content.trim(); if (text.isEmpty) return; final post = _posts[index]; final me = ref.read(currentUserProvider).value; final common = UserPostCommonDto( userId: me?.userId ?? 0, userName: me?.userName ?? '我', content: text, ); _replaceAt( index, post.copyWith( replyCount: post.replyCount + 1, commons: [common, ...post.commons], ), ); try { await ClassApi.instance.addUserComment( userPostId: post.id, content: text, ); } catch (_) { if (!mounted) return; _replaceAt(index, post); } } void _replaceAt(int index, UserPostDto post) { setState(() => _posts[index] = post); } @override Widget build(BuildContext context) { final user = ref.watch(currentUserProvider).value; final className = _buildClassName(user); return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ _buildTitle(className), _buildOnlineStatus(), Expanded(child: _buildBody()), ], ); } /// 列表区:首屏加载 / 失败 / 空态 / 内容(含下拉刷新 + 上拉加载) Widget _buildBody() { if (_loading) { return const Center(child: CircularProgressIndicator()); } if (_failed) { return Center( child: Column( mainAxisSize: MainAxisSize.min, children: [ Text( '加载失败,请检查网络', style: TextStyle(fontSize: 14.sp, color: const Color(0xFF999999)), ), SizedBox(height: 12.h), FilledButton(onPressed: _loadFirstPage, child: const Text('重新加载')), ], ), ); } if (_posts.isEmpty) { return Center( child: Text( '暂无班级动态', style: TextStyle(fontSize: 14.sp, color: const Color(0xFF999999)), ), ); } return RefreshIndicator( onRefresh: _loadFirstPage, child: ListView.builder( controller: _scrollController, physics: const AlwaysScrollableScrollPhysics(), padding: EdgeInsets.symmetric(horizontal: 14.w), itemCount: _posts.length + 1, itemBuilder: (context, index) { if (index >= _posts.length) { // 底部占位:加载中显示菊花,没有更多则显示提示文案 if (_hasMore) { return Padding( padding: EdgeInsets.symmetric(vertical: 16.h), child: const Center( child: SizedBox( width: 22, height: 22, child: CircularProgressIndicator(strokeWidth: 2.5), ), ), ); } return Padding( padding: EdgeInsets.symmetric(vertical: 20.h), child: Center( child: Text( '- AI 爱学蝶变提醒你 没有更多了 -', style: TextStyle( fontSize: 12.sp, color: const Color(0xFFB4C2E0), ), ), ), ); } final post = _posts[index]; return GestureDetector( onTap: () => context.push('/class/my-update-detail', extra: post), child: PostCard( post: post, onLike: () => _toggleLike(index), onComment: (content) => _submitComment(index, content), ), ); }, ), ); } /// 班名标题(class-title-section) Widget _buildTitle(String className) { return Padding( padding: EdgeInsets.fromLTRB(14.w, 16.h, 14.w, 0), child: Text( className, style: TextStyle( fontSize: 18.sp, fontWeight: FontWeight.w600, color: _titleColor, height: 50 / 36, ), ), ); } /// 在线同学条(OnlineStatus) Widget _buildOnlineStatus() { return Container( height: 58.h, margin: EdgeInsets.fromLTRB(14.w, 12.h, 14.w, 15.h), // padding: EdgeInsets.fromLTRB(2.w, 2.h, 5.w, 2.h), decoration: BoxDecoration( image: DecorationImage( image: CachedNetworkImageProvider( ImgUrl.build('/StaticResource/2026-04-06/onlineStatus-bg.png'), ), fit: BoxFit.fill, ), ), child: Stack( fit: StackFit.expand, children: [ LayoutBuilder( builder: (context, rowConstraints) { final rowWidth = rowConstraints.maxWidth; return Row( children: [ _buildOnlineLeft(), Expanded( // 占据剩余空间,把真实可用宽度传给头像组动态计算数量 child: LayoutBuilder( builder: (context, constraints) => _buildAvatarStack(constraints.maxWidth, rowWidth), ), ), // 预留「全部」按钮宽度(按钮已悬浮于右上角,不在 Row 内占位) SizedBox(width: 62.w), ], ); }, ), // 「全部」按钮:悬浮右上角(对标 Vue position: absolute; top/right: 2rpx) Positioned(top: 1, right: 1, child: _buildAllButton()), ], ), ); } Widget _buildOnlineLeft() { return Row( children: [ SizedBox(width: 15.w), // 「在线同学」底部渐变高亮条(对标 Vue 的 linear-gradient 下划线) // clipBehavior: Clip.none 允许渐变条溢出 Stack 边界(bottom 为负值时不被裁剪) Stack( clipBehavior: Clip.none, children: [ Positioned( left: 0, bottom: -4, child: Container( width: 59.5.w, height: 9.h, decoration: BoxDecoration( borderRadius: BorderRadius.circular(50.r), gradient: const LinearGradient( begin: Alignment.centerLeft, end: Alignment.centerRight, stops: [0, 0.3578, 1], colors: [ Color(0x808AECFD), // rgba(138,236,253,0.5) Color(0x331D9CFF), // rgba(29,156,255,0.2) Color(0x003D7DF5), // rgba(61,125,245,0) ], ), ), ), ), const Text( '在线同学', style: TextStyle(fontSize: 14, color: _titleColor), ), ], ), SizedBox(width: 5.w), Text( '$_onlineCount', style: const TextStyle( fontSize: 23, fontWeight: FontWeight.w500, color: _primary, ), ), SizedBox(width: 4.w), const Text('人', style: TextStyle(fontSize: 14, color: _titleColor)), ], ); } /// 头像组:在线在前、离线在后,数量按剩余宽度动态计算(对标 Vue updateAvatarMaxCount,最多 20) /// 可用宽度限制在右侧背景块内(对标 Vue 左区 40% + 44rpx 内边距): /// 左界 = 行宽 40% + 22px,右界 = 「全部」按钮左缘;同时不超过文字右侧空间,避免压住文字 Widget _buildAvatarStack(double maxWidth, double rowWidth) { final size = 25.w; final overlap = size * 0.94; // 0.6 行宽 − 22px 左间距 − 62px 按钮宽 final rightBlockAvail = rowWidth * 0.6 - 84.w; final avail = maxWidth < rightBlockAvail ? maxWidth : rightBlockAvail; // 首个占满 size,后续每个只前进 overlap final fitCount = avail >= size ? ((avail - size) / overlap).floor() + 1 : 0; final urls = _avatarUrls.take(fitCount > 20 ? 20 : fitCount).toList(); if (urls.isEmpty) return const SizedBox.shrink(); return Align( alignment: Alignment.centerRight, child: SizedBox( width: size + (urls.length - 1) * overlap, height: size, child: Stack( children: [ for (int i = 0; i < urls.length; i++) Positioned( left: i * overlap, child: GestureDetector( // 点头像 → 大图预览单张 onTap: () => ImagePreviewViewer.showSingle( context, imageProvider: CachedNetworkImageProvider(urls[i]), ), child: UserCircleAvatar(url: urls[i], radius: size / 2), ), ), ], ), ), ); } /// 右侧「全部」按钮 → 全部同学页 Widget _buildAllButton() { return GestureDetector( onTap: () => context.push('/class/all-student'), child: Container( width: 62.w, height: 33.5.h, alignment: Alignment.center, decoration: BoxDecoration( gradient: const LinearGradient( begin: Alignment.topCenter, end: Alignment.bottomCenter, colors: [Color(0xFF3D7DF5), Color(0xFF3C84FF), Color(0xFF8AD1FD)], ), // 对标 Vue border-radius: 0 30rpx 0 40rpx(右上 15 / 左下 20,其余直角) borderRadius: BorderRadius.only( topRight: Radius.circular(15.r), bottomLeft: Radius.circular(20.r), ), // 对标 Vue box-shadow 外层:0 15rpx 11.3rpx rgba(61,125,245,0.08) boxShadow: const [ BoxShadow( color: Color(0x143D7DF5), offset: Offset(0, 7.5), blurRadius: 5.65, ), ], ), // 对标 Vue box-shadow 内层 inset 0 2rpx 18.4rpx rgba(183,236,255,0.56) // Flutter 无内阴影,用顶部渐变近似(浅蓝光晕从顶边向下淡出) foregroundDecoration: BoxDecoration( borderRadius: BorderRadius.only( topRight: Radius.circular(15.r), bottomLeft: Radius.circular(20.r), ), gradient: const LinearGradient( begin: Alignment.topCenter, end: Alignment.bottomCenter, stops: [0, 0.3], colors: [Color(0x8FB7ECFF), Color(0x00B7ECFF)], ), ), child: Row( mainAxisSize: MainAxisSize.min, children: [ Text('全部', style: TextStyle(fontSize: 14, color: Colors.white)), SizedBox(width: 2.w), Icon(Icons.chevron_right, size: 14, color: Colors.white), ], ), ), ); } /// 头像兜底:空 avatar 用伪造头像 static String _avatarUrl(String? raw) { final url = ImgUrl.build(raw); return url.isEmpty ? ImgUrl.build('/StaticResource/2026-04-03/fake-avatar.png') : url; } }