241 lines
7.1 KiB
Dart
241 lines
7.1 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;
|
|
|
|
Timer? _searchDebounce;
|
|
|
|
@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;
|
|
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,
|
|
);
|
|
}
|
|
|
|
/// 上拉加载下一页
|
|
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);
|
|
});
|