Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
49 changes: 39 additions & 10 deletions lib/models/apis/objects/api_pageable.dart
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);
}
27 changes: 23 additions & 4 deletions lib/models/apis/objects/api_pageable.g.dart

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

175 changes: 134 additions & 41 deletions lib/screens/chat_room_screen.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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';
Expand Down Expand Up @@ -55,6 +57,9 @@ class _ChatRoomScreenState extends State<ChatRoomScreen> {
String _errorMessage = '';
String? _myMemberId;

// 이미지 관련 변수들
final ImagePicker _picker = ImagePicker();

@override
void initState() {
super.initState();
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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), () {
Expand All @@ -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();
Expand Down Expand Up @@ -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),
Expand Down Expand Up @@ -403,7 +493,6 @@ class _ChatRoomScreenState extends State<ChatRoomScreen> {
return;
}
try {
await MemberApi().blockMember(opponentId);
if (context.mounted) {
Navigator.of(context).pop(true); // 모달 닫기
}
Expand Down Expand Up @@ -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: [
Expand Down Expand Up @@ -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(
Expand All @@ -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
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

발신 텍스트 메시지에 maxHeight: 264.h 제약이 적용되어 긴 텍스트가 잘릴 수 있습니다

수신 메시지(Line 703)에는 maxWidth만 설정되어 있지만, 발신 메시지(Line 744)에는 maxHeight: 264.h도 추가되어 있습니다. 이미지 버블과 텍스트 버블이 ternary로 분기되면서 maxHeight가 텍스트 Container에도 적용된 것으로 보입니다. 긴 텍스트 메시지가 264.h에서 잘리게 됩니다.

🔧 수정 제안
                 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
In `@lib/screens/chat_room_screen.dart` around lines 740 - 757, The outgoing
message branch incorrectly applies BoxConstraints(maxWidth: 264.w, maxHeight:
264.h) causing long text in the text bubble to be clipped; update the ternary so
that chatImageBubble(message.type == MessageType.image) keeps the maxHeight
constraint inside the image branch (or within chatImageBubble) while the text
Container uses only BoxConstraints(maxWidth: 264.w) (remove maxHeight) so long
text can wrap; locate the ternary around message.type, the chatImageBubble call,
and the outgoing Container with constraints to make this change.

],
],
),
Expand Down Expand Up @@ -700,7 +793,7 @@ class _ChatRoomScreenState extends State<ChatRoomScreen> {
iconColor: AppColors.opacity60White,
title: '사진 선택하기',
onTap: () {
// TODO: 이미지 선택 및 전송 기능
_onPickImage();
},
),
],
Expand Down
16 changes: 10 additions & 6 deletions lib/services/apis/chat_api.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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,
);
Expand All @@ -88,7 +88,7 @@ class ChatApi {
// chatRooms 필드가 없는 경우 빈 목록 반환
pagedChatRooms = PagedChatRoomDetail(
content: [],
pageable: ApiPageable(size: pageSize, number: pageNumber),
pageable: ApiPageable(pageSize: pageSize, pageNumber: pageNumber),
);
debugPrint('채팅방 목록이 비어있습니다');
}
Expand Down Expand Up @@ -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
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

예외 메시지에 stackTrace 포함 지양
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

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
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, stackTrace) {
throw Exception('채팅 메시지 파싱 실패: $e $stackTrace');
}
if (response.statusCode >= 200 && response.statusCode < 300) {
try {
final Map<String, dynamic> responseData = jsonDecode(response.body);
chatRoomResponse = ChatRoomResponse.fromJson(responseData);
debugPrint('채팅 메시지 조회 성공: ${chatRoomResponse.messages?.content.length ?? 0}개');
} catch (e, stackTrace) {
debugPrint('채팅 메시지 파싱 실패: $e\n$stackTrace');
throw Exception('채팅 메시지 파싱 실패');
}
🤖 Prompt for AI Agents
In `@lib/services/apis/chat_api.dart` around lines 138 - 145, In the catch block
around jsonDecode/ChatRoomResponse.fromJson, stop including the stackTrace in
the Exception message; instead log the stackTrace separately (e.g., debugPrint
or a logger) and rethrow or throw a concise Exception like "채팅 메시지 파싱 실패" so the
error message remains brief while the stackTrace is recorded separately for
debugging; update the catch that currently does "catch (e, stackTrace) { throw
Exception('... $e $stackTrace'); }" to log stackTrace and throw a minimal
message.

} else {
throw Exception('채팅 메시지 조회 실패: ${response.statusCode}');
}
Expand Down
Loading