修复0AI接入后列表无法触发更新的问题
This commit is contained in:
@@ -31,6 +31,16 @@ class _NoteListPageState extends ConsumerState<NoteListPage> {
|
||||
void initState() {
|
||||
super.initState();
|
||||
_scrollController.addListener(_onScroll);
|
||||
// 进页面就重取一次。
|
||||
//
|
||||
// 因为这个 provider 是 keepAlive 的(为的是从详情页返回时保住搜索词和
|
||||
// 标签筛选),再次进入页面**不会**自动重新请求 —— 于是从 Web 后台导入的
|
||||
// 笔记永远看不到。这里主动对一次账。
|
||||
//
|
||||
// 放在 addPostFrameCallback 里:initState 阶段还没法安全地改 provider 状态。
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (mounted) ref.read(noteListProvider.notifier).refreshOnEnter();
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
|
||||
@@ -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<MainShell> createState() => _MainShellState();
|
||||
}
|
||||
|
||||
class _MainShellState extends ConsumerState<MainShell> {
|
||||
class _MainShellState extends ConsumerState<MainShell>
|
||||
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<MainShell> {
|
||||
});
|
||||
}
|
||||
|
||||
@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()),
|
||||
|
||||
@@ -74,8 +74,22 @@ class NoteListState {
|
||||
class NoteListNotifier extends Notifier<NoteListState> {
|
||||
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<NoteListState> {
|
||||
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<NoteListState> {
|
||||
);
|
||||
}
|
||||
|
||||
/// 重新进入页面(或在后台待了一阵再切回来)时调用。
|
||||
///
|
||||
/// 数据还新鲜就什么都不做 —— 频繁进出页面不该反复打接口;
|
||||
/// 数据旧了才重新拉,用来兜住「在 Web 后台或别的设备上改过笔记」这种情况
|
||||
/// —— 那种改动 App 完全收不到通知,只能靠这个时机补上。
|
||||
///
|
||||
/// 静默刷新:不置 loading。内容会原地换掉,不会闪一下加载圈,
|
||||
/// 用户看到的就是「数据自己变新了」。
|
||||
Future<void> 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<void> refreshOnEnter() async {
|
||||
if (state.loading || state.loadingMore) return;
|
||||
await _fetchFirstPage(
|
||||
keyword: state.keyword,
|
||||
tag: state.tag,
|
||||
sort: state.sort,
|
||||
location: state.location,
|
||||
);
|
||||
}
|
||||
|
||||
/// 上拉加载下一页
|
||||
Future<void> loadMore() async {
|
||||
if (state.loadingMore || !state.hasMore || state.loading) return;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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<NotePage> pages;
|
||||
|
||||
int listCalls = 0;
|
||||
int lastRequestedPage = 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++;
|
||||
lastRequestedPage = page;
|
||||
final i = (listCalls - 1).clamp(0, pages.length - 1);
|
||||
return pages[i];
|
||||
}
|
||||
|
||||
@override
|
||||
Future<List<String>> tags() async => const [];
|
||||
|
||||
@override
|
||||
Future<FolderTree> folderTree() async => FolderTree.empty;
|
||||
}
|
||||
|
||||
NotePage pageOf(List<int> 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<NotePage> 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<void>.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<void>.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<void>.delayed(Duration.zero);
|
||||
|
||||
// 用户搜了「MySQL」、选了「运维」标签、切到按标题排序
|
||||
final n = container.read(noteListProvider.notifier);
|
||||
n.setKeyword('MySQL');
|
||||
n.setTag('运维');
|
||||
n.setSort('title');
|
||||
await Future<void>.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<void>.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<void>.delayed(Duration.zero);
|
||||
|
||||
// 记录刷新过程中的 loading 变化
|
||||
final seen = <bool>[];
|
||||
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);
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user