feat(chat-room-bg): 배경 이미지 변경 기능 추가
This commit is contained in:
@@ -12,6 +12,7 @@ enum CharacterApi {
|
|||||||
case getCharacterHome
|
case getCharacterHome
|
||||||
case getCharacterDetail(characterId: Int)
|
case getCharacterDetail(characterId: Int)
|
||||||
case getCharacterImageList(characterId: Int, page: Int, size: Int)
|
case getCharacterImageList(characterId: Int, page: Int, size: Int)
|
||||||
|
case getMyCharacterImageList(characterId: Int64, page: Int, size: Int)
|
||||||
}
|
}
|
||||||
|
|
||||||
extension CharacterApi: TargetType {
|
extension CharacterApi: TargetType {
|
||||||
@@ -27,6 +28,9 @@ extension CharacterApi: TargetType {
|
|||||||
|
|
||||||
case .getCharacterImageList:
|
case .getCharacterImageList:
|
||||||
return "/api/chat/character/image/list"
|
return "/api/chat/character/image/list"
|
||||||
|
|
||||||
|
case .getMyCharacterImageList:
|
||||||
|
return "/api/chat/character/image/list"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -46,6 +50,16 @@ extension CharacterApi: TargetType {
|
|||||||
],
|
],
|
||||||
encoding: URLEncoding.queryString
|
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 {
|
struct CharacterDetailGalleryView: View {
|
||||||
@StateObject var viewModel = CharacterDetailGalleryViewModel()
|
@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
|
let characterId: Int
|
||||||
|
|
||||||
// 계산된 속성들
|
// 계산된 속성들
|
||||||
@@ -30,7 +30,7 @@ struct CharacterDetailGalleryView: View {
|
|||||||
|
|
||||||
// 갤러리 그리드
|
// 갤러리 그리드
|
||||||
ScrollView {
|
ScrollView {
|
||||||
LazyVGrid(columns: columns, spacing: 2) {
|
LazyVGrid(columns: columns, spacing: 0) {
|
||||||
ForEach(Array(viewModel.galleryItems.enumerated()), id: \.element.id) { index, item in
|
ForEach(Array(viewModel.galleryItems.enumerated()), id: \.element.id) { index, item in
|
||||||
galleryImageView(item: item, index: index)
|
galleryImageView(item: item, index: index)
|
||||||
.onAppear {
|
.onAppear {
|
||||||
@@ -38,7 +38,7 @@ struct CharacterDetailGalleryView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.padding(.horizontal, 0)
|
.frame(width: screenSize().width)
|
||||||
}
|
}
|
||||||
|
|
||||||
Spacer()
|
Spacer()
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ struct CharacterImageListResponse: Decodable {
|
|||||||
let items: [CharacterImageListItemResponse]
|
let items: [CharacterImageListItemResponse]
|
||||||
}
|
}
|
||||||
|
|
||||||
struct CharacterImageListItemResponse: Decodable {
|
struct CharacterImageListItemResponse: Decodable, Hashable {
|
||||||
let id: Int
|
let id: Int
|
||||||
let imageUrl: String
|
let imageUrl: String
|
||||||
let isOwned: Bool
|
let isOwned: Bool
|
||||||
|
|||||||
@@ -249,7 +249,14 @@ struct ChatRoomView: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if viewModel.isShowingChangeBgView {
|
if viewModel.isShowingChangeBgView {
|
||||||
ChatBgSelectionView()
|
ChatBgSelectionView(
|
||||||
|
characterId: viewModel.characterId,
|
||||||
|
selectedBgImageId: viewModel.chatRoomBgImageId,
|
||||||
|
onTapBgImage: {
|
||||||
|
viewModel.setBackgroundImage(imageItem: $0)
|
||||||
|
},
|
||||||
|
isShowing: $viewModel.isShowingChangeBgView
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
if viewModel.isShowingChatResetConfirmDialog {
|
if viewModel.isShowingChatResetConfirmDialog {
|
||||||
@@ -325,19 +332,28 @@ struct ChatRoomBgView: View {
|
|||||||
let url: String?
|
let url: String?
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
ZStack {
|
GeometryReader { geo in
|
||||||
if let url = url {
|
let width = geo.size.width
|
||||||
KFImage(URL(string: url))
|
let height = width * 5 / 4
|
||||||
.resizable()
|
|
||||||
.aspectRatio(4/5, contentMode: .fill)
|
ZStack {
|
||||||
.frame(maxWidth: screenSize().width)
|
if let url = url {
|
||||||
|
KFImage(URL(string: url))
|
||||||
|
.resizable()
|
||||||
|
.scaledToFill()
|
||||||
|
.frame(width: width, height: height)
|
||||||
|
.clipped()
|
||||||
|
.ignoresSafeArea()
|
||||||
|
}
|
||||||
|
|
||||||
|
Color.black
|
||||||
|
.opacity(0.6)
|
||||||
.ignoresSafeArea()
|
.ignoresSafeArea()
|
||||||
}
|
}
|
||||||
|
.frame(width: width, height: height)
|
||||||
Color.black
|
.clipped()
|
||||||
.opacity(0.6)
|
|
||||||
.ignoresSafeArea()
|
|
||||||
}
|
}
|
||||||
|
.aspectRatio(4/5, contentMode: .fit)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -16,6 +16,8 @@ final class ChatRoomViewModel: ObservableObject {
|
|||||||
@Published var errorMessage: String = ""
|
@Published var errorMessage: String = ""
|
||||||
@Published var isShowPopup = false
|
@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 characterProfileUrl: String = ""
|
||||||
@Published private(set) var characterName: String = "Character Name"
|
@Published private(set) var characterName: String = "Character Name"
|
||||||
@Published private(set) var characterType: CharacterType = .Character
|
@Published private(set) var characterType: CharacterType = .Character
|
||||||
@@ -135,10 +137,11 @@ final class ChatRoomViewModel: ObservableObject {
|
|||||||
isLoading = true
|
isLoading = true
|
||||||
self.roomId = roomId
|
self.roomId = roomId
|
||||||
self.isHideBg = UserDefaults.standard.bool(forKey: bgHideKey())
|
self.isHideBg = UserDefaults.standard.bool(forKey: bgHideKey())
|
||||||
|
self.chatRoomBgImageId = getSavedBackgroundImageId() ?? 0
|
||||||
|
|
||||||
repository.enterChatRoom(
|
repository.enterChatRoom(
|
||||||
roomId: roomId,
|
roomId: roomId,
|
||||||
characterImageId: getSavedBackgroundImageId()
|
characterImageId: self.chatRoomBgImageId
|
||||||
)
|
)
|
||||||
.sink { result in
|
.sink { result in
|
||||||
switch result {
|
switch result {
|
||||||
@@ -155,6 +158,7 @@ final class ChatRoomViewModel: ObservableObject {
|
|||||||
let decoded = try jsonDecoder.decode(ApiResponse<ChatRoomEnterResponse>.self, from: responseData)
|
let decoded = try jsonDecoder.decode(ApiResponse<ChatRoomEnterResponse>.self, from: responseData)
|
||||||
|
|
||||||
if let data = decoded.data, decoded.success {
|
if let data = decoded.data, decoded.success {
|
||||||
|
self?.characterId = data.character.characterId
|
||||||
self?.characterName = data.character.name
|
self?.characterName = data.character.name
|
||||||
self?.characterType = data.character.characterType
|
self?.characterType = data.character.characterType
|
||||||
self?.characterProfileUrl = data.character.profileImageUrl
|
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() {
|
private func resetData() {
|
||||||
characterProfileUrl = ""
|
characterProfileUrl = ""
|
||||||
characterName = "Character Name"
|
characterName = "Character Name"
|
||||||
|
|||||||
@@ -8,11 +8,117 @@
|
|||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
|
||||||
struct ChatBgSelectionView: View {
|
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 {
|
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 {
|
#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(maxWidth: .infinity)
|
||||||
.frame(height: 1)
|
.frame(height: 1)
|
||||||
}
|
}
|
||||||
|
.contentShape(Rectangle())
|
||||||
.onTapGesture { onTapChangeBg() }
|
.onTapGesture { onTapChangeBg() }
|
||||||
|
|
||||||
HStack(spacing: 0) {
|
HStack(spacing: 0) {
|
||||||
@@ -100,6 +101,7 @@ struct ChatSettingsView: View {
|
|||||||
}
|
}
|
||||||
.padding(.horizontal, 24)
|
.padding(.horizontal, 24)
|
||||||
.padding(.vertical, 12)
|
.padding(.vertical, 12)
|
||||||
|
.contentShape(Rectangle())
|
||||||
.onTapGesture { onTapResetChatRoom() }
|
.onTapGesture { onTapResetChatRoom() }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -34,6 +34,7 @@ struct DetailNavigationBar: View {
|
|||||||
|
|
||||||
Spacer()
|
Spacer()
|
||||||
}
|
}
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
.padding(.horizontal, 13.3)
|
.padding(.horizontal, 13.3)
|
||||||
.frame(height: 50)
|
.frame(height: 50)
|
||||||
.background(Color.black)
|
.background(Color.black)
|
||||||
|
|||||||
Reference in New Issue
Block a user