diff --git a/lib/providers/knowledge_provider.dart b/lib/providers/knowledge_provider.dart new file mode 100644 index 0000000..822f333 --- /dev/null +++ b/lib/providers/knowledge_provider.dart @@ -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 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 { + 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 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 { + 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, + ); + } 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);