511 lines
18 KiB
Dart
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;
|
|
}
|
|
}
|
|
}
|