import 'dart:async'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../../core/api_client.dart'; import '../../core/chat_socket.dart'; import '../../core/msn_api.dart'; import '../../core/session_controller.dart'; import '../../data/message_local_store.dart'; import '../../models/message_model.dart'; import '../../models/room_model.dart'; import '../../models/ws_chat_side_event.dart'; import '../../theme/toss_theme.dart'; import '../profile/user_profile_sheet.dart'; final messageLocalStoreProvider = Provider((ref) => MessageLocalStore()); class ChatPage extends ConsumerStatefulWidget { const ChatPage({super.key, required this.roomId, required this.contextId}); final String roomId; final String contextId; @override ConsumerState createState() => _ChatPageState(); } class _ChatPageState extends ConsumerState { final _controller = TextEditingController(); final _scroll = ScrollController(); final List _items = []; StreamSubscription? _sub; bool _loading = true; String? _contextName; String? _bootstrapError; /// From room list; null until loaded. bool? _roomIsGroup; String? _roomName; String? _lastReadByOther; StreamSubscription? _sideSub; Timer? _typingDebounce; Timer? _typingClear; String? _typingUserId; @override void initState() { super.initState(); _bootstrap(); } Future _bootstrap() async { setState(() { _bootstrapError = null; _loading = true; }); final api = ref.read(msnApiProvider); final store = ref.read(messageLocalStoreProvider); try { try { final contexts = await api.listContexts(); final match = contexts.where((c) => c.id == widget.contextId).toList(); if (match.isNotEmpty && mounted) { setState(() => _contextName = match.first.name); } } catch (_) {} RoomModel? roomMeta; try { final rooms = await api.listRooms(widget.contextId); for (final r in rooms) { if (r.id == widget.roomId) { roomMeta = r; break; } } } catch (_) {} await ref.read(chatSocketProvider).connect(); final list = await api.fetchMessages(widget.roomId); await store.upsertMessages(widget.contextId, widget.roomId, list); final local = await store.listForRoom(widget.contextId, widget.roomId); if (!mounted) return; setState(() { _items ..clear() ..addAll(local.isNotEmpty ? local : list); _loading = false; _roomIsGroup = roomMeta?.isGroup; _roomName = roomMeta?.name; }); if (roomMeta?.isGroup == false) { unawaited(_refreshReadState()); } ref.read(chatSocketProvider).subscribeRoom(widget.roomId); _sub = ref.read(chatSocketProvider).messages.listen((m) { if (m.roomId != widget.roomId) return; setState(() { _items.add(m); }); unawaited(store.upsertMessages(widget.contextId, widget.roomId, [m])); unawaited(_markRead(m)); if (_roomIsGroup == false) { unawaited(_refreshReadState()); } }); _sideSub = ref.read(chatSocketProvider).sideEvents.listen((ev) { if (ev is WsTypingEvent) { if (ev.roomId != widget.roomId) return; final myId = ref.read(sessionProvider).value?.userId; if (myId == null || ev.userId == myId) return; _typingClear?.cancel(); if (ev.active) { setState(() => _typingUserId = ev.userId); _typingClear = Timer(const Duration(seconds: 3), () { if (mounted) setState(() => _typingUserId = null); }); } else { setState(() => _typingUserId = null); } } else if (ev is WsReadEvent) { if (ev.roomId != widget.roomId) return; unawaited(_refreshReadState()); } }); if (_items.isNotEmpty) { unawaited(_markRead(_items.last)); } } catch (e, st) { debugPrint('ChatPage _bootstrap: $e\n$st'); if (!mounted) return; setState(() { _loading = false; _bootstrapError = e.toString(); }); } } Future _refreshReadState() async { if (_roomIsGroup == true) return; try { final id = await ref.read(msnApiProvider).getReadState(widget.roomId); if (!mounted) return; setState(() => _lastReadByOther = id); } catch (_) {} } Future _markRead(MessageModel last) async { try { await ref.read(dioProvider).post( '/api/messages/read', data: {'roomId': widget.roomId, 'upToMessageId': last.id}, ); } catch (_) {} } @override void dispose() { _sub?.cancel(); _sideSub?.cancel(); _typingDebounce?.cancel(); _typingClear?.cancel(); ref.read(chatSocketProvider).sendTyping(widget.roomId, false); _controller.dispose(); _scroll.dispose(); super.dispose(); } void _onInputChanged(String text) { if (_roomIsGroup == true) return; _typingDebounce?.cancel(); final socket = ref.read(chatSocketProvider); if (text.trim().isEmpty) { socket.sendTyping(widget.roomId, false); return; } _typingDebounce = Timer(const Duration(milliseconds: 400), () { socket.sendTyping(widget.roomId, true); }); } int? _lastMyMessageIndex(String me) { for (var i = _items.length - 1; i >= 0; i--) { if (_items[i].senderId == me) return i; } return null; } bool _showReadReceipt(String me) { if (_roomIsGroup == true || _lastReadByOther == null) return false; final myIdx = _lastMyMessageIndex(me); if (myIdx == null) return false; final readIdx = _items.indexWhere((m) => m.id == _lastReadByOther); if (readIdx < 0) return false; return readIdx >= myIdx; } void _send() { final text = _controller.text.trim(); if (text.isEmpty) return; ref.read(chatSocketProvider).sendTyping(widget.roomId, false); final sent = ref.read(chatSocketProvider).sendMessage(widget.roomId, text); _controller.clear(); if (!sent && mounted) { ScaffoldMessenger.of(context).showSnackBar( const SnackBar(content: Text('연결을 기다리는 중입니다. 복구되면 자동으로 전송됩니다.')), ); } } @override Widget build(BuildContext context) { final me = ref.watch(sessionProvider).value?.userId ?? ''; final theme = Theme.of(context); return Scaffold( backgroundColor: TossColors.bg, appBar: AppBar( title: Column( crossAxisAlignment: CrossAxisAlignment.start, mainAxisSize: MainAxisSize.min, children: [ Text( _roomIsGroup == true ? (_roomName ?? '그룹 채팅') : '채팅', style: theme.textTheme.titleLarge, ), if (_typingUserId != null) Padding( padding: const EdgeInsets.only(top: 4), child: Text( '상대가 입력 중…', style: theme.textTheme.labelSmall?.copyWith( color: TossColors.blue, fontWeight: FontWeight.w600, ), ), ), if (_contextName != null) Padding( padding: const EdgeInsets.only(top: 2), child: Row( mainAxisSize: MainAxisSize.min, children: [ Container( padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2), decoration: BoxDecoration( color: TossColors.blue.withValues(alpha: 0.12), borderRadius: BorderRadius.circular(6), ), child: Text( _contextName!, style: theme.textTheme.labelSmall?.copyWith( color: TossColors.blueDark, fontWeight: FontWeight.w600, ), ), ), ], ), ), ], ), actions: [ if (_roomIsGroup == true) IconButton( tooltip: '그룹 정보', icon: const Icon(Icons.groups_outlined), onPressed: _showGroupRoomSheet, ) else if (_roomIsGroup == false) IconButton( tooltip: '프로필', icon: const Icon(Icons.person_outline_rounded), onPressed: () async { final other = await _resolveOtherUserId(); if (!context.mounted || other == null) return; await showUserProfileSheet( context, ref, contextId: widget.contextId, userId: other, ); }, ), ], ), body: Column( children: [ Expanded( child: _loading ? const Center(child: CircularProgressIndicator(color: TossColors.blue)) : _bootstrapError != null && _items.isEmpty ? Center( child: Padding( padding: const EdgeInsets.all(24), child: Column( mainAxisSize: MainAxisSize.min, children: [ Icon(Icons.wifi_off_rounded, size: 48, color: theme.colorScheme.error), const SizedBox(height: 16), Text( '불러오지 못했어요', style: theme.textTheme.titleMedium, ), const SizedBox(height: 8), Text( _bootstrapError!, textAlign: TextAlign.center, style: theme.textTheme.bodySmall, ), const SizedBox(height: 20), FilledButton( onPressed: () { _sub?.cancel(); _sub = null; _bootstrap(); }, child: const Text('다시 시도'), ), ], ), ), ) : ListView.builder( controller: _scroll, padding: const EdgeInsets.symmetric(vertical: 12, horizontal: 8), itemCount: _items.length, itemBuilder: (context, i) { final m = _items[i]; final mine = m.senderId == me; final lastMy = _lastMyMessageIndex(me); final showRead = mine && lastMy == i && _showReadReceipt(me); return Align( alignment: mine ? Alignment.centerRight : Alignment.centerLeft, child: Column( crossAxisAlignment: mine ? CrossAxisAlignment.end : CrossAxisAlignment.start, mainAxisSize: MainAxisSize.min, children: [ Container( margin: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), constraints: BoxConstraints( maxWidth: MediaQuery.sizeOf(context).width * 0.78, ), decoration: BoxDecoration( color: mine ? TossColors.blue : TossColors.surface, borderRadius: BorderRadius.only( topLeft: const Radius.circular(20), topRight: const Radius.circular(20), bottomLeft: Radius.circular(mine ? 20 : 6), bottomRight: Radius.circular(mine ? 6 : 20), ), border: mine ? null : Border.all(color: TossColors.line), boxShadow: [ BoxShadow( color: Colors.black.withValues(alpha: 0.05), blurRadius: 8, offset: const Offset(0, 2), ), ], ), child: Text( m.body, style: theme.textTheme.bodyLarge?.copyWith( color: mine ? Colors.white : TossColors.textPrimary, height: 1.35, ), ), ), if (showRead) Padding( padding: const EdgeInsets.only(right: 12, bottom: 4), child: Text( '읽음', style: theme.textTheme.labelSmall?.copyWith( color: TossColors.textSecondary, ), ), ), ], ), ); }, ), ), Container( color: TossColors.surface, padding: EdgeInsets.fromLTRB( 12, 8, 12, 8 + MediaQuery.paddingOf(context).bottom, ), child: Row( crossAxisAlignment: CrossAxisAlignment.end, children: [ Expanded( child: TextField( controller: _controller, decoration: InputDecoration( hintText: '메시지를 입력하세요', hintStyle: theme.textTheme.bodyMedium, filled: true, fillColor: TossColors.bg, contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), border: OutlineInputBorder( borderRadius: BorderRadius.circular(22), borderSide: BorderSide.none, ), ), minLines: 1, maxLines: 4, onChanged: _onInputChanged, onSubmitted: (_) => _send(), ), ), const SizedBox(width: 8), Material( color: TossColors.blue, borderRadius: BorderRadius.circular(22), child: InkWell( onTap: _send, borderRadius: BorderRadius.circular(22), child: const Padding( padding: EdgeInsets.all(12), child: Icon(Icons.arrow_upward_rounded, color: Colors.white, size: 22), ), ), ), ], ), ), ], ), ); } Future _showGroupRoomSheet() async { try { final res = await ref.read(dioProvider).get>( '/api/rooms/${widget.roomId}/participants', ); final ids = (res.data!['userIds'] as List).cast(); if (!mounted) return; await showModalBottomSheet( context: context, showDragHandle: true, backgroundColor: TossColors.surface, shape: const RoundedRectangleBorder( borderRadius: BorderRadius.vertical(top: Radius.circular(20)), ), builder: (ctx) { final tt = Theme.of(ctx).textTheme; return SafeArea( child: Padding( padding: const EdgeInsets.fromLTRB(24, 8, 24, 24), child: Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.stretch, children: [ Text( _roomName ?? '그룹 채팅', style: tt.titleLarge, ), const SizedBox(height: 8), Text( '참여자 ${ids.length}명', style: tt.bodyLarge, ), const SizedBox(height: 12), Text( '이 맥락에서만 보이는 그룹 대화입니다.', style: tt.bodySmall, ), ], ), ), ); }, ); } catch (_) {} } Future _resolveOtherUserId() async { try { final res = await ref.read(dioProvider).get>( '/api/rooms/${widget.roomId}/participants', ); final ids = (res.data!['userIds'] as List).cast(); final session = ref.read(sessionProvider).value; final meId = session?.userId; for (final id in ids) { if (id != meId) return id; } return ids.isNotEmpty ? ids.first : null; } catch (_) { return null; } } }