Files
life-helper/app/lib/ui/screens/chat_screen.dart
joungmin 3b8ea95aa6 [hotfix] #342 v0.4.2 — ChatScreen SafeArea + LLM 에러 진단 노출
Fix
- ChatScreen body 를 SafeArea(top: false) 로 감쌈. Android edge-to-edge
  모드에서 시스템 nav bar 가 입력창을 덮던 문제 해결.

Dev (#342)
- userTurn catch 블록이 e.toString() + stack trace 를 error 상태에 저장.
- 빨간 에러 컨테이너를 SingleChildScrollView + SelectableText (monospace)
  + 최대 화면 1/3 높이 제약. 스크롤 + 복사 가능. release 빌드에서도
  full stack 노출 (#342 종료 후 follow-up 으로 좁힘).

테스트: chat_session_controller_test 8/8 통과.
APK: app-release.apk 301.0MB SHA 02a5d1c8.

Refs #342
2026-06-15 14:30:21 +09:00

374 lines
13 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: 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 ? '설정으로 가기' : '다시 시도'),
),
),
],
),
);
}
}
/// 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 '취소됨';
}
}
}