QA round 2 인수 노트의 UX 항목 정돈. blocker 아니었음 — Designer 단계
의무 폴리시.
1) ToolCallChatMessage 라벨 한국어화
- chat_screen.dart: _kToolKoreanLabels 맵 추가. 6 tool 모두 한국어
라벨 (예: add_habit → '습관 추가'). 미매핑 tool 은 raw name fallback.
2) ConfirmDialog 좁은 화면 reflow
- confirm_gate.dart: AlertDialog content 를 SingleChildScrollView 로
감쌈. summary box width=double.infinity (좌측 정렬 안정).
3) Streaming cursor 다크모드 contrast
- chat_screen.dart: ▍ 문자를 Text.rich 로 분리해 colorScheme.primary
적용. 다크 모드에서도 onSurface 본문 대비 cursor 가 식별됨.
4) AppBar tooltip 명료성
- chat_screen.dart: '새 대화' → '새 대화 (이전 기록 비우기)'.
history reset 의미 명시.
회귀
- 154 passed (1 skip), 회귀 0
- flutter analyze: clean
Refs #260
276 lines
8.6 KiB
Dart
276 lines
8.6 KiB
Dart
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<ChatScreen> createState() => _ChatScreenState();
|
|
}
|
|
|
|
class _ChatScreenState extends ConsumerState<ChatScreen> {
|
|
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<void> _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),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
],
|
|
);
|
|
}
|
|
}
|
|
|
|
/// 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<String, String> _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 '취소됨';
|
|
}
|
|
}
|
|
}
|