오대리ㅣㅣㅣㅣ
This commit is contained in:
510
mobile/lib/features/chat/chat_page.dart
Normal file
510
mobile/lib/features/chat/chat_page.dart
Normal file
@@ -0,0 +1,510 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user