用户数据隔离优化
This commit is contained in:
@@ -29,6 +29,11 @@ Future<void> forceLogout({String reason = '登录已过期,请重新登录'})
|
|||||||
void _goLoginPage() {
|
void _goLoginPage() {
|
||||||
final nav = navigatorKey.currentState;
|
final nav = navigatorKey.currentState;
|
||||||
if (nav == null) return;
|
if (nav == null) return;
|
||||||
|
// ⚠️ 必须用 pushAndRemoveUntil 把 MainShell 连同所有 push 出来的模块页一起
|
||||||
|
// 卸掉。App 里那批 autoDispose 的 provider(账单列表、统计、首页各卡片等)
|
||||||
|
// 就是靠「页面卸载」才被回收的 —— 换成 pushReplacement 之类的写法,
|
||||||
|
// 它们会带着上一个用户的数据继续活着。
|
||||||
|
// (不带 autoDispose 的那批另有保险:见 lib/providers/session_provider.dart)
|
||||||
nav.pushAndRemoveUntil(
|
nav.pushAndRemoveUntil(
|
||||||
MaterialPageRoute(builder: (_) => const LoginPage()),
|
MaterialPageRoute(builder: (_) => const LoginPage()),
|
||||||
(route) => false,
|
(route) => false,
|
||||||
|
|||||||
@@ -36,6 +36,32 @@ class LocalStorage {
|
|||||||
await prefs.remove(_keyUserInfo);
|
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。
|
||||||
///
|
///
|
||||||
/// 改头像/昵称后用它 —— 那些操作不发新 token,不该走 [setAuth]
|
/// 改头像/昵称后用它 —— 那些操作不发新 token,不该走 [setAuth]
|
||||||
|
|||||||
@@ -27,7 +27,15 @@ class MinePage extends ConsumerWidget {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context, WidgetRef ref) {
|
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 accountAsync = ref.watch(accountProvider);
|
||||||
final budgetAsync = ref.watch(budgetProvider);
|
final budgetAsync = ref.watch(budgetProvider);
|
||||||
|
|
||||||
|
|||||||
@@ -1,13 +1,27 @@
|
|||||||
|
import 'dart:async';
|
||||||
|
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
|
||||||
import '../api/account_api.dart';
|
import '../api/account_api.dart';
|
||||||
import '../core/network/api_exception.dart';
|
import '../core/network/api_exception.dart';
|
||||||
import '../models/account.dart';
|
import '../models/account.dart';
|
||||||
|
import 'session_provider.dart';
|
||||||
|
|
||||||
/// 账户信息(余额 / 总收入 / 总支出)
|
/// 账户信息(余额 / 总收入 / 总支出)
|
||||||
class AccountNotifier extends AsyncNotifier<AccountInfo> {
|
class AccountNotifier extends AsyncNotifier<AccountInfo> {
|
||||||
@override
|
@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]。
|
/// 把账户余额改成 [targetBalance]。
|
||||||
///
|
///
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import '../models/bill_request.dart';
|
|||||||
import '../models/ocr_result.dart';
|
import '../models/ocr_result.dart';
|
||||||
import '../utils/date_util.dart';
|
import '../utils/date_util.dart';
|
||||||
import 'bill_refresh.dart';
|
import 'bill_refresh.dart';
|
||||||
|
import 'session_provider.dart';
|
||||||
|
|
||||||
/// 手动记账表单状态
|
/// 手动记账表单状态
|
||||||
class AddBillFormState {
|
class AddBillFormState {
|
||||||
@@ -52,7 +53,11 @@ class AddBillFormState {
|
|||||||
|
|
||||||
class AddBillFormNotifier extends Notifier<AddBillFormState> {
|
class AddBillFormNotifier extends Notifier<AddBillFormState> {
|
||||||
@override
|
@override
|
||||||
AddBillFormState build() => AddBillFormState(billDate: formatDate(DateTime.now()));
|
AddBillFormState build() {
|
||||||
|
// 换人(含登出)就重建 —— 上一个人没提交完的草稿不该留在新用户的表单里。
|
||||||
|
ref.watch(sessionUserProvider);
|
||||||
|
return AddBillFormState(billDate: formatDate(DateTime.now()));
|
||||||
|
}
|
||||||
|
|
||||||
void setType(int type) {
|
void setType(int type) {
|
||||||
if (type == state.type) return;
|
if (type == state.type) return;
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
|
|||||||
import '../api/ai_api.dart';
|
import '../api/ai_api.dart';
|
||||||
import '../core/storage/local_storage.dart';
|
import '../core/storage/local_storage.dart';
|
||||||
import '../models/ai_config.dart';
|
import '../models/ai_config.dart';
|
||||||
|
import 'session_provider.dart';
|
||||||
|
|
||||||
/// 可选的 AI 配置列表(含虚拟的「默认」项)
|
/// 可选的 AI 配置列表(含虚拟的「默认」项)
|
||||||
final aiConfigsProvider = FutureProvider.autoDispose<List<AiConfigItem>>((ref) {
|
final aiConfigsProvider = FutureProvider.autoDispose<List<AiConfigItem>>((ref) {
|
||||||
@@ -17,6 +18,11 @@ final aiConfigsProvider = FutureProvider.autoDispose<List<AiConfigItem>>((ref) {
|
|||||||
class SelectedAiConfigNotifier extends Notifier<int?> {
|
class SelectedAiConfigNotifier extends Notifier<int?> {
|
||||||
@override
|
@override
|
||||||
int? build() {
|
int? build() {
|
||||||
|
// 换人(含登出)就重建。**必须重读一遍**:存的是配置文件的行 id,
|
||||||
|
// 属于上一个人的数据行,登出时本地那份也被清掉了(LocalStorage
|
||||||
|
// .clearUserScopedData),重读才会落到 null。
|
||||||
|
ref.watch(sessionUserProvider);
|
||||||
|
|
||||||
// 存的就是「真实 id」,null = 用默认。
|
// 存的就是「真实 id」,null = 用默认。
|
||||||
// 默认项在列表里的 id 也是 null,两边语义天然一致。
|
// 默认项在列表里的 id 也是 null,两边语义天然一致。
|
||||||
return LocalStorage.instance.getAiConfigId();
|
return LocalStorage.instance.getAiConfigId();
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
|
|||||||
import '../api/ai_api.dart';
|
import '../api/ai_api.dart';
|
||||||
import '../core/storage/local_storage.dart';
|
import '../core/storage/local_storage.dart';
|
||||||
import '../models/ai_persona.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?> {
|
class PreferredPersonaNotifier extends Notifier<int?> {
|
||||||
@override
|
@override
|
||||||
int? build() {
|
int? build() {
|
||||||
|
// 换人(含登出)就重建。**必须重读一遍**:存的是人设的行 id,
|
||||||
|
// 属于上一个人的数据行,登出时本地那份也被清掉了(LocalStorage
|
||||||
|
// .clearUserScopedData),重读才会落到 null。
|
||||||
|
ref.watch(sessionUserProvider);
|
||||||
|
|
||||||
// 存的是「真实 id」,null = 内置默认。
|
// 存的是「真实 id」,null = 内置默认。
|
||||||
// 默认项在列表里的 id 也是 null,两边语义天然一致。
|
// 默认项在列表里的 id 也是 null,两边语义天然一致。
|
||||||
return LocalStorage.instance.getAiPersonaId();
|
return LocalStorage.instance.getAiPersonaId();
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import '../models/ai.dart';
|
|||||||
import 'ai_config_provider.dart';
|
import 'ai_config_provider.dart';
|
||||||
import 'bill_refresh.dart';
|
import 'bill_refresh.dart';
|
||||||
import 'note_provider.dart';
|
import 'note_provider.dart';
|
||||||
|
import 'session_provider.dart';
|
||||||
|
|
||||||
/// 后端 AI 是否配好了(没配 key 时 App 显示提示,而不是让用户白问一句)
|
/// 后端 AI 是否配好了(没配 key 时 App 显示提示,而不是让用户白问一句)
|
||||||
final aiStatusProvider = FutureProvider.autoDispose<bool>((ref) {
|
final aiStatusProvider = FutureProvider.autoDispose<bool>((ref) {
|
||||||
@@ -95,7 +96,17 @@ class AssistantNotifier extends Notifier<AssistantState> {
|
|||||||
bool _autoRestored = false;
|
bool _autoRestored = false;
|
||||||
|
|
||||||
@override
|
@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 {
|
Future<void> logout() async {
|
||||||
|
// 顺序要紧:先把本地清干净,最后才改 state。
|
||||||
|
//
|
||||||
|
// 改 state 会触发所有 watch `sessionUserProvider` 的 provider 重建,
|
||||||
|
// 而它们 rebuild 时会重读本地存储(AI 选中的行 id、水印设置)。
|
||||||
|
// 反过来先改 state 的话,那批 provider 会读到还没清掉的脏数据。
|
||||||
await LocalStorage.instance.clearAuth();
|
await LocalStorage.instance.clearAuth();
|
||||||
|
await LocalStorage.instance.clearUserScopedData();
|
||||||
state = const AuthState.initial();
|
state = const AuthState.initial();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,12 +5,18 @@ import '../api/statistics_api.dart';
|
|||||||
import '../models/bill.dart';
|
import '../models/bill.dart';
|
||||||
import '../models/statistics_response.dart';
|
import '../models/statistics_response.dart';
|
||||||
import '../utils/date_util.dart';
|
import '../utils/date_util.dart';
|
||||||
|
import 'session_provider.dart';
|
||||||
|
|
||||||
/// 首页选中的月份锚点(该月 1 日 00:00)。
|
/// 首页选中的月份锚点(该月 1 日 00:00)。
|
||||||
/// 切换月份只改这一个状态,依赖它的列表与汇总会自动换 key 重新取数。
|
/// 切换月份只改这一个状态,依赖它的列表与汇总会自动换 key 重新取数。
|
||||||
class MonthAnchorNotifier extends Notifier<DateTime> {
|
class MonthAnchorNotifier extends Notifier<DateTime> {
|
||||||
@override
|
@override
|
||||||
DateTime build() => monthStart(DateTime.now());
|
DateTime build() {
|
||||||
|
// 换人(含登出)就回到当月 —— 不然新用户打开账单页会落在
|
||||||
|
// 上一个人翻到的那个月份上,看着像是他的数据还在。
|
||||||
|
ref.watch(sessionUserProvider);
|
||||||
|
return monthStart(DateTime.now());
|
||||||
|
}
|
||||||
|
|
||||||
bool get canGoNext => state.isBefore(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 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
|
||||||
import '../api/budget_api.dart';
|
import '../api/budget_api.dart';
|
||||||
import '../models/budget.dart';
|
import '../models/budget.dart';
|
||||||
|
import 'session_provider.dart';
|
||||||
|
|
||||||
/// 当月预算(后端只提供当月数据)。
|
/// 当月预算(后端只提供当月数据)。
|
||||||
///
|
///
|
||||||
@@ -9,7 +12,13 @@ import '../models/budget.dart';
|
|||||||
/// [BudgetNotifier.setMonthBudget] 修改预算并拿到后端返回的最新值。
|
/// [BudgetNotifier.setMonthBudget] 修改预算并拿到后端返回的最新值。
|
||||||
class BudgetNotifier extends AsyncNotifier<BudgetInfo> {
|
class BudgetNotifier extends AsyncNotifier<BudgetInfo> {
|
||||||
@override
|
@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 {
|
Future<void> setMonthBudget(double amount) async {
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
|
|||||||
import '../api/note_api.dart';
|
import '../api/note_api.dart';
|
||||||
import '../core/network/api_exception.dart';
|
import '../core/network/api_exception.dart';
|
||||||
import '../models/note.dart';
|
import '../models/note.dart';
|
||||||
|
import 'session_provider.dart';
|
||||||
|
|
||||||
/// 笔记列表页状态
|
/// 笔记列表页状态
|
||||||
class NoteListState {
|
class NoteListState {
|
||||||
@@ -85,14 +86,51 @@ class NoteListNotifier extends Notifier<NoteListState> {
|
|||||||
/// 阈值的作用是避免频繁进出页面时反复打请求。
|
/// 阈值的作用是避免频繁进出页面时反复打请求。
|
||||||
static const _staleAfter = Duration(seconds: 30);
|
static const _staleAfter = Duration(seconds: 30);
|
||||||
|
|
||||||
|
/// 「回到前台对账」的节流窗口。切一次应用就刷三个接口太吵,窗口内只对一次。
|
||||||
|
static const _resumeThrottle = Duration(seconds: 30);
|
||||||
|
|
||||||
Timer? _searchDebounce;
|
Timer? _searchDebounce;
|
||||||
|
|
||||||
/// 上次成功拉到数据的时间;null = 还没加载过
|
/// 上次成功拉到数据的时间;null = 还没加载过
|
||||||
DateTime? _lastLoadedAt;
|
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
|
@override
|
||||||
NoteListState build() {
|
NoteListState build() {
|
||||||
ref.onDispose(() => _searchDebounce?.cancel());
|
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,所以丢到微任务里再发起首次加载
|
// build() 里不能同步读写 state,所以丢到微任务里再发起首次加载
|
||||||
Future.microtask(() => _fetchFirstPage(
|
Future.microtask(() => _fetchFirstPage(
|
||||||
keyword: '',
|
keyword: '',
|
||||||
@@ -103,6 +141,12 @@ class NoteListNotifier extends Notifier<NoteListState> {
|
|||||||
return const NoteListState(loading: true);
|
return const NoteListState(loading: true);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 未登录时一律不发请求(理由见 [build])。
|
||||||
|
///
|
||||||
|
/// 每个会改 loading 标志的入口都要自己挡一下 —— 光靠
|
||||||
|
/// [_fetchFirstPage] 早退的话,`loading: true` 会留在 state 里出不去。
|
||||||
|
bool get _loggedIn => ref.read(sessionUserProvider) != null;
|
||||||
|
|
||||||
/// 取第一页。参数由调用方传入而不是读 state,
|
/// 取第一页。参数由调用方传入而不是读 state,
|
||||||
/// 是为了能在 build() 阶段安全调用。
|
/// 是为了能在 build() 阶段安全调用。
|
||||||
Future<void> _fetchFirstPage({
|
Future<void> _fetchFirstPage({
|
||||||
@@ -111,6 +155,8 @@ class NoteListNotifier extends Notifier<NoteListState> {
|
|||||||
required String sort,
|
required String sort,
|
||||||
required NoteLocation location,
|
required NoteLocation location,
|
||||||
}) async {
|
}) async {
|
||||||
|
// 所有请求都从这里出去,兜底挡一道
|
||||||
|
if (!_loggedIn) return;
|
||||||
try {
|
try {
|
||||||
final result = await ref.read(noteApiProvider).list(
|
final result = await ref.read(noteApiProvider).list(
|
||||||
page: 1,
|
page: 1,
|
||||||
@@ -142,6 +188,7 @@ class NoteListNotifier extends Notifier<NoteListState> {
|
|||||||
|
|
||||||
/// 下拉刷新 / 详情页改动后刷新。保留当前筛选条件。
|
/// 下拉刷新 / 详情页改动后刷新。保留当前筛选条件。
|
||||||
Future<void> refresh() async {
|
Future<void> refresh() async {
|
||||||
|
if (!_loggedIn) return;
|
||||||
state = state.copyWith(loading: true, error: null);
|
state = state.copyWith(loading: true, error: null);
|
||||||
await _fetchFirstPage(
|
await _fetchFirstPage(
|
||||||
keyword: state.keyword,
|
keyword: state.keyword,
|
||||||
@@ -196,6 +243,7 @@ class NoteListNotifier extends Notifier<NoteListState> {
|
|||||||
|
|
||||||
/// 上拉加载下一页
|
/// 上拉加载下一页
|
||||||
Future<void> loadMore() async {
|
Future<void> loadMore() async {
|
||||||
|
if (!_loggedIn) return;
|
||||||
if (state.loadingMore || !state.hasMore || state.loading) return;
|
if (state.loadingMore || !state.hasMore || state.loading) return;
|
||||||
|
|
||||||
state = state.copyWith(loadingMore: true);
|
state = state.copyWith(loadingMore: true);
|
||||||
|
|||||||
@@ -2,13 +2,6 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
|
|||||||
|
|
||||||
import 'note_provider.dart';
|
import 'note_provider.dart';
|
||||||
|
|
||||||
/// 上次「回到前台对账」的时间,用来节流。
|
|
||||||
///
|
|
||||||
/// 切一次应用就刷三个接口太吵,30 秒内只对一次账。
|
|
||||||
DateTime? _lastResumeCheck;
|
|
||||||
|
|
||||||
const _resumeThrottle = Duration(seconds: 30);
|
|
||||||
|
|
||||||
/// App 重新回到前台时,对一次笔记数据的账。
|
/// App 重新回到前台时,对一次笔记数据的账。
|
||||||
///
|
///
|
||||||
/// 存在的理由:**在 Web 后台或另一台设备上改过笔记时,App 收不到任何通知**
|
/// 存在的理由:**在 Web 后台或另一台设备上改过笔记时,App 收不到任何通知**
|
||||||
@@ -22,13 +15,15 @@ const _resumeThrottle = Duration(seconds: 30);
|
|||||||
/// 不主动对账的话,它们会一直显示旧数据。表现很容易被误解成「缓存没刷」,
|
/// 不主动对账的话,它们会一直显示旧数据。表现很容易被误解成「缓存没刷」,
|
||||||
/// 但其实是数据真的没重取过。
|
/// 但其实是数据真的没重取过。
|
||||||
///
|
///
|
||||||
|
/// 节流时间戳存在 [NoteListNotifier] 上,不放这里 —— 放文件级变量的话,
|
||||||
|
/// 它会跨用户残留:新用户第一次切回前台,会被上一个用户留下的时间戳挡掉
|
||||||
|
/// (进程没死,这个变量就一直在)。挂在 Notifier 实例上则会随换人重建而复位。
|
||||||
|
///
|
||||||
/// [force] 为 true 时跳过节流(重新进入页面用)。
|
/// [force] 为 true 时跳过节流(重新进入页面用)。
|
||||||
void refreshNoteDataOnResume(WidgetRef ref, {bool force = false}) {
|
void refreshNoteDataOnResume(WidgetRef ref, {bool force = false}) {
|
||||||
if (!force) {
|
if (!force) {
|
||||||
final now = DateTime.now();
|
final notifier = ref.read(noteListProvider.notifier);
|
||||||
final last = _lastResumeCheck;
|
if (!notifier.claimResumeCheck()) return;
|
||||||
if (last != null && now.difference(last) < _resumeThrottle) return;
|
|
||||||
_lastResumeCheck = now;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 列表走 refreshIfStale 而不是 invalidate:
|
// 列表走 refreshIfStale 而不是 invalidate:
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import '../models/bill_request.dart';
|
|||||||
import '../models/ocr_result.dart';
|
import '../models/ocr_result.dart';
|
||||||
import '../utils/date_util.dart';
|
import '../utils/date_util.dart';
|
||||||
import 'bill_refresh.dart';
|
import 'bill_refresh.dart';
|
||||||
|
import 'session_provider.dart';
|
||||||
|
|
||||||
/// OCR 识别流程状态
|
/// OCR 识别流程状态
|
||||||
class OcrState {
|
class OcrState {
|
||||||
@@ -50,7 +51,12 @@ class OcrState {
|
|||||||
|
|
||||||
class OcrNotifier extends Notifier<OcrState> {
|
class OcrNotifier extends Notifier<OcrState> {
|
||||||
@override
|
@override
|
||||||
OcrState build() => const OcrState();
|
OcrState build() {
|
||||||
|
// 换人(含登出)就重建 —— 上一个人拍的那张图和识别出来的条目都归他,
|
||||||
|
// 不该出现在新用户的界面上。
|
||||||
|
ref.watch(sessionUserProvider);
|
||||||
|
return const OcrState();
|
||||||
|
}
|
||||||
|
|
||||||
void setImagePath(String path) {
|
void setImagePath(String path) {
|
||||||
state = OcrState(imagePath: path);
|
state = OcrState(imagePath: path);
|
||||||
@@ -73,16 +79,21 @@ class OcrNotifier extends Notifier<OcrState> {
|
|||||||
state = state.copyWith(recognizing: true, error: null);
|
state = state.copyWith(recognizing: true, error: null);
|
||||||
try {
|
try {
|
||||||
final results = await ref.read(ocrApiProvider).recognize(path);
|
final results = await ref.read(ocrApiProvider).recognize(path);
|
||||||
|
// await 之后身份可能已经变了(换人/登出会让这个 Notifier 重建,
|
||||||
|
// 或者整个 provider 被回收)。这时候再写 state,上一个人的识别结果
|
||||||
|
// 就落到新会话里了 —— 而且对已销毁的 ref 写 state 会直接抛。
|
||||||
|
if (!ref.mounted) return;
|
||||||
state = state.copyWith(
|
state = state.copyWith(
|
||||||
results: results,
|
results: results,
|
||||||
// 只有一条时自动展开
|
// 只有一条时自动展开
|
||||||
expandedIndexes: results.length == 1 ? {0} : <int>{},
|
expandedIndexes: results.length == 1 ? {0} : <int>{},
|
||||||
);
|
);
|
||||||
} on ApiException catch (e) {
|
} on ApiException catch (e) {
|
||||||
|
if (!ref.mounted) return;
|
||||||
state = state.copyWith(error: e.message);
|
state = state.copyWith(error: e.message);
|
||||||
rethrow;
|
rethrow;
|
||||||
} finally {
|
} 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);
|
await ref.read(billApiProvider).createBillsBatch(bills);
|
||||||
|
// 同 recognize():await 之后可能已经换人了,不能再碰 state/ref
|
||||||
|
if (!ref.mounted) return;
|
||||||
// 让首页列表与统计页重新取数
|
// 让首页列表与统计页重新取数
|
||||||
invalidateBillData(ref);
|
invalidateBillData(ref);
|
||||||
state = const OcrState();
|
state = const OcrState();
|
||||||
} on ApiException catch (e) {
|
} on ApiException catch (e) {
|
||||||
|
if (!ref.mounted) return;
|
||||||
state = state.copyWith(error: e.message);
|
state = state.copyWith(error: e.message);
|
||||||
rethrow;
|
rethrow;
|
||||||
} finally {
|
} 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 '../api/statistics_api.dart';
|
||||||
import '../models/statistics_response.dart';
|
import '../models/statistics_response.dart';
|
||||||
import '../utils/date_util.dart';
|
import '../utils/date_util.dart';
|
||||||
|
import 'session_provider.dart';
|
||||||
|
|
||||||
/// 统计页的时间粒度
|
/// 统计页的时间粒度
|
||||||
enum StatisticsTab { daily, weekly, monthly }
|
enum StatisticsTab { daily, weekly, monthly }
|
||||||
|
|
||||||
class StatisticsTabNotifier extends Notifier<StatisticsTab> {
|
class StatisticsTabNotifier extends Notifier<StatisticsTab> {
|
||||||
@override
|
@override
|
||||||
StatisticsTab build() => StatisticsTab.monthly; // 对齐 uniApp 默认按月
|
StatisticsTab build() {
|
||||||
|
// 这条是纯 UI 偏好、不含任何隐私数据,复位只是为了「换人=全新开始」的一致感:
|
||||||
|
// 不然新用户打开统计页会莫名其妙停在上一个人选的粒度上。
|
||||||
|
ref.watch(sessionUserProvider);
|
||||||
|
return StatisticsTab.monthly; // 对齐 uniApp 默认按月
|
||||||
|
}
|
||||||
|
|
||||||
void set(StatisticsTab tab) => state = tab;
|
void set(StatisticsTab tab) => state = tab;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,16 +7,24 @@ import '../api/photo_api.dart';
|
|||||||
import '../core/storage/local_storage.dart';
|
import '../core/storage/local_storage.dart';
|
||||||
import '../models/photo.dart';
|
import '../models/photo.dart';
|
||||||
import '../models/watermark.dart';
|
import '../models/watermark.dart';
|
||||||
|
import 'session_provider.dart';
|
||||||
|
|
||||||
/// 水印设置(全局共享,改动即落盘)
|
/// 水印设置(改动即落盘)
|
||||||
///
|
///
|
||||||
/// 跟项目里其他 provider 不同的一点:这个**不涉及网络**,纯粹是设备本地的偏好,
|
/// 跟项目里其他 provider 不同的一点:这个**不涉及网络**,纯粹是本地偏好,
|
||||||
/// 所以直接读写 [LocalStorage],没有 api / 没有 refresh 失效那一套。
|
/// 所以直接读写 [LocalStorage],没有 api / 没有 refresh 失效那一套。
|
||||||
///
|
///
|
||||||
|
/// 但它是**按用户隔离**的:水印上印的是自己的品牌信息,登出时连同
|
||||||
|
/// 常用地址一起清掉(`LocalStorage.clearUserScopedData`),别串给下一个账号。
|
||||||
|
///
|
||||||
/// 需要 `LocalStorage.instance.init()` 已经跑过(`main.dart` 启动时做了)。
|
/// 需要 `LocalStorage.instance.init()` 已经跑过(`main.dart` 启动时做了)。
|
||||||
class WatermarkSettingsNotifier extends Notifier<WatermarkSettings> {
|
class WatermarkSettingsNotifier extends Notifier<WatermarkSettings> {
|
||||||
@override
|
@override
|
||||||
WatermarkSettings build() {
|
WatermarkSettings build() {
|
||||||
|
// 换人(含登出)就重建,重读一遍 —— 登出时本地那份已经被清掉了,
|
||||||
|
// 于是一并退回默认值。select 出来的是用户名,只换人会触发重建。
|
||||||
|
ref.watch(sessionUserProvider);
|
||||||
|
|
||||||
final raw = LocalStorage.instance.getWatermarkSettings();
|
final raw = LocalStorage.instance.getWatermarkSettings();
|
||||||
if (raw == null) return WatermarkSettings.defaults;
|
if (raw == null) return WatermarkSettings.defaults;
|
||||||
// 存坏了就当没存过,用默认值兜住 —— 不能让一段脏 JSON 把页面打不开
|
// 存坏了就当没存过,用默认值兜住 —— 不能让一段脏 JSON 把页面打不开
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import 'package:flutter_test/flutter_test.dart';
|
|||||||
import 'package:qiangbao_accounting/api/note_api.dart';
|
import 'package:qiangbao_accounting/api/note_api.dart';
|
||||||
import 'package:qiangbao_accounting/models/note.dart';
|
import 'package:qiangbao_accounting/models/note.dart';
|
||||||
import 'package:qiangbao_accounting/providers/note_provider.dart';
|
import 'package:qiangbao_accounting/providers/note_provider.dart';
|
||||||
|
import 'package:qiangbao_accounting/providers/session_provider.dart';
|
||||||
|
|
||||||
/// 记录每次 list() 的调用,用来断言「有没有真的重新请求」。
|
/// 记录每次 list() 的调用,用来断言「有没有真的重新请求」。
|
||||||
class FakeNoteApi extends NoteApi {
|
class FakeNoteApi extends NoteApi {
|
||||||
@@ -69,7 +70,16 @@ void main() {
|
|||||||
(ProviderContainer, FakeNoteApi) boot(List<NotePage> pages) {
|
(ProviderContainer, FakeNoteApi) boot(List<NotePage> pages) {
|
||||||
final api = FakeNoteApi(pages);
|
final api = FakeNoteApi(pages);
|
||||||
final container = ProviderContainer(
|
final container = ProviderContainer(
|
||||||
overrides: [noteApiProvider.overrideWithValue(api)],
|
overrides: [
|
||||||
|
noteApiProvider.overrideWithValue(api),
|
||||||
|
// 补一个登录态。noteListProvider 现在会 watch 它,未登录时走空态
|
||||||
|
// 护栏、不发请求(那是为了登出瞬间不打无 token 的请求)。
|
||||||
|
//
|
||||||
|
// 这里直接 override 而不是走真实 authProvider:本文件测的是
|
||||||
|
// 「什么时候该对账」,和登录流程无关,override 不用碰
|
||||||
|
// SharedPreferences,也不给这 5 条用例引入无关依赖。
|
||||||
|
sessionUserProvider.overrideWithValue('u'),
|
||||||
|
],
|
||||||
);
|
);
|
||||||
addTearDown(container.dispose);
|
addTearDown(container.dispose);
|
||||||
return (container, api);
|
return (container, api);
|
||||||
|
|||||||
@@ -0,0 +1,404 @@
|
|||||||
|
import 'package:dio/dio.dart';
|
||||||
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:qiangbao_accounting/api/account_api.dart';
|
||||||
|
import 'package:qiangbao_accounting/api/ai_api.dart';
|
||||||
|
import 'package:qiangbao_accounting/api/budget_api.dart';
|
||||||
|
import 'package:qiangbao_accounting/api/note_api.dart';
|
||||||
|
import 'package:qiangbao_accounting/core/storage/local_storage.dart';
|
||||||
|
import 'package:qiangbao_accounting/models/account.dart';
|
||||||
|
import 'package:qiangbao_accounting/models/ai.dart';
|
||||||
|
import 'package:qiangbao_accounting/models/auth_response.dart';
|
||||||
|
import 'package:qiangbao_accounting/models/budget.dart';
|
||||||
|
import 'package:qiangbao_accounting/models/note.dart';
|
||||||
|
import 'package:qiangbao_accounting/providers/account_provider.dart';
|
||||||
|
import 'package:qiangbao_accounting/providers/ai_provider.dart';
|
||||||
|
import 'package:qiangbao_accounting/providers/auth_provider.dart';
|
||||||
|
import 'package:qiangbao_accounting/providers/budget_provider.dart';
|
||||||
|
import 'package:qiangbao_accounting/providers/note_provider.dart';
|
||||||
|
import 'package:shared_preferences/shared_preferences.dart';
|
||||||
|
|
||||||
|
/// 回归测试:**换用户登录后,不能看到上一个用户的数据**。
|
||||||
|
///
|
||||||
|
/// ## 背景(真实踩过的 bug)
|
||||||
|
///
|
||||||
|
/// `ProviderContainer` 在 main.dart 里创建一次、进程存活期内不重建,所以
|
||||||
|
/// **不带 `autoDispose` 的 provider 会活到 App 结束 —— 登出根本不会清它们**。
|
||||||
|
/// 新用户登录后读到的还是缓存值、连请求都不发,于是余额 / 本月预算 / 聊天记录
|
||||||
|
/// 全显示上一个人的。
|
||||||
|
///
|
||||||
|
/// 修法是让这批 provider 都 watch `sessionUserProvider`,换人就重建。
|
||||||
|
///
|
||||||
|
/// ## 为什么这些用例能守住它
|
||||||
|
///
|
||||||
|
/// **刻意走真实的 `authProvider` 链路,不 override `sessionUserProvider`。**
|
||||||
|
/// 一旦 override 掉,`authProvider → sessionUser → 业务 provider` 这条链就断了,
|
||||||
|
/// 把修复改回旧行为测试照样过 —— 那就成了永远绿的假测试。
|
||||||
|
///
|
||||||
|
/// 每条用例都验证过「改坏代码会让它失败」,不是凑数的。
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------- 测试替身
|
||||||
|
|
||||||
|
/// 余额可切换的假账户接口,顺带记调用次数。
|
||||||
|
///
|
||||||
|
/// [next] 第一次是「u1 的余额」,第二次换成「u2 的余额」—— 用它区分
|
||||||
|
/// 界面上显示的到底是重新取回来的、还是上一个用户留下的缓存。
|
||||||
|
class _FakeAccountApi extends AccountApi {
|
||||||
|
_FakeAccountApi(this.next) : super(Dio());
|
||||||
|
|
||||||
|
AccountInfo next;
|
||||||
|
int calls = 0;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<AccountInfo> getAccount() async {
|
||||||
|
calls++;
|
||||||
|
return next;
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<AccountInfo> updateBalance(double initialBalance) async {
|
||||||
|
calls++;
|
||||||
|
return next;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _FakeBudgetApi extends BudgetApi {
|
||||||
|
_FakeBudgetApi(this.next) : super(Dio());
|
||||||
|
|
||||||
|
BudgetInfo next;
|
||||||
|
int calls = 0;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<BudgetInfo> getBudget() async {
|
||||||
|
calls++;
|
||||||
|
return next;
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<BudgetInfo> setCurrentBudget(double amount) async {
|
||||||
|
calls++;
|
||||||
|
return next;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _FakeNoteApi extends NoteApi {
|
||||||
|
_FakeNoteApi() : super(Dio());
|
||||||
|
|
||||||
|
int listCalls = 0;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<NotePage> list({
|
||||||
|
int page = 1,
|
||||||
|
int size = 20,
|
||||||
|
String scope = 'all',
|
||||||
|
int? folderId,
|
||||||
|
String sort = 'updated',
|
||||||
|
String? keyword,
|
||||||
|
String? tag,
|
||||||
|
}) async {
|
||||||
|
listCalls++;
|
||||||
|
return NotePage(
|
||||||
|
items: const [
|
||||||
|
NoteListItem(
|
||||||
|
id: 1,
|
||||||
|
folderId: null,
|
||||||
|
title: 'u1 的笔记',
|
||||||
|
summary: '',
|
||||||
|
tags: [],
|
||||||
|
pinned: false,
|
||||||
|
updateTime: '2026-09-24 10:00',
|
||||||
|
),
|
||||||
|
NoteListItem(
|
||||||
|
id: 2,
|
||||||
|
folderId: null,
|
||||||
|
title: 'u1 的另一篇',
|
||||||
|
summary: '',
|
||||||
|
tags: [],
|
||||||
|
pinned: false,
|
||||||
|
updateTime: '2026-09-24 09:00',
|
||||||
|
),
|
||||||
|
],
|
||||||
|
total: 2,
|
||||||
|
hasMore: false,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<List<String>> tags() async => const [];
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<FolderTree> folderTree() async => FolderTree.empty;
|
||||||
|
}
|
||||||
|
|
||||||
|
class _FakeAiApi extends AiApi {
|
||||||
|
_FakeAiApi() : super(Dio());
|
||||||
|
|
||||||
|
int sessionsCalls = 0;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<List<ChatSessionItem>> sessions() async {
|
||||||
|
sessionsCalls++;
|
||||||
|
return const [
|
||||||
|
ChatSessionItem(id: 9, title: '上一次的对话', updateTime: '2026-09-24 10:00'),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<List<ChatMessageItem>> messages(int sessionId) async => const [
|
||||||
|
ChatMessageItem(id: 1, role: 'user', content: '你好'),
|
||||||
|
ChatMessageItem(id: 2, role: 'assistant', content: '你好呀'),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------- 辅助
|
||||||
|
|
||||||
|
const _u1Money = 100.0;
|
||||||
|
const _u2Money = 777.0;
|
||||||
|
|
||||||
|
AccountInfo _accountWith(double balance) => AccountInfo(
|
||||||
|
initialBalance: balance,
|
||||||
|
balance: balance,
|
||||||
|
totalIncome: 0,
|
||||||
|
totalExpense: 0,
|
||||||
|
);
|
||||||
|
|
||||||
|
BudgetInfo _budgetWith(double amount) => BudgetInfo(
|
||||||
|
amount: amount,
|
||||||
|
usedAmount: 0,
|
||||||
|
remainingAmount: amount,
|
||||||
|
remainingDaily: 0,
|
||||||
|
);
|
||||||
|
|
||||||
|
/// 真登录一次(写本地存储 + 改 auth 状态),不走界面。
|
||||||
|
Future<void> _login(ProviderContainer c, String username) {
|
||||||
|
return c.read(authProvider.notifier).loginSuccess(
|
||||||
|
AuthResponse(token: 'token-$username', username: username, nickname: username),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 等 Riverpod 把重建跑完。
|
||||||
|
///
|
||||||
|
/// 重建**不是同步的** —— `ref.watch` 的依赖变化只是把 provider 标记为失效,
|
||||||
|
/// 真正的重跑走 `container.scheduler` 的 `Timer(Duration.zero)`
|
||||||
|
/// (见 riverpod 的 scheduler.dart)。所以必须让事件循环转几圈。
|
||||||
|
Future<void> _settle() async {
|
||||||
|
for (var i = 0; i < 3; i++) {
|
||||||
|
await Future<void>.delayed(Duration.zero);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
// LocalStorage 是单例,会真的写字。测试里给它一个空实现。
|
||||||
|
TestWidgetsFlutterBinding.ensureInitialized();
|
||||||
|
SharedPreferences.setMockInitialValues({});
|
||||||
|
|
||||||
|
// ------------------------------------------------------------ 账户 / 余额
|
||||||
|
|
||||||
|
test('★ 登出后不再展示上一用户的余额,也不再发请求', () async {
|
||||||
|
final api = _FakeAccountApi(_accountWith(_u1Money));
|
||||||
|
final c = ProviderContainer(
|
||||||
|
overrides: [accountApiProvider.overrideWithValue(api)],
|
||||||
|
);
|
||||||
|
addTearDown(c.dispose);
|
||||||
|
|
||||||
|
// 模拟 MinePage 挂在 IndexedStack 上常驻监听。
|
||||||
|
// 没有监听者的话 Riverpod 不会调度重建,这条测试就测不到东西了。
|
||||||
|
final sub = c.listen(accountProvider, (_, _) {});
|
||||||
|
addTearDown(sub.close);
|
||||||
|
|
||||||
|
await _login(c, 'u1');
|
||||||
|
await _settle();
|
||||||
|
expect(c.read(accountProvider).value?.balance, _u1Money);
|
||||||
|
|
||||||
|
final callsWhileLoggedIn = api.calls;
|
||||||
|
|
||||||
|
await c.read(authProvider.notifier).logout();
|
||||||
|
await _settle();
|
||||||
|
|
||||||
|
expect(c.read(accountProvider).value?.balance, 0,
|
||||||
|
reason: '登出后不能还挂着上一用户的余额');
|
||||||
|
expect(api.calls, callsWhileLoggedIn,
|
||||||
|
reason: '未登录不得再发请求 —— 那时候 token 已经清了,'
|
||||||
|
'请求必然 403,会被 ErrorInterceptor 当成「登录已过期」弹假红条');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('★ 换账号后余额是新账号的,不是上一个留下的缓存', () async {
|
||||||
|
final api = _FakeAccountApi(_accountWith(_u1Money));
|
||||||
|
final c = ProviderContainer(
|
||||||
|
overrides: [accountApiProvider.overrideWithValue(api)],
|
||||||
|
);
|
||||||
|
addTearDown(c.dispose);
|
||||||
|
final sub = c.listen(accountProvider, (_, _) {});
|
||||||
|
addTearDown(sub.close);
|
||||||
|
|
||||||
|
await _login(c, 'u1');
|
||||||
|
await _settle();
|
||||||
|
expect(c.read(accountProvider).value?.balance, _u1Money);
|
||||||
|
|
||||||
|
// 服务端那边 u2 的余额是另一个数
|
||||||
|
api.next = _accountWith(_u2Money);
|
||||||
|
|
||||||
|
await c.read(authProvider.notifier).logout();
|
||||||
|
await _settle();
|
||||||
|
await _login(c, 'u2');
|
||||||
|
await _settle();
|
||||||
|
|
||||||
|
expect(c.read(accountProvider).value?.balance, _u2Money,
|
||||||
|
reason: '换人必须重新取数,不能拿缓存顶上');
|
||||||
|
});
|
||||||
|
|
||||||
|
// ------------------------------------------------------------ 助手 / 聊天记录
|
||||||
|
|
||||||
|
test('★ 登出后聊天记录清空,且新用户还能自动恢复自己的会话', () async {
|
||||||
|
final api = _FakeAiApi();
|
||||||
|
final c = ProviderContainer(overrides: [aiApiProvider.overrideWithValue(api)]);
|
||||||
|
addTearDown(c.dispose);
|
||||||
|
final sub = c.listen(assistantProvider, (_, _) {});
|
||||||
|
addTearDown(sub.close);
|
||||||
|
|
||||||
|
await _login(c, 'u1');
|
||||||
|
await _settle();
|
||||||
|
|
||||||
|
// u1 开了个新对话(会顺手把 _autoRestored 置 true)
|
||||||
|
c.read(assistantProvider.notifier).newChat(personaId: 7);
|
||||||
|
expect(c.read(assistantProvider).personaId, 7);
|
||||||
|
|
||||||
|
await c.read(authProvider.notifier).logout();
|
||||||
|
await _settle();
|
||||||
|
|
||||||
|
final afterLogout = c.read(assistantProvider);
|
||||||
|
expect(afterLogout.personaId, isNull, reason: '会话锁定的人设属于上一个人');
|
||||||
|
expect(afterLogout.sessionId, isNull);
|
||||||
|
expect(afterLogout.bubbles, isEmpty);
|
||||||
|
|
||||||
|
// ★ 这条才是「实例字段必须手动复位」的哨兵。
|
||||||
|
// Riverpod 重建时**不会新建 Notifier 实例**,所以 _autoRestored 不会自己清。
|
||||||
|
// 忘了在 build() 里复位的话,下面这次恢复会被上一轮留下的 true 挡掉,
|
||||||
|
// 新用户进助手页永远看不到自己的历史对话。
|
||||||
|
await _login(c, 'u2');
|
||||||
|
await _settle();
|
||||||
|
await c.read(assistantProvider.notifier).restoreLastSessionIfNeeded();
|
||||||
|
await _settle();
|
||||||
|
|
||||||
|
expect(c.read(assistantProvider).sessionId, 9,
|
||||||
|
reason: '换了人之后第一次进助手页,应该能恢复新用户自己的上一次会话');
|
||||||
|
});
|
||||||
|
|
||||||
|
// ------------------------------------------------------------ 笔记列表
|
||||||
|
|
||||||
|
test('★ 登出后笔记列表清空,且不再发请求', () async {
|
||||||
|
final api = _FakeNoteApi();
|
||||||
|
final c = ProviderContainer(
|
||||||
|
overrides: [noteApiProvider.overrideWithValue(api)],
|
||||||
|
);
|
||||||
|
addTearDown(c.dispose);
|
||||||
|
final sub = c.listen(noteListProvider, (_, _) {});
|
||||||
|
addTearDown(sub.close);
|
||||||
|
|
||||||
|
await _login(c, 'u1');
|
||||||
|
await _settle();
|
||||||
|
expect(api.listCalls, 1);
|
||||||
|
expect(c.read(noteListProvider).items.length, 2);
|
||||||
|
|
||||||
|
await c.read(authProvider.notifier).logout();
|
||||||
|
await _settle();
|
||||||
|
|
||||||
|
final afterLogout = c.read(noteListProvider);
|
||||||
|
expect(afterLogout.items, isEmpty, reason: '笔记属于上一个人,不能留在列表里');
|
||||||
|
expect(afterLogout.loading, isFalse,
|
||||||
|
reason: '登出后的空态不该是「加载中」—— 界面会一直转圈。'
|
||||||
|
'卡住的原因是 build() 里返回了 loading:true 却没发请求,'
|
||||||
|
'没人再把它改回 false');
|
||||||
|
expect(api.listCalls, 1, reason: '未登录不该再发请求');
|
||||||
|
});
|
||||||
|
|
||||||
|
// ------------------------------------------------------------ 预算
|
||||||
|
|
||||||
|
test('★ 换账号后本月预算是新账号的', () async {
|
||||||
|
final api = _FakeBudgetApi(_budgetWith(3000));
|
||||||
|
final c = ProviderContainer(
|
||||||
|
overrides: [budgetApiProvider.overrideWithValue(api)],
|
||||||
|
);
|
||||||
|
addTearDown(c.dispose);
|
||||||
|
final sub = c.listen(budgetProvider, (_, _) {});
|
||||||
|
addTearDown(sub.close);
|
||||||
|
|
||||||
|
await _login(c, 'u1');
|
||||||
|
await _settle();
|
||||||
|
expect(c.read(budgetProvider).value?.amount, 3000);
|
||||||
|
|
||||||
|
api.next = _budgetWith(500);
|
||||||
|
|
||||||
|
await c.read(authProvider.notifier).logout();
|
||||||
|
await _settle();
|
||||||
|
await _login(c, 'u2');
|
||||||
|
await _settle();
|
||||||
|
|
||||||
|
expect(c.read(budgetProvider).value?.amount, 500,
|
||||||
|
reason: '预算卡必须重新取数,不能显示上一个人的预算');
|
||||||
|
});
|
||||||
|
|
||||||
|
// ------------------------------------------------------------ 一个容易被打回的设计决定
|
||||||
|
|
||||||
|
test('★ 改头像/昵称不该把数据清掉(只认用户名,不认整个 AuthState)', () async {
|
||||||
|
// sessionUserProvider 是 select 到「用户名」的,不是 watch 整个 authProvider。
|
||||||
|
// 因为改头像、改昵称也会换掉 AuthState —— 直接 watch 会把聊天记录、
|
||||||
|
// 笔记列表、记账草稿全无谓清空。哪天有人图省事把它改成 watch(authProvider),
|
||||||
|
// 这条会拦下来。
|
||||||
|
final noteApi = _FakeNoteApi();
|
||||||
|
final c = ProviderContainer(
|
||||||
|
overrides: [noteApiProvider.overrideWithValue(noteApi)],
|
||||||
|
);
|
||||||
|
addTearDown(c.dispose);
|
||||||
|
final noteSub = c.listen(noteListProvider, (_, _) {});
|
||||||
|
addTearDown(noteSub.close);
|
||||||
|
final assistantSub = c.listen(assistantProvider, (_, _) {});
|
||||||
|
addTearDown(assistantSub.close);
|
||||||
|
|
||||||
|
await _login(c, 'u1');
|
||||||
|
await _settle();
|
||||||
|
c.read(assistantProvider.notifier).newChat(personaId: 7);
|
||||||
|
expect(c.read(noteListProvider).items.length, 2);
|
||||||
|
|
||||||
|
// 用户在「我的」页面换了头像 —— username 没变,只是头像变了
|
||||||
|
await c.read(authProvider.notifier).updateUserFromResponse(
|
||||||
|
const AuthResponse(token: '', username: 'u1', nickname: 'u1', avatar: 'me.jpg'),
|
||||||
|
);
|
||||||
|
await _settle();
|
||||||
|
|
||||||
|
expect(c.read(assistantProvider).personaId, 7,
|
||||||
|
reason: '换头像不是换人,聊天记录不该被清');
|
||||||
|
expect(c.read(noteListProvider).items.length, 2,
|
||||||
|
reason: '换头像不是换人,笔记列表不该被清空重取');
|
||||||
|
expect(noteApi.listCalls, 1, reason: '不该因为改头像就多打一次请求');
|
||||||
|
});
|
||||||
|
|
||||||
|
// ------------------------------------------------------------ 本地存储
|
||||||
|
|
||||||
|
test('★ 登出清掉本地存着的用户级数据', () async {
|
||||||
|
final c = ProviderContainer();
|
||||||
|
addTearDown(c.dispose);
|
||||||
|
|
||||||
|
await _login(c, 'u1');
|
||||||
|
|
||||||
|
// 这些 key 存的是「属于某个账号」的东西:
|
||||||
|
// 前两个是服务端数据行的 id,后三个是个人资料/偏好
|
||||||
|
await LocalStorage.instance.saveAiConfigId(42);
|
||||||
|
await LocalStorage.instance.saveAiPersonaId(99);
|
||||||
|
await LocalStorage.instance.saveWatermarkSettings({'posX': 0.5});
|
||||||
|
await LocalStorage.instance.saveWatermarkAddresses(['昆明市公安局']);
|
||||||
|
await LocalStorage.instance.saveRememberedPassword('u1', 'encrypted');
|
||||||
|
|
||||||
|
await c.read(authProvider.notifier).logout();
|
||||||
|
|
||||||
|
expect(LocalStorage.instance.getAiConfigId(), isNull,
|
||||||
|
reason: 'AI 配置存的是行 id,留着会让新用户指到别人的配置上');
|
||||||
|
expect(LocalStorage.instance.getAiPersonaId(), isNull, reason: '人设同理');
|
||||||
|
expect(LocalStorage.instance.getWatermarkSettings(), isNull,
|
||||||
|
reason: '水印上印的是自己的品牌信息');
|
||||||
|
expect(LocalStorage.instance.getWatermarkAddresses(), isEmpty,
|
||||||
|
reason: '常用地址是用户常去的地点');
|
||||||
|
expect(LocalStorage.instance.getRememberedAccount(), isNull,
|
||||||
|
reason: '不清的话登录页会把上一账号连明文密码一起回填');
|
||||||
|
});
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user