diff --git a/MeetKey/MeetKey/MeetKey-hybin/HybinMainTabView.swift b/MeetKey/MeetKey/MeetKey-hybin/HybinMainTabView.swift index 45ae359..43c3c96 100644 --- a/MeetKey/MeetKey/MeetKey-hybin/HybinMainTabView.swift +++ b/MeetKey/MeetKey/MeetKey-hybin/HybinMainTabView.swift @@ -57,6 +57,7 @@ struct HybinMainTabView: View { .transition(.move(edge: .bottom).combined(with: .opacity)) } } + .navigationBarBackButtonHidden(true) .ignoresSafeArea(.keyboard) // 키보드 올라올 때 탭 바 밀림 방지 } diff --git a/MeetKey/MeetKey/Network/API/ChatAPI.swift b/MeetKey/MeetKey/Network/API/ChatAPI.swift index b0aa2ad..cc372a7 100644 --- a/MeetKey/MeetKey/Network/API/ChatAPI.swift +++ b/MeetKey/MeetKey/Network/API/ChatAPI.swift @@ -37,7 +37,7 @@ var baseURL: URL { } var headers: [String: String]? { - let token = KeychainManager.load(account: "accessToken") ?? "" + let token = APIConfig.testToken var headers: [String: String] = [ "Content-Type": "application/json" diff --git a/MeetKey/MeetKey/Network/Services/ChatService.swift b/MeetKey/MeetKey/Network/Services/ChatService.swift index ebecd6d..4122666 100644 --- a/MeetKey/MeetKey/Network/Services/ChatService.swift +++ b/MeetKey/MeetKey/Network/Services/ChatService.swift @@ -86,3 +86,24 @@ final class ChatService { } } } + + + +//매칭 뷰에서 메시지 전송하기 위한 로직 +extension ChatService { + /// - Parameters: + /// - roomId: 채팅방 ID + /// - content: 메시지 내용 + /// - type: 메시지 타입 (기본 "TEXT") + func sendMatchMessage(roomId: Int, content: String, type: String = "TEXT") { + // 1. 서버 명세서에 따른 페이로드 구성 (Encodable DTO가 있다고 가정) + // let payload = ["chatRoomId": roomId, "messageType": type, "content": content] + + // 2. 실제 STOMP 전송 로직이 들어갈 자리 + print("🚀 [STOMP SEND MOCK] destination: /pub/chat/send") + print("📦 [Payload]: \(content) (RoomID: \(roomId))") + + // TODO: 전송 로직 구현 후 말씀주세요,,, + // stompClient.send(destination: "/pub/chat/send", body: payload) + } +} diff --git a/MeetKey/MeetKey/Presentation/Header/HeaderOverlay.swift b/MeetKey/MeetKey/Presentation/Header/HeaderOverlay.swift index ad4edad..8d5f485 100644 --- a/MeetKey/MeetKey/Presentation/Header/HeaderOverlay.swift +++ b/MeetKey/MeetKey/Presentation/Header/HeaderOverlay.swift @@ -14,20 +14,20 @@ struct HeaderOverlay: View { let user: User @ObservedObject var reportVM: ReportViewModel var homeStatus: HomeStatus = .idle - + var onLeftAction: () -> Void = {} var onRightAction: () -> Void = {} var onDetailAction: () -> Void = {} - + var onProfileTap: (() -> Void) = {} - + var body: some View { ZStack(alignment: .bottom) { VStack(spacing: 0) { HStack(alignment: .center) { leftArea .frame(width: 40, height: 40) - + if state == .home || state == .homeDetail { homeHeaderText .padding(.leading, 8) @@ -37,14 +37,14 @@ struct HeaderOverlay: View { centerArea Spacer() } - + rightArea .frame(width: 40, height: 40) } .padding(.horizontal, 20) .padding(.bottom, 12) .padding(.top, 16) - + if reportVM.isReportMenuPresented { reportMenuList } @@ -74,27 +74,28 @@ extension HeaderOverlay { } case .chat, .matchingSuccess: Button(action: onLeftAction) { - Image("btn_x_header") // X 아이콘 적용 + Image("btn_x_header") .resizable() .frame(width: 40, height: 40) } default: Button(action: onLeftAction) { - Image("btn_arrow_header") // 뒤로가기 화살표 적용 + Image("btn_arrow_header") .resizable() .frame(width: 40, height: 40) } } } - + @ViewBuilder private var centerArea: some View { switch state { case .home, .homeDetail: EmptyView() - + case .matchingSuccess, .chat: VStack(spacing: 4) { + // 프로필 이미지 부분은 그대로 유지 AsyncImage(url: URL(string: user.profileImage)) { phase in if let image = phase.image { image.resizable() @@ -111,35 +112,44 @@ extension HeaderOverlay { .frame(width: 44, height: 44) .clipShape(Circle()) .overlay(Circle().stroke(Color.white, lineWidth: 2)) - - HStack(spacing: 4) { + + ZStack { Text(user.name) .font(.meetKey(.title5)) .foregroundStyle(Color.text1) - - if let badgeData = user.badge { - let type = BadgeType1.from(score: badgeData.totalScore) - let circleBadgeName = type.assetName.replacingOccurrences( - of: "Badge", - with: "" - ) - - Image(circleBadgeName) - .resizable() - .aspectRatio(contentMode: .fit) - .frame(width: 24, height: 24) + + HStack { + Text(user.name) + .font(.meetKey(.title5)) + .opacity(0) + + if let badgeData = user.badge { + let type = BadgeType1.from( + score: badgeData.totalScore + ) + let circleBadgeName = type.assetName + .replacingOccurrences( + of: "Badge", + with: "" + ) + + Image(circleBadgeName) + .resizable() + .aspectRatio(contentMode: .fit) + .frame(width: 24, height: 24) + } } } } .padding(.top, 8) - + case .otherProfile: - VStack(alignment:.leading ,spacing: 2) { - Text ("Profile") - .font (.meetKey(.body5) ) + VStack(alignment: .leading, spacing: 2) { + Text("Profile") + .font(.meetKey(.body5)) .foregroundStyle(.text5) Text("\(user.name)님의 프로필") - .font(.meetKey (.body1) ) + .font(.meetKey(.body1)) .foregroundStyle(.text3) } default: @@ -148,14 +158,14 @@ extension HeaderOverlay { .foregroundStyle(Color.text1) } } - + @ViewBuilder private var rightArea: some View { if state == .home || state == .homeDetail { Button(action: { withAnimation(.spring()) { onRightAction() } }) { - Image("btn_filter_header") // 필터 아이콘 적용 + Image("btn_filter_header") // 필터 아이콘 적용 .resizable() .frame(width: 40, height: 40) } @@ -163,19 +173,19 @@ extension HeaderOverlay { Button(action: { withAnimation(.spring()) { onRightAction() } }) { - Image("btn_ellipsis_header") // 더보기(점세개) 아이콘 적용 + Image("btn_ellipsis_header") // 더보기(점세개) 아이콘 적용 .resizable() .frame(width: 40, height: 40) } } } - + private var homeHeaderText: some View { VStack(alignment: .leading, spacing: 2) { Text(user.name + "님,") .font(.meetKey(.body5)) .foregroundStyle(Color.text5) - + if homeStatus == .finished { Text("매칭 친구 모두 확인했어요!") .font(.meetKey(.body1)) @@ -187,7 +197,7 @@ extension HeaderOverlay { } } } - + private var reportMenuList: some View { VStack(spacing: 0) { menuItem(title: "프로필 보기", icon: "btn_profile_header") { @@ -198,14 +208,23 @@ extension HeaderOverlay { reportVM.currentReportStep = .block } Divider().padding(.horizontal, 10) - menuItem(title: "신고하기", icon: "btn_report_header", isDestructive: true) { + menuItem( + title: "신고하기", + icon: "btn_report_header", + isDestructive: true + ) { reportVM.currentReportStep = .report } } .padding(.bottom, 10) } - - private func menuItem(title: String, icon: String, isDestructive: Bool = false, action: @escaping () -> Void) -> some View { + + private func menuItem( + title: String, + icon: String, + isDestructive: Bool = false, + action: @escaping () -> Void + ) -> some View { Button(action: { withAnimation { reportVM.isReportMenuPresented = false diff --git a/MeetKey/MeetKey/Presentation/Home/ViewModel/HomeViewModel.swift b/MeetKey/MeetKey/Presentation/Home/ViewModel/HomeViewModel.swift index 9d63170..40f0285 100644 --- a/MeetKey/MeetKey/Presentation/Home/ViewModel/HomeViewModel.swift +++ b/MeetKey/MeetKey/Presentation/Home/ViewModel/HomeViewModel.swift @@ -32,6 +32,11 @@ class HomeViewModel: ObservableObject { @Published var currentFilter = RecommendationRequest() @Published var reportVM = ReportViewModel() + @Published var matchMessageText: String = "" + @Published var matchedRoomId: Int? = nil + @Published var isChattingStarted: Bool = false + @Published var matchChatMessages: [ChatMessageDTO] = [] + private let locationManager = LocationManager.shared private let locationService = LocationService.shared private let recommendationService = RecommendationService.shared @@ -108,13 +113,13 @@ class HomeViewModel: ObservableObject { let response = try await recommendationService.getRecommendation( filter: currentFilter ) - + // 2. 스와이프 정보 업데이트 let swipeInfo = response.data.swipeInfo self.remainingCount = swipeInfo.remainingCount self.totalCount = swipeInfo.totalCount self.hasReachedLimit = (self.remainingCount == 0) - + print("📊 [Swipe] \(remainingCount)/\(totalCount)") // 3. 유저 리스트 변환 및 저장 @@ -296,6 +301,61 @@ extension HomeViewModel { let items: [InterestType] } } +//MARK: - 채팅 +// HomeViewModel+Matching.swift + +extension HomeViewModel { + func sendInitialMatchMessage() async { + // 1. 입력값 유효성 검사 및 전송할 텍스트 보관 + let content = matchMessageText.trimmingCharacters(in: .whitespacesAndNewlines) + guard !content.isEmpty else { return } + + do { + // 2. 채팅방 생성 로직 (방이 없는 경우에만 생성) + if matchedRoomId == nil { + guard let targetUserId = currentUser?.id else { + print("❌ 오류: 대상 사용자 ID를 찾을 수 없습니다.") + return + } + let response = try await ChatService.shared.createChatRoom(targetUserId: targetUserId) + self.matchedRoomId = response.createdChatRoomId + } + + // 3. 방 ID 옵셔널 바인딩 (DTO 생성을 위해 필수) + guard let roomId = matchedRoomId else { + print("❌ 오류: 생성된 방 ID가 없습니다.") + return + } + + // 4. ChatMessageDTO 규격에 맞게 메시지 객체 생성 + let newMessage = ChatMessageDTO( + messageId: Int.random(in: 1...1_000_000), + chatRoomId: roomId, + senderId: me.id, + messageType: .text, + content: content, + duration: nil, + createdAt: DateFormatter.iso8601Full.string(from: Date()), + mine: true + ) + + // 5. 메인 스레드에서 UI 업데이트 및 상태 변경 + await MainActor.run { + withAnimation(.easeInOut) { + self.matchChatMessages.append(newMessage) + self.isChattingStarted = true + self.matchMessageText = "" + } + } + + // 6. (옵션) 서버로 실제 메시지 전송 시도 (STOMP 브릿지) + ChatService.shared.sendMatchMessage(roomId: roomId, content: content) + + } catch { + print("❌ 매칭 채팅 처리 실패: \(error.localizedDescription)") + } + } +} extension User { static let mockData: [User] = [ User( diff --git a/MeetKey/MeetKey/Presentation/Home/Views/MatchingView.swift b/MeetKey/MeetKey/Presentation/Home/Views/MatchingView.swift index 8dacdae..0125434 100644 --- a/MeetKey/MeetKey/Presentation/Home/Views/MatchingView.swift +++ b/MeetKey/MeetKey/Presentation/Home/Views/MatchingView.swift @@ -11,16 +11,61 @@ struct MatchingView: View { @ObservedObject var homeVM: HomeViewModel - @State private var messageText: String = "" - var body: some View { GeometryReader { geometry in let screenSize = geometry.size let safeArea = geometry.safeAreaInsets + let headerHeight: CGFloat = 125 ZStack(alignment: .top) { - backgroundSection(size: screenSize) + if !homeVM.isChattingStarted { + backgroundSection(size: screenSize) + } + + VStack(spacing: 0) { + if homeVM.isChattingStarted { + Spacer().frame(height: headerHeight) + + ScrollViewReader { proxy in + ScrollView { + LazyVStack(spacing: 12) { + ForEach( + homeVM.matchChatMessages, + id: \.messageId + ) { msg in + MatchMessageBubble(message: msg) + } + } + .padding(.horizontal, 20) + .padding(.bottom, 20) + } + .onChange(of: homeVM.matchChatMessages.count) { + oldValue, + newValue in + withAnimation { + if let lastId = homeVM.matchChatMessages + .last?.messageId + { + proxy.scrollTo(lastId, anchor: .bottom) + } + } + } + } + } else { + Spacer() + } + + ChatInputSection( + messageText: $homeVM.matchMessageText, + onSend: { + Task { + await homeVM.sendInitialMatchMessage() + } + } + ) + .padding(.bottom, safeArea.bottom) + } if homeVM.reportVM.isReportMenuPresented { closeOverlay @@ -28,8 +73,15 @@ struct MatchingView: View { VStack { Spacer() - ChatInputSection(messageText: $messageText) - .padding(.bottom, safeArea.bottom) + ChatInputSection( + messageText: $homeVM.matchMessageText, + onSend: { + Task { + await homeVM.sendInitialMatchMessage() + } + } + ) + .padding(.bottom, safeArea.bottom) } HeaderOverlay( @@ -58,10 +110,15 @@ struct MatchingView: View { ) .presentationBackground(Color.background1) .presentationDetents([ - homeVM.reportVM.currentReportStep == .reportCase ? .height(500) : - homeVM.reportVM.currentReportStep == .reportReason ? .height(500) : .medium + homeVM.reportVM.currentReportStep == .reportCase + ? .height(500) + : homeVM.reportVM.currentReportStep == .reportReason + ? .height(500) : .medium ]) - .animation(.spring(response: 0.4, dampingFraction: 0.8), value: homeVM.reportVM.currentReportStep) + .animation( + .spring(response: 0.4, dampingFraction: 0.8), + value: homeVM.reportVM.currentReportStep + ) } } } @@ -75,7 +132,7 @@ struct MatchingView: View { .scaledToFit() .frame(width: 140, height: 140) - VStack(alignment:.center) { + VStack(alignment: .center) { Text("소울 메이트 발견!") .font(.meetKey(.title4)) .foregroundStyle(Color.text2) @@ -100,3 +157,25 @@ struct MatchingView: View { } } } + +struct MatchMessageBubble: View { + let message: ChatMessageDTO + + var body: some View { + HStack { + Spacer() + Text((message.content ?? "").unquoted) + .font(.system(size: 15)) + .padding(.horizontal, 14) + .padding(.vertical, 8) + .foregroundStyle(Color.white) + .background(Color.orange) + .clipShape( + MyRoundedCorner( + radius: 12, + corners: [.topLeft, .bottomLeft, .bottomRight] + ) + ) + } + } +} diff --git a/MeetKey/MeetKey/Presentation/Home/Views/SubViews/HomeProfileDetailView.swift b/MeetKey/MeetKey/Presentation/Home/Views/SubViews/HomeProfileDetailView.swift index ef1c5a4..9d8abd8 100644 --- a/MeetKey/MeetKey/Presentation/Home/Views/SubViews/HomeProfileDetailView.swift +++ b/MeetKey/MeetKey/Presentation/Home/Views/SubViews/HomeProfileDetailView.swift @@ -70,13 +70,25 @@ extension HomeProfileDetailView { image .resizable() .aspectRatio(contentMode: .fill) - .matchedGeometryEffect(id: "profile_card", in: animation) + .matchedGeometryEffect( + id: "profile_card", + in: animation + ) } else { Color.gray.opacity(0.1) } } .frame(width: size.width - 40, height: 330) .clipShape(RoundedRectangle(cornerRadius: 15)) + LinearGradient( + colors: [.gray.opacity(0.7), .clear], + startPoint: .bottom, + endPoint: .center + ) + .frame(width: size.width - 40, height: 150) + .clipShape(RoundedRectangle(cornerRadius: 15)) + .allowsHitTesting(false) + .overlay(alignment: .topTrailing) { if let badgeData = user.badge { homeBadgeView(score: badgeData.totalScore) @@ -92,10 +104,14 @@ extension HomeProfileDetailView { Text("\(user.name)") .font(.meetKey(.title2)) .foregroundStyle(Color.white01) - Text("\(user.age)") + .shadow(color: .black.opacity(0.2), radius: 2, x: 0, y: 1) + + Text("\(user.age!)") .font(.meetKey(.title6)) .foregroundStyle(Color.white01) + .shadow(color: .black.opacity(0.2), radius: 2, x: 0, y: 1) Spacer() + } HStack(alignment: .top, spacing: 6) { Image("location_home") @@ -119,9 +135,11 @@ extension HomeProfileDetailView { .lineLimit(1) } .foregroundStyle(Color.white01.opacity(0.8)) + .shadow(color: .black.opacity(0.2), radius: 2, x: 0, y: 1) } .padding(.horizontal, 20) .padding(.top, 25) + .padding(.bottom, 16) } private func languageSection(user: User) -> some View { diff --git a/MeetKey/MeetKey/Presentation/Home/Views/SubViews/ProfileSectionView.swift b/MeetKey/MeetKey/Presentation/Home/Views/SubViews/ProfileSectionView.swift index 9f6bddc..37a50e5 100644 --- a/MeetKey/MeetKey/Presentation/Home/Views/SubViews/ProfileSectionView.swift +++ b/MeetKey/MeetKey/Presentation/Home/Views/SubViews/ProfileSectionView.swift @@ -54,7 +54,7 @@ extension ProfileSectionView { case .success(let image): image .resizable() - .aspectRatio(contentMode: .fill) + .aspectRatio(contentMode: .fit) case .failure(_), .empty: Color.black.opacity(0.1) @unknown default: @@ -71,7 +71,6 @@ extension ProfileSectionView { endPoint: .center ) } - .ignoresSafeArea() } private func interestTagStack(interests: [String]?) -> some View { diff --git a/MeetKey/MeetKey/Presentation/Home/Views/SubViews/chatInputSection.swift b/MeetKey/MeetKey/Presentation/Home/Views/SubViews/chatInputSection.swift index a94ff24..befcd7f 100644 --- a/MeetKey/MeetKey/Presentation/Home/Views/SubViews/chatInputSection.swift +++ b/MeetKey/MeetKey/Presentation/Home/Views/SubViews/chatInputSection.swift @@ -9,7 +9,7 @@ import SwiftUI struct ChatInputSection: View { @Binding var messageText: String - + var onSend: () -> Void var body: some View { VStack(alignment: .leading, spacing: 16) { @@ -43,7 +43,7 @@ struct ChatInputSection: View { .background(Color(.systemGray6)) .cornerRadius(25) - Button(action: { /* 메시지 전송 */ }) { + Button(action: onSend ) { Image(systemName: "paperplane.fill") .font(.system(size: 18)) .foregroundColor(.white) diff --git a/MeetKey/MeetKey/Resource/Extension/DateFormatter.swift b/MeetKey/MeetKey/Resource/Extension/DateFormatter.swift index 870edbe..e48a139 100644 --- a/MeetKey/MeetKey/Resource/Extension/DateFormatter.swift +++ b/MeetKey/MeetKey/Resource/Extension/DateFormatter.swift @@ -13,4 +13,14 @@ extension DateFormatter { f.locale = Locale(identifier: "ko_KR") return f }() + + //서버와 실시간으로 채팅 시간에 대한 통신을 위해 ISO 8601 제작 + static let iso8601Full: DateFormatter = { + let formatter = DateFormatter() + formatter.dateFormat = "yyyy-MM-dd'T'HH:mm:ss.SSS" + formatter.calendar = Calendar(identifier: .iso8601) + formatter.timeZone = TimeZone(secondsFromGMT: 0) + formatter.locale = Locale(identifier: "ko_KR") + return formatter + }() }