-
Notifications
You must be signed in to change notification settings - Fork 0
20260127 #458 사진 첨부 후 채팅 전송 기능 추가 #490
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
The head ref may contain hidden characters: "20260127_#458_\uC0AC\uC9C4_\uCCA8\uBD80_\uD6C4_\uCC44\uD305_\uC804\uC1A1_\uAE30\uB2A5_\uCD94\uAC00"
Changes from all commits
89aaece
a24a378
2f6af48
8a46d5d
0eec693
cfff363
a967886
7bf7439
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,21 +1,50 @@ | ||
| // lib/models/apis/objects/api_pageable.dart | ||
|
|
||
| import 'package:json_annotation/json_annotation.dart'; | ||
|
|
||
| part 'api_pageable.g.dart'; | ||
|
|
||
| /// Slice/Pageable 메타데이터용 공용 모델 | ||
| @JsonSerializable() | ||
| int _intFromJson(Object? v) => (v as num?)?.toInt() ?? 0; | ||
| bool _boolFromJson(Object? v) => (v as bool?) ?? false; | ||
|
|
||
| @JsonSerializable(explicitToJson: true) | ||
| class ApiPageable { | ||
| /// 한 페이지(슬라이스)의 요소 개수 | ||
| final int size; | ||
| @JsonKey(fromJson: _intFromJson) | ||
| final int pageNumber; | ||
|
|
||
| /// 0-based 페이지(슬라이스) 인덱스 | ||
| final int number; | ||
| @JsonKey(fromJson: _intFromJson) | ||
| final int pageSize; | ||
|
|
||
| const ApiPageable({required this.size, required this.number}); | ||
| @JsonKey(fromJson: _intFromJson) | ||
| final int offset; | ||
|
|
||
| factory ApiPageable.fromJson(Map<String, dynamic> json) => _$ApiPageableFromJson(json); | ||
| @JsonKey(fromJson: _boolFromJson) | ||
| final bool paged; | ||
|
|
||
| @JsonKey(fromJson: _boolFromJson) | ||
| final bool unpaged; | ||
|
|
||
| final ApiSort? sort; // sort 모델이 있으면 유지 | ||
|
|
||
| const ApiPageable({ | ||
| this.pageNumber = 0, | ||
| this.pageSize = 0, | ||
| this.offset = 0, | ||
| this.paged = false, | ||
| this.unpaged = false, | ||
| this.sort, | ||
| }); | ||
|
|
||
| factory ApiPageable.fromJson(Map<String, dynamic> json) => _$ApiPageableFromJson(json); | ||
| Map<String, dynamic> toJson() => _$ApiPageableToJson(this); | ||
| } | ||
|
|
||
| @JsonSerializable() | ||
| class ApiSort { | ||
| final bool? empty; | ||
| final bool? sorted; | ||
| final bool? unsorted; | ||
|
|
||
| const ApiSort({this.empty, this.sorted, this.unsorted}); | ||
|
|
||
| factory ApiSort.fromJson(Map<String, dynamic> json) => _$ApiSortFromJson(json); | ||
| Map<String, dynamic> toJson() => _$ApiSortToJson(this); | ||
| } |
Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -2,6 +2,7 @@ import 'dart:async'; | |
| import 'dart:io'; | ||
| import 'package:flutter/material.dart'; | ||
| import 'package:flutter_screenutil/flutter_screenutil.dart'; | ||
| import 'package:image_picker/image_picker.dart'; | ||
| import 'package:romrom_fe/enums/context_menu_enums.dart'; | ||
| import 'package:romrom_fe/enums/message_type.dart'; | ||
| import 'package:romrom_fe/enums/snack_bar_type.dart'; | ||
|
|
@@ -12,11 +13,12 @@ import 'package:romrom_fe/models/app_colors.dart'; | |
| import 'package:romrom_fe/models/app_theme.dart'; | ||
| import 'package:romrom_fe/screens/item_detail_description_screen.dart'; | ||
| import 'package:romrom_fe/services/apis/chat_api.dart'; | ||
| import 'package:romrom_fe/services/apis/member_api.dart'; | ||
| import 'package:romrom_fe/services/apis/image_api.dart'; | ||
| import 'package:romrom_fe/services/chat_websocket_service.dart'; | ||
| import 'package:romrom_fe/services/member_manager_service.dart'; | ||
| import 'package:romrom_fe/utils/common_utils.dart'; | ||
| import 'package:romrom_fe/utils/error_utils.dart'; | ||
| import 'package:romrom_fe/widgets/chat_image_bubble.dart'; | ||
| import 'package:romrom_fe/widgets/common/common_modal.dart'; | ||
| import 'package:romrom_fe/widgets/common/common_snack_bar.dart'; | ||
| import 'package:romrom_fe/widgets/common/cached_image.dart'; | ||
|
|
@@ -55,6 +57,9 @@ class _ChatRoomScreenState extends State<ChatRoomScreen> { | |
| String _errorMessage = ''; | ||
| String? _myMemberId; | ||
|
|
||
| // 이미지 관련 변수들 | ||
| final ImagePicker _picker = ImagePicker(); | ||
|
|
||
| @override | ||
| void initState() { | ||
| super.initState(); | ||
|
|
@@ -167,13 +172,22 @@ class _ChatRoomScreenState extends State<ChatRoomScreen> { | |
| final localMsg = _pendingLocalMessages.remove(matchedLocalId)!; | ||
| final idx = _messages.indexWhere((m) => m.chatMessageId == localMsg.chatMessageId); | ||
|
|
||
| // 🔧 createdDate 보정 | ||
| // createdDate 보정 | ||
| final fixedServer = ChatMessage( | ||
| chatRoomId: newMessage.chatRoomId ?? localMsg.chatRoomId, | ||
| chatMessageId: newMessage.chatMessageId, | ||
| senderId: newMessage.senderId, | ||
| content: newMessage.content, | ||
| createdDate: newMessage.createdDate, | ||
| senderId: newMessage.senderId ?? localMsg.senderId, | ||
|
|
||
| // content도 서버가 빈 문자열로 주면 로컬 유지하는 게 안전 | ||
| content: (newMessage.content != null && newMessage.content!.trim().isNotEmpty) | ||
| ? newMessage.content | ||
| : localMsg.content, | ||
|
|
||
| createdDate: newMessage.createdDate ?? localMsg.createdDate, | ||
| type: newMessage.type ?? localMsg.type, | ||
| imageUrls: (newMessage.imageUrls != null && newMessage.imageUrls!.isNotEmpty) | ||
| ? newMessage.imageUrls | ||
| : localMsg.imageUrls, | ||
| ); | ||
|
|
||
| if (idx != -1) { | ||
|
|
@@ -229,6 +243,43 @@ class _ChatRoomScreenState extends State<ChatRoomScreen> { | |
| _messageController.clear(); | ||
| } | ||
|
|
||
| /// 이미지 메시지 전송 | ||
| /// imageUrls: 서버에서 반환된 이미지 URL 리스트 | ||
| /// imageMessage: 사진과 함께 전송할 텍스트 메시지 (선택사항) | ||
| Future<void> _sendImage({required List<String> imageUrls, String? imageMessage}) async { | ||
| if (imageUrls.isEmpty) return; | ||
| if (!mounted) return; | ||
|
|
||
| // 1) 로컬에 즉시 추가(낙관적 업데이트) 및 pending에 등록 | ||
| final content = imageMessage ?? '사진을 보냈습니다.'; | ||
| final localId = 'local_${DateTime.now().microsecondsSinceEpoch}'; | ||
| final localMsg = ChatMessage( | ||
| chatRoomId: widget.chatRoomId, | ||
| chatMessageId: localId, | ||
| senderId: _myMemberId, | ||
| createdDate: DateTime.now(), | ||
| content: content, // content 필드 필요 | ||
| type: MessageType.image, | ||
| imageUrls: imageUrls, | ||
| ); | ||
| setState(() { | ||
| _messages.insert(0, localMsg); | ||
| _pendingLocalMessages[localId] = localMsg; | ||
| }); | ||
| _scrollToBottom(); | ||
|
|
||
| // 2) WebSocket을 통해 서버로 전송 | ||
| _wsService.sendMessage( | ||
| chatRoomId: widget.chatRoomId, | ||
| type: MessageType.image, | ||
| content: imageMessage ?? '', | ||
| imageUrls: imageUrls, | ||
| ); | ||
|
|
||
| // 텍스트 입력필드 초기화 | ||
| _messageController.clear(); | ||
| } | ||
|
|
||
| void _scrollToBottom() { | ||
| if (_scrollController.hasClients) { | ||
| Future.delayed(const Duration(milliseconds: 100), () { | ||
|
|
@@ -239,6 +290,42 @@ class _ChatRoomScreenState extends State<ChatRoomScreen> { | |
| } | ||
| } | ||
|
|
||
| /// 이미지 선택 후 전송 | ||
| Future<void> _onPickImage() async { | ||
| try { | ||
| final XFile? picked = await _picker.pickImage(source: ImageSource.gallery); | ||
|
|
||
| if (picked == null) { | ||
| // 사용자가 선택을 취소함 | ||
| return; | ||
| } | ||
|
|
||
| try { | ||
| // 1) 선택된 이미지를 서버에 업로드 | ||
| final uploadedImageUrls = await ImageApi().uploadImages([picked]); | ||
| if (!mounted) return; | ||
|
|
||
| // imageUrls가 비어있는 경우 처리 필요 | ||
| if (uploadedImageUrls.isEmpty) { | ||
| CommonSnackBar.show(context: context, message: '이미지 업로드 실패', type: SnackBarType.error); | ||
| return; | ||
| } | ||
|
|
||
| // 2) 업로드된 URL로 메시지 전송 (imageMessage는 입력필드의 텍스트 사용) | ||
| final textMessage = _messageController.text.trim(); | ||
| await _sendImage(imageUrls: uploadedImageUrls, imageMessage: textMessage.isEmpty ? null : textMessage); | ||
| } catch (e) { | ||
| if (context.mounted) { | ||
| CommonSnackBar.show(context: context, message: '이미지 전송에 실패했습니다: $e', type: SnackBarType.error); | ||
| } | ||
| } | ||
| } catch (e) { | ||
| if (context.mounted) { | ||
| CommonSnackBar.show(context: context, message: '이미지 선택에 실패했습니다: $e', type: SnackBarType.error); | ||
| } | ||
| } | ||
| } | ||
|
|
||
| @override | ||
| void dispose() { | ||
| _messageSubscription?.cancel(); | ||
|
|
@@ -329,6 +416,9 @@ class _ChatRoomScreenState extends State<ChatRoomScreen> { | |
| context.navigateTo(screen: ProfileScreen(memberId: opponent!.memberId!)); | ||
| } | ||
| }, | ||
| onBackPressed: () { | ||
| _leaveRoom(shouldPop: true); | ||
| }, | ||
| showBottomBorder: true, | ||
| titleWidgets: Padding( | ||
| padding: EdgeInsets.only(top: 8.0.h), | ||
|
|
@@ -403,7 +493,6 @@ class _ChatRoomScreenState extends State<ChatRoomScreen> { | |
| return; | ||
| } | ||
| try { | ||
| await MemberApi().blockMember(opponentId); | ||
| if (context.mounted) { | ||
| Navigator.of(context).pop(true); // 모달 닫기 | ||
| } | ||
|
|
@@ -482,13 +571,10 @@ class _ChatRoomScreenState extends State<ChatRoomScreen> { | |
| : chatRoom.tradeRequestHistory?.giveItem; | ||
|
|
||
| return Container( | ||
| padding: EdgeInsets.symmetric(horizontal: 16.w, vertical: 8.h), | ||
| padding: EdgeInsets.only(top: 8.h, bottom: 16.h, left: 16.w, right: 16.w), | ||
| decoration: const BoxDecoration( | ||
| color: AppColors.primaryBlack, | ||
| border: Border( | ||
| top: BorderSide(color: AppColors.opacity10White, width: 1), | ||
| // bottom: BorderSide(color: AppColors.opacity10White, width: 1), | ||
| ), | ||
| border: Border(bottom: BorderSide(color: AppColors.opacity10White, width: 1)), | ||
| ), | ||
| child: Row( | ||
| children: [ | ||
|
|
@@ -610,22 +696,24 @@ class _ChatRoomScreenState extends State<ChatRoomScreen> { | |
| crossAxisAlignment: CrossAxisAlignment.end, | ||
| children: [ | ||
| if (!isMine) ...[ | ||
| Container( | ||
| padding: EdgeInsets.symmetric(horizontal: 12.w, vertical: 8.h), | ||
| constraints: BoxConstraints(maxWidth: 264.w), | ||
| decoration: BoxDecoration( | ||
| color: AppColors.secondaryBlack1, | ||
| borderRadius: BorderRadius.circular(10.r), | ||
| ), | ||
| child: Text( | ||
| message.content ?? '', | ||
| style: CustomTextStyles.p2.copyWith( | ||
| color: AppColors.textColorWhite, | ||
| fontWeight: FontWeight.w400, | ||
| height: 1.2, | ||
| ), | ||
| ), | ||
| ), | ||
| message.type == MessageType.image | ||
| ? chatImageBubble(context, message) | ||
| : Container( | ||
| padding: EdgeInsets.symmetric(horizontal: 12.w, vertical: 8.h), | ||
| constraints: BoxConstraints(maxWidth: 264.w), | ||
| decoration: BoxDecoration( | ||
| color: AppColors.secondaryBlack1, | ||
| borderRadius: BorderRadius.circular(10.r), | ||
| ), | ||
| child: Text( | ||
| message.content ?? '', | ||
| style: CustomTextStyles.p2.copyWith( | ||
| color: AppColors.textColorWhite, | ||
| fontWeight: FontWeight.w400, | ||
| height: 1.2, | ||
| ), | ||
| ), | ||
| ), | ||
| if (showTime) ...[ | ||
| SizedBox(width: 8.w), | ||
| Text( | ||
|
|
@@ -649,19 +737,24 @@ class _ChatRoomScreenState extends State<ChatRoomScreen> { | |
| ), | ||
| SizedBox(width: 8.w), | ||
| ], | ||
| Container( | ||
| padding: EdgeInsets.symmetric(horizontal: 12.w, vertical: 8.h), | ||
| constraints: BoxConstraints(maxWidth: 240.w), | ||
| decoration: BoxDecoration(color: AppColors.primaryYellow, borderRadius: BorderRadius.circular(10.r)), | ||
| child: Text( | ||
| message.content ?? '', | ||
| style: CustomTextStyles.p2.copyWith( | ||
| color: AppColors.textColorBlack, | ||
| fontWeight: FontWeight.w400, | ||
| height: 1.2, | ||
| ), | ||
| ), | ||
| ), | ||
| message.type == MessageType.image | ||
| ? chatImageBubble(context, message) | ||
| : Container( | ||
| padding: EdgeInsets.symmetric(horizontal: 12.w, vertical: 8.h), | ||
| constraints: BoxConstraints(maxWidth: 264.w, maxHeight: 264.h), | ||
| decoration: BoxDecoration( | ||
| color: AppColors.primaryYellow, | ||
| borderRadius: BorderRadius.circular(10.r), | ||
| ), | ||
| child: Text( | ||
| message.content ?? '', | ||
| style: CustomTextStyles.p2.copyWith( | ||
| color: AppColors.textColorBlack, | ||
| fontWeight: FontWeight.w400, | ||
| height: 1.2, | ||
| ), | ||
| ), | ||
| ), | ||
|
Comment on lines
+740
to
+757
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 발신 텍스트 메시지에 수신 메시지(Line 703)에는 🔧 수정 제안 message.type == MessageType.image
? chatImageBubble(context, message)
: Container(
padding: EdgeInsets.symmetric(horizontal: 12.w, vertical: 8.h),
- constraints: BoxConstraints(maxWidth: 264.w, maxHeight: 264.h),
+ constraints: BoxConstraints(maxWidth: 264.w),
decoration: BoxDecoration(🤖 Prompt for AI Agents |
||
| ], | ||
| ], | ||
| ), | ||
|
|
@@ -700,7 +793,7 @@ class _ChatRoomScreenState extends State<ChatRoomScreen> { | |
| iconColor: AppColors.opacity60White, | ||
| title: '사진 선택하기', | ||
| onTap: () { | ||
| // TODO: 이미지 선택 및 전송 기능 | ||
| _onPickImage(); | ||
| }, | ||
| ), | ||
| ], | ||
|
|
||
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -77,8 +77,8 @@ class ChatApi { | |||||||||||||||||||||||||||||||||||||||||
| pagedChatRooms = PagedChatRoomDetail( | ||||||||||||||||||||||||||||||||||||||||||
| content: content, | ||||||||||||||||||||||||||||||||||||||||||
| pageable: ApiPageable( | ||||||||||||||||||||||||||||||||||||||||||
| size: (chatRoomsData['size'] as num?)?.toInt() ?? pageSize, | ||||||||||||||||||||||||||||||||||||||||||
| number: (chatRoomsData['number'] as num?)?.toInt() ?? pageNumber, | ||||||||||||||||||||||||||||||||||||||||||
| pageSize: (chatRoomsData['size'] as num?)?.toInt() ?? pageSize, | ||||||||||||||||||||||||||||||||||||||||||
| pageNumber: (chatRoomsData['number'] as num?)?.toInt() ?? pageNumber, | ||||||||||||||||||||||||||||||||||||||||||
| ), | ||||||||||||||||||||||||||||||||||||||||||
| last: isLast, | ||||||||||||||||||||||||||||||||||||||||||
| ); | ||||||||||||||||||||||||||||||||||||||||||
|
|
@@ -88,7 +88,7 @@ class ChatApi { | |||||||||||||||||||||||||||||||||||||||||
| // chatRooms 필드가 없는 경우 빈 목록 반환 | ||||||||||||||||||||||||||||||||||||||||||
| pagedChatRooms = PagedChatRoomDetail( | ||||||||||||||||||||||||||||||||||||||||||
| content: [], | ||||||||||||||||||||||||||||||||||||||||||
| pageable: ApiPageable(size: pageSize, number: pageNumber), | ||||||||||||||||||||||||||||||||||||||||||
| pageable: ApiPageable(pageSize: pageSize, pageNumber: pageNumber), | ||||||||||||||||||||||||||||||||||||||||||
| ); | ||||||||||||||||||||||||||||||||||||||||||
| debugPrint('채팅방 목록이 비어있습니다'); | ||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||
|
|
@@ -136,9 +136,13 @@ class ChatApi { | |||||||||||||||||||||||||||||||||||||||||
| ); | ||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||
| if (response.statusCode >= 200 && response.statusCode < 300) { | ||||||||||||||||||||||||||||||||||||||||||
| final Map<String, dynamic> responseData = jsonDecode(response.body); | ||||||||||||||||||||||||||||||||||||||||||
| chatRoomResponse = ChatRoomResponse.fromJson(responseData); | ||||||||||||||||||||||||||||||||||||||||||
| debugPrint('채팅 메시지 조회 성공: ${chatRoomResponse.messages?.content.length ?? 0}개'); | ||||||||||||||||||||||||||||||||||||||||||
| try { | ||||||||||||||||||||||||||||||||||||||||||
| final Map<String, dynamic> responseData = jsonDecode(response.body); | ||||||||||||||||||||||||||||||||||||||||||
| chatRoomResponse = ChatRoomResponse.fromJson(responseData); | ||||||||||||||||||||||||||||||||||||||||||
| debugPrint('채팅 메시지 조회 성공: ${chatRoomResponse.messages?.content.length ?? 0}개'); | ||||||||||||||||||||||||||||||||||||||||||
| } catch (e) { | ||||||||||||||||||||||||||||||||||||||||||
| throw Exception('채팅 메시지 파싱 실패: $e'); | ||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||
|
Comment on lines
138
to
145
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 예외 메시지에 stackTrace 포함 지양 🔧 수정 제안 try {
final Map<String, dynamic> responseData = jsonDecode(response.body);
chatRoomResponse = ChatRoomResponse.fromJson(responseData);
debugPrint('채팅 메시지 조회 성공: ${chatRoomResponse.messages?.content.length ?? 0}개');
} catch (e, stackTrace) {
- throw Exception('채팅 메시지 파싱 실패: $e $stackTrace');
+ debugPrint('채팅 메시지 파싱 실패: $e\n$stackTrace');
+ throw Exception('채팅 메시지 파싱 실패');
}📝 Committable suggestion
Suggested change
🤖 Prompt for AI Agents |
||||||||||||||||||||||||||||||||||||||||||
| } else { | ||||||||||||||||||||||||||||||||||||||||||
| throw Exception('채팅 메시지 조회 실패: ${response.statusCode}'); | ||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||
Uh oh!
There was an error while loading. Please reload this page.