flutter架 构项目基础

This commit is contained in:
崔宝新
2026-09-04 15:22:20 +08:00
parent 70967229d0
commit 0c5aee59c0
265 changed files with 35484 additions and 0 deletions
@@ -0,0 +1,130 @@
import 'dart:async';
import 'package:connectivity_plus/connectivity_plus.dart';
import 'package:flutter/widgets.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:internet_connection_checker_plus/internet_connection_checker_plus.dart';
class NetworkStatus {
const NetworkStatus({
required this.networkTypes,
required this.hasInternet,
required this.hasBackend,
});
final List<ConnectivityResult> networkTypes;
final bool hasInternet;
final bool hasBackend;
static const NetworkStatus initial = NetworkStatus(
networkTypes: <ConnectivityResult>[ConnectivityResult.none],
hasInternet: false,
hasBackend: false,
);
NetworkStatus copyWith({
List<ConnectivityResult>? networkTypes,
bool? hasInternet,
bool? hasBackend,
}) {
return NetworkStatus(
networkTypes: networkTypes ?? this.networkTypes,
hasInternet: hasInternet ?? this.hasInternet,
hasBackend: hasBackend ?? this.hasBackend,
);
}
}
class NetworkStatusNotifier extends Notifier<NetworkStatus>
with WidgetsBindingObserver {
final Connectivity _connectivity = Connectivity();
StreamSubscription<List<ConnectivityResult>>? _connectivitySub;
bool _disposed = false;
int _probeSeq = 0;
@override
NetworkStatus build() {
WidgetsBinding.instance.addObserver(this);
ref.onDispose(() {
_disposed = true;
WidgetsBinding.instance.removeObserver(this);
_connectivitySub?.cancel();
});
unawaited(_init());
return NetworkStatus.initial;
}
@override
void didChangeAppLifecycleState(AppLifecycleState state) {
if (state == AppLifecycleState.resumed) {
unawaited(refresh());
}
}
Future<void> refresh() async {
final types = await _connectivity.checkConnectivity();
_handleConnectivityChanged(types);
}
Future<void> _init() async {
final types = await _connectivity.checkConnectivity();
_handleConnectivityChanged(types);
_connectivitySub = _connectivity.onConnectivityChanged.listen(
_handleConnectivityChanged,
);
}
void _handleConnectivityChanged(List<ConnectivityResult> types) {
if (_disposed) return;
state = state.copyWith(networkTypes: types);
if (!_hasConnectivity(types)) {
state = state.copyWith(hasInternet: false, hasBackend: false);
return;
}
unawaited(_probeInternetAndBackend());
}
Future<void> _probeInternetAndBackend() async {
final int seq = ++_probeSeq;
final bool hasInternet = await InternetConnection().hasInternetAccess;
if (_disposed || seq != _probeSeq) return;
if (!hasInternet) {
state = state.copyWith(hasInternet: false, hasBackend: false);
return;
}
final bool hasBackend = await _checkBackendAvailabilityStub();
if (_disposed || seq != _probeSeq) return;
state = state.copyWith(hasInternet: true, hasBackend: hasBackend);
}
Future<bool> _checkBackendAvailabilityStub() async {
// TODO: 接入后端健康探针接口(例如 GET /health),将结果映射到 hasBackend。
await Future<void>.delayed(const Duration(milliseconds: 80));
return true;
}
bool _hasConnectivity(List<ConnectivityResult> types) {
if (types.isEmpty) return false;
if (types.length == 1 && types.first == ConnectivityResult.none) {
return false;
}
return true;
}
}
final networkStatusProvider =
NotifierProvider<NetworkStatusNotifier, NetworkStatus>(
NetworkStatusNotifier.new,
);
+15
View File
@@ -0,0 +1,15 @@
import 'package:flutter/foundation.dart';
/// ============================================================
/// 【privacyAcceptedNotifier】启动隐私协议同意状态
///
/// 为什么这里不用 Riverpod:
/// 1. go_router 的 redirect 需要一个全局可监听的 Listenable;
/// 2. ValueNotifier 足够轻量,适合这种"一个布尔值控制全局门禁"的场景;
/// 3. main.dart 在 runApp 前就能直接把本地状态恢复进来。
///
/// 值含义:
/// true = 已同意,可以正常进入登录/主页
/// false = 未同意,启动 Gate 和路由守卫都会先拦住
/// ============================================================
final ValueNotifier<bool> privacyAcceptedNotifier = ValueNotifier<bool>(false);
+105
View File
@@ -0,0 +1,105 @@
import 'package:flutter/foundation.dart';
import 'package:flutter_riverpod/flutter_riverpod.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);
}
}
/// 登录态变化信号(给 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,
);