From 69a17a7f2a14b5a4250712eb0b3d386bb84f647c Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 5 Jun 2026 05:38:54 +0000 Subject: [PATCH] feat: model download & management UX (P13f-1) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Make on-device model state legible in Settings → AI: each generation/ transcription picker tile now reads Active / Downloaded / ~MB, downloaded-but- inactive models offer "Delete download" to free space (the model stays selectable and re-downloads on demand), and downloading shows an inline determinate progress indicator via the engines' existing onProgress callback (replacing the opaque snackbar). ModelDownloadService gains installedModelIds() + delete() (reusing the existing isInstalled hash check); a new downloadedModelIdsProvider drives the tiles. The embedder floor is excluded from delete (would break semantic search). Also aligned the one outlier gating copy string. First of three P13f phase-close PRs. https://claude.ai/code/session_013JoYmLCosYt5tQ8qwdbL1T --- docs/BACKLOG.md | 5 + docs/VERIFICATION.md | 6 + docs/design/P13-PLAN.md | 39 +++- lib/core/ai/downloaded_models_provider.dart | 9 + lib/core/ai/model_download_service.dart | 23 +++ .../presentation/item_detail_screen.dart | 4 +- .../presentation/ai_settings_screen.dart | 168 ++++++++++++++---- test/core/ai/model_download_service_test.dart | 40 +++++ .../settings/ai_settings_screen_test.dart | 36 ++++ 9 files changed, 282 insertions(+), 48 deletions(-) create mode 100644 lib/core/ai/downloaded_models_provider.dart diff --git a/docs/BACKLOG.md b/docs/BACKLOG.md index 4773cf7..86511ec 100644 --- a/docs/BACKLOG.md +++ b/docs/BACKLOG.md @@ -8,6 +8,11 @@ _(nothing active — pick the next batch from below)_ ## Deferred / future refinements +- [ ] **Model management — embedder delete + item-detail transcribe progress.** P13f-1 added Downloaded/Active + + delete + inline progress for the generation/transcription model pickers. The **embedder** (Gecko floor / + multilingual) is excluded from delete (deleting the active embedder would break semantic search — needs a + guarded "switch back to Gecko first" flow). The item-detail **transcribe-flow** model download still uses an + opaque snackbar (the inline progress landed in the settings picker only). *(From P13f-1.)* - [ ] **Graph view — combined neighborhood∪path overlay.** P13e-3b's in-graph path is a dedicated **path mode** (the canvas swaps to the path). A richer option draws the path *through* the current neighborhood (adding the off-graph path nodes + highlighting the connecting edges in context) — nicer but the force-directed diff --git a/docs/VERIFICATION.md b/docs/VERIFICATION.md index bb41fb1..2915681 100644 --- a/docs/VERIFICATION.md +++ b/docs/VERIFICATION.md @@ -1061,6 +1061,12 @@ entries, or verify after P11c lands.)* - [ ] An unrelated target shows **"No connection found"**; existing P10c-e/f interactions (expand, legend filter, long-press sheet) still work; everything is **absent** when the graph is unavailable. +### P13f-1 — Model download & management UX *(install `app-arm64-v8a-debug.apk`; needs a capable device)* +- [ ] In Settings → AI, each generation/transcription model tile reads its state — **Active** / **Downloaded** / + **~MB** — and downloading a model shows an **inline progress** indicator (not just a snackbar). +- [ ] A **downloaded-but-inactive** model offers **"Delete download"**, which frees the space (the model stays + selectable and re-downloads on demand); the active model has no delete (avoids breaking the active feature). + ### P13 (later subphases) - [ ] **Transcription / summarization / translation / OCR** each work (capability-gated) and write results back to the item. diff --git a/docs/design/P13-PLAN.md b/docs/design/P13-PLAN.md index 8d84af0..f21f230 100644 --- a/docs/design/P13-PLAN.md +++ b/docs/design/P13-PLAN.md @@ -366,16 +366,37 @@ the chain screen (e-3a) and the graph view (e-3b), reusing the same engine. highlights/explains the path. ✓ (CI parts) · APK owed — **P13e feature-complete** (all subphases implemented; consolidated on-device APK pass owed). -### `[ ]` P13f — Capability-gating + model-selector UX polish & phase close *(pure Dart/UI; minimal)* -- **Model-selector UX polish** across the now-real AI features (the P12g picker was built for the engine - surface); any **manual transcription-trigger UX** gaps (transcription itself shipped in P12e — fold in only - what's missing); consistent per-feature gating copy. -- `docs/VERIFICATION.md` rows for every new user-facing behaviour; consolidate the owed on-device pass; flip - P13a–P13f + the P13 summary to done; route deferrals to `docs/BACKLOG.md`. -- **Exit / review:** every P13 feature shows a clear enabled/gated state; opt-ins persist across restart; - **P13 complete** — and with it the v1 AI pillar (next: P14 beta & launch). +### `[~]` P13f — Capability-gating + model-selector UX polish & phase close *(split into 3 PRs)* +Exploration found gating already consistent (shared `aiSummaryAction` copy; tier-ineligible features hide; the +d-3 low-tier path is wired). So the remaining work is **model-download/management UX** + a translation surface + +the docs close-out. Split for phone-reviewable PRs. -> **✅ P13 complete.** _(Filled in at phase close — what shipped across P13a–P13f + the consolidated +#### `[~]` P13f-1 — Model download & management UX *(settings UI + service)* +- Make model state legible in the picker: **Active / Downloaded / ~MB** per tile, a **"Delete download"** + affordance for downloaded-but-inactive models (free space, keep the selection), and an **inline determinate + download progress** indicator (via the engines' already-exposed `onProgress`) replacing the opaque snackbar. +- **Status:** implemented (CI-green; APK owed). `ModelDownloadService` gains `installedModelIds()` + + `delete(modelId)` (reuses existing `isInstalled`); new `downloadedModelIdsProvider`; the generation + + transcription model tiles in `ai_settings_screen.dart` show state + delete + progress. Aligned the one outlier + gating string (`'Translation isn't available …'`). Embedder (Gecko floor) intentionally excluded from delete. + Tests: service (`installedModelIds`/`delete`), the settings tile state + delete affordance. +- **Exit / review:** the picker clearly shows what's downloaded/active; delete frees space; progress is visible. + +#### `[ ]` P13f-2 — Translation settings surface *(native ML Kit)* +- A Settings → AI **Translation card** to manage the on-demand ML Kit language models (list downloaded + languages + sizes, delete to free space), mirroring the OCR card; Android-only, gated elsewhere. + +#### `[ ]` P13f-3 — P13 phase close + phase-close convention *(docs; minimal code)* +- `docs/VERIFICATION.md`: f-1/f-2 rows + a **"P13 — consolidated on-device pass"** cross-feature checklist (the + one owed verification for the phase). Flip **P13a–P13f markers to `[x]`**; fill the P13 summary; mark P13 done + in `docs/ROADMAP.md`; route deferrals to `docs/BACKLOG.md`. +- **`CLAUDE.md` §7 — encode the phase-close convention**: a (sub)phase earns `[x]` when CI is green + exit + criteria met + its per-PR APK spot-check is done; a **top-level phase is closed** by one consolidated + cross-feature on-device pass recorded in `VERIFICATION.md` (the holistic gate). Mirror the legend wording. +- **Exit / review:** every P13 feature shows a clear enabled/gated state; opt-ins persist; **P13 complete** — + the v1 AI pillar (next: P14 beta & launch), with only the consolidated cross-feature pass owed. + +> **✅ P13 complete.** _(Filled in at P13f-3 — what shipped across P13a–P13f + the consolidated > on-device verification pass.)_ --- diff --git a/lib/core/ai/downloaded_models_provider.dart b/lib/core/ai/downloaded_models_provider.dart new file mode 100644 index 0000000..1a2f5e2 --- /dev/null +++ b/lib/core/ai/downloaded_models_provider.dart @@ -0,0 +1,9 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:grabbit/core/ai/model_download_service.dart'; + +/// The ids of AI models with cached files on disk (P13f-1) — drives the +/// "Downloaded / Active / tap to download" state on the model-picker tiles. +/// Existence-based (cheap); invalidate after a download or delete to refresh. +final downloadedModelIdsProvider = FutureProvider>( + (ref) => ref.watch(modelDownloadServiceProvider).installedModelIds(), +); diff --git a/lib/core/ai/model_download_service.dart b/lib/core/ai/model_download_service.dart index b93e257..d6ed27c 100644 --- a/lib/core/ai/model_download_service.dart +++ b/lib/core/ai/model_download_service.dart @@ -239,6 +239,29 @@ class ModelDownloadService { return '${root.path}/$modelId/$filename'; } + /// The ids of models with cached files on disk — a model dir that exists and + /// holds at least one file. Existence-only (no hashing): cheap, for UI state; + /// [isInstalled] is the authoritative hash-checked gate used before inference. + Future> installedModelIds() async { + final root = await modelsRoot(); + if (!await root.exists()) return const {}; + final ids = {}; + await for (final entry in root.list()) { + if (entry is Directory && await entry.list().any((e) => e is File)) { + ids.add(entry.path.split('/').last); + } + } + return ids; + } + + /// Removes [modelId]'s cached files to free space. No-op if absent. The + /// model stays in the catalog and is re-downloadable on demand. + Future delete(String modelId) async { + final root = await modelsRoot(); + final dir = Directory('${root.path}/$modelId'); + if (await dir.exists()) await dir.delete(recursive: true); + } + Future _hashOf(File file) async { final digestSink = _DigestSink(); final input = sha256.startChunkedConversion(digestSink); diff --git a/lib/features/library/presentation/item_detail_screen.dart b/lib/features/library/presentation/item_detail_screen.dart index 6e65520..057cfe1 100644 --- a/lib/features/library/presentation/item_detail_screen.dart +++ b/lib/features/library/presentation/item_detail_screen.dart @@ -1113,7 +1113,9 @@ Future _translateItem( if (readiness == TranslateReadiness.unavailable) { messenger.showSnackBar( - const SnackBar(content: Text("Translation isn't available here")), + const SnackBar( + content: Text("Translation isn't available on this device"), + ), ); return; } diff --git a/lib/features/settings/presentation/ai_settings_screen.dart b/lib/features/settings/presentation/ai_settings_screen.dart index 93bbfa5..b8f9a30 100644 --- a/lib/features/settings/presentation/ai_settings_screen.dart +++ b/lib/features/settings/presentation/ai_settings_screen.dart @@ -4,6 +4,7 @@ import 'dart:io'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart' show rootBundle; import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:grabbit/core/ai/downloaded_models_provider.dart'; import 'package:grabbit/core/ai/embedder_engine_factory.dart'; import 'package:grabbit/core/ai/embedder_engine_provider.dart'; import 'package:grabbit/core/ai/generation_model.dart'; @@ -739,6 +740,7 @@ class _GenerationModelTile extends ConsumerStatefulWidget { class _GenerationModelTileState extends ConsumerState<_GenerationModelTile> { bool _busy = false; + double _progress = 0; GenerationModel get _model => widget.model; @@ -758,19 +760,20 @@ class _GenerationModelTileState extends ConsumerState<_GenerationModelTile> { Future _download() async { final controller = ref.read(settingsControllerProvider.notifier); final messenger = ScaffoldMessenger.of(context); - setState(() => _busy = true); - messenger - ..hideCurrentSnackBar() - ..showSnackBar( - SnackBar( - content: Text( - 'Downloading ${_model.displayName} (~${_model.approxDownloadMb} MB)…', - ), - ), - ); + setState(() { + _busy = true; + _progress = 0; + }); try { - await ref.read(generationEngineProvider).downloadModel(); + await ref + .read(generationEngineProvider) + .downloadModel( + onProgress: (p) { + if (mounted) setState(() => _progress = p); + }, + ); await ref.read(generationEngineProvider).ensureReady(); + ref.invalidate(downloadedModelIdsProvider); messenger ..hideCurrentSnackBar() ..showSnackBar(SnackBar(content: Text('${_model.displayName} ready'))); @@ -793,6 +796,17 @@ class _GenerationModelTileState extends ConsumerState<_GenerationModelTile> { } } + Future _delete() async { + final messenger = ScaffoldMessenger.of(context); + await ref.read(modelDownloadServiceProvider).delete(_model.id); + ref.invalidate(downloadedModelIdsProvider); + messenger + ..hideCurrentSnackBar() + ..showSnackBar( + SnackBar(content: Text('Deleted ${_model.displayName} download')), + ); + } + String get _bandLabel => switch (_model.modelClass) { GenerationModelClass.small => 'Smaller · faster', GenerationModelClass.balanced => 'Recommended', @@ -809,6 +823,13 @@ class _GenerationModelTileState extends ConsumerState<_GenerationModelTile> { ), ); final isSelected = enabled && active?.id == _model.id; + final downloaded = + ref + .watch(downloadedModelIdsProvider) + .asData + ?.value + .contains(_model.id) ?? + false; final theme = Theme.of(context); return ListTile( leading: Icon( @@ -826,17 +847,43 @@ class _GenerationModelTileState extends ConsumerState<_GenerationModelTile> { ), ], ), - subtitle: Text('${_model.blurb} · ~${_model.approxDownloadMb} MB'), - trailing: _busy - ? const SizedBox( - width: 20, - height: 20, - child: CircularProgressIndicator(strokeWidth: 2), - ) - : null, + subtitle: Text( + '${_model.blurb} · ${_stateLabel(isSelected, downloaded)}', + ), + trailing: _trailing(isSelected, downloaded), onTap: _busy ? null : () => _select(!isSelected), ); } + + String _stateLabel(bool active, bool downloaded) { + if (active) return 'Active'; + if (downloaded) return 'Downloaded'; + return '~${_model.approxDownloadMb} MB'; + } + + Widget? _trailing(bool active, bool downloaded) { + if (_busy) { + return SizedBox( + width: 20, + height: 20, + child: CircularProgressIndicator( + strokeWidth: 2, + value: _progress == 0 ? null : _progress, + ), + ); + } + // Free space by deleting a downloaded model that isn't the active one. + if (downloaded && !active) { + return PopupMenuButton( + tooltip: 'Manage download', + onSelected: (_) => _delete(), + itemBuilder: (context) => const [ + PopupMenuItem(value: null, child: Text('Delete download')), + ], + ); + } + return null; + } } /// Labs self-test: runs a fixed prompt through the active generation model and @@ -965,6 +1012,7 @@ class _TranscriptionModelTile extends ConsumerStatefulWidget { class _TranscriptionModelTileState extends ConsumerState<_TranscriptionModelTile> { bool _busy = false; + double _progress = 0; TranscriptionModel get _model => widget.model; @@ -984,19 +1032,20 @@ class _TranscriptionModelTileState Future _download() async { final controller = ref.read(settingsControllerProvider.notifier); final messenger = ScaffoldMessenger.of(context); - setState(() => _busy = true); - messenger - ..hideCurrentSnackBar() - ..showSnackBar( - SnackBar( - content: Text( - 'Downloading ${_model.displayName} (~${_model.approxDownloadMb} MB)…', - ), - ), - ); + setState(() { + _busy = true; + _progress = 0; + }); try { - await ref.read(transcriptionEngineProvider).downloadModel(); + await ref + .read(transcriptionEngineProvider) + .downloadModel( + onProgress: (p) { + if (mounted) setState(() => _progress = p); + }, + ); await ref.read(transcriptionEngineProvider).ensureReady(); + ref.invalidate(downloadedModelIdsProvider); messenger ..hideCurrentSnackBar() ..showSnackBar(SnackBar(content: Text('${_model.displayName} ready'))); @@ -1019,6 +1068,17 @@ class _TranscriptionModelTileState } } + Future _delete() async { + final messenger = ScaffoldMessenger.of(context); + await ref.read(modelDownloadServiceProvider).delete(_model.id); + ref.invalidate(downloadedModelIdsProvider); + messenger + ..hideCurrentSnackBar() + ..showSnackBar( + SnackBar(content: Text('Deleted ${_model.displayName} download')), + ); + } + String get _bandLabel => switch (_model.modelClass) { TranscriptionModelClass.tiny => 'Smaller · faster', TranscriptionModelClass.base => 'Recommended', @@ -1035,6 +1095,13 @@ class _TranscriptionModelTileState ), ); final isSelected = enabled && active.id == _model.id; + final downloaded = + ref + .watch(downloadedModelIdsProvider) + .asData + ?.value + .contains(_model.id) ?? + false; final theme = Theme.of(context); return ListTile( leading: Icon( @@ -1052,17 +1119,42 @@ class _TranscriptionModelTileState ), ], ), - subtitle: Text('${_model.blurb} · ~${_model.approxDownloadMb} MB'), - trailing: _busy - ? const SizedBox( - width: 20, - height: 20, - child: CircularProgressIndicator(strokeWidth: 2), - ) - : null, + subtitle: Text( + '${_model.blurb} · ${_stateLabel(isSelected, downloaded)}', + ), + trailing: _trailing(isSelected, downloaded), onTap: _busy ? null : () => _select(!isSelected), ); } + + String _stateLabel(bool active, bool downloaded) { + if (active) return 'Active'; + if (downloaded) return 'Downloaded'; + return '~${_model.approxDownloadMb} MB'; + } + + Widget? _trailing(bool active, bool downloaded) { + if (_busy) { + return SizedBox( + width: 20, + height: 20, + child: CircularProgressIndicator( + strokeWidth: 2, + value: _progress == 0 ? null : _progress, + ), + ); + } + if (downloaded && !active) { + return PopupMenuButton( + tooltip: 'Manage download', + onSelected: (_) => _delete(), + itemBuilder: (context) => const [ + PopupMenuItem(value: null, child: Text('Delete download')), + ], + ); + } + return null; + } } /// Labs self-test: transcribes a tiny bundled speech sample and shows the diff --git a/test/core/ai/model_download_service_test.dart b/test/core/ai/model_download_service_test.dart index 6d5ef32..3e17fe4 100644 --- a/test/core/ai/model_download_service_test.dart +++ b/test/core/ai/model_download_service_test.dart @@ -268,4 +268,44 @@ void main() { expect(identical(a, b), isTrue); return Future.wait([a, b]); }); + + group('installedModelIds + delete (P13f-1)', () { + test('lists model dirs that hold files; ignores empties', () async { + final bytes = utf8.encode('weights'); + final file = fileFor(bytes); + final svc = service( + source: _FakeByteSource({ + file.url: [bytes], + }), + ); + await svc.ensureDownloaded('m1', [file]); + // An empty dir (no files) must not count as installed. + await Directory('${root.path}/empty').create(recursive: true); + + expect(await svc.installedModelIds(), {'m1'}); + }); + + test('empty when nothing is downloaded', () async { + expect(await service().installedModelIds(), isEmpty); + }); + + test('delete removes the cached model; no-op when absent', () async { + final bytes = utf8.encode('weights'); + final file = fileFor(bytes); + final svc = service( + source: _FakeByteSource({ + file.url: [bytes], + }), + ); + await svc.ensureDownloaded('m1', [file]); + expect(await svc.isInstalled('m1', [file]), isTrue); + + await svc.delete('m1'); + expect(await Directory('${root.path}/m1').exists(), isFalse); + expect(await svc.isInstalled('m1', [file]), isFalse); + expect(await svc.installedModelIds(), isEmpty); + + await svc.delete('ghost'); // absent → no throw + }); + }); } diff --git a/test/features/settings/ai_settings_screen_test.dart b/test/features/settings/ai_settings_screen_test.dart index 652db9f..742f2d0 100644 --- a/test/features/settings/ai_settings_screen_test.dart +++ b/test/features/settings/ai_settings_screen_test.dart @@ -4,6 +4,8 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:grabbit/core/db/database.dart'; import 'package:grabbit/core/db/database_provider.dart'; +import 'package:grabbit/core/ai/downloaded_models_provider.dart'; +import 'package:grabbit/core/ai/generation_model.dart'; import 'package:grabbit/core/device/device_profile.dart'; import 'package:grabbit/core/device/device_tier_provider.dart'; import 'package:grabbit/core/graph/graph_store_provider.dart'; @@ -223,4 +225,38 @@ void main() { // On a CI / non-arm64 host the graph engine is unavailable → a warning entry. expect(notifs.single.severity, NotificationSeverity.warning); }); + + testWidgets( + 'a downloaded model shows its state + delete affordance (P13f-1)', + (tester) async { + tester.view.physicalSize = const Size(1000, 3000); + tester.view.devicePixelRatio = 1.0; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + final db = AppDatabase(NativeDatabase.memory()); + addTearDown(db.close); + + await tester.pumpWidget( + ProviderScope( + overrides: [ + appDatabaseProvider.overrideWithValue(db), + activeDeviceTierProvider.overrideWith( + () => _FixedTier(DeviceTier.high), + ), + // The recommended generation model is cached but not active. + downloadedModelIdsProvider.overrideWith( + (ref) async => {qwen3_0_6b.id}, + ), + ], + child: const MaterialApp(home: AiSettingsScreen()), + ), + ); + await tester.pumpAndSettle(); + + expect(find.text('Qwen3 0.6B'), findsOneWidget); + // Its tile reads "Downloaded" (not "~MB") and offers a delete affordance. + expect(find.textContaining('Downloaded'), findsWidgets); + expect(find.byType(PopupMenuButton), findsWidgets); + }, + ); }