feat(chat-room-bg): 배경 이미지 변경 기능 추가
This commit is contained in:
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -26,4 +26,18 @@ final class CharacterDetailGalleryRepository {
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
func getMyCharacterImageList(
|
||||
characterId: Int64,
|
||||
page: Int,
|
||||
size: Int = 20
|
||||
) -> AnyPublisher<Response, MoyaError> {
|
||||
return characterApi.requestPublisher(
|
||||
.getMyCharacterImageList(
|
||||
characterId: characterId,
|
||||
page: page,
|
||||
size: size
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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<ChatRoomEnterResponse>.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"
|
||||
|
||||
@@ -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)
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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<AnyCancellable>()
|
||||
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<CharacterImageListResponse>.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
|
||||
}
|
||||
}
|
||||
@@ -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() }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -34,6 +34,7 @@ struct DetailNavigationBar: View {
|
||||
|
||||
Spacer()
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.horizontal, 13.3)
|
||||
.frame(height: 50)
|
||||
.background(Color.black)
|
||||
|
||||
Reference in New Issue
Block a user