feat(knowledge): add KnowledgeNotifier with two-tier pagination state
This commit is contained in:
@@ -0,0 +1,220 @@
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../data/models/knowledge_item.dart';
|
||||
import '../data/repositories/knowledge_repository.dart';
|
||||
import 'api_client_provider.dart';
|
||||
|
||||
/// Immutable state for the Knowledge screen's two-tier paginated feed.
|
||||
class KnowledgeState {
|
||||
final List<int> ids;
|
||||
final Map<int, KnowledgeItem> items;
|
||||
final int totalIds;
|
||||
final bool isLoadingIds;
|
||||
final bool isLoadingBatch;
|
||||
final bool hasMore;
|
||||
final String? noteType; // null = All
|
||||
final List<String> activeTags;
|
||||
final String? searchQuery;
|
||||
final Map<String, int> counts;
|
||||
final List<String> availableTags;
|
||||
final String? error;
|
||||
|
||||
const KnowledgeState({
|
||||
this.ids = const [],
|
||||
this.items = const {},
|
||||
this.totalIds = 0,
|
||||
this.isLoadingIds = false,
|
||||
this.isLoadingBatch = false,
|
||||
this.hasMore = false,
|
||||
this.noteType,
|
||||
this.activeTags = const [],
|
||||
this.searchQuery,
|
||||
this.counts = const {},
|
||||
this.availableTags = const [],
|
||||
this.error,
|
||||
});
|
||||
|
||||
/// Items in server-defined ID order, only those already hydrated.
|
||||
List<KnowledgeItem> get orderedItems =>
|
||||
ids.where(items.containsKey).map((id) => items[id]!).toList();
|
||||
|
||||
/// IDs that have been fetched but not yet hydrated.
|
||||
List<int> get unhydratedIds =>
|
||||
ids.where((id) => !items.containsKey(id)).toList();
|
||||
|
||||
KnowledgeState copyWith({
|
||||
List<int>? ids,
|
||||
Map<int, KnowledgeItem>? items,
|
||||
int? totalIds,
|
||||
bool? isLoadingIds,
|
||||
bool? isLoadingBatch,
|
||||
bool? hasMore,
|
||||
Object? noteType = _keep,
|
||||
List<String>? activeTags,
|
||||
Object? searchQuery = _keep,
|
||||
Map<String, int>? counts,
|
||||
List<String>? availableTags,
|
||||
Object? error = _keep,
|
||||
}) =>
|
||||
KnowledgeState(
|
||||
ids: ids ?? this.ids,
|
||||
items: items ?? this.items,
|
||||
totalIds: totalIds ?? this.totalIds,
|
||||
isLoadingIds: isLoadingIds ?? this.isLoadingIds,
|
||||
isLoadingBatch: isLoadingBatch ?? this.isLoadingBatch,
|
||||
hasMore: hasMore ?? this.hasMore,
|
||||
noteType:
|
||||
identical(noteType, _keep) ? this.noteType : noteType as String?,
|
||||
activeTags: activeTags ?? this.activeTags,
|
||||
searchQuery: identical(searchQuery, _keep)
|
||||
? this.searchQuery
|
||||
: searchQuery as String?,
|
||||
counts: counts ?? this.counts,
|
||||
availableTags: availableTags ?? this.availableTags,
|
||||
error: identical(error, _keep) ? this.error : error as String?,
|
||||
);
|
||||
|
||||
static const _keep = Object();
|
||||
}
|
||||
|
||||
class KnowledgeNotifier extends Notifier<KnowledgeState> {
|
||||
@override
|
||||
KnowledgeState build() => const KnowledgeState();
|
||||
|
||||
KnowledgeRepository get _repo => ref.read(knowledgeRepositoryProvider);
|
||||
|
||||
// ── Filter setters — each resets and re-fetches ──────────────────────────
|
||||
|
||||
Future<void> setTypeFilter(String? noteType) async {
|
||||
state = KnowledgeState(noteType: noteType, activeTags: state.activeTags);
|
||||
await _fetchFromScratch();
|
||||
}
|
||||
|
||||
Future<void> toggleTag(String tag) async {
|
||||
final tags = state.activeTags.contains(tag)
|
||||
? state.activeTags.where((t) => t != tag).toList()
|
||||
: [...state.activeTags, tag];
|
||||
state = KnowledgeState(
|
||||
noteType: state.noteType,
|
||||
activeTags: tags,
|
||||
searchQuery: state.searchQuery,
|
||||
);
|
||||
await _fetchFromScratch();
|
||||
}
|
||||
|
||||
Future<void> setSearch(String? q) async {
|
||||
final query = (q?.trim().isEmpty ?? true) ? null : q?.trim();
|
||||
state = KnowledgeState(
|
||||
noteType: state.noteType,
|
||||
activeTags: state.activeTags,
|
||||
searchQuery: query,
|
||||
);
|
||||
await _fetchFromScratch();
|
||||
}
|
||||
|
||||
Future<void> refresh() async {
|
||||
state = KnowledgeState(
|
||||
noteType: state.noteType,
|
||||
activeTags: state.activeTags,
|
||||
searchQuery: state.searchQuery,
|
||||
);
|
||||
await _fetchFromScratch();
|
||||
}
|
||||
|
||||
// ── Scroll-triggered loaders ─────────────────────────────────────────────
|
||||
|
||||
/// Hydrate the next 12 un-hydrated IDs. Call when approaching scroll end.
|
||||
Future<void> hydrateNext() async {
|
||||
if (state.isLoadingBatch) return;
|
||||
final toFetch = state.unhydratedIds.take(12).toList();
|
||||
if (toFetch.isEmpty) {
|
||||
// All fetched IDs are hydrated — try loading more IDs.
|
||||
if (state.hasMore && !state.isLoadingIds) await _loadMoreIds();
|
||||
return;
|
||||
}
|
||||
state = state.copyWith(isLoadingBatch: true);
|
||||
try {
|
||||
final fetched = await _repo.fetchBatch(toFetch);
|
||||
final updated = Map<int, KnowledgeItem>.from(state.items);
|
||||
for (final item in fetched) {
|
||||
updated[item.id] = item;
|
||||
}
|
||||
state = state.copyWith(items: updated, isLoadingBatch: false);
|
||||
} catch (_) {
|
||||
state = state.copyWith(isLoadingBatch: false);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Private helpers ──────────────────────────────────────────────────────
|
||||
|
||||
Future<void> _fetchFromScratch() async {
|
||||
state = state.copyWith(isLoadingIds: true, error: null);
|
||||
try {
|
||||
final (ids, total) = await _repo.fetchIds(
|
||||
noteType: state.noteType,
|
||||
tags: state.activeTags,
|
||||
q: state.searchQuery,
|
||||
limit: 50,
|
||||
offset: 0,
|
||||
);
|
||||
state = state.copyWith(
|
||||
ids: ids,
|
||||
items: {},
|
||||
totalIds: total,
|
||||
isLoadingIds: false,
|
||||
hasMore: ids.length < total,
|
||||
);
|
||||
// Load counts and tags in parallel with the first batch hydration.
|
||||
await Future.wait([
|
||||
hydrateNext(),
|
||||
_loadCounts(),
|
||||
_loadTags(),
|
||||
]);
|
||||
} catch (e) {
|
||||
state = state.copyWith(
|
||||
isLoadingIds: false,
|
||||
error: e.toString(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _loadMoreIds() async {
|
||||
if (!state.hasMore || state.isLoadingIds) return;
|
||||
state = state.copyWith(isLoadingIds: true);
|
||||
try {
|
||||
final (newIds, total) = await _repo.fetchIds(
|
||||
noteType: state.noteType,
|
||||
tags: state.activeTags,
|
||||
q: state.searchQuery,
|
||||
limit: 50,
|
||||
offset: state.ids.length,
|
||||
);
|
||||
final combined = [...state.ids, ...newIds];
|
||||
state = state.copyWith(
|
||||
ids: combined,
|
||||
totalIds: total,
|
||||
isLoadingIds: false,
|
||||
hasMore: combined.length < total,
|
||||
);
|
||||
} catch (_) {
|
||||
state = state.copyWith(isLoadingIds: false);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _loadCounts() async {
|
||||
try {
|
||||
final counts = await _repo.fetchCounts(tags: state.activeTags);
|
||||
state = state.copyWith(counts: counts);
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
Future<void> _loadTags() async {
|
||||
try {
|
||||
final tags = await _repo.fetchTags(noteType: state.noteType);
|
||||
state = state.copyWith(availableTags: tags);
|
||||
} catch (_) {}
|
||||
}
|
||||
}
|
||||
|
||||
final knowledgeProvider =
|
||||
NotifierProvider<KnowledgeNotifier, KnowledgeState>(KnowledgeNotifier.new);
|
||||
Reference in New Issue
Block a user