import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../../ai/tools/tool_envelope.dart'; import '../../state/chat_providers.dart'; import '../../state/chat_warmup_provider.dart'; import 'settings_screen.dart'; /// AI chat surface (#260). Multi-turn tool calling powered by Gemma 4 + /// in-process tool runtime. ConfirmGate modals appear on destructive /// tool calls (add_habit, log_tracker_entry). class ChatScreen extends ConsumerStatefulWidget { const ChatScreen({super.key}); @override ConsumerState createState() => _ChatScreenState(); } class _ChatScreenState extends ConsumerState { final _textCtrl = TextEditingController(); final _scrollCtrl = ScrollController(); @override void initState() { super.initState(); // #311 AC1: ChatScreen mount → background warm-up. depsAsync.data 가 // resolve 되기 전에는 toolDepsProvider 도 미준비라 send 자체가 막혀 // 있으므로, 그 사이에 native init 만 먼저 끝낸다. WidgetsBinding.instance.addPostFrameCallback((_) { if (!mounted) return; ref.read(chatWarmupProvider.notifier).start(); }); } @override void dispose() { _textCtrl.dispose(); _scrollCtrl.dispose(); super.dispose(); } void _scrollToBottom() { WidgetsBinding.instance.addPostFrameCallback((_) { if (!_scrollCtrl.hasClients) return; _scrollCtrl.animateTo( _scrollCtrl.position.maxScrollExtent, duration: const Duration(milliseconds: 200), curve: Curves.easeOut, ); }); } Future _send() async { final text = _textCtrl.text.trim(); if (text.isEmpty) return; _textCtrl.clear(); await ref .read(chatSessionControllerProvider.notifier) .userTurn(text, context); _scrollToBottom(); } @override Widget build(BuildContext context) { final depsAsync = ref.watch(toolDepsProvider); return Scaffold( appBar: AppBar( title: const Text('AI 코치'), actions: [ IconButton( icon: const Icon(Icons.refresh), tooltip: '새 대화 (이전 기록 비우기)', onPressed: () { ref.read(chatSessionControllerProvider.notifier).clear(); }, ), ], ), // Android edge-to-edge: 시스템 nav bar (3-button / gesture handle) 가 // 입력창을 가리지 않도록 SafeArea 로 감싼다. AppBar 가 이미 top inset // 을 처리하므로 top 만 false. body: SafeArea( top: false, child: depsAsync.when( loading: () => const Center(child: CircularProgressIndicator()), error: (e, _) => Center(child: Text('초기화 실패: $e')), data: (_) => _buildBody(context), ), ), ); } Widget _buildBody(BuildContext context) { final state = ref.watch(chatSessionControllerProvider); final warmup = ref.watch(chatWarmupProvider); _scrollToBottom(); // #311 AC3 / UX R3: warmup 중에는 hintText 만 교체. 입력창 자체는 // enabled (사용자가 미리 타이핑 가능 — AC9). final isWarming = warmup is ChatWarmupLoading; final hintText = isWarming ? 'AI 준비 중… 첫 시작은 몇 초 걸려요' : '습관 추가, 기록, 카탈로그 질문…'; // AC10: warmup ready 이고 streaming 중이 아닐 때 send 활성. 빈 텍스트는 // _send() 가 early-return 하므로 별도 gating 불필요 (rebuild race 회피). final canSend = !state.isStreaming && !isWarming; final theme = Theme.of(context); return Column( children: [ if (warmup is ChatWarmupFailed) _WarmupErrorBanner(warmup: warmup), if (state.error != null) // #342 dev — 단말에서 원인 진단을 위해 stack 까지 노출되는 케이스를 // 위해 multi-line + scrollable + selectable. 높이는 화면의 1/3 까지만. Container( width: double.infinity, color: theme.colorScheme.errorContainer, padding: const EdgeInsets.all(12), constraints: BoxConstraints( maxHeight: MediaQuery.of(context).size.height / 3, ), child: SingleChildScrollView( child: SelectableText( state.error!, style: TextStyle( color: theme.colorScheme.onErrorContainer, fontFamily: 'monospace', fontSize: 12, ), ), ), ), Expanded( child: state.messages.isEmpty && state.streamingText == null ? _EmptyChatHint(onPickPrompt: (p) { _textCtrl.text = p; _textCtrl.selection = TextSelection.fromPosition( TextPosition(offset: p.length), ); }) : ListView.builder( controller: _scrollCtrl, padding: const EdgeInsets.all(12), itemCount: state.messages.length + (state.streamingText != null && state.streamingText!.isNotEmpty ? 1 : 0), itemBuilder: (context, i) { if (i < state.messages.length) { return _MessageBubble(message: state.messages[i]); } return _MessageBubble( message: ModelChatMessage(state.streamingText ?? ''), streaming: true, ); }, ), ), const Divider(height: 1), Padding( padding: const EdgeInsets.all(8), child: Row( crossAxisAlignment: CrossAxisAlignment.end, children: [ Expanded( child: TextField( controller: _textCtrl, enabled: !state.isStreaming, decoration: InputDecoration( hintText: hintText, border: const OutlineInputBorder(), isDense: true, ), maxLines: 4, minLines: 1, textInputAction: TextInputAction.send, onSubmitted: (_) => canSend ? _send() : null, ), ), const SizedBox(width: 8), if (state.isStreaming || isWarming) const Padding( padding: EdgeInsets.all(8), child: SizedBox( width: 24, height: 24, child: CircularProgressIndicator(strokeWidth: 2), ), ) else IconButton.filled( onPressed: canSend ? _send : null, icon: const Icon(Icons.send), ), ], ), ), ], ); } } /// #311 AC5 / UX R5+R6: 실패 메시지는 상태만 기술, 행동은 버튼이 담당. /// fileMissing 은 retry 로 회복 불가 — 설정 화면으로 유도해 재다운로드 경로를 연다. /// runtime 은 일시적일 수 있으므로 [다시 시도] (in-place retry). class _WarmupErrorBanner extends ConsumerWidget { final ChatWarmupFailed warmup; const _WarmupErrorBanner({required this.warmup}); @override Widget build(BuildContext context, WidgetRef ref) { final theme = Theme.of(context); final isFileMissing = warmup.kind == ChatWarmupFailureKind.fileMissing; return Container( width: double.infinity, color: theme.colorScheme.errorContainer, padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10), child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ Text( warmup.message, style: TextStyle(color: theme.colorScheme.onErrorContainer), ), const SizedBox(height: 8), Align( alignment: Alignment.centerRight, child: OutlinedButton( onPressed: () { if (isFileMissing) { // SettingsScreen pop 후 자동 retry — 사용자가 거기서 다시 // 다운로드를 완료했다면 ChatScreen 으로 돌아오자마자 회복. // 다운로드 안 했으면 다시 Failed 로 떨어져 같은 배너 노출. Navigator.of(context) .push(MaterialPageRoute( builder: (_) => const SettingsScreen(), )) .then((_) { if (!context.mounted) return; ref.read(chatWarmupProvider.notifier).retry(); }); } else { ref.read(chatWarmupProvider.notifier).retry(); } }, child: Text(isFileMissing ? '설정으로 가기' : '다시 시도'), ), ), ], ), ); } } /// 첫 진입 시 빈 대화창 안내. 사용자가 무엇을 물어볼 수 있는지 /// 예시 prompt 4개 + tool 카테고리 짧은 안내. tap 하면 입력창에 채워지고 /// 사용자가 보내기만 누르면 됨 (자동 send X — 사용자가 문구 수정 여지). class _EmptyChatHint extends StatelessWidget { final ValueChanged onPickPrompt; const _EmptyChatHint({required this.onPickPrompt}); static const _examples = [ '아침 햇빛 받기 습관 추가해줘', '오늘 운동 했어', '내 스트릭 보여줘', '수면 프로토콜 알려줘', ]; @override Widget build(BuildContext context) { final theme = Theme.of(context); return SingleChildScrollView( padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 32), child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ Icon( Icons.smart_toy_outlined, size: 48, color: theme.colorScheme.primary, ), const SizedBox(height: 16), Text( 'AI 코치', style: theme.textTheme.titleLarge, textAlign: TextAlign.center, ), const SizedBox(height: 8), Text( '습관 추가·기록 · 카탈로그 검색 · 스트릭 조회를 도와드려요.', style: theme.textTheme.bodyMedium?.copyWith( color: theme.colorScheme.onSurfaceVariant, ), textAlign: TextAlign.center, ), const SizedBox(height: 24), Text( '예시', style: theme.textTheme.labelLarge?.copyWith( color: theme.colorScheme.onSurfaceVariant, ), ), const SizedBox(height: 8), ..._examples.map((p) => Padding( padding: const EdgeInsets.symmetric(vertical: 4), child: OutlinedButton( onPressed: () => onPickPrompt(p), style: OutlinedButton.styleFrom( alignment: Alignment.centerLeft, padding: const EdgeInsets.symmetric( horizontal: 16, vertical: 12), ), child: Text(p), ), )), ], ), ); } } /// Human-friendly Korean labels for the 6 tools registered in /// `ToolRegistry.defaults()`. Falls back to the raw tool name for any /// future tool that hasn't been mapped yet — better to show the raw id /// than to silently drop the message. const Map _kToolKoreanLabels = { 'search_catalog': '카탈로그 검색', 'query_protocol': '프로토콜 상세', 'list_active_habits': '활성 습관 조회', 'get_streak': '스트릭 조회', 'add_habit': '습관 추가', 'log_tracker_entry': '체크 기록', }; class _MessageBubble extends StatelessWidget { final ChatMessage message; final bool streaming; const _MessageBubble({required this.message, this.streaming = false}); @override Widget build(BuildContext context) { final theme = Theme.of(context); switch (message) { case UserChatMessage m: return _bubble( context, align: Alignment.centerRight, color: theme.colorScheme.primaryContainer, textColor: theme.colorScheme.onPrimaryContainer, text: m.text, ); case ModelChatMessage m: // Streaming cursor uses primary so it stays discoverable in both // light and dark themes (default onSurface low-contrasted with the // surfaceContainerHighest bubble in dark mode). return _bubble( context, align: Alignment.centerLeft, color: theme.colorScheme.surfaceContainerHighest, textColor: theme.colorScheme.onSurface, richText: streaming ? TextSpan( children: [ TextSpan(text: m.text), TextSpan( text: '▍', style: TextStyle(color: theme.colorScheme.primary), ), ], ) : null, text: m.text, ); case ToolCallChatMessage m: final label = _kToolKoreanLabels[m.name] ?? m.name; return Align( alignment: Alignment.center, child: Container( margin: const EdgeInsets.symmetric(vertical: 4), padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6), decoration: BoxDecoration( color: theme.colorScheme.surfaceContainerLow, borderRadius: BorderRadius.circular(12), border: Border.all(color: theme.colorScheme.outlineVariant), ), child: Text( '🛠 $label → ${_toolResultLabel(m.result)}', style: theme.textTheme.bodySmall, textAlign: TextAlign.center, ), ), ); case SystemChatMessage m: return Padding( padding: const EdgeInsets.symmetric(vertical: 4), child: Text( m.text, textAlign: TextAlign.center, style: theme.textTheme.bodySmall?.copyWith( color: theme.colorScheme.outline, ), ), ); } } Widget _bubble( BuildContext context, { required Alignment align, required Color color, required Color textColor, required String text, TextSpan? richText, }) { return Align( alignment: align, child: Container( margin: const EdgeInsets.symmetric(vertical: 4), padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), constraints: BoxConstraints( maxWidth: MediaQuery.of(context).size.width * 0.75, ), decoration: BoxDecoration( color: color, borderRadius: BorderRadius.circular(12), ), child: richText != null ? Text.rich(richText, style: TextStyle(color: textColor)) : Text(text, style: TextStyle(color: textColor)), ), ); } String _toolResultLabel(ToolResult r) { switch (r) { case ToolOk _: return 'OK'; case ToolErr e: return '오류: ${e.code}'; case ToolCancelled _: return '취소됨'; } } }