diff --git a/lib/models/apis/objects/api_pageable.dart b/lib/models/apis/objects/api_pageable.dart index 07c9308..696c8c2 100644 --- a/lib/models/apis/objects/api_pageable.dart +++ b/lib/models/apis/objects/api_pageable.dart @@ -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 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 json) => _$ApiPageableFromJson(json); Map 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 json) => _$ApiSortFromJson(json); + Map toJson() => _$ApiSortToJson(this); +} diff --git a/lib/models/apis/objects/api_pageable.g.dart b/lib/models/apis/objects/api_pageable.g.dart index a079f40..a902837 100644 --- a/lib/models/apis/objects/api_pageable.g.dart +++ b/lib/models/apis/objects/api_pageable.g.dart @@ -6,10 +6,29 @@ part of 'api_pageable.dart'; // JsonSerializableGenerator // ************************************************************************** -ApiPageable _$ApiPageableFromJson(Map json) => - ApiPageable(size: (json['size'] as num).toInt(), number: (json['number'] as num).toInt()); +ApiPageable _$ApiPageableFromJson(Map json) => ApiPageable( + pageNumber: json['pageNumber'] == null ? 0 : _intFromJson(json['pageNumber']), + pageSize: json['pageSize'] == null ? 0 : _intFromJson(json['pageSize']), + offset: json['offset'] == null ? 0 : _intFromJson(json['offset']), + paged: json['paged'] == null ? false : _boolFromJson(json['paged']), + unpaged: json['unpaged'] == null ? false : _boolFromJson(json['unpaged']), + sort: json['sort'] == null ? null : ApiSort.fromJson(json['sort'] as Map), +); Map _$ApiPageableToJson(ApiPageable instance) => { - 'size': instance.size, - 'number': instance.number, + 'pageNumber': instance.pageNumber, + 'pageSize': instance.pageSize, + 'offset': instance.offset, + 'paged': instance.paged, + 'unpaged': instance.unpaged, + 'sort': instance.sort?.toJson(), +}; + +ApiSort _$ApiSortFromJson(Map json) => + ApiSort(empty: json['empty'] as bool?, sorted: json['sorted'] as bool?, unsorted: json['unsorted'] as bool?); + +Map _$ApiSortToJson(ApiSort instance) => { + 'empty': instance.empty, + 'sorted': instance.sorted, + 'unsorted': instance.unsorted, }; diff --git a/lib/screens/chat_room_screen.dart b/lib/screens/chat_room_screen.dart index 02bddea..051d991 100644 --- a/lib/screens/chat_room_screen.dart +++ b/lib/screens/chat_room_screen.dart @@ -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 { String _errorMessage = ''; String? _myMemberId; + // 이미지 관련 변수들 + final ImagePicker _picker = ImagePicker(); + @override void initState() { super.initState(); @@ -167,13 +172,22 @@ class _ChatRoomScreenState extends State { 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 { _messageController.clear(); } + /// 이미지 메시지 전송 + /// imageUrls: 서버에서 반환된 이미지 URL 리스트 + /// imageMessage: 사진과 함께 전송할 텍스트 메시지 (선택사항) + Future _sendImage({required List 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 { } } + /// 이미지 선택 후 전송 + Future _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 { 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 { return; } try { - await MemberApi().blockMember(opponentId); if (context.mounted) { Navigator.of(context).pop(true); // 모달 닫기 } @@ -482,13 +571,10 @@ class _ChatRoomScreenState extends State { : 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 { 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 { ), 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, + ), + ), + ), ], ], ), @@ -700,7 +793,7 @@ class _ChatRoomScreenState extends State { iconColor: AppColors.opacity60White, title: '사진 선택하기', onTap: () { - // TODO: 이미지 선택 및 전송 기능 + _onPickImage(); }, ), ], diff --git a/lib/services/apis/chat_api.dart b/lib/services/apis/chat_api.dart index 477de6a..cb4f3d7 100644 --- a/lib/services/apis/chat_api.dart +++ b/lib/services/apis/chat_api.dart @@ -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 responseData = jsonDecode(response.body); - chatRoomResponse = ChatRoomResponse.fromJson(responseData); - debugPrint('채팅 메시지 조회 성공: ${chatRoomResponse.messages?.content.length ?? 0}개'); + try { + final Map responseData = jsonDecode(response.body); + chatRoomResponse = ChatRoomResponse.fromJson(responseData); + debugPrint('채팅 메시지 조회 성공: ${chatRoomResponse.messages?.content.length ?? 0}개'); + } catch (e) { + throw Exception('채팅 메시지 파싱 실패: $e'); + } } else { throw Exception('채팅 메시지 조회 실패: ${response.statusCode}'); } diff --git a/lib/services/chat_websocket_service.dart b/lib/services/chat_websocket_service.dart index 53720a5..63689bb 100644 --- a/lib/services/chat_websocket_service.dart +++ b/lib/services/chat_websocket_service.dart @@ -200,20 +200,37 @@ class ChatWebSocketService { } /// 메시지 전송 - void sendMessage({required String chatRoomId, required String content, MessageType type = MessageType.text}) { + void sendMessage({ + required String chatRoomId, + required String content, + MessageType type = MessageType.text, + List? imageUrls, + }) { + if (type == MessageType.image && (imageUrls == null || imageUrls.isEmpty)) { + throw Exception('imageUrls is required for image messages'); + } if (!_isConnected || _stompClient == null) { debugPrint('[WebSocket] Cannot send message: Not connected'); throw Exception('STOMP not connected'); } - final payload = jsonEncode({ + final Map payload = { 'chatRoomId': chatRoomId, 'content': content, 'type': type.toString().split('.').last.toUpperCase(), - }); + }; + + // IMAGE 타입인 경우 imageUrls 추가 + if (type == MessageType.image && imageUrls != null) { + payload['imageUrls'] = imageUrls; + } - debugPrint('[WebSocket] Sending message to /app/chat.send'); - _stompClient!.send(destination: '/app/chat.send', body: payload, headers: {'content-type': 'application/json'}); + debugPrint('[WebSocket] Sending message to /app/chat.send\n$payload'); + _stompClient!.send( + destination: '/app/chat.send', + body: jsonEncode(payload), + headers: {'content-type': 'application/json'}, + ); } /// 채팅방 구독 해제 diff --git a/lib/widgets/chat_image_bubble.dart b/lib/widgets/chat_image_bubble.dart new file mode 100644 index 0000000..d3795f0 --- /dev/null +++ b/lib/widgets/chat_image_bubble.dart @@ -0,0 +1,69 @@ +import 'package:cached_network_image/cached_network_image.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_screenutil/flutter_screenutil.dart'; +import 'package:photo_viewer/photo_viewer.dart'; +import 'package:romrom_fe/models/apis/objects/chat_message.dart'; +import 'package:romrom_fe/models/app_colors.dart'; +import 'package:romrom_fe/widgets/common/error_image_placeholder.dart'; + +Widget chatImageBubble(BuildContext context, ChatMessage message) { + final urls = message.imageUrls ?? const []; + if (urls.isEmpty) return const SizedBox.shrink(); + + final base = message.chatMessageId ?? 'local_${message.createdDate?.microsecondsSinceEpoch ?? message.hashCode}'; + + String heroTag(int index) => 'chat_image_${base}_$index'; + + return GestureDetector( + onTap: () { + showPhotoViewer( + context: context, + heroTagBuilder: heroTag, + builders: urls.map((url) { + return (_) => CachedNetworkImage( + imageUrl: url, + fit: BoxFit.contain, + fadeInDuration: Duration.zero, + fadeOutDuration: Duration.zero, + placeholderFadeInDuration: Duration.zero, + placeholder: (_, __) => const Center( + child: SizedBox(width: 32, height: 32, child: CircularProgressIndicator(color: AppColors.primaryYellow)), + ), + errorWidget: (_, __, ___) => const Center(child: ErrorImagePlaceholder()), + ); + }).toList(), + initialPage: 0, + ); + }, + child: Hero( + tag: heroTag(0), + child: Material( + color: AppColors.transparent, + borderRadius: BorderRadius.circular(10.r), + clipBehavior: Clip.antiAlias, + child: Container( + width: 264.w, + constraints: BoxConstraints(maxHeight: 264.h), + child: CachedNetworkImage( + imageUrl: urls.first, + fit: BoxFit.cover, + fadeInDuration: Duration.zero, + fadeOutDuration: Duration.zero, + placeholderFadeInDuration: Duration.zero, + placeholder: (_, __) => const ColoredBox( + color: AppColors.opacity10White, + child: Center( + child: SizedBox( + width: 32, + height: 32, + child: CircularProgressIndicator(color: AppColors.primaryYellow), + ), + ), + ), + errorWidget: (_, __, ___) => const Center(child: ErrorImagePlaceholder()), + ), + ), + ), + ), + ); +} diff --git a/lib/widgets/common/romrom_context_menu.dart b/lib/widgets/common/romrom_context_menu.dart index db5354e..507d166 100644 --- a/lib/widgets/common/romrom_context_menu.dart +++ b/lib/widgets/common/romrom_context_menu.dart @@ -347,7 +347,7 @@ class _MenuOverlay extends StatelessWidget { final item = items[i]; widgets.add( - InkWell( + GestureDetector( onTap: () { if (enableHapticFeedback) { HapticFeedback.selectionClick();