import 'dart:async'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../data/ai/device_capabilities.dart'; import '../data/ai/llm_service.dart'; import '../data/ai/model_lifecycle.dart'; import '../data/db/app_database.dart' as drift; import '../domain/ai/frame_candidate.dart'; import '../domain/ai/suggest_frame.dart'; import '../domain/models/frame_pattern.dart'; import 'providers.dart'; /// Gemma 4 E2B instruction-tuned LiteRT-LM checkpoint (#218 OQ-1 resolved). /// Hosted on HuggingFace `litert-community/gemma-4-E2B-it-litert-lm`. /// File ≈ 2.41GB; SHA-256 pinned for integrity check. /// /// Tests / placeholder builds may override `modelLifecycleProvider` with /// fixture URLs. Production builds optionally inject a private mirror via /// `--dart-define=GEMMA_MODEL_URL=...` (see main.dart). const _kModelUrl = 'https://huggingface.co/litert-community/gemma-4-E2B-it-litert-lm/resolve/main/gemma-4-E2B-it.litertlm'; const _kModelSha256 = '181938105e0eefd105961417e8da75903eacda102c4fce9ce90f50b97139a63c'; /// #218 AC-6: device-capability gate. RAM < 4GB → AI feature disabled. /// Default implementation calls the `life_helper/device_caps` MethodChannel /// (Android). Override in tests with a `_FakeDeviceCapabilities`. final deviceCapabilitiesProvider = Provider((ref) { return PlatformDeviceCapabilities(); }); /// `true` iff the device has ≥ 4GB RAM. Default `false` (fail-closed) while /// the platform call is in flight or on unsupported hosts (iOS / test). final deviceMeetsAiRamProvider = FutureProvider((ref) async { return ref.watch(deviceCapabilitiesProvider).meetsAiMinRam(); }); final modelLifecycleProvider = Provider((ref) { return ModelLifecycle( meta: ref.watch(metaDaoProvider), config: ModelConfig( url: Uri.parse(_kModelUrl), expectedSha256: _kModelSha256, ), ); }); /// Read-only opt-in state. Default OFF; persisted in `meta_kv`. final aiSettingsProvider = FutureProvider((ref) async { final meta = ref.watch(metaDaoProvider); final v = await meta.find(AiMetaKeys.optIn); return v == 'true'; }); /// Toggles opt-in. On opt-out, purges model file via [ModelLifecycle.purge]. /// On opt-in, kicks off `ModelDownloadController.start()` so AC2 (progress UI) /// has a stream to subscribe to. class AiSettingsController { AiSettingsController(this.ref); final Ref ref; Future setOptIn(bool value) async { final meta = ref.read(metaDaoProvider); if (value) { await meta.put(AiMetaKeys.optIn, 'true'); ref.invalidate(aiSettingsProvider); ref.invalidate(modelAvailabilityProvider); // AC2: opt-in triggers download stream so Settings UI can render // progress + pause/resume. Fire-and-forget; controller emits states. ref.read(modelDownloadControllerProvider.notifier).start(); return 0; } // opt-out: cancel any in-flight download, then purge. ref.read(modelDownloadControllerProvider.notifier).cancel(); final freed = await ref.read(modelLifecycleProvider).purge(); await meta.put(AiMetaKeys.optIn, 'false'); ref.invalidate(aiSettingsProvider); ref.invalidate(modelAvailabilityProvider); return freed; } } final aiSettingsControllerProvider = Provider((ref) { return AiSettingsController(ref); }); /// AC2: streams DownloadProgress + supports pause/resume/cancel. /// State `null` means idle (no active subscription). class ModelDownloadController extends StateNotifier { ModelDownloadController(this.ref) : super(null); final Ref ref; StreamSubscription? _sub; void start() { cancel(); final lc = ref.read(modelLifecycleProvider); _sub = lc.download().listen( (p) { state = p; if (p.state == DownloadState.completed) { ref.invalidate(modelAvailabilityProvider); } }, onError: (Object e) { state = DownloadProgress( bytesReceived: state?.bytesReceived ?? 0, totalBytes: state?.totalBytes ?? -1, state: DownloadState.failed, errorMessage: e.toString(), ); }, onDone: () { _sub = null; }, ); } /// Pauses by cancelling the subscription. .tmp file + meta_kv preserved so /// `start()` resumes via HTTP Range header. void pause() { _sub?.cancel(); _sub = null; final cur = state; if (cur != null && cur.state != DownloadState.completed) { state = DownloadProgress( bytesReceived: cur.bytesReceived, totalBytes: cur.totalBytes, state: DownloadState.paused, ); } } void resume() => start(); void cancel() { _sub?.cancel(); _sub = null; state = null; } @override void dispose() { _sub?.cancel(); super.dispose(); } } final modelDownloadControllerProvider = StateNotifierProvider( (ref) => ModelDownloadController(ref), ); final modelAvailabilityProvider = FutureProvider((ref) async { final lc = ref.watch(modelLifecycleProvider); return lc.checkAvailability(); }); /// Loads FramePatterns from DB and converts to domain models. final framePatternsProvider = FutureProvider>( (ref) async { final db = ref.watch(appDatabaseProvider); final rows = await db.select(db.framePatterns).get(); return rows.map(_toDomain).toList(growable: false); }, ); FramePatternModel _toDomain(drift.FramePattern r) => FramePatternModel( id: r.id, domain: r.domain, avoidanceKeyword: r.avoidanceKeyword, l0Example: r.l0Example, l1SimpleReplace: r.l1SimpleReplace, l2Suggestion: r.l2Suggestion, l3Identity: r.l3Identity, ); /// Singleton LLM service for the app. v1 starts unloaded; first /// [suggestFrame] triggers `.load()` via the dialog. Override in tests with /// `MockLlmService`. final llmServiceProvider = Provider((ref) { throw UnimplementedError( 'llmServiceProvider must be overridden (Mock in tests, ' 'GemmaLlmService after OQ-1 in production).', ); }); /// `family` param wraps a SuggestFrameInput. Loads model lazily before /// calling suggestFrame. final frameSuggestionsProvider = FutureProvider.autoDispose .family, SuggestFrameInput>((ref, input) async { final llm = ref.watch(llmServiceProvider); final patterns = await ref.watch(framePatternsProvider.future); if (!llm.isLoaded) { try { await llm.load(); } catch (_) { return const []; } } return suggestFrame( input, llm: llm, framePatterns: patterns, ); });