flutter架 构项目基础
This commit is contained in:
@@ -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,
|
||||
);
|
||||
|
||||
@@ -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);
|
||||
@@ -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,
|
||||
);
|
||||
Reference in New Issue
Block a user