import 'dart:async'; import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; import '../models/models.dart'; import '../services/api_client.dart'; import '../services/message_sync.dart'; import '../services/snooze_service.dart'; import '../services/ws_client.dart'; import '../state/session_state.dart'; import '../theme/app_theme.dart'; import '../widgets/message_bubble.dart'; class ChatScreen extends StatefulWidget { const ChatScreen({ super.key, required this.conversationId, this.title, this.isGroup = false, this.twinDisabledByFlood = false, }); final int conversationId; final String? title; final bool isGroup; // roadmap.md §2.7-C 도배 감지: 목록 화면이 이미 알고 있는 초기 상태를 넘겨 // 받아, 채팅방을 열자마자 (실패한 발송을 기다리지 않고) 배너 + 재개 버튼을 // 보여줄 수 있게 한다. final bool twinDisabledByFlood; @override State createState() => _ChatScreenState(); } class _ChatScreenState extends State with WidgetsBindingObserver { final _input = TextEditingController(); final _draftEdit = TextEditingController(); final _scroll = ScrollController(); final _messages = []; ConversationSocket? _socket; StreamSubscription? _sub; // 오프라인 큐 캐치업(roadmap.md "멀티 디바이스 동기화" / deploy-checklist N4-11): // 소켓 재연결 신호를 듣기 위한 별도 구독. 실제 소켓 끊김/재연결 타이밍은 // 단위 테스트로 검증할 수 없어 — 신호가 왔을 때 REST since_id 캐치업을 // 호출한다는 것만 여기서 보장하고, 나머지(진짜 네트워크 단절, 백그라운드 // 전환에서 OS가 소켓을 실제로 끊는지)는 실기기 QA 대상이다. StreamSubscription? _reconnectSub; String? _banner; DraftResult? _pendingDraft; bool _busy = false; bool _loadingHistory = true; late bool _floodBlocked; late final ApiClient _api; // "이 답장 나답아요?" 피드백(vision.md 지표, deploy-checklist.md N4-12): // 이번 세션에서(또는 서버에서 이미) 평가된 메시지 id들 — 한 번 탭한 메시지엔 // 화면이 다시 그려져도(리로드 없이) 뱃지를 다시 보여주지 않는다. final _ratedMessageIds = {}; // 답장 마감 알림(roadmap.md §2.7-F) — 이 대화방에 걸린 "이따 답장" 스누즈 시각. // 서버에는 존재하지 않는 순수 온디바이스 상태라 화면에 들어올 때 로컬 DB에서 로드한다. DateTime? _snoozedUntil; @override void initState() { super.initState(); WidgetsBinding.instance.addObserver(this); _api = context.read().api; _floodBlocked = widget.twinDisabledByFlood; if (_floodBlocked) { _banner = '도배 감지로 이 대화방의 와카뷰 자동응대가 일시중단되었습니다.'; } _socket = ConversationSocket(widget.conversationId)..connect(); _sub = _socket!.events.listen(_onEvent); // 소켓이 끊겼다가 다시 붙으면(백그라운드, 네트워크 hiccup 등) 그 사이에 // 놓친 메시지를 REST since_id로 다시 받아온다 — ws_client.dart 참고. _reconnectSub = _socket!.reconnects.listen((_) => _catchUp()); _loadHistory(); _loadSnooze(); } @override void didChangeAppLifecycleState(AppLifecycleState state) { // 앱이 백그라운드에 있는 동안 소켓이 조용히 죽어 있었을 수 있다 — 포그라운드로 // 돌아올 때마다 캐치업을 한 번 더 시도한다(소켓 자체의 재연결 신호와는 별개의 // 안전망). 실기기에서 OS가 실제로 어떻게 동작하는지는 QA 대상. if (state == AppLifecycleState.resumed) { _catchUp(); } } /// 오프라인 큐 캐치업 본체: 지금까지 로드된 메시지 중 가장 큰 id 이후를 /// REST로 다시 받아, 소켓/REST가 겹쳐 온 것이 있어도 중복 없이 합친다. Future _catchUp() async { if (!mounted || _loadingHistory) return; final sinceId = highestMessageId(_messages); try { final fresh = await _api.listMessages(widget.conversationId, sinceId: sinceId); if (!mounted || fresh.isEmpty) return; final merged = mergeNewMessages(_messages, fresh); setState(() { _messages ..clear() ..addAll(merged); _syncRatedMessageIds(merged); }); _scrollToEnd(); _markLatestRead(); } on ApiException { // Best-effort — 실시간 소켓이 살아 있으면 다음 이벤트로 계속 채워지고, // 다음 재연결/앱 복귀 때 다시 시도되므로 여기서 에러를 굳이 보여주지 않는다. } } Future _loadSnooze() async { final controller = context.read().snoozeController; if (controller == null) return; final until = await controller.loadSnoozedUntil(widget.conversationId); if (!mounted) return; setState(() => _snoozedUntil = until); } bool get _snoozePastDue => isSnoozePastDue(DateTime.now(), _snoozedUntil); String _formatSnoozeUntil(DateTime dt) => '${dt.month}/${dt.day} ${dt.hour.toString().padLeft(2, '0')}:${dt.minute.toString().padLeft(2, '0')}'; /// "이따 답장" 빠른 선택 메뉴 (roadmap.md §2.7-F). 스누즈가 걸려 있으면 해제 /// 옵션도 함께 보여준다. Future _openSnoozeMenu() async { final now = DateTime.now(); final choice = await showDialog( context: context, builder: (ctx) => SimpleDialog( title: const Text('이따 답장'), children: [ for (final pick in SnoozeQuickPick.values) SimpleDialogOption( onPressed: () => Navigator.pop(ctx, pick), child: Text(pick.label), ), if (_snoozedUntil != null) SimpleDialogOption( onPressed: () => Navigator.pop(ctx, 'clear'), child: Text('스누즈 해제', style: TextStyle(color: Theme.of(ctx).colorScheme.error)), ), ], ), ); if (choice == null || !mounted) return; if (choice == 'clear') { await _clearSnooze(); return; } if (choice is SnoozeQuickPick) { await _applySnooze(resolveSnoozeQuickPick(choice, now)); } } Future _applySnooze(DateTime until) async { final controller = context.read().snoozeController; if (controller != null) { await controller.applySnooze( conversationId: widget.conversationId, until: until, title: '답장 마감', body: '${widget.title ?? "대화방 #${widget.conversationId}"}에 답장할 시간이에요', ); } if (!mounted) return; setState(() { _snoozedUntil = until; _banner = '이따 답장: ${_formatSnoozeUntil(until)}에 리마인드합니다 (본인에게만 표시).'; }); } /// 사용자가 직접 "스누즈 해제"를 눌렀을 때 — 배너로 알려 준다. Future _clearSnooze() async { await _clearSnoozeQuiet(); if (!mounted) return; setState(() => _banner = '스누즈를 해제했습니다.'); } /// 답장을 실제로 보내서 스누즈가 자동으로 풀릴 때 — 조용히 처리(배너로 덮어쓰지 않음). Future _clearSnoozeQuiet() async { if (_snoozedUntil == null) return; final controller = context.read().snoozeController; if (controller != null) await controller.clearSnooze(widget.conversationId); if (!mounted) return; setState(() => _snoozedUntil = null); } Future _loadHistory() async { final session = context.read(); try { final history = await session.api.listMessages(widget.conversationId); if (!mounted) return; setState(() { _messages ..clear() ..addAll(history); _loadingHistory = false; _syncRatedMessageIds(history); }); _scrollToEnd(); // 읽음 마커는 화면에 들어올 때가 아니라 "나갈 때"(dispose) 찍는다 -- // 즉시 찍으면 그룹 채팅방을 여는 순간 안 본 메시지가 0이 되어 버려서 // "안 본 동안 요약" 버튼이 항상 빈 결과만 보여주게 된다(roadmap.md // §2.7-A). 화면이 열려 있는 동안 실시간으로 온 새 메시지는 지금 // 보고 있는 것이니 그대로 마커를 따라가도 된다 (_onEvent 참고). } on ApiException catch (e) { if (!mounted) return; setState(() { _loadingHistory = false; _banner = '히스토리 로드 실패 (${e.statusCode})'; }); } } /// 단톡 따라잡기(roadmap.md §2.7-A) 읽음 마커. Future _markLatestRead() async { if (_messages.isEmpty) return; final latestId = _messages.map((m) => m.id).reduce((a, b) => a > b ? a : b); try { await _api.markRead(widget.conversationId, latestId); } on ApiException { // Best-effort — an unread badge staying stale isn't worth surfacing an error for. } } void _scrollToEnd() { WidgetsBinding.instance.addPostFrameCallback((_) { if (!_scroll.hasClients) return; _scroll.animateTo( _scroll.position.maxScrollExtent, duration: const Duration(milliseconds: 200), curve: Curves.easeOut, ); }); } /// 서버에서 이미 평가된 메시지(다른 세션/기기에서 탭했거나, 새로고침 전에 /// 이미 평가된 경우)를 _ratedMessageIds에 반영 — UI를 다시 보여주지 않기 /// 위함. 실제 판정은 message_sync.dart의 순수 함수(ratedMessageIdsFrom)로 /// 분리해 위젯 없이도 테스트할 수 있게 했다. void _syncRatedMessageIds(List messages) { _ratedMessageIds.addAll(ratedMessageIdsFrom(messages)); } /// "이 답장 나답아요?" 원탭 피드백(vision.md 지표, deploy-checklist.md /// N4-12) — 트윈이 보낸 메시지 위에서만 노출(MessageBubble이 이미 twin + /// isMine + !retracted로 걸러서 호출). 한 번 탭하면 다시 평가를 받지 않음. Future _submitFeedback(ChatMessage message, bool natural) async { setState(() => _ratedMessageIds.add(message.id)); try { await _api.submitMessageFeedback(message.id, natural); } on ApiException { // Best-effort — this is a soft signal, not a safety-critical action; // if it fails to save server-side we don't re-offer the tap (that // would look like a broken button), we just quietly drop it. } } void _onEvent(Map event) { final retractionId = _socket!.parseRetractionId(event); if (retractionId != null) { setState(() { final i = _messages.indexWhere((m) => m.id == retractionId); if (i >= 0) { final m = _messages[i]; _messages[i] = ChatMessage( id: m.id, conversationId: m.conversationId, senderId: m.senderId, senderMode: m.senderMode, text: m.text, retracted: true, createdAt: m.createdAt, draftEdited: m.draftEdited, naturalnessRating: m.naturalnessRating, ); } }); return; } final msg = _socket!.parseMessageEvent(event); if (msg == null) return; if (_messages.any((m) => m.id == msg.id)) return; setState(() => _messages.add(msg)); _scrollToEnd(); _markLatestRead(); } @override void dispose() { // 화면을 나가는 시점에 읽음 마커를 찍는다 — _loadHistory()의 주석 참고. // Fire-and-forget: dispose는 동기라 기다릴 수 없고, 실패해도 안 읽음 // 배지가 조금 늦게 갱신되는 정도라 굳이 에러를 보여줄 필요 없음. _markLatestRead(); WidgetsBinding.instance.removeObserver(this); _sub?.cancel(); _reconnectSub?.cancel(); _socket?.dispose(); _input.dispose(); _draftEdit.dispose(); _scroll.dispose(); super.dispose(); } Future _sendHuman() async { final session = context.read(); final text = _input.text.trim(); if (text.isEmpty || session.user == null) return; setState(() => _busy = true); try { final msg = await session.api.sendMessage( conversationId: widget.conversationId, senderId: session.user!.id, text: text, ); _input.clear(); if (!_messages.any((m) => m.id == msg.id)) { setState(() => _messages.add(msg)); } _scrollToEnd(); // roadmap.md §2.7-F: 답장을 실제로 보냈으니 걸려 있던 스누즈는 자동 해제. _clearSnoozeQuiet(); } on ApiException catch (e) { setState(() => _banner = '전송 실패 (${e.statusCode})'); } finally { setState(() => _busy = false); } } Future _requestDraft() async { final session = context.read(); setState(() { _busy = true; _banner = null; _pendingDraft = null; }); try { final contextLines = _messages .where((m) => !m.retracted) .map((m) => '${m.isTwin ? "와카뷰" : "상대"}: ${m.text}') .toList(); if (_input.text.trim().isNotEmpty) { contextLines.add('상대: ${_input.text.trim()}'); } final style = session.styleExamples.isNotEmpty ? session.styleExamples : const ['ㅇㅇ 알겠음', 'ㅋㅋ 그래', '나중에 연락할게']; final draft = await session.api.requestDraft( conversationId: widget.conversationId, contextLines: contextLines.isEmpty ? ['상대: 안녕'] : contextLines, styleExamples: style, ); setState(() { _pendingDraft = draft; _draftEdit.text = draft.isEscalate ? '' : draft.text; if (draft.isEscalate) { _banner = null; } }); } on ApiException catch (e) { setState(() => _banner = '초안 실패 (${e.statusCode}): ${e.body}'); } finally { setState(() => _busy = false); } } Future _sendTwinApproved() async { final session = context.read(); final draft = _pendingDraft; if (draft == null || draft.isEscalate || session.user == null) return; final text = _draftEdit.text.trim(); if (text.isEmpty) return; // L0: twin send is forbidden server-side — move text to human composer // instead. 그룹 대화는 전역 자율성 레벨과 무관하게 항상 L0 취급 // (PRD.md §2.3-③, roadmap.md §2.7-A "이 시나리오는 L0 고정, 자동 발송 없음"). if (session.autonomyLevel == AutonomyLevel.L0 || widget.isGroup) { setState(() { _input.text = text; _pendingDraft = null; _draftEdit.clear(); _banner = widget.isGroup ? '단톡에서는 와카뷰가 자동으로 보내지 않습니다. 초안을 검토하고 아래 입력창에서 직접 보내세요.' : 'L0(비서 모드)에서는 와카뷰로 보낼 수 없습니다. 아래 입력창에서 직접 보내거나, 자율성 설정을 L1으로 바꾸세요.'; }); return; } setState(() => _busy = true); try { final msg = await session.api.sendMessage( conversationId: widget.conversationId, senderId: session.user!.id, text: text, senderMode: SenderMode.twin, approved: true, // 초안 무수정 발송률(PRD.md §5, N4-12): draft.text는 사용자가 편집 // 컨트롤러(_draftEdit)를 건드리기 전 AI 초안 원문 — 서버가 이걸 // 최종 text와 diff해 draft_edited를 계산한다. originalDraftText: draft.text, ); setState(() { _pendingDraft = null; _draftEdit.clear(); if (!_messages.any((m) => m.id == msg.id)) _messages.add(msg); }); _scrollToEnd(); // roadmap.md §2.7-F: 와카뷰가 대신 답장을 보냈어도 답장은 답장이므로 스누즈 해제. _clearSnoozeQuiet(); } on ApiException catch (e) { setState(() { _banner = '와카뷰 발송 차단 (${e.statusCode}): ${e.body}'; // roadmap.md §2.7-C: 이 화면을 열어둔 채로 있다가 도배 임계치를 // 새로 넘긴 경우, 목록에서 넘겨받은 초기 상태와 무관하게 지금 // 바로 재개 버튼을 보여줘야 한다. if (e.body.contains('도배')) _floodBlocked = true; }); } finally { setState(() => _busy = false); } } /// 도배 감지로 자동 중단된 자동응대를 다시 켠다 (roadmap.md §2.7-C, /// AGENTS.md "every automatic action needs post-hoc notification + /// one-tap undo"). 거부권(veto)과 달리 이 중단은 시스템이 자동으로 취한 /// 조치라서 되돌리기 경로가 있어야 한다. Future _resumeFlood() async { final session = context.read(); setState(() => _busy = true); try { await session.api.resetFlood(widget.conversationId); if (!mounted) return; setState(() { _floodBlocked = false; _banner = '와카뷰 자동응대를 다시 켰습니다.'; }); } on ApiException catch (e) { setState(() => _banner = '재개 실패 (${e.statusCode})'); } finally { if (mounted) setState(() => _busy = false); } } void _rejectDraft() { setState(() { _pendingDraft = null; _draftEdit.clear(); _banner = '초안을 버렸습니다. 직접 쓰거나 다시 초안을 요청하세요.'; }); } Future _veto() async { final ok = await showDialog( context: context, builder: (ctx) => AlertDialog( title: const Text('거부권을 쓸까요?'), content: const Text('이 대화방에서 와카뷰 자동응대가 즉시 중단됩니다. 이후에는 다시 켤 수 없습니다.'), actions: [ TextButton(onPressed: () => Navigator.pop(ctx, false), child: const Text('취소')), FilledButton.tonal(onPressed: () => Navigator.pop(ctx, true), child: const Text('거부권 사용')), ], ), ); if (ok != true || !mounted) return; final session = context.read(); try { await session.api.vetoConversation(widget.conversationId); setState(() => _banner = '거부권 적용: 이 대화방에서 와카뷰 자동응대가 중단됩니다.'); } on ApiException catch (e) { setState(() => _banner = '거부권 실패 (${e.statusCode})'); } } Future _retract(ChatMessage message) async { final session = context.read(); try { await session.api.retractMessage(message.id); } on ApiException catch (e) { setState(() => _banner = '되돌리기 실패 (${e.statusCode})'); } } /// 단톡 따라잡기(roadmap.md §2.7-A): 안 본 동안 온 메시지를 3~5줄로 요약해 /// 보여준다. 답장이 필요해 보이면 그 자리에서 초안 요청으로 이어갈 수 있음 /// — 발송은 항상 사람이 직접(이 화면의 L0 고정 규칙 그대로). Future _openSummary() async { final session = context.read(); GroupSummaryResult? result; String? error; await showDialog( context: context, builder: (ctx) => StatefulBuilder( builder: (ctx, setDialogState) { if (result == null && error == null) { session.api.getGroupSummary(widget.conversationId).then((r) { setDialogState(() => result = r); }).catchError((e) { setDialogState(() => error = e is ApiException ? '요약 실패 (${e.statusCode})' : '요약 실패'); }); } final r = result; return AlertDialog( title: const Text('안 본 동안 요약'), content: SizedBox( width: 320, child: error != null ? Text(error!, style: TextStyle(color: Theme.of(ctx).colorScheme.error)) : r == null ? const SizedBox( height: 60, child: Center(child: CircularProgressIndicator(strokeWidth: 2)), ) : r.isEmpty ? const Text('안 본 메시지가 없습니다.') : Text(r.summary.isEmpty ? '요약할 내용이 없습니다.' : r.summary), ), actions: [ TextButton(onPressed: () => Navigator.pop(ctx), child: const Text('닫기')), if (r != null && !r.isEmpty && r.needsReply) FilledButton( onPressed: () { Navigator.pop(ctx); _requestDraft(); }, child: const Text('초안 요청'), ), ], ); }, ), ); } Widget _buildL1Panel(BuildContext context) { final theme = Theme.of(context); final draft = _pendingDraft; if (draft == null) return const SizedBox.shrink(); if (draft.isEscalate) { return Container( margin: const EdgeInsets.fromLTRB(12, 0, 12, 8), padding: const EdgeInsets.all(14), decoration: BoxDecoration( color: theme.colorScheme.errorContainer.withValues(alpha: 0.55), borderRadius: BorderRadius.circular(AppTheme.rPanel), border: Border.all(color: AppTheme.glassBorder(theme.brightness)), ), child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ Row( children: [ Icon(Icons.warning_amber_rounded, size: 18, color: theme.colorScheme.onErrorContainer), const SizedBox(width: 6), Text( '직접 확인 필요', style: theme.textTheme.titleSmall?.copyWith(color: theme.colorScheme.onErrorContainer), ), ], ), const SizedBox(height: 6), Text( draft.text.isEmpty ? '민감·확정성 내용으로 와카뷰 발송이 보류되었습니다.' : draft.text, style: TextStyle(color: theme.colorScheme.onErrorContainer), ), const SizedBox(height: 4), Align( alignment: Alignment.centerRight, child: TextButton(onPressed: _rejectDraft, child: const Text('닫기')), ), ], ), ); } final level = context.watch().autonomyLevel; final isL0 = level == AutonomyLevel.L0 || widget.isGroup; final title = isL0 ? '초안 (L0) — 직접 보내기' : level == AutonomyLevel.L1 ? 'L1 승인 — 수정 후 보내기' : '초안 (L2) — 승인 후 보내기'; return Container( margin: const EdgeInsets.fromLTRB(12, 0, 12, 8), padding: const EdgeInsets.all(14), decoration: BoxDecoration( color: AppTheme.glassFill(theme.brightness), borderRadius: BorderRadius.circular(AppTheme.rPanel), border: Border.all(color: AppTheme.glassBorder(theme.brightness)), ), child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ Text( title, style: theme.textTheme.titleSmall?.copyWith( color: theme.colorScheme.onSurface, fontWeight: FontWeight.w600, ), ), if (isL0) ...[ const SizedBox(height: 6), Text( widget.isGroup ? '단톡에서는 와카뷰 자동 발송이 항상 막혀 있습니다. 초안을 입력창으로 옮긴 뒤 직접 보내세요.' : 'L0에서는 와카뷰 발송이 막혀 있습니다. 초안을 입력창으로 옮긴 뒤 직접 보내거나, 메뉴 → 자율성에서 L1으로 바꾸세요.', style: theme.textTheme.bodySmall?.copyWith(color: theme.colorScheme.onSurfaceVariant), ), ], const SizedBox(height: 10), TextField( controller: _draftEdit, minLines: 2, maxLines: 5, style: TextStyle(color: theme.colorScheme.onSurface), decoration: InputDecoration(fillColor: theme.colorScheme.surface), ), const SizedBox(height: 10), Row( children: [ TextButton(onPressed: _busy ? null : _rejectDraft, child: const Text('버리기')), const Spacer(), FilledButton.tonal( onPressed: _busy ? null : _requestDraft, child: const Text('다시 초안'), ), const SizedBox(width: 8), FilledButton( onPressed: _busy ? null : _sendTwinApproved, child: Text(isL0 ? '입력창으로 옮기기' : '승인하고 보내기'), ), ], ), ], ), ); } @override Widget build(BuildContext context) { final session = context.watch(); final me = session.user?.id; final theme = Theme.of(context); return Scaffold( appBar: AppBar( title: Text(widget.title ?? '대화방 #${widget.conversationId}'), actions: [ if (widget.isGroup) IconButton( tooltip: '안 본 동안 요약', onPressed: _openSummary, icon: const Icon(Icons.summarize_outlined), ), IconButton( // 답장 마감 알림(roadmap.md §2.7-F) — "이따 답장" 스누즈. 본인에게만 // 보이는 순수 온디바이스 상태라 상대방 화면에는 전혀 나타나지 않는다. tooltip: _snoozedUntil != null ? '이따 답장 (${_formatSnoozeUntil(_snoozedUntil!)})' : '이따 답장', onPressed: _busy ? null : _openSnoozeMenu, icon: Icon(_snoozedUntil != null ? Icons.alarm_on : Icons.alarm_add_outlined), ), IconButton( tooltip: '거부권 (와카뷰 자동응대 중단)', onPressed: _busy ? null : _veto, icon: const Icon(Icons.block), ), ], ), body: Column( children: [ if (_banner != null) MaterialBanner( leading: Icon(Icons.info_outline, color: theme.colorScheme.onSurfaceVariant), content: Text(_banner!), actions: [ // 도배 감지 자동중단의 one-tap undo (AGENTS.md 안전 불변식) — // 거부권과 달리 되돌릴 수 있으므로 여기서 바로 재개 가능. if (_floodBlocked) TextButton(onPressed: _busy ? null : _resumeFlood, child: const Text('자동응대 재개')), TextButton(onPressed: () => setState(() => _banner = null), child: const Text('닫기')), ], ), if (_snoozePastDue) MaterialBanner( leading: Icon(Icons.alarm, color: theme.colorScheme.tertiary), content: Text('답장 마감 시간이 지났습니다 (${_formatSnoozeUntil(_snoozedUntil!)}).'), actions: [ TextButton(onPressed: _busy ? null : _clearSnooze, child: const Text('스누즈 해제')), ], ), Expanded( child: _loadingHistory ? const Center(child: CircularProgressIndicator()) : _messages.isEmpty ? Center( child: Text( '아직 메시지가 없습니다. 첫 메시지를 보내 보세요.', style: theme.textTheme.bodyMedium?.copyWith(color: theme.colorScheme.onSurfaceVariant), ), ) : ListView.builder( controller: _scroll, padding: const EdgeInsets.symmetric(vertical: 8), itemCount: _messages.length, itemBuilder: (context, i) { final m = _messages[i]; final isMine = me != null && m.senderId == me; return MessageBubble( message: m, isMine: isMine, onRetract: m.isTwin ? () => _retract(m) : null, // "이 답장 나답아요?" (vision.md 지표, N4-12): 트윈이 // 실제로 보낸(=내가 보낸) 메시지에만 노출, 이미 // 평가된 메시지는 다시 보여주지 않는다. alreadyRated: _ratedMessageIds.contains(m.id), onFeedback: m.isTwin && isMine && !m.retracted ? (natural) => _submitFeedback(m, natural) : null, ); }, ), ), _buildL1Panel(context), SafeArea( child: Container( padding: const EdgeInsets.fromLTRB(10, 8, 10, 10), decoration: BoxDecoration( color: AppTheme.glassFill(theme.brightness), border: Border( top: BorderSide(color: AppTheme.glassBorder(theme.brightness).withValues(alpha: 0.7)), ), ), child: Row( crossAxisAlignment: CrossAxisAlignment.end, children: [ IconButton( tooltip: '초안 요청', onPressed: _busy ? null : _requestDraft, icon: const Icon(Icons.auto_awesome_outlined), ), Expanded( child: TextField( controller: _input, minLines: 1, maxLines: 4, textInputAction: TextInputAction.send, onSubmitted: (_) { if (!_busy) _sendHuman(); }, decoration: const InputDecoration(hintText: '메시지'), ), ), const SizedBox(width: 6), IconButton.filled( tooltip: '보내기', onPressed: _busy ? null : _sendHuman, style: IconButton.styleFrom( backgroundColor: theme.colorScheme.primary, foregroundColor: theme.colorScheme.onPrimary, ), icon: const Icon(Icons.arrow_upward_rounded), ), ], ), ), ), ], ), ); } }