用户数据隔离优化
This commit is contained in:
@@ -29,6 +29,11 @@ Future<void> forceLogout({String reason = '登录已过期,请重新登录'})
|
||||
void _goLoginPage() {
|
||||
final nav = navigatorKey.currentState;
|
||||
if (nav == null) return;
|
||||
// ⚠️ 必须用 pushAndRemoveUntil 把 MainShell 连同所有 push 出来的模块页一起
|
||||
// 卸掉。App 里那批 autoDispose 的 provider(账单列表、统计、首页各卡片等)
|
||||
// 就是靠「页面卸载」才被回收的 —— 换成 pushReplacement 之类的写法,
|
||||
// 它们会带着上一个用户的数据继续活着。
|
||||
// (不带 autoDispose 的那批另有保险:见 lib/providers/session_provider.dart)
|
||||
nav.pushAndRemoveUntil(
|
||||
MaterialPageRoute(builder: (_) => const LoginPage()),
|
||||
(route) => false,
|
||||
|
||||
@@ -36,6 +36,32 @@ class LocalStorage {
|
||||
await prefs.remove(_keyUserInfo);
|
||||
}
|
||||
|
||||
/// 清掉「属于上一个用户」的全部本地数据。**登出时调**(`AuthNotifier.logout`)。
|
||||
///
|
||||
/// 为什么不和 [clearAuth] 合并成一个方法:`clearAuth` 还有另一个调用点
|
||||
/// —— `AuthNotifier.load()` 在启动时发现 token 过期也会调它。那种情况
|
||||
/// 重新登录的通常还是同一个人,把他的水印配置和记住的账号一并抹掉是净损失。
|
||||
/// 登出才是明确的「换人」信号。
|
||||
///
|
||||
/// ⚠️ **以后新增「用户级」的存储 key,记得加到这里。**
|
||||
/// 判断标准:这条数据属于某个账号(尤其是它存的是数据库行 id)就该加;
|
||||
/// 纯粹跟设备走的(比如界面缩放、主题)不用加。
|
||||
///
|
||||
/// 内存里那批 provider 不归这里管 —— 它们靠 `sessionUserProvider`
|
||||
/// 触发重建(见 lib/providers/session_provider.dart)。
|
||||
Future<void> clearUserScopedData() async {
|
||||
final prefs = await _getPrefs();
|
||||
// AI 选择:存的是服务端数据行的 id,跨用户会指到别人的配置上
|
||||
await prefs.remove(_keyAiConfigId);
|
||||
await prefs.remove(_keyAiPersonaId);
|
||||
// 水印:印的是自己的品牌信息;常用地址是用户常去的地点
|
||||
await prefs.remove(_keyWatermarkSettings);
|
||||
await prefs.remove(_keyWatermarkAddresses);
|
||||
// 记住的账号密码:不清的话登录页会把上一个人的账号连明文密码一起回填
|
||||
await prefs.remove(_keySavedUsername);
|
||||
await prefs.remove(_keySavedPassword);
|
||||
}
|
||||
|
||||
/// 只更新用户信息,不动 token。
|
||||
///
|
||||
/// 改头像/昵称后用它 —— 那些操作不发新 token,不该走 [setAuth]
|
||||
|
||||
@@ -27,7 +27,15 @@ class MinePage extends ConsumerWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final user = ref.watch(authProvider).user;
|
||||
final auth = ref.watch(authProvider);
|
||||
|
||||
// 登出瞬间这里还会重建一帧(这个页在 MainShell 的 IndexedStack 里常驻,
|
||||
// 而 provider 的重建走 Timer 调度、会先于页面卸载发生)。此时 account /
|
||||
// budget 已经是空态了,照常渲染会闪一下「余额 ¥0.00 / 未设置预算」,
|
||||
// 然后登录页才盖上来。直接不渲染,过渡帧就没有内容可闪。
|
||||
if (!auth.isLoggedIn) return const SizedBox.shrink();
|
||||
|
||||
final user = auth.user;
|
||||
final accountAsync = ref.watch(accountProvider);
|
||||
final budgetAsync = ref.watch(budgetProvider);
|
||||
|
||||
|
||||
@@ -1,13 +1,27 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../api/account_api.dart';
|
||||
import '../core/network/api_exception.dart';
|
||||
import '../models/account.dart';
|
||||
import 'session_provider.dart';
|
||||
|
||||
/// 账户信息(余额 / 总收入 / 总支出)
|
||||
class AccountNotifier extends AsyncNotifier<AccountInfo> {
|
||||
@override
|
||||
Future<AccountInfo> build() => ref.watch(accountApiProvider).getAccount();
|
||||
FutureOr<AccountInfo> build() {
|
||||
// 换人(含登出)就重建,绝不把上一个人的余额留给下一个。
|
||||
//
|
||||
// 未登录时直接给空态、**不发请求**:登出瞬间 MinePage 还挂在
|
||||
// IndexedStack 上(页面卸载在下一帧,而 provider 重建走 Timer 调度,
|
||||
// 会先发生),这时 token 已经被清掉了 —— 发出去必然 403,
|
||||
// 会被 ErrorInterceptor 当成「登录已过期」,弹一个假的红条。
|
||||
//
|
||||
// 同步返回的好处:走 AsyncData 而不是 AsyncLoading,不闪加载圈。
|
||||
if (ref.watch(sessionUserProvider) == null) return AccountInfo.empty;
|
||||
return ref.watch(accountApiProvider).getAccount();
|
||||
}
|
||||
|
||||
/// 把账户余额改成 [targetBalance]。
|
||||
///
|
||||
|
||||
@@ -6,6 +6,7 @@ import '../models/bill_request.dart';
|
||||
import '../models/ocr_result.dart';
|
||||
import '../utils/date_util.dart';
|
||||
import 'bill_refresh.dart';
|
||||
import 'session_provider.dart';
|
||||
|
||||
/// 手动记账表单状态
|
||||
class AddBillFormState {
|
||||
@@ -52,7 +53,11 @@ class AddBillFormState {
|
||||
|
||||
class AddBillFormNotifier extends Notifier<AddBillFormState> {
|
||||
@override
|
||||
AddBillFormState build() => AddBillFormState(billDate: formatDate(DateTime.now()));
|
||||
AddBillFormState build() {
|
||||
// 换人(含登出)就重建 —— 上一个人没提交完的草稿不该留在新用户的表单里。
|
||||
ref.watch(sessionUserProvider);
|
||||
return AddBillFormState(billDate: formatDate(DateTime.now()));
|
||||
}
|
||||
|
||||
void setType(int type) {
|
||||
if (type == state.type) return;
|
||||
|
||||
@@ -3,6 +3,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import '../api/ai_api.dart';
|
||||
import '../core/storage/local_storage.dart';
|
||||
import '../models/ai_config.dart';
|
||||
import 'session_provider.dart';
|
||||
|
||||
/// 可选的 AI 配置列表(含虚拟的「默认」项)
|
||||
final aiConfigsProvider = FutureProvider.autoDispose<List<AiConfigItem>>((ref) {
|
||||
@@ -17,6 +18,11 @@ final aiConfigsProvider = FutureProvider.autoDispose<List<AiConfigItem>>((ref) {
|
||||
class SelectedAiConfigNotifier extends Notifier<int?> {
|
||||
@override
|
||||
int? build() {
|
||||
// 换人(含登出)就重建。**必须重读一遍**:存的是配置文件的行 id,
|
||||
// 属于上一个人的数据行,登出时本地那份也被清掉了(LocalStorage
|
||||
// .clearUserScopedData),重读才会落到 null。
|
||||
ref.watch(sessionUserProvider);
|
||||
|
||||
// 存的就是「真实 id」,null = 用默认。
|
||||
// 默认项在列表里的 id 也是 null,两边语义天然一致。
|
||||
return LocalStorage.instance.getAiConfigId();
|
||||
|
||||
@@ -3,6 +3,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import '../api/ai_api.dart';
|
||||
import '../core/storage/local_storage.dart';
|
||||
import '../models/ai_persona.dart';
|
||||
import 'session_provider.dart';
|
||||
|
||||
/// 可选的人设列表(含虚拟的「默认」项)
|
||||
///
|
||||
@@ -28,6 +29,11 @@ final aiPersonasProvider = FutureProvider.autoDispose<List<AiPersonaItem>>((ref)
|
||||
class PreferredPersonaNotifier extends Notifier<int?> {
|
||||
@override
|
||||
int? build() {
|
||||
// 换人(含登出)就重建。**必须重读一遍**:存的是人设的行 id,
|
||||
// 属于上一个人的数据行,登出时本地那份也被清掉了(LocalStorage
|
||||
// .clearUserScopedData),重读才会落到 null。
|
||||
ref.watch(sessionUserProvider);
|
||||
|
||||
// 存的是「真实 id」,null = 内置默认。
|
||||
// 默认项在列表里的 id 也是 null,两边语义天然一致。
|
||||
return LocalStorage.instance.getAiPersonaId();
|
||||
|
||||
@@ -6,6 +6,7 @@ import '../models/ai.dart';
|
||||
import 'ai_config_provider.dart';
|
||||
import 'bill_refresh.dart';
|
||||
import 'note_provider.dart';
|
||||
import 'session_provider.dart';
|
||||
|
||||
/// 后端 AI 是否配好了(没配 key 时 App 显示提示,而不是让用户白问一句)
|
||||
final aiStatusProvider = FutureProvider.autoDispose<bool>((ref) {
|
||||
@@ -95,7 +96,17 @@ class AssistantNotifier extends Notifier<AssistantState> {
|
||||
bool _autoRestored = false;
|
||||
|
||||
@override
|
||||
AssistantState build() => const AssistantState();
|
||||
AssistantState build() {
|
||||
// 换人(含登出)就重建 —— 聊天记录属于上一个人,不能留给下一个。
|
||||
//
|
||||
// _autoRestored 必须手动复位:Riverpod 重建时不会新建 Notifier 实例
|
||||
// (它把实例缓存在 classListenable.result 里),字段不会自己清。
|
||||
// 不复位的话新用户进助手页时 restoreLastSessionIfNeeded() 会直接
|
||||
// 早退 —— 明明 _autoRestored 是上一个人留下的,却再也不恢复会话了。
|
||||
ref.watch(sessionUserProvider);
|
||||
_autoRestored = false;
|
||||
return const AssistantState();
|
||||
}
|
||||
|
||||
/// 打开助手页时调用:界面还是空的就把最近一次对话展开。
|
||||
///
|
||||
|
||||
@@ -62,7 +62,13 @@ class AuthNotifier extends Notifier<AuthState> {
|
||||
}
|
||||
|
||||
Future<void> logout() async {
|
||||
// 顺序要紧:先把本地清干净,最后才改 state。
|
||||
//
|
||||
// 改 state 会触发所有 watch `sessionUserProvider` 的 provider 重建,
|
||||
// 而它们 rebuild 时会重读本地存储(AI 选中的行 id、水印设置)。
|
||||
// 反过来先改 state 的话,那批 provider 会读到还没清掉的脏数据。
|
||||
await LocalStorage.instance.clearAuth();
|
||||
await LocalStorage.instance.clearUserScopedData();
|
||||
state = const AuthState.initial();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,12 +5,18 @@ import '../api/statistics_api.dart';
|
||||
import '../models/bill.dart';
|
||||
import '../models/statistics_response.dart';
|
||||
import '../utils/date_util.dart';
|
||||
import 'session_provider.dart';
|
||||
|
||||
/// 首页选中的月份锚点(该月 1 日 00:00)。
|
||||
/// 切换月份只改这一个状态,依赖它的列表与汇总会自动换 key 重新取数。
|
||||
class MonthAnchorNotifier extends Notifier<DateTime> {
|
||||
@override
|
||||
DateTime build() => monthStart(DateTime.now());
|
||||
DateTime build() {
|
||||
// 换人(含登出)就回到当月 —— 不然新用户打开账单页会落在
|
||||
// 上一个人翻到的那个月份上,看着像是他的数据还在。
|
||||
ref.watch(sessionUserProvider);
|
||||
return monthStart(DateTime.now());
|
||||
}
|
||||
|
||||
bool get canGoNext => state.isBefore(monthStart(DateTime.now()));
|
||||
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../api/budget_api.dart';
|
||||
import '../models/budget.dart';
|
||||
import 'session_provider.dart';
|
||||
|
||||
/// 当月预算(后端只提供当月数据)。
|
||||
///
|
||||
@@ -9,7 +12,13 @@ import '../models/budget.dart';
|
||||
/// [BudgetNotifier.setMonthBudget] 修改预算并拿到后端返回的最新值。
|
||||
class BudgetNotifier extends AsyncNotifier<BudgetInfo> {
|
||||
@override
|
||||
Future<BudgetInfo> build() => ref.watch(budgetApiProvider).getBudget();
|
||||
FutureOr<BudgetInfo> build() {
|
||||
// 换人(含登出)就重建,绝不把上一个人的预算留给下一个。
|
||||
// 未登录直接给空态、不发请求 —— 理由同 account_provider.dart 的说明
|
||||
// (登出瞬间页面还没卸载,发出去必然 403,会弹假的「登录已过期」)。
|
||||
if (ref.watch(sessionUserProvider) == null) return BudgetInfo.empty;
|
||||
return ref.watch(budgetApiProvider).getBudget();
|
||||
}
|
||||
|
||||
/// 设置/修改当月预算(立即生效,可随时修改)
|
||||
Future<void> setMonthBudget(double amount) async {
|
||||
|
||||
@@ -5,6 +5,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import '../api/note_api.dart';
|
||||
import '../core/network/api_exception.dart';
|
||||
import '../models/note.dart';
|
||||
import 'session_provider.dart';
|
||||
|
||||
/// 笔记列表页状态
|
||||
class NoteListState {
|
||||
@@ -85,14 +86,51 @@ class NoteListNotifier extends Notifier<NoteListState> {
|
||||
/// 阈值的作用是避免频繁进出页面时反复打请求。
|
||||
static const _staleAfter = Duration(seconds: 30);
|
||||
|
||||
/// 「回到前台对账」的节流窗口。切一次应用就刷三个接口太吵,窗口内只对一次。
|
||||
static const _resumeThrottle = Duration(seconds: 30);
|
||||
|
||||
Timer? _searchDebounce;
|
||||
|
||||
/// 上次成功拉到数据的时间;null = 还没加载过
|
||||
DateTime? _lastLoadedAt;
|
||||
|
||||
/// 上次「回到前台对账」的时间;null = 还没对过
|
||||
///
|
||||
/// 故意做成实例字段而不是 `note_refresh.dart` 里的文件级变量:
|
||||
/// 实例字段会随 [build] 复位,换人后节流窗口自动作废。放文件级的话,
|
||||
/// 新用户第一次切回前台会被上一个用户留下的时间戳挡掉,
|
||||
/// 看不到自己刚在 Web 上改的笔记。
|
||||
DateTime? _lastResumeAt;
|
||||
|
||||
/// 是否该在「回到前台」这个时机对一次账。返回 true 时顺手记下时间。
|
||||
bool claimResumeCheck() {
|
||||
final now = DateTime.now();
|
||||
final last = _lastResumeAt;
|
||||
if (last != null && now.difference(last) < _resumeThrottle) return false;
|
||||
_lastResumeAt = now;
|
||||
return true;
|
||||
}
|
||||
|
||||
@override
|
||||
NoteListState build() {
|
||||
ref.onDispose(() => _searchDebounce?.cancel());
|
||||
|
||||
// 换人(含登出)就重建 —— 笔记、搜索词、标签筛选都属于上一个人。
|
||||
//
|
||||
// 下面几个实例字段要**手动**复位:Riverpod 重建时不会新建 Notifier 实例
|
||||
// (它用 `classListenable.result ??=` 把实例缓存住了),字段不会自己清。
|
||||
// _lastLoadedAt 尤其要紧 —— 不清的话 refreshIfStale() 会以为「刚加载过」,
|
||||
// 对新用户直接 no-op,笔记列表就永远停在上一个人的数据。
|
||||
_lastLoadedAt = null;
|
||||
_lastResumeAt = null;
|
||||
_searchDebounce?.cancel();
|
||||
_searchDebounce = null;
|
||||
|
||||
// 未登录不发请求:登出瞬间列表页还挂在 IndexedStack 上,这里会立刻重建,
|
||||
// 而 token 已经被清掉了 —— 发出去必然 403,会被 ErrorInterceptor 当成
|
||||
// 「登录已过期」,弹一个假的红条。界面反正马上就没了,直接给空态。
|
||||
if (ref.watch(sessionUserProvider) == null) return const NoteListState();
|
||||
|
||||
// build() 里不能同步读写 state,所以丢到微任务里再发起首次加载
|
||||
Future.microtask(() => _fetchFirstPage(
|
||||
keyword: '',
|
||||
@@ -103,6 +141,12 @@ class NoteListNotifier extends Notifier<NoteListState> {
|
||||
return const NoteListState(loading: true);
|
||||
}
|
||||
|
||||
/// 未登录时一律不发请求(理由见 [build])。
|
||||
///
|
||||
/// 每个会改 loading 标志的入口都要自己挡一下 —— 光靠
|
||||
/// [_fetchFirstPage] 早退的话,`loading: true` 会留在 state 里出不去。
|
||||
bool get _loggedIn => ref.read(sessionUserProvider) != null;
|
||||
|
||||
/// 取第一页。参数由调用方传入而不是读 state,
|
||||
/// 是为了能在 build() 阶段安全调用。
|
||||
Future<void> _fetchFirstPage({
|
||||
@@ -111,6 +155,8 @@ class NoteListNotifier extends Notifier<NoteListState> {
|
||||
required String sort,
|
||||
required NoteLocation location,
|
||||
}) async {
|
||||
// 所有请求都从这里出去,兜底挡一道
|
||||
if (!_loggedIn) return;
|
||||
try {
|
||||
final result = await ref.read(noteApiProvider).list(
|
||||
page: 1,
|
||||
@@ -142,6 +188,7 @@ class NoteListNotifier extends Notifier<NoteListState> {
|
||||
|
||||
/// 下拉刷新 / 详情页改动后刷新。保留当前筛选条件。
|
||||
Future<void> refresh() async {
|
||||
if (!_loggedIn) return;
|
||||
state = state.copyWith(loading: true, error: null);
|
||||
await _fetchFirstPage(
|
||||
keyword: state.keyword,
|
||||
@@ -196,6 +243,7 @@ class NoteListNotifier extends Notifier<NoteListState> {
|
||||
|
||||
/// 上拉加载下一页
|
||||
Future<void> loadMore() async {
|
||||
if (!_loggedIn) return;
|
||||
if (state.loadingMore || !state.hasMore || state.loading) return;
|
||||
|
||||
state = state.copyWith(loadingMore: true);
|
||||
|
||||
@@ -2,13 +2,6 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import 'note_provider.dart';
|
||||
|
||||
/// 上次「回到前台对账」的时间,用来节流。
|
||||
///
|
||||
/// 切一次应用就刷三个接口太吵,30 秒内只对一次账。
|
||||
DateTime? _lastResumeCheck;
|
||||
|
||||
const _resumeThrottle = Duration(seconds: 30);
|
||||
|
||||
/// App 重新回到前台时,对一次笔记数据的账。
|
||||
///
|
||||
/// 存在的理由:**在 Web 后台或另一台设备上改过笔记时,App 收不到任何通知**
|
||||
@@ -22,13 +15,15 @@ const _resumeThrottle = Duration(seconds: 30);
|
||||
/// 不主动对账的话,它们会一直显示旧数据。表现很容易被误解成「缓存没刷」,
|
||||
/// 但其实是数据真的没重取过。
|
||||
///
|
||||
/// 节流时间戳存在 [NoteListNotifier] 上,不放这里 —— 放文件级变量的话,
|
||||
/// 它会跨用户残留:新用户第一次切回前台,会被上一个用户留下的时间戳挡掉
|
||||
/// (进程没死,这个变量就一直在)。挂在 Notifier 实例上则会随换人重建而复位。
|
||||
///
|
||||
/// [force] 为 true 时跳过节流(重新进入页面用)。
|
||||
void refreshNoteDataOnResume(WidgetRef ref, {bool force = false}) {
|
||||
if (!force) {
|
||||
final now = DateTime.now();
|
||||
final last = _lastResumeCheck;
|
||||
if (last != null && now.difference(last) < _resumeThrottle) return;
|
||||
_lastResumeCheck = now;
|
||||
final notifier = ref.read(noteListProvider.notifier);
|
||||
if (!notifier.claimResumeCheck()) return;
|
||||
}
|
||||
|
||||
// 列表走 refreshIfStale 而不是 invalidate:
|
||||
|
||||
@@ -8,6 +8,7 @@ import '../models/bill_request.dart';
|
||||
import '../models/ocr_result.dart';
|
||||
import '../utils/date_util.dart';
|
||||
import 'bill_refresh.dart';
|
||||
import 'session_provider.dart';
|
||||
|
||||
/// OCR 识别流程状态
|
||||
class OcrState {
|
||||
@@ -50,7 +51,12 @@ class OcrState {
|
||||
|
||||
class OcrNotifier extends Notifier<OcrState> {
|
||||
@override
|
||||
OcrState build() => const OcrState();
|
||||
OcrState build() {
|
||||
// 换人(含登出)就重建 —— 上一个人拍的那张图和识别出来的条目都归他,
|
||||
// 不该出现在新用户的界面上。
|
||||
ref.watch(sessionUserProvider);
|
||||
return const OcrState();
|
||||
}
|
||||
|
||||
void setImagePath(String path) {
|
||||
state = OcrState(imagePath: path);
|
||||
@@ -73,16 +79,21 @@ class OcrNotifier extends Notifier<OcrState> {
|
||||
state = state.copyWith(recognizing: true, error: null);
|
||||
try {
|
||||
final results = await ref.read(ocrApiProvider).recognize(path);
|
||||
// await 之后身份可能已经变了(换人/登出会让这个 Notifier 重建,
|
||||
// 或者整个 provider 被回收)。这时候再写 state,上一个人的识别结果
|
||||
// 就落到新会话里了 —— 而且对已销毁的 ref 写 state 会直接抛。
|
||||
if (!ref.mounted) return;
|
||||
state = state.copyWith(
|
||||
results: results,
|
||||
// 只有一条时自动展开
|
||||
expandedIndexes: results.length == 1 ? {0} : <int>{},
|
||||
);
|
||||
} on ApiException catch (e) {
|
||||
if (!ref.mounted) return;
|
||||
state = state.copyWith(error: e.message);
|
||||
rethrow;
|
||||
} finally {
|
||||
state = state.copyWith(recognizing: false);
|
||||
if (ref.mounted) state = state.copyWith(recognizing: false);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -113,14 +124,17 @@ class OcrNotifier extends Notifier<OcrState> {
|
||||
));
|
||||
}
|
||||
await ref.read(billApiProvider).createBillsBatch(bills);
|
||||
// 同 recognize():await 之后可能已经换人了,不能再碰 state/ref
|
||||
if (!ref.mounted) return;
|
||||
// 让首页列表与统计页重新取数
|
||||
invalidateBillData(ref);
|
||||
state = const OcrState();
|
||||
} on ApiException catch (e) {
|
||||
if (!ref.mounted) return;
|
||||
state = state.copyWith(error: e.message);
|
||||
rethrow;
|
||||
} finally {
|
||||
state = state.copyWith(saving: false);
|
||||
if (ref.mounted) state = state.copyWith(saving: false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import 'auth_provider.dart';
|
||||
|
||||
/// 当前登录用户的身份。`null` = 未登录;非 null(可能是空串)= 已登录。
|
||||
///
|
||||
/// ## 它是干什么的
|
||||
///
|
||||
/// **所有「属于某个用户」的 provider 都应该 watch 它。** 换了人(含登出)
|
||||
/// 它们就会自动重建,上一个人的余额、预算、聊天记录不会留给下一个。
|
||||
///
|
||||
/// 为什么不能只靠 `autoDispose`:`ProviderContainer` 在 main.dart 里创建一次、
|
||||
/// 进程存活期内不重建,所以**不带 autoDispose 的 provider 会活到 App 结束**,
|
||||
/// 登出根本不会清它们。新用户登录后读到的还是缓存值,连请求都不发 ——
|
||||
/// 表现就是「换了账号还看到上一个人的余额」。
|
||||
///
|
||||
/// ## 两个刻意的选择
|
||||
///
|
||||
/// **不直接 watch(authProvider),而是 select 出用户名。**
|
||||
/// 改头像、改昵称也会换掉 `AuthState`。而依赖 `sessionUserProvider` 的
|
||||
/// 那一批 provider 一旦被标记失效就会重建 —— 换个头像就把聊天记录、
|
||||
/// 笔记列表、记账草稿全清一遍,明显不对。select 到用户名后,这些变化
|
||||
/// 根本不会传导出去。
|
||||
///
|
||||
/// (细究的话 Riverpod 对 `==` 相等的值不会通知下游,所以「watch 整个
|
||||
/// AuthState 但返回值仍是用户名」其实也不会误清。但那是靠值相等兜底,
|
||||
/// 语义上等于承认「改头像也算换人」,不该赌这个 —— 直接 select 更明确。)
|
||||
///
|
||||
/// **用 isLoggedIn 判空,而不是只看 `user?.username`。**
|
||||
/// `AuthNotifier.load()` 在「本地有 token 但拿不到 userInfo」时会得到
|
||||
/// `AuthState(token: t, user: null)`。只看 username 会把这种情况误判成
|
||||
/// 未登录,于是各处的空态护栏把整个 App 打回空数据。
|
||||
final sessionUserProvider = Provider<String?>((ref) {
|
||||
return ref.watch(authProvider.select(
|
||||
(auth) => auth.isLoggedIn ? (auth.user?.username ?? '') : null,
|
||||
));
|
||||
});
|
||||
@@ -3,13 +3,19 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import '../api/statistics_api.dart';
|
||||
import '../models/statistics_response.dart';
|
||||
import '../utils/date_util.dart';
|
||||
import 'session_provider.dart';
|
||||
|
||||
/// 统计页的时间粒度
|
||||
enum StatisticsTab { daily, weekly, monthly }
|
||||
|
||||
class StatisticsTabNotifier extends Notifier<StatisticsTab> {
|
||||
@override
|
||||
StatisticsTab build() => StatisticsTab.monthly; // 对齐 uniApp 默认按月
|
||||
StatisticsTab build() {
|
||||
// 这条是纯 UI 偏好、不含任何隐私数据,复位只是为了「换人=全新开始」的一致感:
|
||||
// 不然新用户打开统计页会莫名其妙停在上一个人选的粒度上。
|
||||
ref.watch(sessionUserProvider);
|
||||
return StatisticsTab.monthly; // 对齐 uniApp 默认按月
|
||||
}
|
||||
|
||||
void set(StatisticsTab tab) => state = tab;
|
||||
}
|
||||
|
||||
@@ -7,16 +7,24 @@ import '../api/photo_api.dart';
|
||||
import '../core/storage/local_storage.dart';
|
||||
import '../models/photo.dart';
|
||||
import '../models/watermark.dart';
|
||||
import 'session_provider.dart';
|
||||
|
||||
/// 水印设置(全局共享,改动即落盘)
|
||||
/// 水印设置(改动即落盘)
|
||||
///
|
||||
/// 跟项目里其他 provider 不同的一点:这个**不涉及网络**,纯粹是设备本地的偏好,
|
||||
/// 跟项目里其他 provider 不同的一点:这个**不涉及网络**,纯粹是本地偏好,
|
||||
/// 所以直接读写 [LocalStorage],没有 api / 没有 refresh 失效那一套。
|
||||
///
|
||||
/// 但它是**按用户隔离**的:水印上印的是自己的品牌信息,登出时连同
|
||||
/// 常用地址一起清掉(`LocalStorage.clearUserScopedData`),别串给下一个账号。
|
||||
///
|
||||
/// 需要 `LocalStorage.instance.init()` 已经跑过(`main.dart` 启动时做了)。
|
||||
class WatermarkSettingsNotifier extends Notifier<WatermarkSettings> {
|
||||
@override
|
||||
WatermarkSettings build() {
|
||||
// 换人(含登出)就重建,重读一遍 —— 登出时本地那份已经被清掉了,
|
||||
// 于是一并退回默认值。select 出来的是用户名,只换人会触发重建。
|
||||
ref.watch(sessionUserProvider);
|
||||
|
||||
final raw = LocalStorage.instance.getWatermarkSettings();
|
||||
if (raw == null) return WatermarkSettings.defaults;
|
||||
// 存坏了就当没存过,用默认值兜住 —— 不能让一段脏 JSON 把页面打不开
|
||||
|
||||
Reference in New Issue
Block a user