import 'dart:async'; import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; import '../models/models.dart'; import '../services/api_client.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}); final int conversationId; final String? title; final bool isGroup; @override State createState() => _ChatScreenState(); } class _ChatScreenState extends State { final _input = TextEditingController(); final _draftEdit = TextEditingController(); final _scroll = ScrollController(); final _messages = []; ConversationSocket? _socket; StreamSubscription? _sub; String? _banner; DraftResult? _pendingDraft; bool _busy = false; bool _loadingHistory = true; late final ApiClient _api; @override void initState() { super.initState(); _api = context.read().api; _socket = ConversationSocket(widget.conversationId)..connect(); _sub = _socket!.events.listen(_onEvent); _loadHistory(); } 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; }); _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, ); }); } 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, ); } }); 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(); _sub?.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(); } 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, ); setState(() { _pendingDraft = null; _draftEdit.clear(); if (!_messages.any((m) => m.id == msg.id)) _messages.add(msg); }); _scrollToEnd(); } on ApiException catch (e) { setState(() => _banner = '와카뷰 발송 차단 (${e.statusCode}): ${e.body}'); } finally { 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( 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: [ TextButton(onPressed: () => setState(() => _banner = null), 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]; return MessageBubble( message: m, isMine: me != null && m.senderId == me, onRetract: m.isTwin ? () => _retract(m) : null, ); }, ), ), _buildL1Panel(context), SafeArea( child: Padding( padding: const EdgeInsets.fromLTRB(12, 8, 12, 12), child: Row( crossAxisAlignment: CrossAxisAlignment.end, children: [ Expanded( child: TextField( controller: _input, minLines: 1, maxLines: 4, decoration: const InputDecoration(hintText: '메시지'), ), ), const SizedBox(width: 8), IconButton.filledTonal( tooltip: '초안 요청', onPressed: _busy ? null : _requestDraft, icon: const Icon(Icons.auto_awesome), ), const SizedBox(width: 4), IconButton.filled( tooltip: '보내기', onPressed: _busy ? null : _sendHuman, icon: const Icon(Icons.send), ), ], ), ), ), ], ), ); } }