import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_screenutil/flutter_screenutil.dart'; import 'package:cached_network_image/cached_network_image.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'; import 'package:new_learn/core/utils/img_url.dart'; import 'package:new_learn/core/utils/screen_adapt_template.dart'; /// ============================================================ /// 【MyPointPage】我的积分页 —— 对标 Vue pages/myPackage/myPoint/index.vue /// /// 一、数据来源(不动,保持现有实现): /// 1. 顶部积分数字 → currentUser.userPoints(登录时已拉取) /// 2. 积分记录分页 → UserPointApi.getRecordPage → CommonPagedList /// /// 二、UI 对齐 Vue(本次改动): /// 1. 背景 #F5F8FA + AppBar 透明(extendBodyBehindAppBar) /// 2. 顶部大号积分数字 + 装饰色带 + "积分" 小标签(代替原橙色渐变卡) /// 3. 列表标题栏:左侧主题色竖条 + "积分获取记录" /// 4. 列表项:SubjectTag("积分") + 消息 + 时间 + 积分值(去掉圆形加减图标) /// 5. 加分 #1D2B47 深色 / 扣分 #687FAD 浅灰蓝(对齐 Vue CSS) /// /// 三、字段类型确认: /// UserPointRecord.points = int(固定整数) /// UserModel.userPoints = double(总积分可能有小数) /// ============================================================ class MyPointPage extends ConsumerStatefulWidget { const MyPointPage({super.key}); @override ConsumerState createState() => _MyPointPageState(); } class _MyPointPageState extends ConsumerState { static const int _pageSize = 15; /// 展开/收起控制:key = 记录在当前页内的 index final Set _expandedSet = {}; @override void initState() { super.initState(); talker.debug('打开页面:我的积分'); } @override Widget build(BuildContext context) { final user = ref.watch(currentUserProvider).value; // 对齐 Vue isPadDevice():宽度 >= 600dp 视为 Pad final isPad = MediaQuery.sizeOf(context).width >= AppAdapt.tabletBreakpoint; return Scaffold( // Vue 端整体背景 #F5F8FA + 主题化背景图(Flutter 无图资源,用纯色) backgroundColor: const Color(0xFFF5F8FA), extendBodyBehindAppBar: true, appBar: AppBar( // Vue 端 NavBar 是透明背景 backgroundColor: Colors.transparent, surfaceTintColor: Colors.transparent, // 滚动时不叠加半透明色,背景保持不变 elevation: 0, title: const Text('我的积分', style: TextStyle(color: Color(0xFF1D2B47))), iconTheme: const IconThemeData(color: Color(0xFF1D2B47)), leading: IconButton( icon: const Icon(Icons.arrow_back), onPressed: () => context.canPop() ? context.pop() : context.go('/my'), ), ), // 对齐 Vue .score-page:背景图 + #F5F8FA 底色 // background-size: 100% auto → BoxFit.fitWidth body: Stack( children: [ // 底层:主题化背景图(Vue themes/default.json → backgroundImage / backgroundImage_pad) // 用 CachedNetworkImage + ImgUrl.build 加载远端 CDN 图片 Positioned( top: 0, left: 0, right: 0, child: SizedBox( height: isPad ? 345.h : 260.h, // Vue topSpaceHeight: 690rpx/360rpx child: CachedNetworkImage( imageUrl: ImgUrl.build( isPad ? '/StaticResource/2026-06-02/myScore-bg-pad1780381071330.png' : '/StaticResource/2026-04-07/myScore-bg.png', ), fit: BoxFit.fitWidth, alignment: Alignment.topCenter, placeholder: (_, __) => const SizedBox.shrink(), errorWidget: (_, __, ___) => const SizedBox.shrink(), ), ), ), // 积分数字行(大号数字 + 装饰色带 + "积分" 标签) // Vue: score-summary-position top: 80rpx(pad: 260rpx) Positioned( top: isPad ? 145.h : 125.h, left: 16.w, right: 0, child: _PointHeaderBlock( totalPoints: user?.userPoints ?? 0.0, isPad: isPad, ), ), // 白色卡片(叠到渐变背景上 ~40.h) // Vue: record-section-position margin-top: -100rpx(pad: -140rpx) Positioned( top: isPad ? 255.h : 230.h, left: 14.w, right: 14.w, bottom: 0, child: _RecordListSection( pageSize: _pageSize, expandedSet: _expandedSet, onToggleExpand: (index) { setState(() { if (_expandedSet.contains(index)) { _expandedSet.remove(index); } else { _expandedSet.add(index); } }); }, ), ), ], ), ); } } /// 顶部积分数字行:大号数字 + 装饰色带 + "积分" 标签 /// 对齐 Vue .score-summary(Pad/手机 双尺寸) class _PointHeaderBlock extends StatelessWidget { const _PointHeaderBlock({required this.totalPoints, required this.isPad}); final double totalPoints; final bool isPad; @override Widget build(BuildContext context) { // Vue 端字号:pad 132rpx/手机 88rpx → sp;label pad 42rpx/手机 28rpx → sp final scoreFontSize = isPad ? 66.sp : 44.sp; final scoreLineHeight = isPad ? 132 / 132 : 103 / 88; final labelFontSize = isPad ? 21.sp : 14.sp; final labelMarginBottom = isPad ? 3.h : 3.h; // margin-bottom: 6rpx return Row( crossAxisAlignment: CrossAxisAlignment.end, children: [ // 积分数字 + 装饰色带(Stack 叠) Stack( clipBehavior: Clip.none, children: [ // 装饰色带:Vue themes/default.json → score-summary__decor-background // linear-gradient(90deg, rgba(61,125,245,0) 0%, rgba(29,156,255,0.2) 35.78%, rgba(138,236,253,0.5) 100%) Positioned( left: 0, right: 0, bottom: isPad ? -10.h : -5.h, // Vue bottom: -10rpx child: Container( height: isPad ? 12.h : 9.h, // height: 18rpx decoration: BoxDecoration( borderRadius: BorderRadius.circular(50.r), gradient: const LinearGradient( begin: Alignment.centerLeft, end: Alignment.centerRight, colors: [ Color(0x003D7DF5), // rgba(61,125,245,0) Color(0x331D9CFF), // rgba(29,156,255,0.2) Color(0x808AECFD), // rgba(138,236,253,0.5) ], stops: [0, 0.3578, 1], ), ), ), ), // 积分数字 Text( _formatTotal(totalPoints), style: TextStyle( fontSize: scoreFontSize, fontWeight: FontWeight.w500, color: const Color(0xFF1D2B47), height: scoreLineHeight, ), ), ], ), SizedBox(width: 8.w), // "积分" 小标签 Padding( padding: EdgeInsets.only(bottom: labelMarginBottom), child: Text( '积分', style: TextStyle( fontSize: labelFontSize, fontWeight: FontWeight.w400, color: const Color(0xFF687FAD), ), ), ), ], ); } /// 总积分格式化:整数不显示 .0,有小数才显示 String _formatTotal(double v) { if (v.truncateToDouble() == v) { return v.toInt().toString(); } return v.toString(); } } /// 积分记录列表区(带标题栏 + 白色圆角卡片) class _RecordListSection extends StatefulWidget { const _RecordListSection({ required this.pageSize, required this.expandedSet, required this.onToggleExpand, }); final int pageSize; final Set expandedSet; final ValueChanged onToggleExpand; @override State<_RecordListSection> createState() => _RecordListSectionState(); } class _RecordListSectionState extends State<_RecordListSection> { @override Widget build(BuildContext context) { // 对齐 Vue 叠层:header 有顶部圆角,list 有全圆角 + 向上叠 10.h // list 的顶部圆角从 header 底部露出来(Vue margin-top: -18rpx) // 整个卡片的底部圆角由 list Container 自己提供(撑到屏幕底) return Column( children: [ // header:仅顶部圆角(Vue border-radius 32rpx 32rpx 0 0) Container( width: double.infinity, height: 71.h, decoration: BoxDecoration( borderRadius: BorderRadius.only( topLeft: Radius.circular(16.r), topRight: Radius.circular(16.r), ), gradient: const LinearGradient( begin: Alignment.topCenter, end: Alignment.bottomCenter, colors: [Color(0xFFFFFFFF), Color(0xFFFFFFFF), Color(0xFFEDF3F7)], stops: [0, 0.5, 1], ), ), padding: EdgeInsets.fromLTRB(14.w, 16.h, 14.w, 16.h), child: Row( crossAxisAlignment: CrossAxisAlignment.center, children: [ Container( width: 3.w, height: 14.h, decoration: BoxDecoration( color: const Color(0xFF3D7DF5), borderRadius: BorderRadius.circular(10.r), ), ), SizedBox(width: 8.w), Text( '积分获取记录', style: TextStyle( fontSize: 16.sp, fontWeight: FontWeight.w400, color: const Color(0xFF1D2B47), ), ), ], ), ), // list:向上叠 10.h,自己有全圆角(Vue record-list 全圆角 + margin-top: -18rpx) Expanded( child: Transform.translate( offset: Offset(0, -10.h), child: Container( width: double.infinity, decoration: BoxDecoration( color: Colors.white, borderRadius: BorderRadius.circular(16.r), ), child: CommonPagedList( padding: EdgeInsets.zero, load: (page) async { talker.debug( '加载积分记录:pageIndex=$page pageSize=${widget.pageSize}', ); final res = await UserPointApi.instance.getRecordPage( pageIndex: page, pageSize: widget.pageSize, ); return PagingResult( data: res.data, hasNext: res.total > page * widget.pageSize, ); }, separatorBuilder: (_, __) => Divider( height: 1, thickness: 1, color: const Color(0xFFEDF3F7), indent: 14.w, endIndent: 14.w, ), buildItem: (context, item, index) => _RecordRow( record: item, expanded: widget.expandedSet.contains(index), onToggle: () => widget.onToggleExpand(index), ), ), ), ), ), ], ); } } /// 列表标题栏:左侧竖条 + "积分获取记录" /// 对齐 Vue .record-header(渐变背景 + 顶部圆角 + 白色边框) class _RecordHeader extends StatelessWidget { @override Widget build(BuildContext context) { return Container( width: double.infinity, height: 71.h, // 仅渐变背景,不设圆角 → 由外层 Container 的 clipBehavior 统一裁剪 decoration: const BoxDecoration( gradient: LinearGradient( begin: Alignment.topCenter, end: Alignment.bottomCenter, colors: [Color(0xFFFFFFFF), Color(0xFFFFFFFF), Color(0xFFEDF3F7)], stops: [0, 0.5, 1], ), ), padding: EdgeInsets.fromLTRB(14.w, 16.h, 14.w, 16.h), child: Row( crossAxisAlignment: CrossAxisAlignment.center, children: [ // 左侧主题色竖条 Container( width: 3.w, height: 14.h, decoration: BoxDecoration( color: const Color(0xFF3D7DF5), borderRadius: BorderRadius.circular(10.r), ), ), SizedBox(width: 8.w), Text( '积分获取记录', style: TextStyle( fontSize: 16.sp, fontWeight: FontWeight.w400, color: const Color(0xFF1D2B47), ), ), ], ), ); } } /// 单条积分记录行(对标 Vue .record-item) class _RecordRow extends StatelessWidget { const _RecordRow({ required this.record, required this.expanded, required this.onToggle, }); final UserPointRecord record; final bool expanded; final VoidCallback onToggle; @override Widget build(BuildContext context) { // Vue 端:加分 #1D2B47 深色 / 扣分 #687FAD 浅灰蓝 final bool isAdd = record.points > 0; final Color scoreColor = isAdd ? const Color(0xFF1D2B47) : const Color(0xFF687FAD); // points 是 int:加分加 "+",扣分直接显示负数(带 "-") final String scoreText = isAdd ? '+${record.points}' : '${record.points}'; // 简化溢出判断:Vue 用隐藏 DOM 精确测量,Flutter 用字符长度近似 final bool overflow = record.pointMessage.length > 15; return Padding( padding: EdgeInsets.symmetric(vertical: 15.h, horizontal: 14.w), child: Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ // 左侧:SubjectTag + 消息 + 时间 Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ // SubjectTag("积分"):对标 Vue Container( margin: EdgeInsets.only(top: 3.h), padding: EdgeInsets.symmetric( horizontal: 6.w, vertical: 1.h, ), decoration: BoxDecoration( color: const Color(0xFFFFF6E3), borderRadius: BorderRadius.circular(4.r), ), child: Text( '积分', style: TextStyle( fontSize: 11.sp, color: const Color(0xFFE5A301), ), ), ), SizedBox(width: 8.w), // 消息文字(可展开/收起) Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( record.pointMessage, maxLines: expanded ? null : 1, overflow: expanded ? TextOverflow.visible : TextOverflow.ellipsis, style: TextStyle( fontSize: 15.sp, color: const Color(0xFF1D2B47), height: 1.6, ), ), if (overflow) GestureDetector( onTap: onToggle, child: Padding( padding: EdgeInsets.only(top: 5.h), child: Text( expanded ? '收起' : '全文', style: TextStyle( fontSize: 12.sp, color: const Color(0xFF3D7DF5), ), ), ), ), ], ), ), ], ), SizedBox(height: 10.h), // 时间 Text( _formatBizTime(record.bizTime), style: TextStyle( fontSize: 12.sp, color: const Color(0xFFB4C2E0), ), ), ], ), ), SizedBox(width: 9.w), // 右侧:积分变动值 Padding( padding: EdgeInsets.only(top: 9.h), child: Text( scoreText, style: TextStyle( fontSize: 18.sp, fontWeight: FontWeight.w400, color: scoreColor, ), ), ), ], ), ); } /// Vue 端 formatBizTime:替换 T→空格、去掉 Z、截前 19 位 String _formatBizTime(String bizTime) { if (bizTime.isEmpty) return ''; final s = bizTime.replaceAll('T', ' ').replaceAll('Z', ''); // Dart String.substring 不越界,手动判断长度 return s.length > 19 ? s.substring(0, 19) : s; } }