diff --git a/lib/pages/note/note_list_page.dart b/lib/pages/note/note_list_page.dart index 63e0159..53842f4 100644 --- a/lib/pages/note/note_list_page.dart +++ b/lib/pages/note/note_list_page.dart @@ -31,6 +31,16 @@ class _NoteListPageState extends ConsumerState { void initState() { super.initState(); _scrollController.addListener(_onScroll); + // 进页面就重取一次。 + // + // 因为这个 provider 是 keepAlive 的(为的是从详情页返回时保住搜索词和 + // 标签筛选),再次进入页面**不会**自动重新请求 —— 于是从 Web 后台导入的 + // 笔记永远看不到。这里主动对一次账。 + // + // 放在 addPostFrameCallback 里:initState 阶段还没法安全地改 provider 状态。 + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) ref.read(noteListProvider.notifier).refreshOnEnter(); + }); } @override diff --git a/lib/pages/shell/main_shell.dart b/lib/pages/shell/main_shell.dart index 74c1762..6187f53 100644 --- a/lib/pages/shell/main_shell.dart +++ b/lib/pages/shell/main_shell.dart @@ -2,6 +2,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../../providers/auth_provider.dart'; +import '../../providers/note_refresh.dart'; import '../add/add_page.dart'; import '../assistant/assistant_page.dart'; import '../home/dashboard_page.dart'; @@ -24,12 +25,16 @@ class MainShell extends ConsumerStatefulWidget { ConsumerState createState() => _MainShellState(); } -class _MainShellState extends ConsumerState { +class _MainShellState extends ConsumerState + with WidgetsBindingObserver { int _index = 0; // 默认选中「首页」 @override void initState() { super.initState(); + // 监听前后台切换:在电脑上改完笔记 → 切回 App 时数据要是新的。 + // 这类「绕过 App 的写操作」没有任何通知可听,只能靠这个时机对账。 + WidgetsBinding.instance.addObserver(this); // 兜底:未登录直接回登录页 WidgetsBinding.instance.addPostFrameCallback((_) { if (!ref.read(authProvider).isLoggedIn) { @@ -38,6 +43,21 @@ class _MainShellState extends ConsumerState { }); } + @override + void dispose() { + WidgetsBinding.instance.removeObserver(this); + super.dispose(); + } + + @override + void didChangeAppLifecycleState(AppLifecycleState state) { + // resumed 才是真正的「回到前台」;inactive/paused 是离开过程中的中间态, + // 那些时候对账没意义(用户正在离开),还会白白打请求。 + if (state == AppLifecycleState.resumed) { + refreshNoteDataOnResume(ref); + } + } + void _goLogin() { Navigator.of(context).pushAndRemoveUntil( MaterialPageRoute(builder: (_) => const LoginPage()), diff --git a/lib/providers/note_provider.dart b/lib/providers/note_provider.dart index 94f3043..724e3b1 100644 --- a/lib/providers/note_provider.dart +++ b/lib/providers/note_provider.dart @@ -74,8 +74,22 @@ class NoteListState { class NoteListNotifier extends Notifier { static const _pageSize = 20; + /// 距上次成功加载超过这个时长,重新进入页面时再拉一次。 + /// + /// 存在的理由:在 Web 后台(或另一台设备上)改过笔记时,**App 收不到任何 + /// 通知** —— 不像 AI 那样有 SSE 的 tool 事件可听。而这个 Notifier 是 + /// keepAlive 的(为的是从详情页返回时保住搜索词和筛选),再次进入页面 + /// 根本不会重新请求,于是永远显示几分钟前的老数据。 + /// 只能在「用户重新看到这个页面」的时机主动对一次账。 + /// + /// 阈值的作用是避免频繁进出页面时反复打请求。 + static const _staleAfter = Duration(seconds: 30); + Timer? _searchDebounce; + /// 上次成功拉到数据的时间;null = 还没加载过 + DateTime? _lastLoadedAt; + @override NoteListState build() { ref.onDispose(() => _searchDebounce?.cancel()); @@ -108,6 +122,7 @@ class NoteListNotifier extends Notifier { tag: tag, ); if (!ref.mounted) return; + _lastLoadedAt = DateTime.now(); state = state.copyWith( items: result.items, loading: false, @@ -136,6 +151,49 @@ class NoteListNotifier extends Notifier { ); } + /// 重新进入页面(或在后台待了一阵再切回来)时调用。 + /// + /// 数据还新鲜就什么都不做 —— 频繁进出页面不该反复打接口; + /// 数据旧了才重新拉,用来兜住「在 Web 后台或别的设备上改过笔记」这种情况 + /// —— 那种改动 App 完全收不到通知,只能靠这个时机补上。 + /// + /// 静默刷新:不置 loading。内容会原地换掉,不会闪一下加载圈, + /// 用户看到的就是「数据自己变新了」。 + Future refreshIfStale() async { + // 正在加载中就让它自己走完,别打断 + if (state.loading || state.loadingMore) return; + + final loadedAt = _lastLoadedAt; + if (loadedAt != null && DateTime.now().difference(loadedAt) < _staleAfter) { + return; + } + + await _fetchFirstPage( + keyword: state.keyword, + tag: state.tag, + sort: state.sort, + location: state.location, + ); + } + + /// 列表页每次进入时调用。 + /// + /// 和 [refreshIfStale] 的区别:这里**总是重取**。 + /// 因为「离开页面又回来」是一个明确的动作,用户的期待就是「看到最新的」; + /// 而 30 秒的节流是给 `resumed` 那种高频时机用的,不该用在这里 —— + /// 否则你在 Web 导完笔记立刻切回 App,可能因为「30 秒内刚对过账」而看不到。 + /// + /// 静默刷新:不置 loading,内容原地换掉,不闪加载圈。 + Future refreshOnEnter() async { + if (state.loading || state.loadingMore) return; + await _fetchFirstPage( + keyword: state.keyword, + tag: state.tag, + sort: state.sort, + location: state.location, + ); + } + /// 上拉加载下一页 Future loadMore() async { if (state.loadingMore || !state.hasMore || state.loading) return; diff --git a/lib/providers/note_refresh.dart b/lib/providers/note_refresh.dart new file mode 100644 index 0000000..202c4a2 --- /dev/null +++ b/lib/providers/note_refresh.dart @@ -0,0 +1,43 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import 'note_provider.dart'; + +/// 上次「回到前台对账」的时间,用来节流。 +/// +/// 切一次应用就刷三个接口太吵,30 秒内只对一次账。 +DateTime? _lastResumeCheck; + +const _resumeThrottle = Duration(seconds: 30); + +/// App 重新回到前台时,对一次笔记数据的账。 +/// +/// 存在的理由:**在 Web 后台或另一台设备上改过笔记时,App 收不到任何通知** +/// —— 不像 AI 那样有 SSE 的 tool 事件可听。而这个 App 里几个笔记相关的 +/// provider 都因为「常驻页面」而不会被回收: +/// +/// · noteListProvider 是 keepAlive 的 Notifier(为保住搜索词和筛选) +/// · noteOverviewProvider 被首页仪表盘的笔记卡片常驻监听 +/// · 标签 / 目录计数 被笔记列表页监听 +/// +/// 不主动对账的话,它们会一直显示旧数据。表现很容易被误解成「缓存没刷」, +/// 但其实是数据真的没重取过。 +/// +/// [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; + } + + // 列表走 refreshIfStale 而不是 invalidate: + // invalidate 会重建 Notifier、丢掉搜索词和标签筛选 —— 那正是 keepAlive 要保的东西。 + // 它自己还会判断「数据够不够新」,够新就什么都不做。 + ref.read(noteListProvider.notifier).refreshIfStale(); + + // 这三个没有需要保留的状态,直接失效重取 + ref.invalidate(noteOverviewProvider); + ref.invalidate(noteTagsProvider); + ref.invalidate(noteFolderTreeProvider); +} diff --git a/test/note_refresh_test.dart b/test/note_refresh_test.dart new file mode 100644 index 0000000..82c48b3 --- /dev/null +++ b/test/note_refresh_test.dart @@ -0,0 +1,163 @@ +import 'package:dio/dio.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import 'package:qiangbao_accounting/api/note_api.dart'; +import 'package:qiangbao_accounting/models/note.dart'; +import 'package:qiangbao_accounting/providers/note_provider.dart'; + +/// 记录每次 list() 的调用,用来断言「有没有真的重新请求」。 +class FakeNoteApi extends NoteApi { + FakeNoteApi(this.pages) : super(Dio()); + + /// 依次返回的结果:第 1 次调用返回 pages[0],第 2 次返回 pages[1]…… + /// 模拟「服务端数据被外部改过」的场景。 + final List pages; + + int listCalls = 0; + int lastRequestedPage = 0; + + @override + Future list({ + int page = 1, + int size = 20, + String scope = 'all', + int? folderId, + String sort = 'updated', + String? keyword, + String? tag, + }) async { + listCalls++; + lastRequestedPage = page; + final i = (listCalls - 1).clamp(0, pages.length - 1); + return pages[i]; + } + + @override + Future> tags() async => const []; + + @override + Future folderTree() async => FolderTree.empty; +} + +NotePage pageOf(List ids) => NotePage( + items: ids + .map((id) => NoteListItem( + id: id, + folderId: null, + title: '笔记 $id', + summary: '', + tags: const [], + pinned: false, + updateTime: '2026-09-24 10:00', + )) + .toList(), + total: ids.length, + hasMore: false, + ); + +/// 回归测试:**外部(Web 后台 / 别的设备)改过笔记后,App 必须能自己发现**。 +/// +/// 背景(真实踩过的 bug,和 AI 写账单那次同源): +/// `noteListProvider` 是 keepAlive 的 —— 为了从详情页返回时保住搜索词和 +/// 标签筛选。代价是**再次进入页面不会重新请求**,于是你在 Web 后台导入的 +/// 笔记,手机上永远看不到(表现是「还是那 2 条」)。 +/// +/// 这类「绕过 App 的写操作」没有任何通知可听,只能在恰当的时机主动对账。 +/// 这三个入口就是那三个时机,任何一个是坏的都会让用户以为「导入失败了」。 +void main() { + (ProviderContainer, FakeNoteApi) boot(List pages) { + final api = FakeNoteApi(pages); + final container = ProviderContainer( + overrides: [noteApiProvider.overrideWithValue(api)], + ); + addTearDown(container.dispose); + return (container, api); + } + + test('首次进入页面会加载', () async { + final (container, api) = boot([pageOf([1, 2])]); + + container.listen(noteListProvider, (_, _) {}); + // build() 里是 Future.microtask,等它跑完 + await Future.delayed(Duration.zero); + + expect(api.listCalls, 1); + expect(container.read(noteListProvider).items.length, 2); + }); + + test('refreshOnEnter:重新进入页面能拿到外部新增的笔记', () async { + // 第 1 次进页面 2 条;在 Web 上导入了 1 篇之后,第 2 次应该是 3 条 + final (container, api) = boot([ + pageOf([1, 2]), + pageOf([3, 1, 2]), + ]); + + container.listen(noteListProvider, (_, _) {}); + await Future.delayed(Duration.zero); + expect(container.read(noteListProvider).items.length, 2); + + // 模拟「退出页面 → 在 Web 导入 → 再次进入页面」 + await container.read(noteListProvider.notifier).refreshOnEnter(); + + expect(api.listCalls, 2, reason: '进页面必须重新请求,不能吃缓存'); + expect(container.read(noteListProvider).items.length, 3, + reason: 'Web 导入的那篇必须出现'); + }); + + test('refreshOnEnter 保留筛选条件(不能把搜索词和标签弄丢)', () async { + final (container, api) = boot([pageOf([1, 2]), pageOf([1, 2])]); + + container.listen(noteListProvider, (_, _) {}); + await Future.delayed(Duration.zero); + + // 用户搜了「MySQL」、选了「运维」标签、切到按标题排序 + final n = container.read(noteListProvider.notifier); + n.setKeyword('MySQL'); + n.setTag('运维'); + n.setSort('title'); + await Future.delayed(const Duration(milliseconds: 400)); // 等搜索防抖 + + final before = container.read(noteListProvider); + + await n.refreshOnEnter(); + final after = container.read(noteListProvider); + + expect(after.keyword, before.keyword, reason: '搜索词要还在'); + expect(after.tag, before.tag, reason: '标签筛选要还在'); + expect(after.sort, before.sort, reason: '排序方式要还在'); + }); + + test('refreshIfStale:刚加载过就不重复请求(避免频繁进出页面白刷)', () async { + final (container, api) = boot([pageOf([1, 2])]); + + container.listen(noteListProvider, (_, _) {}); + await Future.delayed(Duration.zero); + expect(api.listCalls, 1); + + // 刚加载完,此时切回前台不该再打一次 + await container.read(noteListProvider.notifier).refreshIfStale(); + + expect(api.listCalls, 1, reason: '数据还新鲜,不该白打请求'); + }); + + test('刷新是静默的:不置 loading,界面不会闪加载圈', () async { + final (container, api) = boot([pageOf([1, 2]), pageOf([3, 1, 2])]); + + container.listen(noteListProvider, (_, _) {}); + await Future.delayed(Duration.zero); + + // 记录刷新过程中的 loading 变化 + final seen = []; + final sub = container.listen(noteListProvider, (_, next) { + seen.add(next.loading); + }); + addTearDown(sub.close); + + await container.read(noteListProvider.notifier).refreshOnEnter(); + + expect(seen.contains(true), isFalse, + reason: '静默刷新不该出现 loading=true,否则列表会闪一下'); + expect(container.read(noteListProvider).items.length, 3); + }); +}