Files
App.K12.Student.Miniapp_Flu…/lib/features/my/my_page.dart
T
2026-09-04 15:22:20 +08:00

577 lines
20 KiB
Dart
Raw 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/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),
],
),
),
);
}
}