299 lines
9.6 KiB
Dart
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);
|
|
});
|