import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../../ai/tools/tool_envelope.dart'; import '../../state/chat_providers.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 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(); }, ), ], ), body: 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); _scrollToBottom(); return Column( children: [ if (state.error != null) Container( width: double.infinity, color: Theme.of(context).colorScheme.errorContainer, padding: const EdgeInsets.all(12), child: Text( state.error!, style: TextStyle( color: Theme.of(context).colorScheme.onErrorContainer, ), ), ), Expanded( child: 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: const InputDecoration( hintText: '습관 추가, 기록, 카탈로그 질문…', border: OutlineInputBorder(), isDense: true, ), maxLines: 4, minLines: 1, textInputAction: TextInputAction.send, onSubmitted: (_) => _send(), ), ), const SizedBox(width: 8), state.isStreaming ? const Padding( padding: EdgeInsets.all(8), child: SizedBox( width: 24, height: 24, child: CircularProgressIndicator(strokeWidth: 2), ), ) : IconButton.filled( onPressed: _send, icon: const Icon(Icons.send), ), ], ), ), ], ); } } 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: return _bubble( context, align: Alignment.centerLeft, color: theme.colorScheme.surfaceContainerHighest, textColor: theme.colorScheme.onSurface, text: m.text + (streaming ? '▍' : ''), ); case ToolCallChatMessage m: 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( '🛠 ${m.name} → ${_toolResultLabel(m.result)}', style: theme.textTheme.bodySmall, ), ), ); 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, }) { 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: 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 '취소됨'; } } }