diff --git a/SodaLive/Sources/Chat/Character/CharacterApi.swift b/SodaLive/Sources/Chat/Character/CharacterApi.swift index 3ba1ec6..a4724f1 100644 --- a/SodaLive/Sources/Chat/Character/CharacterApi.swift +++ b/SodaLive/Sources/Chat/Character/CharacterApi.swift @@ -12,6 +12,7 @@ enum CharacterApi { case getCharacterHome case getCharacterDetail(characterId: Int) case getCharacterImageList(characterId: Int, page: Int, size: Int) + case getMyCharacterImageList(characterId: Int64, page: Int, size: Int) } extension CharacterApi: TargetType { @@ -27,6 +28,9 @@ extension CharacterApi: TargetType { case .getCharacterImageList: return "/api/chat/character/image/list" + + case .getMyCharacterImageList: + return "/api/chat/character/image/list" } } @@ -46,6 +50,16 @@ extension CharacterApi: TargetType { ], encoding: URLEncoding.queryString ) + + case .getMyCharacterImageList(let characterId, let page, let size): + return .requestParameters( + parameters: [ + "characterId": characterId, + "page": page, + "size": size + ], + encoding: URLEncoding.queryString + ) } } diff --git a/SodaLive/Sources/Chat/Character/Detail/Gallery/CharacterDetailGalleryRepository.swift b/SodaLive/Sources/Chat/Character/Detail/Gallery/CharacterDetailGalleryRepository.swift index e05fc95..d20387a 100644 --- a/SodaLive/Sources/Chat/Character/Detail/Gallery/CharacterDetailGalleryRepository.swift +++ b/SodaLive/Sources/Chat/Character/Detail/Gallery/CharacterDetailGalleryRepository.swift @@ -26,4 +26,18 @@ final class CharacterDetailGalleryRepository { ) ) } + + func getMyCharacterImageList( + characterId: Int64, + page: Int, + size: Int = 20 + ) -> AnyPublisher { + return characterApi.requestPublisher( + .getMyCharacterImageList( + characterId: characterId, + page: page, + size: size + ) + ) + } } diff --git a/SodaLive/Sources/Chat/Character/Detail/Gallery/CharacterDetailGalleryView.swift b/SodaLive/Sources/Chat/Character/Detail/Gallery/CharacterDetailGalleryView.swift index 2f53aa3..0e13acc 100644 --- a/SodaLive/Sources/Chat/Character/Detail/Gallery/CharacterDetailGalleryView.swift +++ b/SodaLive/Sources/Chat/Character/Detail/Gallery/CharacterDetailGalleryView.swift @@ -10,7 +10,7 @@ import SwiftUI struct CharacterDetailGalleryView: View { @StateObject var viewModel = CharacterDetailGalleryViewModel() - private let columns = Array(repeating: GridItem(.flexible()), count: 3) + private let columns = Array(repeating: GridItem(.flexible(), spacing: 0), count: 3) let characterId: Int // 계산된 속성들 @@ -30,7 +30,7 @@ struct CharacterDetailGalleryView: View { // 갤러리 그리드 ScrollView { - LazyVGrid(columns: columns, spacing: 2) { + LazyVGrid(columns: columns, spacing: 0) { ForEach(Array(viewModel.galleryItems.enumerated()), id: \.element.id) { index, item in galleryImageView(item: item, index: index) .onAppear { @@ -38,7 +38,7 @@ struct CharacterDetailGalleryView: View { } } } - .padding(.horizontal, 0) + .frame(width: screenSize().width) } Spacer() diff --git a/SodaLive/Sources/Chat/Character/Detail/Gallery/CharacterImageListResponse.swift b/SodaLive/Sources/Chat/Character/Detail/Gallery/CharacterImageListResponse.swift index 975c821..54ab0f9 100644 --- a/SodaLive/Sources/Chat/Character/Detail/Gallery/CharacterImageListResponse.swift +++ b/SodaLive/Sources/Chat/Character/Detail/Gallery/CharacterImageListResponse.swift @@ -11,7 +11,7 @@ struct CharacterImageListResponse: Decodable { let items: [CharacterImageListItemResponse] } -struct CharacterImageListItemResponse: Decodable { +struct CharacterImageListItemResponse: Decodable, Hashable { let id: Int let imageUrl: String let isOwned: Bool diff --git a/SodaLive/Sources/Chat/Talk/Room/ChatRoomView.swift b/SodaLive/Sources/Chat/Talk/Room/ChatRoomView.swift index 8d3ee93..d219a65 100644 --- a/SodaLive/Sources/Chat/Talk/Room/ChatRoomView.swift +++ b/SodaLive/Sources/Chat/Talk/Room/ChatRoomView.swift @@ -249,7 +249,14 @@ struct ChatRoomView: View { } if viewModel.isShowingChangeBgView { - ChatBgSelectionView() + ChatBgSelectionView( + characterId: viewModel.characterId, + selectedBgImageId: viewModel.chatRoomBgImageId, + onTapBgImage: { + viewModel.setBackgroundImage(imageItem: $0) + }, + isShowing: $viewModel.isShowingChangeBgView + ) } if viewModel.isShowingChatResetConfirmDialog { @@ -325,19 +332,28 @@ struct ChatRoomBgView: View { let url: String? var body: some View { - ZStack { - if let url = url { - KFImage(URL(string: url)) - .resizable() - .aspectRatio(4/5, contentMode: .fill) - .frame(maxWidth: screenSize().width) + GeometryReader { geo in + let width = geo.size.width + let height = width * 5 / 4 + + ZStack { + if let url = url { + KFImage(URL(string: url)) + .resizable() + .scaledToFill() + .frame(width: width, height: height) + .clipped() + .ignoresSafeArea() + } + + Color.black + .opacity(0.6) .ignoresSafeArea() } - - Color.black - .opacity(0.6) - .ignoresSafeArea() + .frame(width: width, height: height) + .clipped() } + .aspectRatio(4/5, contentMode: .fit) } } diff --git a/SodaLive/Sources/Chat/Talk/Room/ChatRoomViewModel.swift b/SodaLive/Sources/Chat/Talk/Room/ChatRoomViewModel.swift index 4c9b7e9..7104aa5 100644 --- a/SodaLive/Sources/Chat/Talk/Room/ChatRoomViewModel.swift +++ b/SodaLive/Sources/Chat/Talk/Room/ChatRoomViewModel.swift @@ -16,6 +16,8 @@ final class ChatRoomViewModel: ObservableObject { @Published var errorMessage: String = "" @Published var isShowPopup = false + @Published var chatRoomBgImageId: Int = 0 + @Published private(set) var characterId: Int64 = 0 @Published private(set) var characterProfileUrl: String = "" @Published private(set) var characterName: String = "Character Name" @Published private(set) var characterType: CharacterType = .Character @@ -135,10 +137,11 @@ final class ChatRoomViewModel: ObservableObject { isLoading = true self.roomId = roomId self.isHideBg = UserDefaults.standard.bool(forKey: bgHideKey()) + self.chatRoomBgImageId = getSavedBackgroundImageId() ?? 0 repository.enterChatRoom( roomId: roomId, - characterImageId: getSavedBackgroundImageId() + characterImageId: self.chatRoomBgImageId ) .sink { result in switch result { @@ -155,6 +158,7 @@ final class ChatRoomViewModel: ObservableObject { let decoded = try jsonDecoder.decode(ApiResponse.self, from: responseData) if let data = decoded.data, decoded.success { + self?.characterId = data.character.characterId self?.characterName = data.character.name self?.characterType = data.character.characterType self?.characterProfileUrl = data.character.profileImageUrl @@ -352,6 +356,12 @@ final class ChatRoomViewModel: ObservableObject { } } + func setBackgroundImage(imageItem: CharacterImageListItemResponse) { + UserDefaults.standard.set(imageItem.id, forKey: bgImageIdKey()) + chatRoomBgImageUrl = imageItem.imageUrl + chatRoomBgImageId = imageItem.id + } + private func resetData() { characterProfileUrl = "" characterName = "Character Name" diff --git a/SodaLive/Sources/Chat/Talk/Room/Settings/ChatBgSelectionView.swift b/SodaLive/Sources/Chat/Talk/Room/Settings/ChatBgSelectionView.swift index d8b790e..545acd6 100644 --- a/SodaLive/Sources/Chat/Talk/Room/Settings/ChatBgSelectionView.swift +++ b/SodaLive/Sources/Chat/Talk/Room/Settings/ChatBgSelectionView.swift @@ -8,11 +8,117 @@ import SwiftUI struct ChatBgSelectionView: View { + + @StateObject var viewModel = ChatBgSelectionViewModel() + + private let columns = Array( + repeating: GridItem(.flexible(), spacing: 0), + count: 3 + ) + + let characterId: Int64 + let selectedBgImageId: Int + let onTapBgImage: (CharacterImageListItemResponse) -> Void + + @Binding var isShowing: Bool + var body: some View { - Text(/*@START_MENU_TOKEN@*/"Hello, World!"/*@END_MENU_TOKEN@*/) + BaseView(isLoading: $viewModel.isLoading) { + VStack(spacing: 0) { + DetailNavigationBar(title: "배경 사진 선택") { + isShowing = false + } + // 갤러리 그리드 + ScrollView { + LazyVGrid(columns: columns, spacing: 0) { + ForEach(viewModel.galleryItems, id: \.self) { item in + galleryImageView(item: item) + .onAppear { + viewModel.loadMoreIfNeeded(currentItem: item) + } + } + } + .frame(maxWidth: screenSize().width) + } + + Spacer() + } + .popup(isPresented: $viewModel.isShowPopup, type: .toast, position: .top, autohideIn: 2) { + GeometryReader { geo in + HStack { + Spacer() + Text(viewModel.errorMessage) + .padding(.vertical, 13.3) + .frame(alignment: .center) + .frame(maxWidth: .infinity) + .padding(.horizontal, 33.3) + .font(.custom(Font.medium.rawValue, size: 12)) + .background(Color.button) + .foregroundColor(Color.white) + .multilineTextAlignment(.center) + .cornerRadius(20) + .padding(.top, 66.7) + Spacer() + } + } + } + } + .onAppear { + viewModel.characterId = characterId + viewModel.loadInitialData() + } + } + + @ViewBuilder + private func galleryImageView(item: CharacterImageListItemResponse) -> some View { + GeometryReader { geo in + let width = geo.size.width + let height = width * 5 / 4 + + ZStack(alignment: .bottomTrailing) { + // 이미지 + AsyncImage(url: URL(string: item.imageUrl)) { image in + image + .resizable() + .scaledToFill() + .frame(width: width, height: height) + .clipped() + } placeholder: { + Rectangle() + .fill(Color.gray.opacity(0.3)) + } + .overlay { + Rectangle() + .stroke(lineWidth: 1) + .foregroundColor(.button) + .opacity(selectedBgImageId == item.id ? 1 : 0) + } + + if selectedBgImageId == item.id { + Text("현재 배경") + .font(.custom(Font.preRegular.rawValue, size: 12)) + .foregroundColor(.white) + .padding(.horizontal, 6) + .padding(.vertical, 2) + .background(Color.button) + .cornerRadius(6.7) + .padding(6) + } + } + .frame(width: width, height: height) + .clipped() + .contentShape(Rectangle()) + .onTapGesture { onTapBgImage(item) } + } + .aspectRatio(4/5, contentMode: .fit) } } #Preview { - ChatBgSelectionView() + ChatBgSelectionView( + characterId: 0, + selectedBgImageId: 1, + onTapBgImage: { _ in }, + isShowing: .constant(true) + ) } diff --git a/SodaLive/Sources/Chat/Talk/Room/Settings/ChatBgSelectionViewModel.swift b/SodaLive/Sources/Chat/Talk/Room/Settings/ChatBgSelectionViewModel.swift new file mode 100644 index 0000000..a80b2c4 --- /dev/null +++ b/SodaLive/Sources/Chat/Talk/Room/Settings/ChatBgSelectionViewModel.swift @@ -0,0 +1,96 @@ +// +// ChatBgSelectionViewModel.swift +// SodaLive +// +// Created by klaus on 9/4/25. +// + +import Foundation +import Combine +import Moya + +final class ChatBgSelectionViewModel: ObservableObject { + // MARK: - Published State + @Published var isLoading: Bool = false + @Published var errorMessage: String = "" + @Published var isShowPopup = false + + @Published var galleryItems: [CharacterImageListItemResponse] = [] + + private let repository = CharacterDetailGalleryRepository() + private var subscription = Set() + private var currentPage = 0 + private var isLast = false + + var characterId: Int64 = 0 + + // MARK: - Public Methods + func loadInitialData() { + currentPage = 0 + isLast = false + galleryItems.removeAll() + loadImageList() + } + + func loadMoreIfNeeded(currentItem: CharacterImageListItemResponse) { + guard !isLast, + !isLoading, + let lastItem = galleryItems.last, + lastItem.id == currentItem.id else { return } + + loadImageList() + } + + // MARK: - Private Methods + private func loadImageList() { + guard !isLoading else { return } + + isLoading = true + + repository.getMyCharacterImageList( + characterId: characterId, + page: currentPage, + size: 20 + ) + .receive(on: DispatchQueue.main) + .sink { result in + switch result { + case .finished: + DEBUG_LOG("finish") + case .failure(let error): + ERROR_LOG(error.localizedDescription) + } + } receiveValue: { [weak self] response in + let responseData = response.data + self?.isLoading = false + + do { + let jsonDecoder = JSONDecoder() + let decoded = try jsonDecoder.decode(ApiResponse.self, from: responseData) + + if let data = decoded.data, decoded.success { + self?.currentPage += 1 + self?.handleImageListResponse(data) + } else { + if let message = decoded.message { + self?.errorMessage = message + } else { + self?.errorMessage = "다시 시도해 주세요.\n계속 같은 문제가 발생할 경우 고객센터로 문의 주시기 바랍니다." + } + + self?.isShowPopup = true + } + } catch { + ERROR_LOG(String(describing: error)) + self?.errorMessage = "다시 시도해 주세요.\n계속 같은 문제가 발생할 경우 고객센터로 문의 주시기 바랍니다." + self?.isShowPopup = true + } + } + .store(in: &subscription) + } + + private func handleImageListResponse(_ response: CharacterImageListResponse) { + galleryItems.append(contentsOf: response.items) + isLast = response.items.isEmpty + } +} diff --git a/SodaLive/Sources/Chat/Talk/Room/Settings/ChatSettingsView.swift b/SodaLive/Sources/Chat/Talk/Room/Settings/ChatSettingsView.swift index 3fde9ec..4ebcd19 100644 --- a/SodaLive/Sources/Chat/Talk/Room/Settings/ChatSettingsView.swift +++ b/SodaLive/Sources/Chat/Talk/Room/Settings/ChatSettingsView.swift @@ -56,6 +56,7 @@ struct ChatSettingsView: View { .frame(maxWidth: .infinity) .frame(height: 1) } + .contentShape(Rectangle()) .onTapGesture { onTapChangeBg() } HStack(spacing: 0) { @@ -100,6 +101,7 @@ struct ChatSettingsView: View { } .padding(.horizontal, 24) .padding(.vertical, 12) + .contentShape(Rectangle()) .onTapGesture { onTapResetChatRoom() } } } diff --git a/SodaLive/Sources/NavigationBar/DetailNavigationBar.swift b/SodaLive/Sources/NavigationBar/DetailNavigationBar.swift index 9b17eea..467cf15 100644 --- a/SodaLive/Sources/NavigationBar/DetailNavigationBar.swift +++ b/SodaLive/Sources/NavigationBar/DetailNavigationBar.swift @@ -34,6 +34,7 @@ struct DetailNavigationBar: View { Spacer() } + .frame(maxWidth: .infinity) .padding(.horizontal, 13.3) .frame(height: 50) .background(Color.black)