flutter架 构项目基础
This commit is contained in:
@@ -0,0 +1,403 @@
|
||||
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: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;
|
||||
_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 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;
|
||||
_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);
|
||||
_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 + (_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),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
return PostCard(
|
||||
post: _posts[index],
|
||||
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: Row(
|
||||
children: [
|
||||
_buildOnlineLeft(),
|
||||
const Spacer(),
|
||||
_buildAvatarStack(),
|
||||
_buildAllButton(),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildOnlineLeft() {
|
||||
return Row(
|
||||
children: [
|
||||
SizedBox(width: 15.w),
|
||||
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)),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
/// 头像组:在线在前、离线在后,最多展示 6 个
|
||||
Widget _buildAvatarStack() {
|
||||
final urls = _avatarUrls.take(6).toList();
|
||||
if (urls.isEmpty) return const SizedBox.shrink();
|
||||
final size = 25.w;
|
||||
final overlap = size * 0.94;
|
||||
return 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: UserCircleAvatar(url: urls[i], radius: size / 2),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// 右侧「全部」按钮
|
||||
Widget _buildAllButton() {
|
||||
return Container(
|
||||
width: 62.w,
|
||||
height: 34.h,
|
||||
alignment: Alignment.center,
|
||||
decoration: BoxDecoration(
|
||||
gradient: const LinearGradient(
|
||||
begin: Alignment.topCenter,
|
||||
end: Alignment.bottomCenter,
|
||||
colors: [Color(0xFF3D7DF5), Color(0xFF3C84FF), Color(0xFF8AD1FD)],
|
||||
),
|
||||
borderRadius: BorderRadius.only(
|
||||
topRight: Radius.circular(15.r),
|
||||
bottomLeft: Radius.circular(15.r),
|
||||
bottomRight: Radius.circular(20.r),
|
||||
),
|
||||
),
|
||||
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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user