import 'dart:io'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../data/ai/llm_service.dart'; import '../data/ai/model_lifecycle.dart'; import 'ai_providers.dart'; /// State machine for ChatScreen LLM warm-up (#311). /// /// See `docs/design/311-llm-warmup/README.md` §6 / fn-chat_warmup_controller.md. sealed class ChatWarmupState { const ChatWarmupState(); } final class ChatWarmupIdle extends ChatWarmupState { const ChatWarmupIdle(); } final class ChatWarmupLoading extends ChatWarmupState { const ChatWarmupLoading(); } final class ChatWarmupReady extends ChatWarmupState { const ChatWarmupReady(); } /// Warm-up was not attempted because [ModelLifecycle.quickCheck] returned /// something other than `ready` (download incomplete, opt-out, corrupt). /// UI behaves as if warm-up didn't exist; the first user send falls back to /// the existing lazy `userTurn` path. final class ChatWarmupUnavailable extends ChatWarmupState { const ChatWarmupUnavailable(); } /// `kind` discriminates the retry copy: `fileMissing` is a settings-level /// recovery; `runtime` is a transient retry. enum ChatWarmupFailureKind { fileMissing, runtime } final class ChatWarmupFailed extends ChatWarmupState { final String message; final ChatWarmupFailureKind kind; const ChatWarmupFailed(this.message, this.kind); } /// Drives `LlmService.load()` on ChatScreen mount so the first user send /// doesn't pay native-init latency. AC1-AC12 (12개) 모두 본 controller 가 /// 흡수한다 (UI binding 은 chat_screen.dart 가 본 state 를 watch). class ChatWarmupController extends StateNotifier { ChatWarmupController({ required this.llm, required this.lifecycle, }) : super(const ChatWarmupIdle()); final LlmService llm; final ModelLifecycle lifecycle; bool _disposed = false; /// Idempotent. Re-entrant guard via the Loading state — duplicate `start` /// calls during an in-flight load do nothing (the running future will set /// the final state). External callers use [retry] instead. Future start() async { if (state is ChatWarmupLoading) return; // AC11 / UX R4: fast path. Skip Loading entirely if the underlying // service is already loaded — prevents 1-frame label flicker on // ChatScreen re-entry. if (llm.isLoaded) { _safeSet(const ChatWarmupReady()); return; } final availability = await lifecycle.quickCheck(); if (_disposed) return; if (availability != ModelAvailability.ready) { _safeSet(const ChatWarmupUnavailable()); return; } _safeSet(const ChatWarmupLoading()); try { await llm.load(); } catch (e) { if (_disposed) return; final kind = e is FileSystemException ? ChatWarmupFailureKind.fileMissing : ChatWarmupFailureKind.runtime; _safeSet(ChatWarmupFailed(_messageFor(kind), kind)); return; } if (_disposed) return; _safeSet(const ChatWarmupReady()); } Future retry() async { if (_disposed) return; _safeSet(const ChatWarmupIdle()); await start(); } /// AC6 / AC12: state는 disposed 인스턴스에는 더 이상 쓰지 않는다. /// StateNotifier 의 setter 는 disposed 시 throw 하므로 가드 필수. void _safeSet(ChatWarmupState s) { if (_disposed) return; state = s; } /// UX R5 / AC12: 메시지는 **상태**만 기술. "다시 시도해주세요" 같은 /// 명령형은 [다시 시도] 버튼이 담당하므로 본 문안에 넣지 않는다. String _messageFor(ChatWarmupFailureKind kind) { switch (kind) { case ChatWarmupFailureKind.fileMissing: return 'AI 모델 파일을 찾을 수 없어요.'; case ChatWarmupFailureKind.runtime: return 'AI 를 시작하지 못했어요.'; } } @override void dispose() { _disposed = true; super.dispose(); } } /// autoDispose: ChatScreen 이 pop 되면 controller 도 dispose → mount race 안전. final chatWarmupProvider = StateNotifierProvider.autoDispose( (ref) { return ChatWarmupController( llm: ref.watch(llmServiceProvider), lifecycle: ref.watch(modelLifecycleProvider), ); }, );