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