flutter架 构项目基础
This commit is contained in:
@@ -0,0 +1,576 @@
|
||||
import 'package:flutter/foundation.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/components/image_preview_viewer.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:new_learn/core/logger/app_talker.dart';
|
||||
import 'package:new_learn/core/network/dio_client.dart';
|
||||
import 'package:new_learn/core/network/oss_api.dart';
|
||||
import 'package:new_learn/core/network/oss_uploader.dart';
|
||||
import 'package:new_learn/core/providers/user_provider.dart';
|
||||
import 'package:new_learn/core/services/image_processor.dart';
|
||||
import 'package:new_learn/core/services/local_notification_service.dart';
|
||||
import 'package:new_learn/core/services/picker_service.dart';
|
||||
import 'package:new_learn/core/storage/user_storage.dart';
|
||||
import 'package:new_learn/core/api/auth/api.dart';
|
||||
|
||||
/// ============================================================
|
||||
/// 【MyPage】「我的」tab 页
|
||||
///
|
||||
/// 一、本页结构(Column):
|
||||
/// 渐变头部(用户信息)→ Expanded(SingleChildScrollView(菜单))
|
||||
/// 固定头部 + 菜单可滚动,是「我的」页经典布局。
|
||||
///
|
||||
/// 二、当前菜单:
|
||||
/// 暂时只保留【我的积分】入口(点击 context.push('/myPoint') 进积分页),
|
||||
/// 后续菜单项一个个加回来,点击各自 push 对应页面。
|
||||
///
|
||||
/// 三、Riverpod 全局用户:
|
||||
/// 头部头像/用户名/班级/积分全部来自 currentUserProvider(登录后
|
||||
/// login_page 已 set),本页 ref.watch 自动响应更新,不再写死。
|
||||
///
|
||||
/// 四、屏幕适配(flutter_screenutil):
|
||||
/// 所有尺寸按设计稿 375x812 适配:.w 宽/.h 高/.sp 字号/.r 圆角。
|
||||
///
|
||||
/// 五、高频坑:
|
||||
/// 1. SingleChildScrollView 里不要嵌套无界高度的 ListView;
|
||||
/// 2. 菜单项统一 64.h 高,点击区用 InkWell 包满整行;
|
||||
/// 3. 加了 .w/.h/.sp 的 widget 不能再用 const(值是运行时计算)。
|
||||
/// ============================================================
|
||||
class MyPage extends ConsumerWidget {
|
||||
const MyPage({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
// 全局用户(登录后自动有值)
|
||||
final user = ref.watch(currentUserProvider).value;
|
||||
|
||||
return Scaffold(
|
||||
body: SafeArea(
|
||||
child: Column(
|
||||
children: [
|
||||
// 顶部渐变背景区域(用户信息头部)
|
||||
Container(
|
||||
width: double.infinity,
|
||||
decoration: const BoxDecoration(
|
||||
gradient: LinearGradient(
|
||||
begin: Alignment.topCenter,
|
||||
end: Alignment.bottomCenter,
|
||||
colors: [Color(0xFFFFE2C2), Color(0xFFFFFFFF)],
|
||||
),
|
||||
),
|
||||
child: Padding(
|
||||
padding: EdgeInsets.only(bottom: 20.h),
|
||||
child: _UserHeader(
|
||||
name: user?.userName ?? '未登录',
|
||||
className: user?.className ?? '-',
|
||||
avatar: user?.avatar ?? '',
|
||||
),
|
||||
),
|
||||
),
|
||||
// 菜单列表区域(可滚动)
|
||||
Expanded(
|
||||
child: SingleChildScrollView(
|
||||
child: Column(
|
||||
children: [
|
||||
SizedBox(height: 12.h),
|
||||
// 功能菜单(先只留"我的积分",其余后补)
|
||||
_MenuCard(
|
||||
items: [
|
||||
_MenuItem(
|
||||
color: const Color(0xFFFF9800),
|
||||
title: '我的积分',
|
||||
iconData: Icons.stars_rounded,
|
||||
trailingText: '${user?.userPoints ?? 0}积分',
|
||||
onTap: () => context.push('/myPoint'),
|
||||
),
|
||||
if (!kReleaseMode)
|
||||
_MenuItem(
|
||||
color: const Color(0xFF2563EB),
|
||||
title: '测试通知(15秒)',
|
||||
iconData: Icons.notifications_active_outlined,
|
||||
onTap: () async {
|
||||
talker.debug('触发测试通知:15 秒后弹出,点击进入我的积分');
|
||||
await LocalNotificationService.instance
|
||||
.showMyPointNow();
|
||||
await LocalNotificationService.instance
|
||||
.scheduleMyPointAfter15Seconds();
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
SizedBox(height: 24.h),
|
||||
// 退出登录(守卫自动踢回登录页,见 app_router.dart)
|
||||
_MenuCard(
|
||||
items: [
|
||||
_MenuItem(
|
||||
color: const Color(0xFFEF4444),
|
||||
title: '退出登录',
|
||||
iconData: Icons.logout_rounded,
|
||||
onTap: () => _confirmLogout(context, ref),
|
||||
),
|
||||
],
|
||||
),
|
||||
SizedBox(height: 24.h),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 退出登录:确认弹窗 → 清 token → 清全局登录态 → 守卫自动踢回登录页
|
||||
Future<void> _confirmLogout(BuildContext context, WidgetRef ref) async {
|
||||
final ok = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (dialogContext) => AlertDialog(
|
||||
title: const Text('退出登录'),
|
||||
content: const Text('确定要退出当前账号吗?'),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(dialogContext).pop(false),
|
||||
child: const Text('取消'),
|
||||
),
|
||||
FilledButton(
|
||||
onPressed: () => Navigator.of(dialogContext).pop(true),
|
||||
child: const Text('退出'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
if (ok != true) return;
|
||||
|
||||
// ① 清内存 Token:Dio 拦截器立刻停止携带 Authorization
|
||||
DioClient.instance.accessToken = null;
|
||||
// ② 清磁盘缓存:安全存储的 token + prefs 里的用户信息(记住密码保留)
|
||||
await UserStorage.instance.clearAll();
|
||||
// ③ 清全局登录态 → set(null) 内部 authTick.value++
|
||||
// → GoRouter.refresh() → 守卫 redirect 把当前页踢回 /login。
|
||||
// 这里不需要手动 context.go('/login'),守卫会自动接管。
|
||||
ref.read(currentUserProvider.notifier).set(null);
|
||||
}
|
||||
|
||||
/// 用户信息头部:圆形头像 + 用户名 + 班级
|
||||
///
|
||||
/// 头像显示三级优先:
|
||||
/// 1. 刚选中的本地新头像(上传成功后替换为服务器 URL)
|
||||
/// 2. 服务器返回的 avatar 网络图(登录/缓存里有 URL)
|
||||
/// 3. 默认头像:用户名首字圆(还没上传过头像时兜底)
|
||||
///
|
||||
/// 点击头像 → 弹出「拍照/从相册」→ 选完 OSS 直传(场景 avatar)
|
||||
/// → 上传成功更新全局用户头像(currentUserProvider + 本地缓存)。
|
||||
class _UserHeader extends ConsumerStatefulWidget {
|
||||
const _UserHeader({
|
||||
required this.name,
|
||||
required this.className,
|
||||
required this.avatar,
|
||||
});
|
||||
|
||||
final String name;
|
||||
final String className;
|
||||
final String avatar;
|
||||
|
||||
@override
|
||||
ConsumerState<_UserHeader> createState() => _UserHeaderState();
|
||||
}
|
||||
|
||||
class _UserHeaderState extends ConsumerState<_UserHeader> {
|
||||
static const String _avatarHeroTag = 'my-page-avatar';
|
||||
|
||||
/// 刚选中的本地新头像(null = 还没选)
|
||||
PickedImage? _pickedAvatar;
|
||||
|
||||
/// 点击头像:
|
||||
/// 1. 没头像 → 直接更换;
|
||||
/// 2. 有头像 → 先弹动作表,支持"预览头像 / 更换头像"。
|
||||
Future<void> _onAvatarTap() async {
|
||||
if (!_hasPreviewableAvatar) {
|
||||
await _pickAndUploadAvatar();
|
||||
return;
|
||||
}
|
||||
|
||||
final action = await showModalBottomSheet<String>(
|
||||
context: context,
|
||||
backgroundColor: Colors.white,
|
||||
shape: const RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.vertical(top: Radius.circular(16)),
|
||||
),
|
||||
builder: (dialogContext) => SafeArea(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
ListTile(
|
||||
leading: const Icon(Icons.visibility_outlined),
|
||||
title: const Text('预览头像'),
|
||||
onTap: () => Navigator.of(dialogContext).pop('preview'),
|
||||
),
|
||||
const Divider(height: 1),
|
||||
ListTile(
|
||||
leading: const Icon(Icons.photo_camera_outlined),
|
||||
title: const Text('更换头像'),
|
||||
onTap: () => Navigator.of(dialogContext).pop('change'),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
if (!mounted) return;
|
||||
if (action == 'preview') {
|
||||
await _previewAvatar();
|
||||
return;
|
||||
}
|
||||
if (action == 'change') {
|
||||
await _pickAndUploadAvatar();
|
||||
}
|
||||
}
|
||||
|
||||
bool get _hasPreviewableAvatar {
|
||||
return _pickedAvatar != null || widget.avatar.isNotEmpty;
|
||||
}
|
||||
|
||||
Future<void> _previewAvatar() async {
|
||||
final picked = _pickedAvatar;
|
||||
if (picked?.bytes != null) {
|
||||
await ImagePreviewViewer.showSingle(
|
||||
context,
|
||||
imageProvider: MemoryImage(picked!.bytes!),
|
||||
heroTag: _avatarHeroTag,
|
||||
label: '头像预览',
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (widget.avatar.isNotEmpty) {
|
||||
await ImagePreviewViewer.showSingle(
|
||||
context,
|
||||
imageProvider: NetworkImage(widget.avatar),
|
||||
heroTag: _avatarHeroTag,
|
||||
label: '头像预览',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 选择头像:拍照/相册 → OSS 直传 → 调业务接口更新头像字段 → 更新全局用户
|
||||
///
|
||||
/// 这里和 UniApp 旧项目保持一致,完整链路是:
|
||||
/// 1. 先选图片;
|
||||
/// 2. 本地压缩/方向修正;
|
||||
/// 3. 上传到 OSS,拿到最终图片 url;
|
||||
/// 4. 调 /api/v1/User/UpdateUserAvatar,把服务器里的 avatar 字段改掉;
|
||||
/// 5. 更新 currentUserProvider + 本地缓存,让界面立刻刷新。
|
||||
///
|
||||
/// 为什么"我的"页这里不再重新请求用户信息接口:
|
||||
/// 登录成功时已经把完整用户信息拉下来放进了 currentUserProvider;
|
||||
/// 头像上传成功后,本次只有 avatar 这一个字段发生变化,
|
||||
/// 所以前端直接做一次局部更新就够了,没必要再额外请求整份用户资料。
|
||||
Future<void> _pickAndUploadAvatar() async {
|
||||
final picked = await PickerService.instance.showPickSheet(context, maxCount: 1);
|
||||
if (picked == null || picked.isEmpty) return;
|
||||
|
||||
final image = picked.first;
|
||||
// 先显示本地临时预览,让用户选完图后有即时反馈。
|
||||
setState(() => _pickedAvatar = image);
|
||||
|
||||
// ① 读图片字节:Web 走 bytes;原生走 photo_manager 纯 Dart 的 originBytes
|
||||
// (不 import dart:io,Web 才能编译)
|
||||
final bytes = image.bytes ?? await image.entity?.originBytes;
|
||||
if (bytes == null || bytes.isEmpty) {
|
||||
_toast('读取图片失败,请重试');
|
||||
return;
|
||||
}
|
||||
|
||||
// ② 压缩 + EXIF 方向修正(后台 isolate 跑,不卡页面)
|
||||
// 头像只需 512 长边;null = 尺寸超出 14~4000 像素硬限制
|
||||
final processed = await ImageProcessor.process(
|
||||
bytes: bytes,
|
||||
fileName: image.name,
|
||||
maxDimension: 512,
|
||||
);
|
||||
if (processed == null) {
|
||||
_toast('图片尺寸不符合要求,请重新选择');
|
||||
return;
|
||||
}
|
||||
|
||||
// ③ OSS 直传:申请路径 → 拿 STS → V4 签名 → PUT(拦截器自动带 Token)
|
||||
try {
|
||||
final fileName = _replaceExtension(
|
||||
image.name ?? 'avatar_${DateTime.now().millisecondsSinceEpoch}.jpg',
|
||||
processed.extension,
|
||||
);
|
||||
debugPrint(
|
||||
'头像处理:${processed.originalBytes}B -> ${processed.processedBytes}B '
|
||||
'(${processed.contentType}, $fileName)',
|
||||
);
|
||||
final url = await OssUploader.uploadImage(
|
||||
bytes: processed.bytes,
|
||||
fileName: fileName,
|
||||
contentType: processed.contentType,
|
||||
scene: UploadScene.avatar,
|
||||
);
|
||||
|
||||
// ④ 通知业务接口"当前用户头像已改成这个 OSS 地址"。
|
||||
// 这一步对应 UniApp 的 UpdateUserAvatar({ avatar: url }),
|
||||
// 目的是把服务器里当前用户资料的 avatar 字段真正更新掉。
|
||||
await UserApi.updateUserAvatar(url);
|
||||
|
||||
// ⑤ 头像字段只改本地当前用户信息即可,不需要"我的"页再重新请求接口。
|
||||
// updateAvatar(url) 内部会做两件事:
|
||||
// - 更新 currentUserProvider 的内存状态;
|
||||
// - 把更新后的用户对象写回 SharedPreferences。
|
||||
// 因此所有 ref.watch(currentUserProvider) 的页面都会一起刷新。
|
||||
await ref.read(currentUserProvider.notifier).updateAvatar(url);
|
||||
|
||||
// 上传成功后清掉本地临时预览,改为展示服务器返回的头像 URL。
|
||||
// 否则界面会一直优先显示 _pickedAvatar,看起来像"方向没修正",
|
||||
// 实际上你看到的是本地原图/相册缩略图,不是处理后的上传结果。
|
||||
if (mounted) {
|
||||
setState(() => _pickedAvatar = null);
|
||||
}
|
||||
|
||||
if (!mounted) return;
|
||||
_toast('头像上传成功');
|
||||
} catch (e) {
|
||||
if (!mounted) return;
|
||||
_toast('头像上传失败:$e');
|
||||
}
|
||||
}
|
||||
|
||||
/// 轻提示(统一用 SnackBar)
|
||||
void _toast(String message) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text(message), duration: const Duration(seconds: 2)),
|
||||
);
|
||||
}
|
||||
|
||||
/// 头像主体:按「本地新图 → 网络图 → 默认首字圆」三级渲染
|
||||
Widget _buildAvatar() {
|
||||
final size = 62.w;
|
||||
final picked = _pickedAvatar;
|
||||
|
||||
// 一级:刚选中的本地头像
|
||||
if (picked != null) {
|
||||
return Hero(
|
||||
tag: _avatarHeroTag,
|
||||
child: ClipOval(
|
||||
child: PickerService.buildThumb(picked, width: size, height: size),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// 二级:服务器返回的网络头像(加载失败自动降级到默认头像)
|
||||
if (widget.avatar.isNotEmpty) {
|
||||
return Hero(
|
||||
tag: _avatarHeroTag,
|
||||
child: ClipOval(
|
||||
child: Image.network(
|
||||
widget.avatar,
|
||||
width: size,
|
||||
height: size,
|
||||
fit: BoxFit.cover,
|
||||
errorBuilder: (_, _, _) => _buildDefaultAvatar(),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// 三级:默认头像(首字圆)
|
||||
return _buildDefaultAvatar();
|
||||
}
|
||||
|
||||
/// 默认头像:用户名首字圆形色块
|
||||
Widget _buildDefaultAvatar() {
|
||||
final avatarChar =
|
||||
widget.name.isNotEmpty ? widget.name.characters.first : '?';
|
||||
return Container(
|
||||
width: 62.w,
|
||||
height: 62.h,
|
||||
decoration: const BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
color: Color(0xFF4A90E2),
|
||||
),
|
||||
alignment: Alignment.center,
|
||||
child: Text(
|
||||
avatarChar,
|
||||
style: TextStyle(
|
||||
fontSize: 26.sp,
|
||||
color: Colors.white,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Padding(
|
||||
padding: EdgeInsets.fromLTRB(16.w, 14.h, 16.w, 0),
|
||||
child: Row(
|
||||
children: [
|
||||
// 头像(点击可更换)
|
||||
GestureDetector(
|
||||
onTap: _onAvatarTap,
|
||||
child: Stack(
|
||||
children: [
|
||||
_buildAvatar(),
|
||||
// 右下角小相机角标,暗示可点击换头像
|
||||
Positioned(
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
child: Container(
|
||||
width: 20.w,
|
||||
height: 20.h,
|
||||
decoration: const BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
color: Color(0xFF4A90E2),
|
||||
border: Border.fromBorderSide(
|
||||
BorderSide(color: Colors.white, width: 2),
|
||||
),
|
||||
),
|
||||
child: Icon(
|
||||
Icons.photo_camera,
|
||||
size: 11.w,
|
||||
color: Colors.white,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
SizedBox(width: 12.w),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
widget.name,
|
||||
style: TextStyle(
|
||||
fontSize: 20.sp,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: const Color(0xFF1A1A1A),
|
||||
),
|
||||
),
|
||||
SizedBox(height: 4.h),
|
||||
Text(
|
||||
widget.className,
|
||||
style: TextStyle(fontSize: 13.sp, color: const Color(0xFF8A8A8A)),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
String _replaceExtension(String fileName, String extension) {
|
||||
final dotIndex = fileName.lastIndexOf('.');
|
||||
final safeExtension = extension.toLowerCase();
|
||||
if (dotIndex <= 0) return '$fileName.$safeExtension';
|
||||
return '${fileName.substring(0, dotIndex)}.$safeExtension';
|
||||
}
|
||||
|
||||
/// 菜单列表项数据
|
||||
class _MenuItem {
|
||||
const _MenuItem({
|
||||
required this.color,
|
||||
required this.title,
|
||||
required this.onTap,
|
||||
this.iconData,
|
||||
this.trailingText,
|
||||
});
|
||||
|
||||
final Color color;
|
||||
final String title;
|
||||
final VoidCallback onTap; // 点击跳转
|
||||
final IconData? iconData; // 普通图标
|
||||
final String? trailingText; // 右侧文字,如 "382积分"
|
||||
}
|
||||
|
||||
/// 圆角白色卡片菜单容器
|
||||
class _MenuCard extends StatelessWidget {
|
||||
const _MenuCard({required this.items});
|
||||
|
||||
final List<_MenuItem> items;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
margin: EdgeInsets.symmetric(horizontal: 16.w),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.circular(16.r),
|
||||
),
|
||||
child: Column(
|
||||
children: [
|
||||
for (int i = 0; i < items.length; i++) ...[
|
||||
if (i > 0)
|
||||
Divider(
|
||||
height: 1,
|
||||
thickness: 1,
|
||||
indent: 64.w,
|
||||
endIndent: 16.w,
|
||||
color: const Color(0xFFF0F2F5),
|
||||
),
|
||||
_buildRow(items[i]),
|
||||
],
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildRow(_MenuItem item) {
|
||||
return InkWell(
|
||||
onTap: item.onTap,
|
||||
child: SizedBox(
|
||||
height: 64.h,
|
||||
child: Row(
|
||||
children: [
|
||||
SizedBox(width: 16.w),
|
||||
// 左侧彩色方形图标
|
||||
Container(
|
||||
width: 36.w,
|
||||
height: 36.h,
|
||||
decoration: BoxDecoration(
|
||||
color: item.color,
|
||||
borderRadius: BorderRadius.circular(10.r),
|
||||
),
|
||||
alignment: Alignment.center,
|
||||
child: Icon(item.iconData, color: Colors.white, size: 20.w),
|
||||
),
|
||||
SizedBox(width: 12.w),
|
||||
// 中间标题
|
||||
Expanded(
|
||||
child: Text(
|
||||
item.title,
|
||||
style: TextStyle(
|
||||
fontSize: 15.sp,
|
||||
fontWeight: FontWeight.w500,
|
||||
color: const Color(0xFF333333),
|
||||
),
|
||||
),
|
||||
),
|
||||
// 右侧文字(如积分)
|
||||
if (item.trailingText != null) ...[
|
||||
Text(
|
||||
item.trailingText!,
|
||||
style: TextStyle(fontSize: 13.sp, color: const Color(0xFF999999)),
|
||||
),
|
||||
SizedBox(width: 2.w),
|
||||
],
|
||||
// 右侧浅蓝色箭头
|
||||
const Icon(Icons.chevron_right, size: 22, color: Color(0xFF7FB0F2)),
|
||||
SizedBox(width: 10.w),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,230 @@
|
||||
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<MyPointPage> createState() => _MyPointPageState();
|
||||
}
|
||||
|
||||
class _MyPointPageState extends ConsumerState<MyPointPage> {
|
||||
/// 每页条数(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<UserPointRecord>(
|
||||
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<UserPointRecord>(
|
||||
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,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user