flutter架 构项目基础

This commit is contained in:
崔宝新
2026-09-04 15:22:20 +08:00
parent 70967229d0
commit 0c5aee59c0
265 changed files with 35484 additions and 0 deletions
+403
View File
@@ -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;
}
}