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 ids; final Map items; final int totalIds; final bool isLoadingIds; final bool isLoadingBatch; final bool hasMore; final String? noteType; // null = All final List activeTags; final String? searchQuery; final Map counts; final List 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 get orderedItems => ids.where(items.containsKey).map((id) => items[id]!).toList(); /// IDs that have been fetched but not yet hydrated. List get unhydratedIds => ids.where((id) => !items.containsKey(id)).toList(); KnowledgeState copyWith({ List? ids, Map? items, int? totalIds, bool? isLoadingIds, bool? isLoadingBatch, bool? hasMore, Object? noteType = _keep, List? activeTags, Object? searchQuery = _keep, Map? counts, List? 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 { @override KnowledgeState build() => const KnowledgeState(); KnowledgeRepository get _repo => ref.read(knowledgeRepositoryProvider); // ── Filter setters — each resets and re-fetches ────────────────────────── Future setTypeFilter(String? noteType) async { state = KnowledgeState(noteType: noteType, activeTags: state.activeTags); await _fetchFromScratch(); } Future 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 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 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 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.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 _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 _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, ); // Hydrate the newly fetched IDs immediately — the user is // already at the scroll bottom so _onScroll won't re-fire. await hydrateNext(); } catch (_) { state = state.copyWith(isLoadingIds: false); } } Future _loadCounts() async { try { final counts = await _repo.fetchCounts(tags: state.activeTags); state = state.copyWith(counts: counts); } catch (_) {} } Future _loadTags() async { try { final tags = await _repo.fetchTags(noteType: state.noteType); state = state.copyWith(availableTags: tags); } catch (_) {} } } final knowledgeProvider = NotifierProvider(KnowledgeNotifier.new);