import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_screenutil/flutter_screenutil.dart'; import 'package:go_router/go_router.dart'; import 'package:new_learn/core/api/my_point/api.dart'; import 'package:new_learn/core/api/my_point/model.dart'; import 'package:new_learn/core/logger/app_talker.dart'; import 'package:new_learn/core/providers/user_provider.dart'; import 'package:new_learn/core/components/common_paged_list.dart'; /// ============================================================ /// 【MyPointPage】我的积分页(点「我的」tab 的"我的积分"进入) /// /// 一、数据来源(对齐 Vue3 端 api/userPoint/index.ts): /// 1. 顶部积分卡:当前总积分来自 Riverpod 全局用户 /// currentUser.userPoints(登录时已拉取,无需再请求接口) /// 2. 积分明细列表:GetUserPointRecordPagedList(游标分页,见下方) /// /// 二、列表为什么用 CommonPagedList: /// 积分记录可能几千条,必须懒加载(只渲染屏幕可见行)+ 上拉分页。 /// 组件基于 infinite_scroll_pagination 封装(Flutter Favorite 认证), /// 已内置:下拉刷新 / 触底加载 / 防重入 / 失败重试 / 空状态 / 没有更多。 /// 页面只做两件事:load(调接口)+ buildItem(画每一行)。 /// /// 三、页码分页: /// CommonPagedList 的 load 只给 pageKey(首页 1,之后 +1),业务把 /// pageKey 当 pageIndex 用。hasNext 用 total 判断:总条数 > 已请求条数。 /// 对应 Vue3 端 type.ts 的 UserPointRecordRequest{pageIndex,pageSize}。 /// /// 四、高频坑: /// 1. 列表要包在 Expanded 里(Column 里无限高度会报 unbounded height); /// 2. 积分正负号决定颜色:points>0 绿色加分,<0 橙色扣分; /// 3. bizTime 后端直接给字符串,不需要自己格式化; /// 4. go_router ShellRoute push 出来的页面不自动显示返回箭头 /// (issue #144687),必须手动加 leading,见 build 里 AppBar。 /// ============================================================ class MyPointPage extends ConsumerStatefulWidget { const MyPointPage({super.key}); @override ConsumerState createState() => _MyPointPageState(); } class _MyPointPageState extends ConsumerState { /// 每页条数(CommonPagedList 无 pageSize 概念,由业务在 load 里自定) static const int _pageSize = 10; @override void initState() { super.initState(); talker.debug('打开页面:我的积分'); } @override Widget build(BuildContext context) { // 全局用户:当前总积分(登录后才有值;未登录 0) final user = ref.watch(currentUserProvider).value; return Scaffold( appBar: AppBar( title: const Text('我的积分'), // go_router 已知 issue #144687:从 ShellRoute(4 tab)内 push 出来的 // 页面,AppBar 不自动显示返回箭头。这里手动加: // 能 pop(正常从"我的"进入)→ pop 回上一页; // 不能 pop(如 Web 深链/刷新直达)→ 兜底回"我的"tab。 leading: IconButton( icon: const Icon(Icons.arrow_back), onPressed: () => context.canPop() ? context.pop() : context.go('/my'), ), ), body: Column( children: [ // ---------- 顶部积分卡 ---------- _PointHeaderCard(totalPoints: user?.userPoints ?? 0), // ---------- 积分明细列表(分页) ---------- Expanded( child: CommonPagedList( load: (page) async { talker.debug('加载积分记录:pageIndex=$page pageSize=$_pageSize'); // pageKey 当 pageIndex;CommonPagedList 从 1 开始递增 final res = await UserPointApi.instance.getRecordPage( pageIndex: page, pageSize: _pageSize, ); return PagingResult( data: res.data, hasNext: res.total > page * _pageSize, ); }, buildItem: (context, item, index) => _RecordRow(record: item), ), ), ], ), ); } } /// 顶部积分卡:当前总积分 class _PointHeaderCard extends StatelessWidget { const _PointHeaderCard({required this.totalPoints}); final double totalPoints; @override Widget build(BuildContext context) { return Container( width: double.infinity, margin: EdgeInsets.fromLTRB(16.w, 12.h, 16.w, 12.h), padding: EdgeInsets.symmetric(horizontal: 20.w, vertical: 18.h), decoration: BoxDecoration( gradient: const LinearGradient( colors: [Color(0xFFFF9800), Color(0xFFFFB74D)], begin: Alignment.topLeft, end: Alignment.bottomRight, ), borderRadius: BorderRadius.circular(16.r), ), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( '当前积分', style: TextStyle(fontSize: 13.sp, color: Colors.white.withValues(alpha: 0.9)), ), SizedBox(height: 6.h), Text( // 积分可能是小数(接口返回 105.5) totalPoints.toString(), style: TextStyle( fontSize: 34.sp, color: Colors.white, fontWeight: FontWeight.bold, ), ), SizedBox(height: 8.h), Text( '积分明细 · 变动随时记录', style: TextStyle( fontSize: 12.sp, color: Colors.white70, ), ), ], ), ); } } /// 单条积分记录行 class _RecordRow extends StatelessWidget { const _RecordRow({required this.record}); final UserPointRecord record; @override Widget build(BuildContext context) { // 正负号决定颜色:加分绿 / 扣分橙 / 0 灰 final isAdd = record.points > 0; final isSub = record.points < 0; final Color signColor = isAdd ? const Color(0xFF2E7D32) : (isSub ? const Color(0xFFE65100) : const Color(0xFF9E9E9E)); final Color iconBg = isAdd ? const Color(0xFFE8F5E9) : (isSub ? const Color(0xFFFFF3E0) : const Color(0xFFF5F5F5)); final String sign = isAdd ? '+' : (isSub ? '-' : ''); return Padding( padding: EdgeInsets.symmetric(horizontal: 16.w, vertical: 10.h), child: Row( children: [ // 左侧圆形图标(加减符号) Container( width: 40.w, height: 40.h, decoration: BoxDecoration( color: iconBg, shape: BoxShape.circle, ), alignment: Alignment.center, child: Text( sign, style: TextStyle( fontSize: 22.sp, color: signColor, fontWeight: FontWeight.bold, ), ), ), SizedBox(width: 12.w), // 中间:说明 + 时间 Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( record.pointMessage, maxLines: 1, overflow: TextOverflow.ellipsis, style: TextStyle( fontSize: 15.sp, fontWeight: FontWeight.w500, color: const Color(0xFF333333), ), ), SizedBox(height: 4.h), Text( record.bizTime, style: TextStyle(fontSize: 12.sp, color: const Color(0xFF999999)), ), ], ), ), SizedBox(width: 8.w), // 右侧:积分变动值 Text( '$sign${record.points.abs()}', style: TextStyle( fontSize: 16.sp, fontWeight: FontWeight.bold, color: signColor, ), ), ], ), ); } }