Files
App.K12.Student.Miniapp_Flu…/lib/core/providers/user_provider.dart
T
崔宝新 5e4cfe9ac6 feat: 新增诊断报告全流程功能,适配登录后报告跳转逻辑
实现了入门测/出门测报告的完整链路:
1. 新增UserInfo模型的出门测报告标记字段与相关接口
2. 新增全局报告路由拦截器,自动在登录后/应用回前台时检查报告状态
3. 新增报告相关API、页面与路由配置,包含欢迎页、H5壳子、AI计算页
4. 适配登录成功后的报告跳转逻辑,更新用户状态管理方法
2026-09-09 16:49:51 +08:00

150 lines
7.4 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_riverpod/flutter_riverpod.dart';
import 'package:new_learn/core/api/auth/api.dart';
import 'package:new_learn/core/models/user_model.dart';
import 'package:new_learn/core/storage/user_storage.dart';
/// ============================================================
/// 【currentUserProvider】全局当前用户状态(Riverpod,对标 Vue 的 Pinia store)
///
/// //冷启动 → build() 从 SharedPreferences 读缓存 → 页面拿到用户信息
/// 登录成功 → set(user) → 所有 watch 的页面自动刷新 + 路由守卫知道已登录
/// 退出登录 → set(null) → 所有 watch 的页面刷新 + 路由守卫踢回 /login
/// 换头像 → updateAvatar(url) → 内存 + 本地缓存都更新,页面自动刷新
///
/// 一、为什么用 AsyncNotifier 而不是 StateProvider:
/// AsyncNotifier 的 build() 可以【异步加载初始值】——冷启动时自动从
/// shared_preferences 读缓存回填,页面 ref.watch 后无需自己判断加载态;
/// StateProvider 的初始值是同步写死的,不适合"启动时从磁盘读"。
///
/// 二、怎么用(三句话):
/// 1. 读取:`final user = ref.watch(currentUserProvider).value;`
/// 页面必须用 ConsumerWidget(build 有 ref)或 Consumer 包裹;
/// 2. 更新:`ref.read(currentUserProvider.notifier).set(user);`
/// 登录成功后调用,所有 ref.watch 的页面自动刷新;
/// 3. 全局:ProviderScope 包在 main.dart 根,任何页面都能拿到。
///
/// 三、State 生命周期(AsyncValue):
/// - AsyncData(user) → 有值:已登录/有缓存
/// - AsyncData(null) → 明确未登录
/// - AsyncLoading() → 正在读缓存(一闪而过)
/// - AsyncError() → 读缓存异常
///
/// 四、高频坑:
/// 1. 不要直接改 state 引用,要用 set() 统一入口,方便以后加日志;
/// 2. 退出登录时调 set(null) → authTick 自增 → GoRouter.refresh(),
/// 登录守卫 redirect 会自动把当前页踢回 /login,无需手动跳转;
/// 3. AsyncNotifier 的 build 只在首次访问时执行,全局只有一份状态;
/// 4. 冷启动走 build()(读缓存),不走 set(),所以 authTick 不触发;
/// 运行中登录/登出才走 set(),此时守卫才会重新计算跳转。
/// ============================================================
class CurrentUserNotifier extends AsyncNotifier<CurrentUserExtension?> {
/// 冷启动初始值:从 shared_preferences 读缓存(登录时已写入)
@override
Future<CurrentUserExtension?> build() async {
return UserStorage.instance.readUserInfo();
}
/// 登录成功 / 手动刷新后,写入全局状态(所有 ref.watch 页面自动刷新)
void set(CurrentUserExtension? user) {
state = AsyncData(user);
// 通知路由守卫:登录态变化了,触发 GoRouter.refresh() 重算跳转
authTick.value++;
}
/// 只更新头像:登录态没变,不需要触发路由守卫重算。
///
/// 使用场景:
/// 头像上传成功后,后端接口 /api/v1/User/UpdateUserAvatar 已经把服务器里的
/// avatar 字段改掉了。此时前端不需要再去"我的"页重新请求一次用户信息,
/// 只要把 currentUserProvider 里的 avatar 同步改掉即可。
///
/// 这段代码做了两件事:
/// 1. 更新 Riverpod 内存状态:让 ref.watch(currentUserProvider) 的页面立刻刷新;
/// 2. 更新本地缓存:保证 App 重启后还是新头像,不会回退成旧值。
///
/// 注意:
/// 这里只是"用户资料局部字段更新",不是登录/登出,所以不需要 authTick++。
Future<void> updateAvatar(String avatar) async {
// 先拿到当前全局用户对象。
// state 是 AsyncValue<CurrentUserExtension?>,value 就是里面当前的用户数据。
final current = state.value;
// 防御:如果当前没有用户信息(比如未登录),就没有可更新的对象,直接返回。
if (current == null) return;
// 基于旧对象复制一个新对象,只替换 avatar 字段,其他字段保持不变。
// 这是状态管理里的常见写法:不要直接改旧对象,而是生成一个"新状态"。
final updated = current.copyWith(avatar: avatar);
// 把 Riverpod 状态更新成新对象。
// 一旦这里赋值成功,所有 ref.watch(currentUserProvider) 的页面都会自动 rebuild,
// 所以"我的"页头像会立刻刷新,不用再手动 setState 整个页面。
state = AsyncData(updated);
// 同步写回本地缓存:
// 这样不仅当前界面是新头像,App 下次重启时从 SharedPreferences 读出来的
// 也还是最新头像。
await UserStorage.instance.saveUserInfo(updated);
}
/// 强制从服务器拉最新用户信息(对齐 Vue App.vue 的 fetchCurrentUserExtension(true))
///
/// 使用场景:
/// 1. App 回前台(WidgetsBindingObserver resumed):拿最新 isShowReport / isShowAfterReport;
/// 2. 报告页调完 ChangeShowReport / ChangeShowAfterReport 后:刷新全局状态;
/// 3. 登录成功后:确保后端最新数据。
Future<CurrentUserExtension?> refresh() async {
try {
final ext = await UserApi.getCurrentUserExtension();
state = AsyncData(ext);
await UserStorage.instance.saveUserInfo(ext);
return ext;
} catch (_) {
// 网络失败:保持现有状态,不要清空(可能本来就是 AsyncData)
return state.value;
}
}
/// 只改本地报告标记为 false(studentInfo subjectReports 为空时降级处理)
///
/// 对应 Vue studentInfo.vue 的:
/// userInfo.isShowReport = false; uni.setStorageSync('userInfo', userInfo);
/// app.setUserInfo(Object.assign({}, app.globalData.userInfo, { isShowReport: false }));
///
/// 注意:不走后端接口!因为 subjectReports 为空意味着后端可能也没报告数据,
/// 直接本地置 false 避免拦截器下次又打回来。
Future<void> markShowReportFalse() async {
final current = state.value;
if (current == null) return;
final updated = current.copyWith(isShowReport: false);
state = AsyncData(updated);
await UserStorage.instance.saveUserInfo(updated);
}
/// 只改本地出门测试报告标记为 false(降级场景用)
Future<void> markShowAfterReportFalse() async {
final current = state.value;
if (current == null) return;
final updated = current.copyWith(isShowAfterReport: false);
state = AsyncData(updated);
await UserStorage.instance.saveUserInfo(updated);
}
}
/// 登录态变化信号(给 go_router 的 refreshListenable 用)
///
/// 为什么需要它:go_router 的 redirect 只在【导航发生时】执行。
/// 登出/登录不导航,守卫就不会重新判断——需要一个外部信号去主动
/// 触发一次重算。用 ValueNotifier 当信号源:
/// set(null) 登出 → authTick.value++ → GoRouter.refresh()
/// → redirect 重跑 → 未登录访问 /my → 自动重定向 /login。
/// 这就是「响应式登录守卫」:业务代码永远不用手动跳登录页。
final ValueNotifier<int> authTick = ValueNotifier<int>(0);
/// 全局用户信息 Provider
final currentUserProvider =
AsyncNotifierProvider<CurrentUserNotifier, CurrentUserExtension?>(
CurrentUserNotifier.new,
);