feat(chat-original): 원작 상세 화면 및 캐릭터 무한 스크롤 로딩 구현
This commit is contained in:
@@ -167,4 +167,6 @@ enum AppStep {
|
|||||||
case chatRoom(id: Int)
|
case chatRoom(id: Int)
|
||||||
|
|
||||||
case newCharacterAll
|
case newCharacterAll
|
||||||
|
|
||||||
|
case originalWorkDetail(originalId: Int)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,11 @@
|
|||||||
|
//
|
||||||
|
// OriginalWorkCharactersPageResponse.swift
|
||||||
|
// SodaLive
|
||||||
|
//
|
||||||
|
// Created by klaus on 9/15/25.
|
||||||
|
//
|
||||||
|
|
||||||
|
struct OriginalWorkCharactersPageResponse: Decodable {
|
||||||
|
let totalCount: Int
|
||||||
|
let content: [Character]
|
||||||
|
}
|
||||||
@@ -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: []
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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]
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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<AnyCancellable>()
|
||||||
|
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<OriginalWorkDetailResponse>.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<OriginalWorkCharactersPageResponse>.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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -41,6 +41,10 @@ struct OriginalTabView: View {
|
|||||||
size: width
|
size: width
|
||||||
)
|
)
|
||||||
.onAppear { viewModel.loadMoreIfNeeded(currentIndex: idx) }
|
.onAppear { viewModel.loadMoreIfNeeded(currentIndex: idx) }
|
||||||
|
.onTapGesture {
|
||||||
|
AppState.shared
|
||||||
|
.setAppStep(step: .originalWorkDetail(originalId: item.id))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.padding(.horizontal, horizontalPadding)
|
.padding(.horizontal, horizontalPadding)
|
||||||
|
|||||||
@@ -257,6 +257,9 @@ struct ContentView: View {
|
|||||||
case .newCharacterAll:
|
case .newCharacterAll:
|
||||||
NewCharacterListView()
|
NewCharacterListView()
|
||||||
|
|
||||||
|
case .originalWorkDetail(let originalId):
|
||||||
|
OriginalWorkDetailView(originalId: originalId)
|
||||||
|
|
||||||
default:
|
default:
|
||||||
EmptyView()
|
EmptyView()
|
||||||
.frame(width: 0, height: 0, alignment: .topLeading)
|
.frame(width: 0, height: 0, alignment: .topLeading)
|
||||||
|
|||||||
Reference in New Issue
Block a user