Files
2026-09-08 13:45:13 +08:00

511 lines
18 KiB
Dart
Raw Permalink 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: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<MyPointPage> createState() => _MyPointPageState();
}
class _MyPointPageState extends ConsumerState<MyPointPage> {
static const int _pageSize = 15;
/// 展开/收起控制:key = 记录在当前页内的 index
final Set<int> _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<int> expandedSet;
final ValueChanged<int> 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<UserPointRecord>(
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<UserPointRecord>(
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 <SubjectTag subject="积分" />
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;
}
}