import 'dart:async'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../api/note_api.dart'; import '../core/network/api_exception.dart'; import '../models/note.dart'; /// 笔记列表页状态 class NoteListState { final List items; final bool loading; // 首屏 / 刷新 / 切筛选条件 final bool loadingMore; // 上拉加载下一页 final String? error; final int page; // 已加载到第几页,0 表示还没加载 final bool hasMore; final int total; final String keyword; final String tag; final String sort; // updated | created | title final NoteLocation location; const NoteListState({ this.items = const [], this.loading = false, this.loadingMore = false, this.error, this.page = 0, this.hasMore = false, this.total = 0, this.keyword = '', this.tag = '', this.sort = 'updated', this.location = NoteLocation.all, }); /// 真正的一条都没有(不是加载中、也不是出错) bool get isEmpty => items.isEmpty && !loading && error == null; /// 是否处于筛选态——决定空状态该显示"还没有笔记"还是"没有匹配结果" bool get hasFilter => keyword.isNotEmpty || tag.isNotEmpty; NoteListState copyWith({ List? items, bool? loading, bool? loadingMore, Object? error = _sentinel, int? page, bool? hasMore, int? total, String? keyword, String? tag, String? sort, NoteLocation? location, }) { return NoteListState( items: items ?? this.items, loading: loading ?? this.loading, loadingMore: loadingMore ?? this.loadingMore, error: error == _sentinel ? this.error : error as String?, page: page ?? this.page, hasMore: hasMore ?? this.hasMore, total: total ?? this.total, keyword: keyword ?? this.keyword, tag: tag ?? this.tag, sort: sort ?? this.sort, location: location ?? this.location, ); } static const _sentinel = Object(); } 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()); // build() 里不能同步读写 state,所以丢到微任务里再发起首次加载 Future.microtask(() => _fetchFirstPage( keyword: '', tag: '', sort: 'updated', location: NoteLocation.all, )); return const NoteListState(loading: true); } /// 取第一页。参数由调用方传入而不是读 state, /// 是为了能在 build() 阶段安全调用。 Future _fetchFirstPage({ required String keyword, required String tag, required String sort, required NoteLocation location, }) async { try { final result = await ref.read(noteApiProvider).list( page: 1, size: _pageSize, scope: location.apiScope, folderId: location.folderId, sort: sort, keyword: keyword, tag: tag, ); if (!ref.mounted) return; _lastLoadedAt = DateTime.now(); state = state.copyWith( items: result.items, loading: false, error: null, page: 1, hasMore: result.hasMore, total: result.total, ); } on ApiException catch (e) { if (!ref.mounted) return; state = state.copyWith(loading: false, error: e.message); } catch (_) { if (!ref.mounted) return; state = state.copyWith(loading: false, error: '笔记加载失败'); } } /// 下拉刷新 / 详情页改动后刷新。保留当前筛选条件。 Future refresh() async { state = state.copyWith(loading: true, error: null); await _fetchFirstPage( keyword: state.keyword, tag: state.tag, sort: state.sort, location: state.location, ); } /// 重新进入页面(或在后台待了一阵再切回来)时调用。 /// /// 数据还新鲜就什么都不做 —— 频繁进出页面不该反复打接口; /// 数据旧了才重新拉,用来兜住「在 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; state = state.copyWith(loadingMore: true); try { final next = state.page + 1; final result = await ref.read(noteApiProvider).list( page: next, size: _pageSize, scope: state.location.apiScope, folderId: state.location.folderId, sort: state.sort, keyword: state.keyword, tag: state.tag, ); if (!ref.mounted) return; state = state.copyWith( // 用 id 去重:加载过程中如果有笔记被置顶/更新, // 它可能同时出现在两页里,直接拼接会出现重复卡片 items: _mergeUnique(state.items, result.items), loadingMore: false, page: next, hasMore: result.hasMore, total: result.total, ); } catch (_) { if (!ref.mounted) return; state = state.copyWith(loadingMore: false); } } List _mergeUnique(List old, List added) { final seen = old.map((e) => e.id).toSet(); return [...old, ...added.where((e) => !seen.contains(e.id))]; } /// 搜索关键词。带防抖,避免每敲一个字都打一次接口。 void setKeyword(String keyword) { if (keyword == state.keyword) return; state = state.copyWith(keyword: keyword); _searchDebounce?.cancel(); _searchDebounce = Timer(const Duration(milliseconds: 350), () { if (!ref.mounted) return; refresh(); }); } /// 标签筛选 void setTag(String tag) { if (tag == state.tag) return; state = state.copyWith(tag: tag); refresh(); } /// 切换浏览的目录 void setLocation(NoteLocation location) { if (location.scope == state.location.scope && location.folderId == state.location.folderId) { return; } state = state.copyWith(location: location); refresh(); } /// 切换排序方式 void setSort(String sort) { if (sort == state.sort) return; state = state.copyWith(sort: sort); refresh(); } } /// 列表用普通 NotifierProvider(不加 autoDispose): /// 从详情页返回时搜索关键词、标签筛选和所在目录都要还在,不能被回收重建。 final noteListProvider = NotifierProvider(NoteListNotifier.new); /// 笔记详情 final noteDetailProvider = FutureProvider.autoDispose.family((ref, id) { return ref.watch(noteApiProvider).detail(id); }); /// 标签列表 final noteTagsProvider = FutureProvider.autoDispose>((ref) { return ref.watch(noteApiProvider).tags(); }); /// 目录树 final noteFolderTreeProvider = FutureProvider.autoDispose((ref) { return ref.watch(noteApiProvider).folderTree(); }); /// 首页仪表盘用:只想知道「一共几篇、最近什么时候更新」。 /// /// 故意不复用 [noteListProvider] —— 那个 Notifier 带着搜索、标签、分页和防抖, /// 为了一个数字把它挂到首页上,既会多打请求也会引入一堆无关状态。 /// 这里只取第 1 页第 1 条,用返回的 total 拿总数。 final noteOverviewProvider = FutureProvider.autoDispose((ref) { return ref.watch(noteApiProvider).list(page: 1, size: 1); });