Files
iykyk_msn/mobile/lib/features/chat/chat_page.dart
2026-04-07 16:17:03 +09:00

511 lines
18 KiB
Dart

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<MessageLocalStore>((ref) => MessageLocalStore());
class ChatPage extends ConsumerStatefulWidget {
const ChatPage({super.key, required this.roomId, required this.contextId});
final String roomId;
final String contextId;
@override
ConsumerState<ChatPage> createState() => _ChatPageState();
}
class _ChatPageState extends ConsumerState<ChatPage> {
final _controller = TextEditingController();
final _scroll = ScrollController();
final List<MessageModel> _items = [];
StreamSubscription<MessageModel>? _sub;
bool _loading = true;
String? _contextName;
String? _bootstrapError;
/// From room list; null until loaded.
bool? _roomIsGroup;
String? _roomName;
String? _lastReadByOther;
StreamSubscription<Object>? _sideSub;
Timer? _typingDebounce;
Timer? _typingClear;
String? _typingUserId;
@override
void initState() {
super.initState();
_bootstrap();
}
Future<void> _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<void> _refreshReadState() async {
if (_roomIsGroup == true) return;
try {
final id = await ref.read(msnApiProvider).getReadState(widget.roomId);
if (!mounted) return;
setState(() => _lastReadByOther = id);
} catch (_) {}
}
Future<void> _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<void> _showGroupRoomSheet() async {
try {
final res = await ref.read(dioProvider).get<Map<String, dynamic>>(
'/api/rooms/${widget.roomId}/participants',
);
final ids = (res.data!['userIds'] as List<dynamic>).cast<String>();
if (!mounted) return;
await showModalBottomSheet<void>(
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<String?> _resolveOtherUserId() async {
try {
final res = await ref.read(dioProvider).get<Map<String, dynamic>>(
'/api/rooms/${widget.roomId}/participants',
);
final ids = (res.data!['userIds'] as List<dynamic>).cast<String>();
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;
}
}
}