470 lines
15 KiB
Dart
470 lines
15 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: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;
|
||
}
|
||
}
|