From 16dcc9f0fe12f69a5c033806f7f14937055a17c6 Mon Sep 17 00:00:00 2001 From: Yu Sung Date: Tue, 16 Sep 2025 15:10:51 +0900 Subject: [PATCH] =?UTF-8?q?feat(chat-original):=20=EC=9B=90=EC=9E=91=20?= =?UTF-8?q?=EC=83=81=EC=84=B8=20=ED=99=94=EB=A9=B4=20=EB=B0=8F=20=EC=BA=90?= =?UTF-8?q?=EB=A6=AD=ED=84=B0=20=EB=AC=B4=ED=95=9C=20=EC=8A=A4=ED=81=AC?= =?UTF-8?q?=EB=A1=A4=20=EB=A1=9C=EB=94=A9=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- SodaLive/Sources/App/AppStep.swift | 2 + .../OriginalWorkCharactersPageResponse.swift | 11 ++ .../Detail/OriginalWorkDetailHeaderView.swift | 121 ++++++++++++++++ .../Detail/OriginalWorkDetailResponse.swift | 17 +++ .../Detail/OriginalWorkDetailView.swift | 117 ++++++++++++++++ .../Detail/OriginalWorkDetailViewModel.swift | 132 ++++++++++++++++++ .../Chat/Original/OriginalTabView.swift | 4 + SodaLive/Sources/ContentView.swift | 3 + 8 files changed, 407 insertions(+) create mode 100644 SodaLive/Sources/Chat/Original/Detail/OriginalWorkCharactersPageResponse.swift create mode 100644 SodaLive/Sources/Chat/Original/Detail/OriginalWorkDetailHeaderView.swift create mode 100644 SodaLive/Sources/Chat/Original/Detail/OriginalWorkDetailResponse.swift create mode 100644 SodaLive/Sources/Chat/Original/Detail/OriginalWorkDetailView.swift create mode 100644 SodaLive/Sources/Chat/Original/Detail/OriginalWorkDetailViewModel.swift diff --git a/SodaLive/Sources/App/AppStep.swift b/SodaLive/Sources/App/AppStep.swift index f02dbf7..1172de0 100644 --- a/SodaLive/Sources/App/AppStep.swift +++ b/SodaLive/Sources/App/AppStep.swift @@ -167,4 +167,6 @@ enum AppStep { case chatRoom(id: Int) case newCharacterAll + + case originalWorkDetail(originalId: Int) } diff --git a/SodaLive/Sources/Chat/Original/Detail/OriginalWorkCharactersPageResponse.swift b/SodaLive/Sources/Chat/Original/Detail/OriginalWorkCharactersPageResponse.swift new file mode 100644 index 0000000..7589210 --- /dev/null +++ b/SodaLive/Sources/Chat/Original/Detail/OriginalWorkCharactersPageResponse.swift @@ -0,0 +1,11 @@ +// +// OriginalWorkCharactersPageResponse.swift +// SodaLive +// +// Created by klaus on 9/15/25. +// + +struct OriginalWorkCharactersPageResponse: Decodable { + let totalCount: Int + let content: [Character] +} diff --git a/SodaLive/Sources/Chat/Original/Detail/OriginalWorkDetailHeaderView.swift b/SodaLive/Sources/Chat/Original/Detail/OriginalWorkDetailHeaderView.swift new file mode 100644 index 0000000..f8565a1 --- /dev/null +++ b/SodaLive/Sources/Chat/Original/Detail/OriginalWorkDetailHeaderView.swift @@ -0,0 +1,121 @@ +// +// OriginalWorkDetailHeaderView.swift +// SodaLive +// +// Created by klaus on 9/16/25. +// + +import SwiftUI +import Kingfisher + +struct OriginalWorkDetailHeaderView: View { + + let item: OriginalWorkDetailResponse + + @State var isDescriptionExpanded = false + + var body: some View { + VStack(spacing: 0) { + if let imageUrl = item.imageUrl { + KFImage(URL(string: imageUrl)) + .resizable() + .scaledToFill() + .frame(width: 168, height: 168 * 432 / 306) + .clipped() + .cornerRadius(16) + } + + Text(item.title) + .font(.custom(Font.preBold.rawValue, size: 26)) + .foregroundColor(.white) + .padding(.top, 40) + + HStack(spacing: 4) { + Text(item.contentType) + .font(.custom(Font.preRegular.rawValue, size: 14)) + .foregroundColor(Color(hex: "B0BEC5")) + .padding(.horizontal, 7) + .padding(.vertical, 3) + .background(Color(hex: "263238")) + .cornerRadius(4) + .overlay { + RoundedRectangle(cornerRadius: 4) + .strokeBorder(lineWidth: 1) + .foregroundColor(.white) + } + + Text(item.category) + .font(.custom(Font.preRegular.rawValue, size: 14)) + .foregroundColor(.button) + .padding(.horizontal, 7) + .padding(.vertical, 3) + .background(Color(hex: "263238")) + .cornerRadius(4) + .overlay { + RoundedRectangle(cornerRadius: 4) + .strokeBorder(lineWidth: 1) + .foregroundColor(.button) + } + + if item.isAdult { + Text("19+") + .font(.custom(Font.preRegular.rawValue, size: 14)) + .foregroundColor(Color(hex: "ff5c49")) + .padding(.horizontal, 7) + .padding(.vertical, 3) + .background(Color(hex: "263238")) + .cornerRadius(4) + .overlay { + RoundedRectangle(cornerRadius: 4) + .strokeBorder(lineWidth: 1) + .foregroundColor(Color(hex: "ff5c49")) + } + } + } + .padding(.top, 14) + + Text(item.description) + .font(.custom(Font.preRegular.rawValue, size: 14)) + .foregroundColor(Color(hex: "cfd8dc")) + .lineLimit(!isDescriptionExpanded ? 2 : Int.max) + .truncationMode(.tail) + .frame(maxWidth: .infinity) + .padding(.top, 14) + .onTapGesture { + isDescriptionExpanded.toggle() + } + + Text("원작 보러가기") + .font(.custom(Font.preBold.rawValue, size: 16)) + .foregroundColor(.button) + .frame(maxWidth: .infinity) + .padding(.vertical, 15) + .overlay { + RoundedRectangle(cornerRadius: 8) + .strokeBorder(lineWidth: 1) + .foregroundColor(.button) + } + .padding(.top, 24) + .onTapGesture { + if let link = item.originalLink, let url = URL(string: link) { + UIApplication.shared.open(url) + } + } + } + } +} + +#Preview { + OriginalWorkDetailHeaderView( + item: OriginalWorkDetailResponse( + imageUrl: "https://picsum.photos/400", + title: "작품제목", + contentType: "웹소설", + category: "로맨스", + isAdult: true, + description: "작품설명입니다.보이스온의 오픈월드 캐릭터톡은 청소년 보호를 위해 본인인증한성인만 이용이 가능합니다.캐릭터톡 서비스를 이용하시려면 본인인증을 하고 이용해주세요.", + originalLink: "https://apple.com", + characters: [] + ) + ) +} diff --git a/SodaLive/Sources/Chat/Original/Detail/OriginalWorkDetailResponse.swift b/SodaLive/Sources/Chat/Original/Detail/OriginalWorkDetailResponse.swift new file mode 100644 index 0000000..3e5abbd --- /dev/null +++ b/SodaLive/Sources/Chat/Original/Detail/OriginalWorkDetailResponse.swift @@ -0,0 +1,17 @@ +// +// OriginalWorkDetailResponse.swift +// SodaLive +// +// Created by klaus on 9/15/25. +// + +struct OriginalWorkDetailResponse: Decodable { + let imageUrl: String? + let title: String + let contentType: String + let category: String + let isAdult: Bool + let description: String + let originalLink: String? + let characters: [Character] +} diff --git a/SodaLive/Sources/Chat/Original/Detail/OriginalWorkDetailView.swift b/SodaLive/Sources/Chat/Original/Detail/OriginalWorkDetailView.swift new file mode 100644 index 0000000..6b98af2 --- /dev/null +++ b/SodaLive/Sources/Chat/Original/Detail/OriginalWorkDetailView.swift @@ -0,0 +1,117 @@ +// +// OriginalWorkDetailView.swift +// SodaLive +// +// Created by klaus on 9/15/25. +// + +import SwiftUI +import Kingfisher + +struct OriginalWorkDetailView: View { + + @StateObject var viewModel = OriginalWorkDetailViewModel() + + let originalId: Int + + private let horizontalPadding: CGFloat = 12 + private let gridSpacing: CGFloat = 12 + + var body: some View { + NavigationStack { + BaseView(isLoading: $viewModel.isLoading) { + ZStack(alignment: .top) { + if let imageUrl = viewModel.response?.imageUrl { + KFImage(URL(string: imageUrl)) + .resizable() + .scaledToFit() + .frame(maxWidth: .infinity) + .blur(radius: 25) + } + + Color.black.opacity(0.5).ignoresSafeArea() + + VStack(spacing: 0) { + HStack(spacing: 0) { + Image("ic_back") + .resizable() + .frame(width: 24, height: 24) + .onTapGesture { + AppState.shared.back() + } + + Spacer() + } + .padding(.horizontal, 24) + .frame(height: 56) + + if let response = viewModel.response { + GeometryReader { geo in + let totalSpacing: CGFloat = gridSpacing * 2 + let width = (geo.size.width - (horizontalPadding * 2) - totalSpacing) / 3 + + ScrollView(.vertical, showsIndicators: false) { + VStack(spacing: 0) { + OriginalWorkDetailHeaderView(item: response) + .padding(.horizontal, 24) + .padding(.bottom, 24) + + LazyVGrid( + columns: Array( + repeating: GridItem( + .flexible(), + spacing: gridSpacing, + alignment: .topLeading + ), + count: 3 + ), + alignment: .leading, + spacing: gridSpacing + ) { + ForEach(viewModel.characters.indices, id: \.self) { idx in + let item = viewModel.characters[idx] + + NavigationLink(value: item.characterId) { + CharacterItemView( + character: item, + size: width, + rank: 0, + isShowRank: false + ) + .onAppear { viewModel.loadMoreIfNeeded(currentIndex: idx) } + } + } + } + .padding(.horizontal, horizontalPadding) + + if viewModel.isLoadingMore { + HStack { + Spacer() + ProgressView() + .progressViewStyle(CircularProgressViewStyle(tint: .white)) + .padding(.vertical, 16) + Spacer() + } + } + } + } + } + } + } + } + } + .onAppear { + if viewModel.response == nil { + viewModel.originalId = originalId + } + } + .navigationDestination(for: Int.self) { characterId in + CharacterDetailView(characterId: characterId) + } + } + } +} + +#Preview { + OriginalWorkDetailView(originalId: 0) +} diff --git a/SodaLive/Sources/Chat/Original/Detail/OriginalWorkDetailViewModel.swift b/SodaLive/Sources/Chat/Original/Detail/OriginalWorkDetailViewModel.swift new file mode 100644 index 0000000..1ecec0c --- /dev/null +++ b/SodaLive/Sources/Chat/Original/Detail/OriginalWorkDetailViewModel.swift @@ -0,0 +1,132 @@ +// +// OriginalWorkDetailViewModel.swift +// SodaLive +// +// Created by klaus on 9/15/25. +// + +import Foundation +import Combine +import Moya + +final class OriginalWorkDetailViewModel: ObservableObject { + @Published var isLoading = false + @Published var errorMessage = "" + @Published var isShowPopup = false + @Published var isLoadingMore: Bool = false + + @Published private(set) var characters: [Character] = [] + @Published private(set) var totalCount: Int = 0 + @Published private(set) var response: OriginalWorkDetailResponse? = nil + + private let repository = OriginalWorkRepository() + private var subscription = Set() + private var currentPage: Int = 0 + private var hasMorePages: Bool = true + + var originalId: Int = 0 { + didSet { + fetchDetail() + } + } + + // MARK: - API + func loadMoreIfNeeded(currentIndex: Int) { + guard hasMorePages, + !isLoading, + !isLoadingMore, + currentIndex >= characters.count - 3 else { return } + fetchCharacters() + } + + private func fetchDetail() { + isLoading = true + + repository.getOriginalDetail(id: originalId) + .receive(on: DispatchQueue.main) + .sink { [weak self] completion in + switch completion { + case .finished: + DEBUG_LOG("finish") + case .failure(let error): + ERROR_LOG(error.localizedDescription) + self?.isLoading = false + self?.errorMessage = "다시 시도해 주세요.\n계속 같은 문제가 발생할 경우 고객센터로 문의 주시기 바랍니다." + self?.isShowPopup = true + } + } receiveValue: { [weak self] response in + guard let self = self else { return } + do { + let jsonDecoder = JSONDecoder() + let decoded = try jsonDecoder.decode(ApiResponse.self, from: response.data) + if let data = decoded.data, decoded.success { + self.response = data + self.characters = data.characters + self.isLoading = false + } else { + if let message = decoded.message { + self.errorMessage = message + } else { + self.errorMessage = "다시 시도해 주세요.\n계속 같은 문제가 발생할 경우 고객센터로 문의 주시기 바랍니다." + } + self.isShowPopup = true + self.isLoading = false + } + } catch { + self.isLoading = false + self.errorMessage = "다시 시도해 주세요.\n계속 같은 문제가 발생할 경우 고객센터로 문의 주시기 바랍니다." + self.isShowPopup = true + } + } + .store(in: &subscription) + } + + private func fetchCharacters() { + currentPage += 1 + isLoadingMore = true + + repository.getOriginalWorkCharacters(id: originalId, page: currentPage) + .receive(on: DispatchQueue.main) + .sink { [weak self] completion in + switch completion { + case .finished: + DEBUG_LOG("finish") + case .failure(let error): + ERROR_LOG(error.localizedDescription) + self?.isLoadingMore = false + self?.errorMessage = "다시 시도해 주세요.\n계속 같은 문제가 발생할 경우 고객센터로 문의 주시기 바랍니다." + self?.isShowPopup = true + } + } receiveValue: { [weak self] response in + guard let self = self else { return } + do { + let jsonDecoder = JSONDecoder() + let decoded = try jsonDecoder.decode(ApiResponse.self, from: response.data) + if let data = decoded.data, decoded.success { + self.totalCount = data.totalCount + + if !data.content.isEmpty { + self.characters.append(contentsOf: data.content) + } else { + hasMorePages = false + } + + self.isLoadingMore = false + } else { + if let message = decoded.message { + self.errorMessage = message + } else { + self.errorMessage = "다시 시도해 주세요.\n계속 같은 문제가 발생할 경우 고객센터로 문의 주시기 바랍니다." + } + self.isShowPopup = true + self.isLoadingMore = false + } + } catch { + self.isLoading = false + self.errorMessage = "다시 시도해 주세요.\n계속 같은 문제가 발생할 경우 고객센터로 문의 주시기 바랍니다." + self.isShowPopup = true + } + } + .store(in: &subscription) + } +} diff --git a/SodaLive/Sources/Chat/Original/OriginalTabView.swift b/SodaLive/Sources/Chat/Original/OriginalTabView.swift index bd586fa..a8e221e 100644 --- a/SodaLive/Sources/Chat/Original/OriginalTabView.swift +++ b/SodaLive/Sources/Chat/Original/OriginalTabView.swift @@ -41,6 +41,10 @@ struct OriginalTabView: View { size: width ) .onAppear { viewModel.loadMoreIfNeeded(currentIndex: idx) } + .onTapGesture { + AppState.shared + .setAppStep(step: .originalWorkDetail(originalId: item.id)) + } } } .padding(.horizontal, horizontalPadding) diff --git a/SodaLive/Sources/ContentView.swift b/SodaLive/Sources/ContentView.swift index b9ccdb0..57d1880 100644 --- a/SodaLive/Sources/ContentView.swift +++ b/SodaLive/Sources/ContentView.swift @@ -257,6 +257,9 @@ struct ContentView: View { case .newCharacterAll: NewCharacterListView() + case .originalWorkDetail(let originalId): + OriginalWorkDetailView(originalId: originalId) + default: EmptyView() .frame(width: 0, height: 0, alignment: .topLeading)