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
1 change: 1 addition & 0 deletions MeetKey/MeetKey/MeetKey-hybin/HybinMainTabView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,7 @@ struct HybinMainTabView: View {
.transition(.move(edge: .bottom).combined(with: .opacity))
}
}
.navigationBarBackButtonHidden(true)
.ignoresSafeArea(.keyboard) // 키보드 올라올 때 탭 바 밀림 방지
}

Expand Down
2 changes: 1 addition & 1 deletion MeetKey/MeetKey/Network/API/ChatAPI.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
21 changes: 21 additions & 0 deletions MeetKey/MeetKey/Network/Services/ChatService.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
}
95 changes: 57 additions & 38 deletions MeetKey/MeetKey/Presentation/Header/HeaderOverlay.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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
}
Expand Down Expand Up @@ -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()
Expand All @@ -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:
Expand All @@ -148,34 +158,34 @@ 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)
}
} else {
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))
Expand All @@ -187,7 +197,7 @@ extension HeaderOverlay {
}
}
}

private var reportMenuList: some View {
VStack(spacing: 0) {
menuItem(title: "프로필 보기", icon: "btn_profile_header") {
Expand All @@ -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
Expand Down
64 changes: 62 additions & 2 deletions MeetKey/MeetKey/Presentation/Home/ViewModel/HomeViewModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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. 유저 리스트 변환 및 저장
Expand Down Expand Up @@ -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(
Expand Down
Loading