271 lines
8.2 KiB
Dart
271 lines
8.2 KiB
Dart
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
|
|
|
import '../data/models/knowledge_item.dart';
|
|
import '../data/repositories/knowledge_repository.dart';
|
|
import 'api_client_provider.dart';
|
|
|
|
export '../data/models/knowledge_item.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 {
|
|
// Keep current items visible during re-fetch (no flicker).
|
|
try {
|
|
if (state.noteType == 'task') {
|
|
final tasks = await ref.read(tasksApiProvider).getAll();
|
|
final items = {
|
|
for (final t in tasks) t.id: KnowledgeItem.fromTask(t),
|
|
};
|
|
state = state.copyWith(
|
|
ids: tasks.map((t) => t.id).toList(),
|
|
items: items,
|
|
totalIds: tasks.length,
|
|
hasMore: false,
|
|
);
|
|
await _loadCounts();
|
|
return;
|
|
}
|
|
|
|
final (ids, total) = await _repo.fetchIds(
|
|
noteType: state.noteType,
|
|
tags: state.activeTags,
|
|
q: state.searchQuery,
|
|
limit: 50,
|
|
offset: 0,
|
|
);
|
|
// Hydrate the new IDs
|
|
final batch = await _repo.fetchBatch(ids);
|
|
final freshItems = {for (final item in batch) item.id: item};
|
|
state = state.copyWith(
|
|
ids: ids,
|
|
items: freshItems,
|
|
totalIds: total,
|
|
hasMore: ids.length < total,
|
|
);
|
|
await Future.wait([_loadCounts(), _loadTags()]);
|
|
} catch (_) {
|
|
// Silent — stale data is better than an error on refresh
|
|
}
|
|
}
|
|
|
|
// ── 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 {
|
|
if (state.noteType == 'task') {
|
|
// Tasks live under /api/tasks — fetch all and convert directly.
|
|
final tasks = await ref.read(tasksApiProvider).getAll();
|
|
final items = {
|
|
for (final t in tasks) t.id: KnowledgeItem.fromTask(t),
|
|
};
|
|
state = state.copyWith(
|
|
ids: tasks.map((t) => t.id).toList(),
|
|
items: items,
|
|
totalIds: tasks.length,
|
|
isLoadingIds: false,
|
|
hasMore: false,
|
|
);
|
|
await _loadCounts();
|
|
return;
|
|
}
|
|
|
|
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);
|