This repository has been archived on 2026-06-02. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
FabledApp/lib/providers/knowledge_provider.dart
bvandeusen 4240c90d55 fix(voice,knowledge): mic recording + pagination + STT-only mode
Voice: recreate AudioRecorder each session to avoid stale native
state, improve permission handling (re-check after settings), show
feedback after 3 consecutive empty transcripts, allow STT-only mode
when TTS is unavailable.

Knowledge: hydrate IDs immediately after loading more pages so
scroll-based pagination doesn't stall at the bottom.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-12 11:34:00 -04:00

274 lines
8.4 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,
);
// 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<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);