Files
App.K12.Student.Miniapp_Flu…/lib/features/class/point_rank_view.dart
T
2026-09-08 13:45:13 +08:00

470 lines
15 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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/api/class/api.dart';
import 'package:new_learn/core/api/class/model.dart';
import 'package:new_learn/core/components/common_paged_list.dart';
import 'package:new_learn/core/components/user_circle_avatar.dart';
import 'package:new_learn/core/providers/user_provider.dart';
import 'package:new_learn/core/utils/img_url.dart';
import 'package:new_learn/features/class/widgets/point_rank_widgets.dart';
/// ============================================================
/// 【PointRankView】「积分排行」子页 —— 对标 Vue pointRank.vue
///
/// 一、Vue 源结构:
/// 海报图 → 分类(本班级/同年级/同云校) → 子 tab(总榜/本周/上周/今日/昨天)
/// → 规则排序栏 → 排行列表(RankItem) → 底部「我的排名」卡 → 积分规则弹窗。
///
/// 二、数据对接(已接通):
/// - 人数统计 → getUserCount
/// - 榜单列表 → getUserRankPagedList(游标分页,CommonPagedList)
/// - 我的排名 → 同一接口返回的 myRank
/// - 分类/子tab/排序切换 → 通过 ValueKey 重建列表,自动重新拉第一页
///
/// 三、组件已抽离到 widgets/point_rank_widgets.dart(RankRow/EnergyRuleModal/
/// RuleSection/TriangleClipper),本文件只保留页面状态与数据逻辑。
///
/// 四、换算规则:rpx → /2 后接 .w/.h/.sp/.r
/// ============================================================
class PointRankView extends ConsumerStatefulWidget {
const PointRankView({super.key});
@override
ConsumerState<PointRankView> createState() => _PointRankViewState();
}
class _PointRankViewState extends ConsumerState<PointRankView> {
static const Color _primary = Color(0xFF3D7DF5);
static const int _pageSize = 20;
int _activeCategory = 0; // 0 本班级 / 1 同年级 / 2 同云校
int _activeSubTab = 0; // 总榜/本周/上周/今日/昨天
bool _sortDesc = true; // true 积分倒序 / false 正序
bool _showRule = false;
int? _classCount;
int? _gradeCount;
int? _cloudCount;
UserRank? _myRank;
int _prevRankPageId = 0;
static const List<String> _categories = ['本班级', '同年级', '同云校'];
static const List<String> _subTabs = ['总榜', '本周', '上周', '今日', '昨天'];
@override
void initState() {
super.initState();
_fetchUserCount();
}
Future<void> _fetchUserCount() async {
try {
final res = await UserRankApi.instance.getUserCount();
if (!mounted) return;
setState(() {
_classCount = res.classUserCount;
_gradeCount = res.gradeUserCount;
_cloudCount = res.cloudSchoolUserCount;
});
} catch (_) {
// 人数统计失败静默处理
}
}
Future<PagingResult<UserRank>> _loadRank(int page) async {
final prev = page == 1 ? 0 : _prevRankPageId;
final res = await UserRankApi.instance.getRankPage(
pageIndex: page,
pageSize: _pageSize,
prevPageId: prev,
userRankType: _activeSubTab,
userClass: _activeCategory,
desc: _sortDesc,
);
if (res.userRanks.isNotEmpty) {
_prevRankPageId = res.userRanks.last.userId;
}
if (page == 1 && mounted) {
setState(() => _myRank = res.myRank);
}
return PagingResult<UserRank>(
data: res.userRanks,
hasNext: res.userRanks.length >= _pageSize,
);
}
String _countText(int? count) => count == null ? '--' : '$count';
String _formatPoints(double p) {
if (p == p.roundToDouble()) return p.toInt().toString();
return p.toString();
}
@override
Widget build(BuildContext context) {
final currentUserId = ref.watch(currentUserProvider).value?.userId ?? 0;
return Stack(
children: [
Column(
children: [
_buildPoster(),
_buildCategoryBar(),
Expanded(child: _buildRankListContainer(currentUserId)),
],
),
if (_myRank != null)
Positioned(left: 0, right: 0, bottom: 0, child: _buildMyRankCard()),
if (_showRule)
EnergyRuleModal(onClose: () => setState(() => _showRule = false)),
],
);
}
Widget _buildPoster() {
return Container(
margin: EdgeInsets.only(top: 22.h),
alignment: Alignment.center,
child: CachedNetworkImage(
imageUrl: ImgUrl.build(
'/StaticResource/2026-07-29/pointRankPoster1785291500821.png',
),
width: 170.w,
height: 30.h,
fit: BoxFit.fill,
errorWidget: (_, _, _) => const SizedBox(width: 170, height: 30),
),
);
}
Widget _buildCategoryBar() {
return Container(
margin: EdgeInsets.only(top: 20.h),
padding: EdgeInsets.symmetric(horizontal: 14.w),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceAround,
children: [
for (int i = 0; i < _categories.length; i++) _buildCategoryItem(i),
],
),
);
}
Widget _buildCategoryItem(int index) {
final active = _activeCategory == index;
final countText = switch (index) {
0 => _countText(_classCount),
1 => _countText(_gradeCount),
_ => _countText(_cloudCount),
};
return GestureDetector(
onTap: () {
if (_activeCategory == index) return;
setState(() => _activeCategory = index);
},
child: Column(
children: [
Container(
width: 105.w,
height: 45.h,
decoration: BoxDecoration(
color: active
? Colors.white
: Colors.white.withValues(alpha: 0.5),
borderRadius: BorderRadius.circular(8.r),
// 始终保留 1px border 占位,避免激活/未激活切换时 content 区域差 2px 抖动
border: Border.all(
color: active ? Colors.transparent : Colors.white,
width: 1,
),
),
// Web 端回退字体行高偏大,固定高度可能溢出 → 内容放不下时自动缩小
child: FittedBox(
fit: BoxFit.scaleDown,
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(
_categories[index],
style: TextStyle(
fontSize: 13,
fontWeight: FontWeight.w500,
color: active ? _primary : const Color(0xFF687FAD),
),
),
// SizedBox(height: 0.5.h),
Text(
'$countText人',
style: TextStyle(
fontSize: 11,
color: active ? _primary : const Color(0xFF687FAD),
),
),
],
),
),
),
// 始终渲染三角形占位,visible 切换避免外层 Column 高度变化抖动
Visibility(
visible: active,
maintainSize: true,
maintainAnimation: true,
maintainState: true,
child: _buildTriangle(),
),
],
),
);
}
Widget _buildTriangle() {
return ClipPath(
clipper: TriangleClipper(),
child: Container(width: 14.w, height: 8.h, color: Colors.white),
);
}
Widget _buildRankListContainer(int currentUserId) {
return Container(
margin: EdgeInsets.only(top: 8.h),
decoration: const BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.vertical(top: Radius.circular(16)),
),
child: Column(
children: [
_buildSubTabList(),
_buildRuleSortBar(),
Expanded(
child: CommonPagedList<UserRank>(
// 分类/子tab/排序变化 → 重建列表,自动重拉第一页
key: ValueKey('rank-$_activeCategory-$_activeSubTab-$_sortDesc'),
load: _loadRank,
padding: EdgeInsets.fromLTRB(14.w, 13.h, 14.w, 121.h),
emptyWidget: Center(
child: Text(
'暂无积分排行',
style: TextStyle(
fontSize: 14.sp,
color: const Color(0xFF999999),
),
),
),
buildItem: (context, item, index) {
final isSelf = item.userId != 0 && item.userId == currentUserId;
return RankRow(
item: item,
category: _activeCategory,
isSelf: isSelf,
);
},
),
),
],
),
);
}
Widget _buildSubTabList() {
return Container(
margin: EdgeInsets.only(top: 12.h),
padding: EdgeInsets.symmetric(horizontal: 25.w),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceAround,
children: [
for (int i = 0; i < _subTabs.length; i++) _buildSubTabItem(i),
],
),
);
}
Widget _buildSubTabItem(int index) {
final active = _activeSubTab == index;
return GestureDetector(
onTap: () {
if (_activeSubTab == index) return;
setState(() => _activeSubTab = index);
},
child: Container(
width: 52.w,
height: 32.h,
alignment: Alignment.center,
decoration: BoxDecoration(
color: active ? _primary : const Color(0xFFF5F8FA),
borderRadius: BorderRadius.circular(20.r),
),
child: Text(
_subTabs[index],
style: TextStyle(
fontSize: 14,
fontWeight: active ? FontWeight.w500 : FontWeight.w400,
color: active ? Colors.white : const Color(0xFF687FAD),
),
),
),
);
}
Widget _buildRuleSortBar() {
return Container(
margin: EdgeInsets.only(top: 21.h),
padding: EdgeInsets.symmetric(horizontal: 14.w),
child: Row(
children: [
Expanded(
child: GestureDetector(
onTap: () => setState(() => _showRule = true),
child: Row(
children: [
const Flexible(
child: Text(
'积分数值越高排名越靠前,同分并列同名次',
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(fontSize: 12, color: Color(0xFF687FAD)),
),
),
SizedBox(width: 4.w),
const Icon(
Icons.info_outline,
size: 13,
color: Color(0xFF687FAD),
),
],
),
),
),
GestureDetector(
onTap: () => setState(() => _sortDesc = !_sortDesc),
child: Row(
children: [
Text(
_sortDesc ? '积分倒序' : '积分正序',
style: const TextStyle(
fontSize: 14,
fontWeight: FontWeight.w500,
color: _primary,
),
),
SizedBox(width: 5.w),
_buildSortIcons(),
],
),
),
],
),
);
}
Widget _buildSortIcons() {
final up = _sortDesc
? '/StaticResource/2026-07-29/reverseOrder_up_icon1785291938907.png'
: '/StaticResource/2026-07-29/properOrder_up_icon1785291938906.png';
final down = _sortDesc
? '/StaticResource/2026-07-29/reverseOrder_down_icon1785291938905.png'
: '/StaticResource/2026-07-29/properOrder_down_icon1785291938907.png';
return Column(
mainAxisSize: MainAxisSize.min,
children: [
CachedNetworkImage(
imageUrl: ImgUrl.build(up),
width: 12.w,
height: 7.h,
fit: BoxFit.contain,
),
CachedNetworkImage(
imageUrl: ImgUrl.build(down),
width: 12.w,
height: 7.h,
fit: BoxFit.contain,
),
],
);
}
Widget _buildMyRankCard() {
final rank = _myRank!;
return Container(
height: 70.h,
padding: EdgeInsets.fromLTRB(10.w, 0, 14.w, 0),
decoration: const BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [Color(0xFFD6EEFF), Color(0xFFFFFFFF)],
),
borderRadius: BorderRadius.vertical(top: Radius.circular(16)),
),
child: Row(
children: [
SizedBox(
width: 50.w,
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(
'${rank.rank}',
style: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.bold,
color: Color(0xFF1D2B47),
),
),
SizedBox(height: 8.h),
const Text(
'我的排名',
style: TextStyle(fontSize: 9, color: Color(0xFF687FAD)),
),
],
),
),
UserCircleAvatar(url: _avatarUrl(rank.userAvatar), radius: 22.w),
SizedBox(width: 10.w),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(
rank.userName.isEmpty ? '我' : rank.userName,
overflow: TextOverflow.ellipsis,
style: TextStyle(
fontSize: 15,
fontWeight: FontWeight.bold,
color: Color(0xFF1D2B47),
),
),
if (_activeCategory != 0 && rank.userClassName.isNotEmpty) ...[
SizedBox(height: 3.h),
Text(
rank.userClassName,
style: TextStyle(fontSize: 12, color: Color(0xFF687FAD)),
),
],
],
),
),
Text(
_formatPoints(rank.points),
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.w500,
color: _primary,
),
),
SizedBox(width: 25.w),
],
),
);
}
static String _avatarUrl(String? raw) {
final url = ImgUrl.build(raw);
return url.isEmpty
? ImgUrl.build('/StaticResource/2026-04-11/default_avatar.png')
: url;
}
}