Files
Aq-Accounting-Flutter/lib/providers/note_provider.dart
T

299 lines
9.6 KiB
Dart

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<NoteListItem> 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<NoteListItem>? 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<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());
// build() 里不能同步读写 state,所以丢到微任务里再发起首次加载
Future.microtask(() => _fetchFirstPage(
keyword: '',
tag: '',
sort: 'updated',
location: NoteLocation.all,
));
return const NoteListState(loading: true);
}
/// 取第一页。参数由调用方传入而不是读 state,
/// 是为了能在 build() 阶段安全调用。
Future<void> _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<void> 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<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;
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<NoteListItem> _mergeUnique(List<NoteListItem> old, List<NoteListItem> 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, NoteListState>(NoteListNotifier.new);
/// 笔记详情
final noteDetailProvider =
FutureProvider.autoDispose.family<NoteDetail, int>((ref, id) {
return ref.watch(noteApiProvider).detail(id);
});
/// 标签列表
final noteTagsProvider = FutureProvider.autoDispose<List<String>>((ref) {
return ref.watch(noteApiProvider).tags();
});
/// 目录树
final noteFolderTreeProvider = FutureProvider.autoDispose<FolderTree>((ref) {
return ref.watch(noteApiProvider).folderTree();
});
/// 首页仪表盘用:只想知道「一共几篇、最近什么时候更新」。
///
/// 故意不复用 [noteListProvider] —— 那个 Notifier 带着搜索、标签、分页和防抖,
/// 为了一个数字把它挂到首页上,既会多打请求也会引入一堆无关状态。
/// 这里只取第 1 页第 1 条,用返回的 total 拿总数。
final noteOverviewProvider = FutureProvider.autoDispose<NotePage>((ref) {
return ref.watch(noteApiProvider).list(page: 1, size: 1);
});