feat: 完成班级模块全量功能迭代
本次提交完成了班级模块的多项核心功能开发与优化: 1. 新增DELETE请求封装,支持业务删除接口且不自动重试 2. 完善UserPostCommonDto模型,新增头像字段适配详情接口 3. 重构路由架构,使用StatefulShellRoute实现Tab页面保活 4. 新增我的互动页面,包含我的动态/点赞/留言三个子页 5. 新增全部同学页面,展示班级全员信息与在线状态 6. 新增动态详情页面,支持查看完整评论与点赞头像 7. 抽离通用PostListBody组件,复用列表分页与交互逻辑 8. 优化班级列表页加载逻辑与图片预览交互 9. 新增评论删除、多图预览等细节交互功能
This commit is contained in:
@@ -2,6 +2,7 @@ import 'package:cached_network_image/cached_network_image.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_screenutil/flutter_screenutil.dart';
|
||||
import 'package:new_learn/core/api/class/model.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/utils/img_url.dart';
|
||||
|
||||
@@ -23,12 +24,16 @@ class PostCard extends StatelessWidget {
|
||||
required this.post,
|
||||
required this.onLike,
|
||||
required this.onComment,
|
||||
this.showCommentComposer = true,
|
||||
});
|
||||
|
||||
final UserPostDto post;
|
||||
final VoidCallback onLike;
|
||||
final ValueChanged<String> onComment;
|
||||
|
||||
/// 是否显示卡片内嵌的评论预览 + 输入框(列表页 true;详情页由全量留言接管)
|
||||
final bool showCommentComposer;
|
||||
|
||||
String get _avatar {
|
||||
final url = ImgUrl.build(post.avatar);
|
||||
return url.isEmpty
|
||||
@@ -106,6 +111,7 @@ class PostCard extends StatelessWidget {
|
||||
post: post,
|
||||
onLike: onLike,
|
||||
onComment: onComment,
|
||||
showComposer: showCommentComposer,
|
||||
),
|
||||
],
|
||||
),
|
||||
@@ -295,11 +301,26 @@ class AlbumGrid extends StatelessWidget {
|
||||
runSpacing: 9.h,
|
||||
children: [
|
||||
for (int i = 0; i < displayed.length; i++)
|
||||
_gridItem(displayed[i], i, urls.length),
|
||||
GestureDetector(
|
||||
onTap: () => _preview(context, urls, i),
|
||||
child: _gridItem(displayed[i], i, urls.length),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
/// 点击相册图:进入整组大图预览,定位到点中的那张
|
||||
void _preview(BuildContext context, List<String> allUrls, int index) {
|
||||
ImagePreviewViewer.show(
|
||||
context,
|
||||
items: [
|
||||
for (final u in allUrls)
|
||||
PreviewImageItem(imageProvider: CachedNetworkImageProvider(u)),
|
||||
],
|
||||
initialIndex: index,
|
||||
);
|
||||
}
|
||||
|
||||
Widget _gridItem(String url, int index, int total) {
|
||||
final more = total > 9 && index == 8 ? total - 9 : 0;
|
||||
return ClipRRect(
|
||||
@@ -344,12 +365,16 @@ class LikeCommentBar extends StatelessWidget {
|
||||
required this.post,
|
||||
required this.onLike,
|
||||
required this.onComment,
|
||||
this.showComposer = true,
|
||||
});
|
||||
|
||||
final UserPostDto post;
|
||||
final VoidCallback onLike;
|
||||
final ValueChanged<String> onComment;
|
||||
|
||||
/// 是否显示评论预览 + 输入框(列表页 true;详情页 false,由全量留言列表接管)
|
||||
final bool showComposer;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Column(
|
||||
@@ -370,12 +395,14 @@ class LikeCommentBar extends StatelessWidget {
|
||||
MetaButton(icon: Icons.chat_bubble_outline, count: post.replyCount),
|
||||
],
|
||||
),
|
||||
if (post.commons.isNotEmpty) ...[
|
||||
if (showComposer) ...[
|
||||
if (post.commons.isNotEmpty) ...[
|
||||
SizedBox(height: 12.h),
|
||||
CommentPreview(post: post),
|
||||
],
|
||||
SizedBox(height: 12.h),
|
||||
CommentPreview(post: post),
|
||||
CommentInput(onSubmit: onComment),
|
||||
],
|
||||
SizedBox(height: 12.h),
|
||||
CommentInput(onSubmit: onComment),
|
||||
],
|
||||
);
|
||||
}
|
||||
@@ -517,20 +544,25 @@ class _CommentInputState extends State<CommentInput> {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.end,
|
||||
children: [
|
||||
Expanded(
|
||||
child: Container(
|
||||
height: 30.h,
|
||||
padding: EdgeInsets.symmetric(horizontal: 12.w),
|
||||
alignment: Alignment.centerLeft,
|
||||
constraints: BoxConstraints(minHeight: 30.h),
|
||||
padding: EdgeInsets.symmetric(horizontal: 12.w, vertical: 5.h),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
border: Border.all(color: const Color(0xFFE3ECFF)),
|
||||
borderRadius: BorderRadius.circular(10.r),
|
||||
),
|
||||
// multiline:键盘右下角为「换行」,可换行写多条评论
|
||||
child: TextField(
|
||||
controller: _controller,
|
||||
style: const TextStyle(fontSize: 12, color: Color(0xFF1D2B47)),
|
||||
keyboardType: TextInputType.multiline,
|
||||
textInputAction: TextInputAction.newline,
|
||||
minLines: 1,
|
||||
maxLines: 4,
|
||||
decoration: const InputDecoration(
|
||||
isDense: true,
|
||||
border: InputBorder.none,
|
||||
|
||||
@@ -0,0 +1,265 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:flutter_screenutil/flutter_screenutil.dart';
|
||||
import 'package:new_learn/core/api/class/api.dart';
|
||||
import 'package:new_learn/core/api/class/model.dart';
|
||||
import 'package:new_learn/core/providers/user_provider.dart';
|
||||
import 'package:new_learn/features/class/widgets/class_update_widgets.dart';
|
||||
|
||||
/// ============================================================
|
||||
/// 【PostListBody】通用动态列表(游标分页 + 点赞/评论乐观更新)
|
||||
///
|
||||
/// 从 class_update_view.dart 的列表逻辑抽离,去掉班名/在线条,供
|
||||
/// 「我的互动」三个子页(我的动态/我的点赞/我的留言)复用。
|
||||
///
|
||||
/// 参数:
|
||||
/// fetchPage —— 单页动态加载器(业务签名统一 {pageIndex,pageSize,prevPageId})
|
||||
/// emptyText —— 空态文案
|
||||
/// removeOnUnlike—— 取消点赞时是否从列表移除该项(仅"我的点赞"用)
|
||||
/// onTapPost —— 点卡片进详情(可选)
|
||||
///
|
||||
/// 为什么不用 CommonPagedList:点赞/评论需对单条动态原位更新、保留滚动位置,
|
||||
/// 故用 ListView + 手动游标分页,list 由本 State 直接持有。
|
||||
/// ============================================================
|
||||
class PostListBody extends ConsumerStatefulWidget {
|
||||
const PostListBody({
|
||||
super.key,
|
||||
required this.fetchPage,
|
||||
required this.emptyText,
|
||||
this.removeOnUnlike = false,
|
||||
this.onTapPost,
|
||||
});
|
||||
|
||||
final Future<UserPostResponse> Function({
|
||||
required int pageIndex,
|
||||
required int pageSize,
|
||||
required int prevPageId,
|
||||
}) fetchPage;
|
||||
|
||||
final String emptyText;
|
||||
final bool removeOnUnlike;
|
||||
final ValueChanged<UserPostDto>? onTapPost;
|
||||
|
||||
@override
|
||||
ConsumerState<PostListBody> createState() => _PostListBodyState();
|
||||
}
|
||||
|
||||
class _PostListBodyState extends ConsumerState<PostListBody> {
|
||||
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;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_scrollController.addListener(_onScroll);
|
||||
_loadFirstPage();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_scrollController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _onScroll() {
|
||||
if (_scrollController.position.pixels >=
|
||||
_scrollController.position.maxScrollExtent - 80) {
|
||||
_loadMore();
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _loadFirstPage() async {
|
||||
setState(() {
|
||||
_loading = true;
|
||||
_failed = false;
|
||||
});
|
||||
try {
|
||||
final res = await widget.fetchPage(
|
||||
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;
|
||||
_hasMore = 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 widget.fetchPage(
|
||||
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;
|
||||
_hasMore = 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);
|
||||
// 我的点赞:取消点赞时直接移除该项
|
||||
if (widget.removeOnUnlike && !liked) {
|
||||
setState(() => _posts.removeAt(index));
|
||||
} else {
|
||||
_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;
|
||||
// 失败回滚:移除场景回插到原位,更新场景恢复旧值
|
||||
if (widget.removeOnUnlike && !liked) {
|
||||
if (index <= _posts.length) {
|
||||
setState(() => _posts.insert(index, post));
|
||||
}
|
||||
} else {
|
||||
_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) {
|
||||
return _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(
|
||||
widget.emptyText,
|
||||
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 + (_hasMore ? 1 : 0),
|
||||
itemBuilder: (context, index) {
|
||||
if (index >= _posts.length) {
|
||||
return Padding(
|
||||
padding: EdgeInsets.symmetric(vertical: 16.h),
|
||||
child: const Center(
|
||||
child: SizedBox(
|
||||
width: 22,
|
||||
height: 22,
|
||||
child: CircularProgressIndicator(strokeWidth: 2.5),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
final post = _posts[index];
|
||||
return GestureDetector(
|
||||
onTap: widget.onTapPost == null
|
||||
? null
|
||||
: () => widget.onTapPost!(post),
|
||||
child: PostCard(
|
||||
post: post,
|
||||
onLike: () => _toggleLike(index),
|
||||
onComment: (content) => _submitComment(index, content),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user