남은 P1/P2 3건. - ChatScreen 빈 상태: 아이콘 + 한 줄 설명 + 예시 prompt 4개 (tap → _textCtrl 자동 채움, 자동 send X). - CheckIn 날짜: '2026-06-15' raw → '6월 15일 (월)' 한국식. DB 저장은 _ymd 유지. - HabitCreate '프레임 레벨' → '표현 방식' + helperText. 아이템: '조건부 행동 (예: 아침에 햇빛 받기)' / '정체성 (예: 나는 일찍 자는 사람)'. - 설계서 #342 README — D 섹션 + AC-D1/D2/D3 추가. - CHANGELOG v0.4.2 UX round 2 블록. 167 tests passed, analyze clean. Refs #342
448 lines
15 KiB
Dart
448 lines
15 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';
|
|
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<ChatScreen> createState() => _ChatScreenState();
|
|
}
|
|
|
|
class _ChatScreenState extends ConsumerState<ChatScreen> {
|
|
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<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();
|
|
},
|
|
),
|
|
],
|
|
),
|
|
// 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<String> 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<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 '취소됨';
|
|
}
|
|
}
|
|
}
|