522 lines
17 KiB
Dart
522 lines
17 KiB
Dart
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<ClassUpdateView> createState() => _ClassUpdateViewState();
|
||
}
|
||
|
||
class _ClassUpdateViewState extends ConsumerState<ClassUpdateView> {
|
||
static const Color _titleColor = Color(0xFF1D2B47);
|
||
static const Color _primary = Color(0xFF3D7DF5);
|
||
|
||
static const int _pageSize = 10;
|
||
|
||
final ScrollController _scrollController = ScrollController();
|
||
final List<UserPostDto> _posts = [];
|
||
int _pageIndex = 1;
|
||
int _prevPageId = 0;
|
||
bool _loading = true; // 首屏加载中
|
||
bool _loadingMore = false;
|
||
bool _hasMore = true;
|
||
bool _failed = false;
|
||
|
||
int _onlineCount = 0;
|
||
List<String> _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<void> _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<void> _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<void> _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<void> _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<void> _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;
|
||
}
|
||
}
|