From b441c91df40c03c68e0b31212176494d20ed2a5c Mon Sep 17 00:00:00 2001 From: aengzu Date: Thu, 5 Sep 2024 12:28:44 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20=EB=A6=AC=EC=95=A1=EC=85=98=20=EB=B0=98?= =?UTF-8?q?=EC=9D=91=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ios/Podfile.lock | 13 + lib/data/mapper/message_response_mapper.dart | 1 + lib/domain/entities/chat/message.dart | 26 +- .../usecase/generate_response_usecase.dart | 12 +- .../usecase/send_user_message_usecase.dart | 2 +- .../chatting/controller/chat_viewmodel.dart | 34 ++- .../screens/chatting/view/chat_screen.dart | 247 +++++++++--------- .../chatting/view/components/chat_bubble.dart | 198 +++++++------- .../view/components/chat_profile_section.dart | 30 +-- .../chatting/view/components/messages.dart | 70 ++++- pubspec.lock | 16 ++ pubspec.yaml | 1 + 12 files changed, 375 insertions(+), 275 deletions(-) diff --git a/ios/Podfile.lock b/ios/Podfile.lock index d88d452..7443ed0 100644 --- a/ios/Podfile.lock +++ b/ios/Podfile.lock @@ -2,6 +2,9 @@ PODS: - device_info_plus (0.0.1): - Flutter - Flutter (1.0.0) + - fluttertoast (0.0.2): + - Flutter + - Toast - path_provider_foundation (0.0.1): - Flutter - FlutterMacOS @@ -11,6 +14,7 @@ PODS: - sqflite (0.0.3): - Flutter - FlutterMacOS + - Toast (4.1.1) - url_launcher_ios (0.0.1): - Flutter - vibration (1.7.5): @@ -19,17 +23,24 @@ PODS: DEPENDENCIES: - device_info_plus (from `.symlinks/plugins/device_info_plus/ios`) - Flutter (from `Flutter`) + - fluttertoast (from `.symlinks/plugins/fluttertoast/ios`) - path_provider_foundation (from `.symlinks/plugins/path_provider_foundation/darwin`) - shared_preferences_foundation (from `.symlinks/plugins/shared_preferences_foundation/darwin`) - sqflite (from `.symlinks/plugins/sqflite/darwin`) - url_launcher_ios (from `.symlinks/plugins/url_launcher_ios/ios`) - vibration (from `.symlinks/plugins/vibration/ios`) +SPEC REPOS: + trunk: + - Toast + EXTERNAL SOURCES: device_info_plus: :path: ".symlinks/plugins/device_info_plus/ios" Flutter: :path: Flutter + fluttertoast: + :path: ".symlinks/plugins/fluttertoast/ios" path_provider_foundation: :path: ".symlinks/plugins/path_provider_foundation/darwin" shared_preferences_foundation: @@ -44,9 +55,11 @@ EXTERNAL SOURCES: SPEC CHECKSUMS: device_info_plus: 97af1d7e84681a90d0693e63169a5d50e0839a0d Flutter: e0871f40cf51350855a761d2e70bf5af5b9b5de7 + fluttertoast: e9a18c7be5413da53898f660530c56f35edfba9c path_provider_foundation: 2b6b4c569c0fb62ec74538f866245ac84301af46 shared_preferences_foundation: fcdcbc04712aee1108ac7fda236f363274528f78 sqflite: 673a0e54cc04b7d6dba8d24fb8095b31c3a99eec + Toast: 1f5ea13423a1e6674c4abdac5be53587ae481c4e url_launcher_ios: 5334b05cef931de560670eeae103fd3e431ac3fe vibration: 7d883d141656a1c1a6d8d238616b2042a51a1241 diff --git a/lib/data/mapper/message_response_mapper.dart b/lib/data/mapper/message_response_mapper.dart index 3c1d057..4615648 100644 --- a/lib/data/mapper/message_response_mapper.dart +++ b/lib/data/mapper/message_response_mapper.dart @@ -5,6 +5,7 @@ import 'package:palink_v2/domain/entities/chat/message.dart'; extension MessageResponseMapper on MessageResponse { Message toDomain() { return Message( + id: messageId.toString(), sender: sender, messageText: messageText, timestamp: timestamp, diff --git a/lib/domain/entities/chat/message.dart b/lib/domain/entities/chat/message.dart index 6581370..e7b3081 100644 --- a/lib/domain/entities/chat/message.dart +++ b/lib/domain/entities/chat/message.dart @@ -1,17 +1,41 @@ class Message { + String id; bool sender; String messageText; String timestamp; int affinityScore; int rejectionScore; + List reactions; Message({ + required this.id, required this.sender, required this.messageText, required this.timestamp, required this.affinityScore, required this.rejectionScore, - }); + List? reactions,}) : reactions = reactions ?? []; + + // copyWith 메서드 추가 + Message copyWith({ + String? id, + bool? sender, + String? messageText, + String? timestamp, + int? affinityScore, + int? rejectionScore, + List? reactions, + }) { + return Message( + id: id ?? this.id, + sender: sender ?? this.sender, + messageText: messageText ?? this.messageText, + timestamp: timestamp ?? this.timestamp, + affinityScore: affinityScore ?? this.affinityScore, + rejectionScore: rejectionScore ?? this.rejectionScore, + reactions: reactions ?? this.reactions, + ); + } } diff --git a/lib/domain/usecase/generate_response_usecase.dart b/lib/domain/usecase/generate_response_usecase.dart index 7ee3bfe..8af6c8b 100644 --- a/lib/domain/usecase/generate_response_usecase.dart +++ b/lib/domain/usecase/generate_response_usecase.dart @@ -2,6 +2,7 @@ import 'package:get/get.dart'; import 'package:palink_v2/data/mapper/ai_response_mapper.dart'; import 'package:palink_v2/data/models/ai_response/ai_response.dart'; import 'package:palink_v2/data/models/chat/message_request.dart'; +import 'package:palink_v2/data/models/chat/message_response.dart'; import 'package:palink_v2/di/locator.dart'; import 'package:palink_v2/domain/entities/character/character.dart'; import 'package:palink_v2/domain/entities/chat/message.dart'; @@ -22,16 +23,14 @@ class GenerateResponseUsecase { GenerateResponseUsecase(this.getUserInfoUseCase, this.fetchChatHistoryUsecase, this.generateTipUsecase); - Future execute(int conversationId, Character character) async { + Future> execute(int conversationId, Character character) async { // STEP1) 사용자 정보 가져오기 User? user = await getUserInfoUseCase.execute(); - // STEP2) 이전 대화 기록 페치 final chatHistoryResponse = await fetchChatHistoryUsecase.execute(conversationId); String chatHistory = _formatChatHistory(chatHistoryResponse!); - // STEP3) AI와의 대화 시작 final inputs = { 'input': '유저의 마지막 말에 대해 대답하세요. 맥락을 기억합니다.', @@ -42,12 +41,11 @@ class GenerateResponseUsecase { }; AIResponse? aiResponse = await aiRepository.processChat(inputs); - + MessageResponse? messageResponse; // STEP 4) AI 응답을 메시지로 변환하여 저장 if (aiResponse != null) { final messageRequest = aiResponse.toMessageRequest(); - await chatRepository.saveMessage(conversationId, messageRequest); - + messageResponse = await chatRepository.saveMessage(conversationId, messageRequest); await aiRepository.saveMemoryContext(inputs, {'response': aiResponse}); final tip = await generateTipUsecase.execute(aiResponse.text); @@ -57,7 +55,7 @@ class GenerateResponseUsecase { : tipViewModel.updateTip('팁 생성 전입니다!'); } - return aiResponse; + return {messageResponse?.messageId.toString(): aiResponse}; // Map 반환 } // chatHistoryResponse를 JSON 또는 텍스트로 변환하는 함수 String _formatChatHistory(List chatHistoryResponse) { diff --git a/lib/domain/usecase/send_user_message_usecase.dart b/lib/domain/usecase/send_user_message_usecase.dart index 65b10e1..ad78b00 100644 --- a/lib/domain/usecase/send_user_message_usecase.dart +++ b/lib/domain/usecase/send_user_message_usecase.dart @@ -25,7 +25,7 @@ class SendUserMessageUsecase { return _mapResponseToDomain(messageResponse); } - Future generateAIResponse( + Future> generateAIResponse( int chatRoomId, Character character) async { return await generateResponseUsecase.execute(chatRoomId, character); } diff --git a/lib/presentation/screens/chatting/controller/chat_viewmodel.dart b/lib/presentation/screens/chatting/controller/chat_viewmodel.dart index e720bfe..5ca2df4 100644 --- a/lib/presentation/screens/chatting/controller/chat_viewmodel.dart +++ b/lib/presentation/screens/chatting/controller/chat_viewmodel.dart @@ -70,21 +70,19 @@ class ChatViewModel extends GetxController { messages.insert(0, userMessage); // 사용자 메시지를 리스트에 추가 } - var aiResponseMessage = await sendMessageUsecase.generateAIResponse(chatRoomId, character); - if (aiResponseMessage != null) { - var aiMessage = convertAIResponseToMessage(aiResponseMessage); + var responseMap = await sendMessageUsecase.generateAIResponse(chatRoomId, character); + if (responseMap.isNotEmpty) { + Message? aiMessage = convertAIResponseToMessage(responseMap.values.first!, responseMap.keys.first!.toString()); if (aiMessage != null) { messages.insert(0, aiMessage); // AI 응답 메시지를 리스트에 추가 } + + _handleQuestAchievements(responseMap.values.first!); // 퀘스트 달성 확인 + _checkIfConversationEnded(responseMap.values.first!); // 대화 종료 여부 확인 + textController.clear(); // 메시지 입력창 초기화 } else { print('AI 응답이 없습니다'); } - - _loadMessages(); // 메시지 로드 - - _handleQuestAchievements(aiResponseMessage!); // 퀘스트 달성 확인 - _checkIfConversationEnded(aiResponseMessage!); // 대화 종료 여부 확인 - textController.clear(); // 메시지 입력창 초기화 } catch (e) { print('메시지 전송 실패 : $e'); } finally { @@ -94,13 +92,14 @@ class ChatViewModel extends GetxController { // AIResponse를 Message로 변환하는 메서드 - Message? convertAIResponseToMessage(AIResponse aiResponse) { + Message? convertAIResponseToMessage(AIResponse aiResponse, String messageId) { return Message( sender: false, messageText: aiResponse.text, timestamp: DateTime.now().toIso8601String(), affinityScore: aiResponse.affinityScore, // 매핑 - rejectionScore: aiResponse.rejectionScore // 매핑 + rejectionScore: aiResponse.rejectionScore, + id: messageId, // 매핑 ); } @@ -144,5 +143,18 @@ class ChatViewModel extends GetxController { return character.quest; } + // 유저가 리액션을 하면 메시지에 reaction 추가하기 + void addReactionToMessage(Message message, String reaction) { + final updatedReactions = List.from(message.reactions); + updatedReactions.add(reaction); + + final index = messages.indexOf(message); + if (index != -1) { + final updatedMessages = List.from(messages); // 새로운 리스트 복사 + updatedMessages[index] = message.copyWith(reactions: updatedReactions); // 업데이트된 메시지 적용 + messages.value = updatedMessages; // 새로운 리스트로 할당하여 UI 갱신 + } + } + } diff --git a/lib/presentation/screens/chatting/view/chat_screen.dart b/lib/presentation/screens/chatting/view/chat_screen.dart index c0ab2f4..aafa4db 100644 --- a/lib/presentation/screens/chatting/view/chat_screen.dart +++ b/lib/presentation/screens/chatting/view/chat_screen.dart @@ -6,11 +6,9 @@ import 'package:palink_v2/di/locator.dart'; import 'package:palink_v2/presentation/screens/chatting/controller/chat_viewmodel.dart'; import 'package:palink_v2/presentation/screens/chatting/controller/tip_viewmodel.dart'; import 'package:palink_v2/presentation/screens/chatting/view/components/chat_profile_section.dart'; -import 'package:palink_v2/presentation/screens/common/custom_btn.dart'; import 'package:palink_v2/presentation/screens/common/custom_button_md.dart'; import 'package:sizing/sizing.dart'; import 'components/messages.dart'; -import 'components/profile_image.dart'; import 'components/tip_button.dart'; class ChatScreen extends StatelessWidget { @@ -19,145 +17,146 @@ class ChatScreen extends StatelessWidget { final String initialTip; // 첫번째 AI 메시지에 대한 팁 ChatScreen({ - super.key, required this.viewModel, required this.initialTip, + super.key, + required this.viewModel, + required this.initialTip, }); @override Widget build(BuildContext context) { // 초기 팁 업데이트 tipViewModel.updateTip(initialTip); - // 퀘스트 정보 팝업을 처음 빌드할 때 표시 - WidgetsBinding.instance.addPostFrameCallback((_) { - _showQuestPopup(context); - }); return GestureDetector( onTap: () { FocusScope.of(context).unfocus(); }, child: Scaffold( - appBar: AppBar( - toolbarHeight: 0.1.sh, - backgroundColor: Colors.white, - title: ProfileSection( - imagePath: viewModel.character.image, - characterName: viewModel.character.name, - questStatus: viewModel.questStatus, // 퀘스트 달성 여부 전달 - onProfileTapped: () => _showQuestPopup(context), // 다이얼로그 트리거 콜백 전달 - ), - centerTitle: true, - elevation: 0, + appBar: AppBar( + toolbarHeight: 0.1.sh, + backgroundColor: Colors.white, + title: ProfileSection( + imagePath: viewModel.character.image, + characterName: viewModel.character.name, + questStatus: viewModel.questStatus, + onProfileTapped: () => _showQuestPopup(context), ), - extendBodyBehindAppBar: false, - body: Container( - color: Colors.white, - child: Stack( - children: [ - Column( - children: [ - Expanded( - child: Obx(() { - return viewModel.messages.isEmpty - ? const Center( - child: Text( - '메시지가 없습니다.', - style: TextStyle(color: Colors.black), - ), - ) - : Messages( - messages: viewModel.messages, - userId: viewModel.chatRoomId, - characterImg: viewModel.character.image, - likingLevels: viewModel.likingLevels, - ); - }), - ), - _sendMessageField(viewModel), - ], - ), - Positioned( - bottom: 100, - right: 20, - child: Obx(() { - return TipButton( - tipContent: tipViewModel.tipContent.value, - isExpanded: tipViewModel.isExpanded.value, - isLoading: tipViewModel.isLoading.value, - onToggle: tipViewModel.toggle, - backgroundColor: tipViewModel.tipContent.value.isEmpty - ? Colors.white70 - : AppColors.deepBlue, // 원래의 배경색으로 대체하세요 - ); - }), - ), - ], - ), - ) + centerTitle: true, + elevation: 0, + ), + extendBodyBehindAppBar: false, + body: Container( + color: Colors.white, + child: Stack( + children: [ + Column( + children: [ + Expanded( + child: Obx(() { + return viewModel.messages.isEmpty + ? const Center( + child: Text( + '메시지가 없습니다.', + style: TextStyle(color: Colors.black), + ), + ) + : Messages( + messages: viewModel.messages, + userId: viewModel.chatRoomId, + characterImg: viewModel.character.image, + likingLevels: viewModel.likingLevels, + onReactionAdded: (message, reaction) { + viewModel.addReactionToMessage(message, reaction); + // 여기서 어떻게 UI 업데이트 되도록 해야할지? + }, + ); + }), + ), + _sendMessageField(viewModel), + ], + ), + Positioned( + bottom: 100, + right: 20, + child: Obx(() { + return TipButton( + tipContent: tipViewModel.tipContent.value, + isExpanded: tipViewModel.isExpanded.value, + isLoading: tipViewModel.isLoading.value, + onToggle: tipViewModel.toggle, + backgroundColor: tipViewModel.tipContent.value.isEmpty + ? Colors.white70 + : AppColors.deepBlue, + ); + }), + ), + ], + ), + ), ), ); } - Widget _sendMessageField(ChatViewModel viewModel) => - SafeArea( - child: Container( - height: 0.07.sh, - decoration: const BoxDecoration( - boxShadow: [ - BoxShadow(color: Color.fromARGB(18, 0, 0, 0), blurRadius: 10) - ], - ), - padding: const EdgeInsets.only(left: 10, right: 10, bottom: 10), - child: Row( - children: [ - const SizedBox(width: 10), - Expanded( - child: TextField( - maxLines: null, - keyboardType: TextInputType.multiline, - textCapitalization: TextCapitalization.sentences, - controller: viewModel.textController, - decoration: InputDecoration( - suffixIcon: IconButton( - onPressed: () { - if (viewModel.textController.text.isNotEmpty) { - viewModel.sendMessage(); - viewModel.textController.clear(); - } - }, - icon: const Icon(Icons.send), - color: Colors.blue, - iconSize: 25, - ), - hintText: "여기에 메시지를 입력하세요", - hintMaxLines: 1, - contentPadding: EdgeInsets.symmetric( - horizontal: 0.05.sw, vertical: 0.01.sh), - hintStyle: const TextStyle( - fontSize: 16, - ), - fillColor: Colors.white, - filled: true, - enabledBorder: OutlineInputBorder( - borderRadius: BorderRadius.circular(30.0), - borderSide: const BorderSide( - color: Colors.white, - width: 0.2, - ), - ), - focusedBorder: OutlineInputBorder( - borderRadius: BorderRadius.circular(30.0), - borderSide: const BorderSide( - color: Colors.black26, - width: 0.2, - ), - ), + Widget _sendMessageField(ChatViewModel viewModel) => SafeArea( + child: Container( + height: 0.07.sh, + decoration: const BoxDecoration( + boxShadow: [ + BoxShadow(color: Color.fromARGB(18, 0, 0, 0), blurRadius: 10) + ], + ), + padding: const EdgeInsets.only(left: 10, right: 10, bottom: 10), + child: Row( + children: [ + const SizedBox(width: 10), + Expanded( + child: TextField( + maxLines: null, + keyboardType: TextInputType.multiline, + textCapitalization: TextCapitalization.sentences, + controller: viewModel.textController, + decoration: InputDecoration( + suffixIcon: IconButton( + onPressed: () { + if (viewModel.textController.text.isNotEmpty) { + viewModel.sendMessage(); + viewModel.textController.clear(); + } + }, + icon: const Icon(Icons.send), + color: Colors.blue, + iconSize: 25, + ), + hintText: "여기에 메시지를 입력하세요", + hintMaxLines: 1, + contentPadding: EdgeInsets.symmetric( + horizontal: 0.05.sw, vertical: 0.01.sh), + hintStyle: const TextStyle( + fontSize: 16, + ), + fillColor: Colors.white, + filled: true, + enabledBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(30.0), + borderSide: const BorderSide( + color: Colors.white, + width: 0.2, + ), + ), + focusedBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(30.0), + borderSide: const BorderSide( + color: Colors.black26, + width: 0.2, ), ), ), - ], + ), ), - ), - ); + ], + ), + ), + ); bool _isDialogOpen = false; @@ -165,7 +164,7 @@ class ChatScreen extends StatelessWidget { if (!_isDialogOpen) { _isDialogOpen = true; final questInfo = await viewModel.getQuestInformation(); - Get.dialog( + await Get.dialog( Dialog( backgroundColor: Colors.white, shape: RoundedRectangleBorder( @@ -193,8 +192,7 @@ class ChatScreen extends StatelessWidget { const SizedBox(height: 30), CustomButtonMD( onPressed: () { - Get.back(); - _isDialogOpen = false; // 다이얼로그 닫힘 상태 업데이트 + Get.back(); // 다이얼로그 닫기 }, label: '확인했습니다!', ), @@ -207,5 +205,4 @@ class ChatScreen extends StatelessWidget { }); } } - -} \ No newline at end of file +} diff --git a/lib/presentation/screens/chatting/view/components/chat_bubble.dart b/lib/presentation/screens/chatting/view/components/chat_bubble.dart index d24f935..fb48aa8 100644 --- a/lib/presentation/screens/chatting/view/components/chat_bubble.dart +++ b/lib/presentation/screens/chatting/view/components/chat_bubble.dart @@ -1,5 +1,7 @@ import 'package:flutter/material.dart'; +import 'package:flutter_chat_reactions/widgets/stacked_reactions.dart'; import 'package:palink_v2/core/theme/app_colors.dart'; +import 'package:palink_v2/core/theme/app_fonts.dart'; import 'package:palink_v2/domain/entities/chat/message.dart'; import 'package:sizing/sizing.dart'; import 'liking_bar.dart'; @@ -9,98 +11,30 @@ class ChatBubbles extends StatefulWidget { final bool isSender; final String characterImg; final int affinityScore; + final Function(Message, String) onReactionAdded; - ChatBubbles(this.message, this.isSender, this.characterImg, this.affinityScore); + ChatBubbles({ + required this.message, + required this.isSender, + required this.characterImg, + required this.affinityScore, + required this.onReactionAdded, + }); @override _ChatBubblesState createState() => _ChatBubblesState(); } class _ChatBubblesState extends State { - OverlayEntry? _overlayEntry; - - void _showReactionMenu(BuildContext context, Offset offset) { - _overlayEntry = OverlayEntry( - builder: (context) => GestureDetector( - onTap: () { - _removeOverlay(); - }, - child: Stack( - children: [ - Positioned.fill( - child: Container( - color: Colors.transparent, // transparent background to detect taps outside the menu - ), - ), - Positioned( - left: offset.dx, - top: offset.dy - 50, // adjust the position as needed - child: Material( - color: Colors.transparent, - child: Container( - padding: const EdgeInsets.all(8), - decoration: BoxDecoration( - color: Colors.white, - borderRadius: BorderRadius.circular(20), - boxShadow: const [ - BoxShadow( - color: Colors.black26, - blurRadius: 4, - spreadRadius: 2, - ), - ], - ), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - IconButton( - icon: Icon(Icons.thumb_up), - onPressed: () { - // 리액션 추가 로직 - _removeOverlay(); - }, - ), - IconButton( - icon: Icon(Icons.favorite), - onPressed: () { - // 리액션 추가 로직 - _removeOverlay(); - }, - ), - IconButton( - icon: Icon(Icons.sentiment_satisfied), - onPressed: () { - // 리액션 추가 로직 - _removeOverlay(); - }, - ), - // 다른 리액션들을 추가할 수 있습니다. - ], - ), - ), - ), - ), - ], - ), - ), - ); - - Overlay.of(context)!.insert(_overlayEntry!); - } - - void _removeOverlay() { - _overlayEntry?.remove(); - _overlayEntry = null; - } - @override Widget build(BuildContext context) { return Column( - crossAxisAlignment: widget.isSender ? CrossAxisAlignment.end : CrossAxisAlignment.start, + crossAxisAlignment: + widget.isSender ? CrossAxisAlignment.end : CrossAxisAlignment.start, children: [ if (!widget.isSender) Row( - crossAxisAlignment: CrossAxisAlignment.start, + crossAxisAlignment: CrossAxisAlignment.end, children: [ Column( children: [ @@ -116,48 +50,98 @@ class _ChatBubblesState extends State { ], ), Flexible( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, + child: Stack( children: [ - GestureDetector( - onLongPressStart: (details) { - _showReactionMenu(context, details.globalPosition); - }, - child: Container( - margin: EdgeInsets.only(top: 10, bottom: 5, right: 0.18.sw, left: 0.05.sw), - padding: EdgeInsets.symmetric(horizontal: 0.04.sw, vertical: 0.011.sh), + Container( + margin: EdgeInsets.only( + top: 10, bottom: 5, right: 0.25.sw, left: 0.05.sw), + padding: EdgeInsets.symmetric( + horizontal: 0.04.sw, vertical: 0.011.sh), decoration: BoxDecoration( color: AppColors.lightGray, borderRadius: BorderRadius.circular(20), ), - child: Text( - widget.message.messageText, - style: const TextStyle(color: Colors.black), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + widget.message.messageText, + overflow: TextOverflow.ellipsis, + style: textTheme().bodySmall, + ), + const SizedBox(height: 5), + Row( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + Text( + widget.message.timestamp, + style: textTheme().bodySmall?.copyWith( + color: Colors.grey, + fontSize: 11, + ), + ), + ], + ) + ])), + if (widget.message.reactions.isNotEmpty) + Positioned( + bottom: 4, + left: 20, + child: StackedReactions( + reactions: widget.message.reactions, + stackedValue: 4.0, ), ), - ), ], ), ), ], ), if (widget.isSender) - GestureDetector( - onLongPressStart: (details) { - _showReactionMenu(context, details.globalPosition); - }, - child: Container( - margin: EdgeInsets.only(top: 10, bottom: 5, right: 0.05.sw, left: 0.3.sw), - padding: EdgeInsets.symmetric(horizontal: 0.05.sw, vertical: 0.01.sh), - decoration: BoxDecoration( - color: AppColors.lightBlue, - borderRadius: BorderRadius.circular(20), - ), - child: Text( - widget.message.messageText, - style: const TextStyle(color: Colors.black), + Stack( + children: [ + Container( + margin: EdgeInsets.only( + top: 10, bottom: 5, right: 0.05.sw, left: 0.3.sw), + padding: EdgeInsets.symmetric( + horizontal: 0.05.sw, vertical: 0.01.sh), + decoration: BoxDecoration( + color: AppColors.lightBlue, + borderRadius: BorderRadius.circular(20), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + widget.message.messageText, + style: const TextStyle(color: Colors.black), + ), + const SizedBox(height: 5), + Row( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + Text( + widget.message.timestamp, + style: textTheme().bodySmall?.copyWith( + color: Colors.grey, + fontSize: 11, + ), + ), + ], + ), + ], + ), ), - ), + if (widget.message.reactions.isNotEmpty) + Positioned( + bottom: 4, + right: 20, + child: StackedReactions( + reactions: widget.message.reactions, + stackedValue: 4.0, + ), + ), + ], ), ], ); diff --git a/lib/presentation/screens/chatting/view/components/chat_profile_section.dart b/lib/presentation/screens/chatting/view/components/chat_profile_section.dart index 83323a8..6eec0bc 100644 --- a/lib/presentation/screens/chatting/view/components/chat_profile_section.dart +++ b/lib/presentation/screens/chatting/view/components/chat_profile_section.dart @@ -20,9 +20,7 @@ class ProfileSection extends StatelessWidget { @override Widget build(BuildContext context) { - return InkWell( - onTap: () => onProfileTapped(), // 프로필을 클릭하면 다이얼로그 트리거 - child: Row( + return Row( children: [ ProfileImage( path: imagePath, @@ -42,20 +40,22 @@ class ProfileSection extends StatelessWidget { ), ), Spacer(), - Obx(() => Row( - children: List.generate(5, (index) { - return Padding( - padding: const EdgeInsets.symmetric(horizontal: 4.0), - child: Icon( - questStatus[index] ? Icons.check_circle : Icons.circle, - color: questStatus[index] ? Colors.blue : Colors.grey, - size: 16, - ), - ); - }), + Obx(() => InkWell( + onTap: () => onProfileTapped(), + child: Row( + children: List.generate(5, (index) { + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 4.0), + child: Icon( + questStatus[index] ? Icons.check_circle : Icons.circle, + color: questStatus[index] ? Colors.blue : Colors.grey, + size: 16, + ), + ); + }), + ), )), ], - ), ); } } diff --git a/lib/presentation/screens/chatting/view/components/messages.dart b/lib/presentation/screens/chatting/view/components/messages.dart index 70ad2a0..d2eef5d 100644 --- a/lib/presentation/screens/chatting/view/components/messages.dart +++ b/lib/presentation/screens/chatting/view/components/messages.dart @@ -1,20 +1,23 @@ import 'package:flutter/material.dart'; +import 'package:flutter_chat_reactions/flutter_chat_reactions.dart'; +import 'package:flutter_chat_reactions/utilities/hero_dialog_route.dart'; import 'package:palink_v2/domain/entities/chat/message.dart'; import 'package:palink_v2/domain/entities/likability/liking_level.dart'; import 'chat_bubble.dart'; - class Messages extends StatelessWidget { final List likingLevels; final List messages; final int userId; - final String characterImg; // 캐릭터 이미지 추가 + final String characterImg; + final Function(Message, String) onReactionAdded; Messages({ required this.likingLevels, required this.messages, required this.userId, - required this.characterImg, // 캐릭터 이미지 추가 + required this.characterImg, + required this.onReactionAdded, }); @override @@ -29,14 +32,65 @@ class Messages extends StatelessWidget { final like = messages[index].affinityScore; final isSender = message.sender; - return ChatBubbles( - message, - isSender, - characterImg, // 캐릭터 이미지 전달 - like, // 호감도 점수 전달 + return GestureDetector( + onLongPress: () { + Navigator.of(context).push( + HeroDialogRoute(builder: (context) { + return Theme( + data: Theme.of(context).copyWith( + dialogBackgroundColor: Colors.white, + ), + child: ReactionsDialogWidget( + id: message.id.toString(), + messageWidget: ChatBubbles( + message: message, + isSender: isSender, + characterImg: characterImg, + affinityScore: like, + onReactionAdded: (Message msg, String reaction) { + onReactionAdded(message, reaction); + }, + ), + onReactionTap: (reaction) { + if (reaction == '➕') { + // 이모지 선택기 표시 + } else { + addReactionToMessage( + message: message, + reaction: reaction, + ); + } + }, + onContextMenuTap: (menuItem) { + print('menu item: $menuItem'); + } + ), + ); + }), + ); + }, + child: Hero( + tag: message.id, + child: ChatBubbles( + message: message, + isSender: isSender, + characterImg: characterImg, + affinityScore: like, + onReactionAdded: (Message msg, String reaction) { + onReactionAdded(msg, reaction); + }, + ), + ), ); }, ), ); } + + void addReactionToMessage({ + required Message message, + required String reaction, + }) { + message.reactions.add(reaction); + } } diff --git a/pubspec.lock b/pubspec.lock index 75b1074..72a67bb 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -22,6 +22,14 @@ packages: url: "https://pub.dev" source: hosted version: "6.5.0" + animate_do: + dependency: transitive + description: + name: animate_do + sha256: "7a3162729f0ea042f9dd84da217c5bde5472ad9cef644079929d4304a5dc4ca0" + url: "https://pub.dev" + source: hosted + version: "3.3.4" animated_text_kit: dependency: "direct main" description: @@ -395,6 +403,14 @@ packages: url: "https://pub.dev" source: hosted version: "4.5.0" + flutter_chat_reactions: + dependency: "direct main" + description: + name: flutter_chat_reactions + sha256: "20dfb534d9939112098d7aa540476352afba67eefb7d06aac131fb2670360f07" + url: "https://pub.dev" + source: hosted + version: "0.1.0" flutter_chat_types: dependency: "direct main" description: diff --git a/pubspec.yaml b/pubspec.yaml index 4a3c7b4..73da6c4 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -65,6 +65,7 @@ dependencies: sqflite_common_ffi_web: ^0.4.4 fluttertoast: ^8.2.8 flutter_spinkit: ^5.2.1 + flutter_chat_reactions: ^0.1.0 dev_dependencies: flutter_test: