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 '../widgets/message_bubble.dart'; class ChatScreen extends StatefulWidget { const ChatScreen({super.key, required this.conversationId}); final int conversationId; @override State createState() => _ChatScreenState(); } class _ChatScreenState extends State { final _input = TextEditingController(); final _messages = []; ConversationSocket? _socket; StreamSubscription? _sub; String? _banner; DraftResult? _pendingDraft; bool _busy = false; @override void initState() { super.initState(); _socket = ConversationSocket(widget.conversationId)..connect(); _sub = _socket!.events.listen(_onEvent); } 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)); } @override void dispose() { _sub?.cancel(); _socket?.dispose(); _input.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)); } } 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 draft = await session.api.requestDraft( conversationId: widget.conversationId, contextLines: contextLines.isEmpty ? ['상대: 안녕'] : contextLines, styleExamples: const ['ㅇㅇ 알겠음', 'ㅋㅋ 그래', '나중에 연락할게'], ); setState(() { _pendingDraft = draft; if (draft.isEscalate) { _banner = '에스컬레이션: ${draft.text}'; } }); } 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; setState(() => _busy = true); try { final msg = await session.api.sendMessage( conversationId: widget.conversationId, senderId: session.user!.id, text: draft.text, senderMode: SenderMode.twin, approved: true, ); setState(() { _pendingDraft = null; if (!_messages.any((m) => m.id == msg.id)) _messages.add(msg); }); } on ApiException catch (e) { setState(() => _banner = '분신 발송 차단 (${e.statusCode}): ${e.body}'); } finally { setState(() => _busy = false); } } Future _veto() async { 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})'); } } @override Widget build(BuildContext context) { final session = context.watch(); final me = session.user?.id; return Scaffold( appBar: AppBar( title: Text('대화방 #${widget.conversationId}'), actions: [ TextButton(onPressed: _busy ? null : _veto, child: const Text('거부권')), ], ), body: Column( children: [ if (_banner != null) MaterialBanner( content: Text(_banner!), actions: [ TextButton(onPressed: () => setState(() => _banner = null), child: const Text('닫기')), ], ), Expanded( child: ListView.builder( 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, ); }, ), ), if (_pendingDraft != null && !_pendingDraft!.isEscalate) Material( color: Theme.of(context).colorScheme.secondaryContainer, child: ListTile( title: const Text('분신 초안 (L1 승인)'), subtitle: Text(_pendingDraft!.text), trailing: FilledButton( onPressed: _busy ? null : _sendTwinApproved, child: const Text('보내기'), ), ), ), SafeArea( child: Padding( padding: const EdgeInsets.fromLTRB(12, 8, 12, 12), child: Row( children: [ Expanded( child: TextField( controller: _input, minLines: 1, maxLines: 4, decoration: const InputDecoration( hintText: '메시지', border: OutlineInputBorder(), ), ), ), 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), ), ], ), ), ), ], ), ); } }