diff --git a/lib/dialogs/chat_dialog.dart b/lib/dialogs/chat_dialog.dart new file mode 100644 index 0000000..e7acfca --- /dev/null +++ b/lib/dialogs/chat_dialog.dart @@ -0,0 +1,392 @@ +import 'package:flutter/material.dart'; +import 'package:firebase_database/firebase_database.dart'; +import 'dart:async'; + +enum ChatType { + all, // 전체 채팅 + team // 팀 채팅 +} + +class ChatMessage { + final String message; + final String senderNickname; + final int timestamp; + + ChatMessage({ + required this.message, + required this.senderNickname, + required this.timestamp, + }); + + factory ChatMessage.fromSnapshot(DataSnapshot snapshot) { + final data = snapshot.value as Map; + return ChatMessage( + message: data['message'] ?? '', + senderNickname: data['sender_nickname'] ?? data['sender_name'] ?? '', + timestamp: data['timestamp'] ?? 0, + ); + } +} + +class ChatDialog extends StatefulWidget { + const ChatDialog({ + Key? key, + required this.roomType, + required this.roomStatus, + required this.roomSeq, + required this.teamName, + required this.myNickname, + }) : super(key: key); + + final String roomType; + final String roomStatus; + final String roomSeq; + final String teamName; // 사용자의 팀 이름 + final String myNickname; // 사용자 닉네임 + + @override + State createState() => _ChatDialogState(); +} + +class _ChatDialogState extends State { + ChatType _selectedChatType = ChatType.all; + final TextEditingController _messageController = TextEditingController(); + final ScrollController _scrollController = ScrollController(); + List _messages = []; + + late DatabaseReference _chatRef; + StreamSubscription? _chatSubscription; + + bool get _isTeamChatEnabled => widget.roomType.toUpperCase() == 'TEAM'; + + @override + void initState() { + super.initState(); + _initChatRef(); + } + + void _initChatRef() { + _subscribeToChatMessages(); + } + + void _subscribeToChatMessages() { + _chatSubscription?.cancel(); + + final roomKey = 'korea-${widget.roomSeq}'; + final path = _selectedChatType == ChatType.all + ? 'rooms/$roomKey/chats/all' + : 'rooms/$roomKey/chats/team/${widget.teamName}'; + + _chatRef = FirebaseDatabase.instance.ref(path); + + // orderByKey()를 사용하여 메시지 ID(timestamp) 기준으로 정렬 + _chatSubscription = _chatRef + .orderByKey() + .onValue + .listen((event) { + if (!event.snapshot.exists) { + setState(() => _messages = []); + return; + } + + try { + final messagesMap = event.snapshot.value as Map; + final List messages = []; + + messagesMap.forEach((key, value) { + if (value is Map) { + messages.add(ChatMessage( + message: value['message'] ?? '', + senderNickname: value['sender_nickname'] ?? '', + timestamp: value['timestamp'] ?? 0, + )); + } + }); + + setState(() => _messages = messages); + _scrollToBottom(); + } catch (e) { + setState(() => _messages = []); + } + }); + } + + void _scrollToBottom() { + if (_scrollController.hasClients) { + _scrollController.animateTo( + _scrollController.position.maxScrollExtent, + duration: const Duration(milliseconds: 300), + curve: Curves.easeOut, + ); + } + } + + Future _sendMessage() async { + if (_messageController.text.trim().isEmpty) return; + + try { + final timestamp = DateTime.now().millisecondsSinceEpoch; + final messageId = 'id$timestamp'; // timestamp를 이용한 메시지 ID 생성 + final message = _messageController.text.trim(); + + final messageData = { + 'message': message, + 'sender_nickname': widget.myNickname, + 'timestamp': timestamp, + }; + + // messageId를 사용하여 직접 참조 생성 + final newMessageRef = _chatRef.child(messageId); + + await newMessageRef.set(messageData); + + _messageController.clear(); + _scrollToBottom(); + } catch (e) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('message error: ${e.toString()}')), + ); + } + } + } + + @override + void dispose() { + _chatSubscription?.cancel(); + _messageController.dispose(); + _scrollController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return Dialog( + child: Container( + width: MediaQuery.of(context).size.width * 0.8, + height: MediaQuery.of(context).size.height * 0.7, + padding: const EdgeInsets.all(16.0), + child: Column( + children: [ + // 모달 헤더 + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + const Text( + 'Chat', + style: TextStyle( + fontSize: 20, + fontWeight: FontWeight.bold, + ), + ), + IconButton( + icon: const Icon(Icons.close), + onPressed: () => Navigator.pop(context), + ), + ], + ), + // 채팅 타입 선택 탭 - TEAM 타입일 때만 표시 + if (_isTeamChatEnabled) ...[ + Container( + margin: const EdgeInsets.symmetric(vertical: 8.0), + decoration: BoxDecoration( + color: Colors.grey[200], + borderRadius: BorderRadius.circular(25), + ), + child: Row( + children: [ + Expanded( + child: GestureDetector( + onTap: () { + setState(() => _selectedChatType = ChatType.all); + _subscribeToChatMessages(); + }, + child: Container( + padding: const EdgeInsets.symmetric(vertical: 8.0), + decoration: BoxDecoration( + color: _selectedChatType == ChatType.all + ? Colors.white + : Colors.transparent, + borderRadius: BorderRadius.circular(25), + boxShadow: _selectedChatType == ChatType.all + ? [ + BoxShadow( + color: Colors.grey.withOpacity(0.3), + blurRadius: 4, + offset: const Offset(0, 2), + ) + ] + : null, + ), + child: const Text( + 'Global Chat', + textAlign: TextAlign.center, + style: TextStyle(fontWeight: FontWeight.bold), + ), + ), + ), + ), + Expanded( + child: GestureDetector( + onTap: () { + setState(() => _selectedChatType = ChatType.team); + _subscribeToChatMessages(); + }, + child: Container( + padding: const EdgeInsets.symmetric(vertical: 8.0), + decoration: BoxDecoration( + color: _selectedChatType == ChatType.team + ? Colors.white + : Colors.transparent, + borderRadius: BorderRadius.circular(25), + boxShadow: _selectedChatType == ChatType.team + ? [ + BoxShadow( + color: Colors.grey.withOpacity(0.3), + blurRadius: 4, + offset: const Offset(0, 2), + ) + ] + : null, + ), + child: const Text( + 'Team Chat', + textAlign: TextAlign.center, + style: TextStyle(fontWeight: FontWeight.bold), + ), + ), + ), + ), + ], + ), + ), + const Divider(), + ], + // 채팅 내용이 표시될 영역 + Expanded( + child: Container( + padding: const EdgeInsets.all(8.0), + decoration: BoxDecoration( + color: Colors.grey[100], + borderRadius: BorderRadius.circular(12), + ), + child: ListView.builder( + controller: _scrollController, + itemCount: _messages.length, + itemBuilder: (context, index) { + final message = _messages[index]; + final isMyMessage = message.senderNickname == widget.myNickname; + + return Padding( + padding: const EdgeInsets.symmetric(vertical: 4.0), + child: Row( + mainAxisAlignment: isMyMessage + ? MainAxisAlignment.end + : MainAxisAlignment.start, + children: [ + if (!isMyMessage) ...[ + Text( + message.senderNickname, + style: const TextStyle( + fontSize: 12, + color: Colors.grey, + ), + ), + const SizedBox(width: 8), + ], + Container( + padding: const EdgeInsets.symmetric( + horizontal: 12, + vertical: 8, + ), + decoration: BoxDecoration( + color: isMyMessage ? Colors.blue[100] : Colors.white, + borderRadius: BorderRadius.circular(16), + border: Border.all( + color: Colors.grey[300]!, + ), + ), + child: Text(message.message), + ), + ], + ), + ); + }, + ), + ), + ), + // 메시지 입력 영역 + Container( + padding: const EdgeInsets.symmetric(vertical: 8.0), + child: Row( + children: [ + Expanded( + child: TextField( + controller: _messageController, + decoration: InputDecoration( + hintText: 'Enter a message', + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(20), + ), + contentPadding: const EdgeInsets.symmetric( + horizontal: 16, + vertical: 8, + ), + ), + onSubmitted: (_) => _sendMessage(), + ), + ), + const SizedBox(width: 8), + IconButton( + icon: const Icon(Icons.send), + onPressed: _sendMessage, + ), + ], + ), + ), + ], + ), + ), + ); + } +} + +class ChatButton extends StatelessWidget { + const ChatButton({ + Key? key, + required this.roomType, + required this.roomStatus, + required this.roomSeq, + required this.teamName, + required this.myNickname, + }) : super(key: key); + + final String roomType; + final String roomStatus; + final String roomSeq; + final String teamName; + final String myNickname; + + void _openChatDialog(BuildContext context) { + showDialog( + context: context, + barrierDismissible: false, + builder: (context) => ChatDialog( + roomType: roomType, + roomStatus: roomStatus, + roomSeq: roomSeq, + teamName: teamName, + myNickname: myNickname, + ), + ); + } + + @override + Widget build(BuildContext context) { + return FloatingActionButton( + onPressed: () => _openChatDialog(context), + child: const Icon(Icons.chat), + mini: true, + ); + } +} \ No newline at end of file diff --git a/lib/views/room/playing_private_page.dart b/lib/views/room/playing_private_page.dart index 9a0476b..00029e5 100644 --- a/lib/views/room/playing_private_page.dart +++ b/lib/views/room/playing_private_page.dart @@ -11,6 +11,7 @@ import '../../dialogs/score_edit_dialog.dart'; // 점수 수정 모달 import '../../dialogs/user_info_basic_dialog.dart'; // 일반 유저 정보 모달 import '../../plugins/admob.dart'; import '../../config/config.dart'; +import '../../dialogs/chat_dialog.dart'; class PlayingPrivatePage extends StatefulWidget { final int roomSeq; @@ -62,6 +63,9 @@ class _PlayingPrivatePageState extends State { // userListMap: { userSeq: true/false } Map _userListMap = {}; + String _teamName = ''; + String _myNickname = ''; + @override void initState() { super.initState(); @@ -157,6 +161,11 @@ class _PlayingPrivatePageState extends State { // Mark admin (사회자 표시) uData['nickname'] = '☆' + (uData['nickname'] ?? 'User'); } + // 채팅 데이터 세팅 + if (uSeq.toString() == mySeq) { + _teamName = uData['team_name'] ?? ''; + _myNickname = uData['nickname'] ?? ''; + } rawList.add({ 'user_seq': uSeq, 'participant_type': (uData['participant_type'] ?? '').toString().toUpperCase(), @@ -551,6 +560,15 @@ class _PlayingPrivatePageState extends State { ), ], ), + floatingActionButton: Stack( + children: [ + Positioned( + bottom: 30, // AdBannerWidget 위에 위치하도록 조정 + right: 0, + child: ChatButton(roomType: 'PRIVATE', roomStatus: 'RUNNING', roomSeq: widget.roomSeq.toString(), teamName: _teamName, myNickname: _myNickname), + ), + ], + ), bottomNavigationBar: AdBannerWidget(), ), ); diff --git a/lib/views/room/playing_team_page.dart b/lib/views/room/playing_team_page.dart index d3a3bf1..8cfc6a2 100644 --- a/lib/views/room/playing_team_page.dart +++ b/lib/views/room/playing_team_page.dart @@ -11,6 +11,7 @@ import '../../dialogs/score_edit_dialog.dart'; import '../../dialogs/user_info_basic_dialog.dart'; import '../../plugins/admob.dart'; import '../../config/config.dart'; +import '../../dialogs/chat_dialog.dart'; class PlayingTeamPage extends StatefulWidget { final int roomSeq; @@ -65,6 +66,9 @@ class _PlayingTeamPageState extends State { // 점수 공개 범위 String scoreOpenRange = 'ALL'; + String _teamName = ''; + String _myNickname = ''; + @override void initState() { super.initState(); @@ -159,6 +163,11 @@ class _PlayingTeamPageState extends State { // 관리자(사회자) 표시 uData['nickname'] = '☆' + (uData['nickname'] ?? 'User'); } + // 채팅 데이터 세팅 + if (uSeq.toString() == mySeq) { + _teamName = uData['team_name'] ?? ''; + _myNickname = uData['nickname'] ?? ''; + } rawList.add({ 'user_seq': uSeq, 'participant_type': (uData['participant_type'] ?? '').toString().toUpperCase(), @@ -517,6 +526,15 @@ class _PlayingTeamPageState extends State { ), ], ), + floatingActionButton: Stack( + children: [ + Positioned( + bottom: 30, // AdBannerWidget 위에 위치하도록 조정 + right: 0, + child: ChatButton(roomType: 'TEAM', roomStatus: 'RUNNING', roomSeq: widget.roomSeq.toString(), teamName: _teamName, myNickname: _myNickname), + ), + ], + ), bottomNavigationBar: AdBannerWidget(), ), ); @@ -569,7 +587,6 @@ class _PlayingTeamPageState extends State { final score = (scoreOpenRange == 'ALL') ? tempScore : '-'; final nickname = userData['nickname'] ?? 'User'; // '유저' final profileImg = userData['profile_img'] ?? ''; - print('profileImg: $profileImg'); final bool isActive = _userListMap[userSeq] ?? true; final hasExited = !isActive; diff --git a/lib/views/room/waiting_room_private_page.dart b/lib/views/room/waiting_room_private_page.dart index bcfed03..ea7a109 100644 --- a/lib/views/room/waiting_room_private_page.dart +++ b/lib/views/room/waiting_room_private_page.dart @@ -9,6 +9,7 @@ import '../../dialogs/response_dialog.dart'; import '../../dialogs/yes_no_dialog.dart'; import '../../dialogs/room_setting_dialog.dart'; import '../../dialogs/user_info_private_dialog.dart'; +import '../../dialogs/chat_dialog.dart'; import 'playing_private_page.dart'; // Ads @@ -75,6 +76,10 @@ class _WaitingRoomPrivatePageState extends State { double scaleFactor = 1.0; double buttonScaleFactor = 1.0; + // 채팅을 위한 데이터 + String _myNickname = ''; + String _teamName = ''; + @override void initState() { super.initState(); @@ -150,6 +155,10 @@ class _WaitingRoomPrivatePageState extends State { if (val is Map) { final tempUserSeq = val['user_seq'].toString() ?? '0'; if (tempUserSeq != '0') { + if (val['user_seq'].toString() == mySeq) { + _teamName = val['team_name'] ?? ''; + _myNickname = val['nickname'] ?? ''; + } tempList.add({ 'user_seq': tempUserSeq, 'participant_type': val['participant_type'] ?? '', @@ -655,6 +664,15 @@ class _WaitingRoomPrivatePageState extends State { onPressed: () => _onLeaveRoom(), ), ), + floatingActionButton: Stack( + children: [ + Positioned( + bottom: 30, // AdBannerWidget 위에 위치하도록 조정 + right: 0, + child: ChatButton(roomType: widget.roomType, roomStatus: 'WAIT', roomSeq: widget.roomSeq.toString(), teamName: _teamName, myNickname: _myNickname), + ), + ], + ), bottomNavigationBar: AdBannerWidget(), body: _isLoading ? const Center(child: CircularProgressIndicator()) @@ -730,7 +748,6 @@ class _WaitingRoomPrivatePageState extends State { } else if (isAdmin) { roleIcon = '☆ '; // 사회자 } - print('profileImg: ${Config.baseUrl}/images$profileImg'); final displayName = '$roleIcon$userName'; diff --git a/lib/views/room/waiting_room_team_page.dart b/lib/views/room/waiting_room_team_page.dart index bfc34da..60fe20f 100644 --- a/lib/views/room/waiting_room_team_page.dart +++ b/lib/views/room/waiting_room_team_page.dart @@ -15,6 +15,7 @@ import '../../dialogs/yes_no_dialog.dart'; import '../../dialogs/room_setting_dialog.dart'; import '../../dialogs/user_info_team_dialog.dart'; import '../../dialogs/team_name_edit_dialog.dart'; +import '../../dialogs/chat_dialog.dart'; import 'playing_team_page.dart'; // Ads @@ -85,6 +86,10 @@ class _WaitingRoomTeamPageState extends State { double scaleFactor = 1.0; double buttonScaleFactor = 1.0; + // 채팅을 위한 데이터 + String _teamName = ''; + String _myNickname = ''; + @override void initState() { super.initState(); @@ -161,6 +166,10 @@ class _WaitingRoomTeamPageState extends State { final tempUserSeq = val['user_seq'].toString() ?? '0'; if (tempUserSeq != '0') { if (val is Map) { + if (val['user_seq'].toString() == mySeq) { + _teamName = val['team_name'] ?? ''; + _myNickname = val['nickname'] ?? ''; + } tempList.add({ 'user_seq': val['user_seq'].toString() ?? '0', 'participant_type': val['participant_type'] ?? '', @@ -697,6 +706,15 @@ class _WaitingRoomTeamPageState extends State { onPressed: () => _onLeaveRoom(), ), ), + floatingActionButton: Stack( + children: [ + Positioned( + bottom: 30, // AdBannerWidget 위에 위치하도록 조정 + right: 0, + child: ChatButton(roomType: widget.roomType, roomStatus: 'WAIT', roomSeq: widget.roomSeq.toString(), teamName: _teamName, myNickname: _myNickname), + ), + ], + ), bottomNavigationBar: AdBannerWidget(), body: _isLoading ? const Center(child: CircularProgressIndicator())