Files
Aq-Accounting-Flutter/lib/providers/note_provider.dart
T
2026-09-23 15:33:59 +08:00

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);
});