Files
life-helper/app/lib/ui/screens/chat_screen.dart
joungmin c18dca1def [Designer] #342 UX round 2 — chat 빈 상태 + 한국식 날짜 + 표현 방식
남은 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
2026-06-15 15:28:03 +09:00

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 '취소됨';
}
}
}