diff --git a/SodaLive/Sources/Chat/ChatTabView.swift b/SodaLive/Sources/Chat/ChatTabView.swift index 264bcc7..3487215 100644 --- a/SodaLive/Sources/Chat/ChatTabView.swift +++ b/SodaLive/Sources/Chat/ChatTabView.swift @@ -16,11 +16,13 @@ struct ChatTabView: View { private enum InnerTab: Int, CaseIterable { case character = 0 - case talk = 1 + case original = 1 + case talk = 2 var title: String { switch self { case .character: return "캐릭터" + case .original: return "작품별" case .talk: return "톡" } } @@ -103,6 +105,12 @@ struct ChatTabView: View { onTap: { if selectedTab != .character { selectedTab = .character } } ) + ChatInnerTab( + title: InnerTab.original.title, + isSelected: selectedTab == .original, + onTap: { if selectedTab != .original { selectedTab = .original } } + ) + ChatInnerTab( title: InnerTab.talk.title, isSelected: selectedTab == .talk, @@ -115,6 +123,8 @@ struct ChatTabView: View { switch selectedTab { case .character: CharacterView(onSelectCharacter: handleCharacterSelection) + case .original: + OriginalTabView() case .talk: TalkView() } diff --git a/SodaLive/Sources/Chat/Original/OriginalTabItemView.swift b/SodaLive/Sources/Chat/Original/OriginalTabItemView.swift new file mode 100644 index 0000000..82dc090 --- /dev/null +++ b/SodaLive/Sources/Chat/Original/OriginalTabItemView.swift @@ -0,0 +1,54 @@ +// +// OriginalTabItemView.swift +// SodaLive +// +// Created by klaus on 9/15/25. +// + +import SwiftUI +import Kingfisher + +struct OriginalTabItemView: View { + + let item: OriginalWorkListItemResponse + let size: CGFloat + + var body: some View { + VStack(alignment: .leading, spacing: 4) { + KFImage(URL(string: item.imageUrl!)) + .placeholder { Color.gray.opacity(0.2) } + .retry(maxCount: 2, interval: .seconds(1)) + .cancelOnDisappear(true) + .resizable() + .scaledToFill() + .frame(width: size, height: size * 432 / 306) + .clipped() + .cornerRadius(16) + + Text(item.title) + .font(.custom(Font.preRegular.rawValue, size: 18)) + .foregroundColor(.white) + .lineLimit(1) + .truncationMode(.tail) + + Text(item.contentType) + .font(.custom(Font.preRegular.rawValue, size: 14)) + .foregroundColor(Color(hex: "78909C")) + .lineLimit(1) + .truncationMode(.tail) + } + .frame(width: size, alignment: .leading) + } +} + +#Preview { + OriginalTabItemView( + item: OriginalWorkListItemResponse( + id: 1, + imageUrl: "https://picsum.photos/300", + title: "작품제목", + contentType: "웹툰" + ), + size: 106 + ) +} diff --git a/SodaLive/Sources/Chat/Original/OriginalTabView.swift b/SodaLive/Sources/Chat/Original/OriginalTabView.swift new file mode 100644 index 0000000..bd586fa --- /dev/null +++ b/SodaLive/Sources/Chat/Original/OriginalTabView.swift @@ -0,0 +1,90 @@ +// +// OriginalTabView.swift +// SodaLive +// +// Created by klaus on 9/15/25. +// + +import SwiftUI + +struct OriginalTabView: View { + + @StateObject var viewModel = OriginalWorkViewModel() + + private let horizontalPadding: CGFloat = 12 + private let gridSpacing: CGFloat = 12 + + var body: some View { + BaseView(isLoading: $viewModel.isLoading) { + GeometryReader { geo in + let totalSpacing: CGFloat = gridSpacing * 2 + let width = (geo.size.width - (horizontalPadding * 2) - totalSpacing) / 3 + + ScrollView(.vertical, showsIndicators: false) { + LazyVGrid( + columns: Array( + repeating: GridItem( + .flexible(), + spacing: gridSpacing, + alignment: .topLeading + ), + count: 3 + ), + alignment: .leading, + spacing: gridSpacing + ) { + ForEach(viewModel.items.indices, id: \.self) { idx in + let item = viewModel.items[idx] + + OriginalTabItemView( + item: item, + size: width + ) + .onAppear { viewModel.loadMoreIfNeeded(currentIndex: idx) } + } + } + .padding(.horizontal, horizontalPadding) + + if viewModel.isLoadingMore { + HStack { + Spacer() + ProgressView() + .progressViewStyle(CircularProgressViewStyle(tint: .white)) + .padding(.vertical, 16) + Spacer() + } + } + } + } + .frame(minHeight: 0, maxHeight: .infinity) + .padding(.vertical, 12) + .onAppear { + // 최초 1회만 로드하여 상세 진입 후 복귀 시 스크롤 위치가 유지되도록 함 + if viewModel.items.isEmpty { + viewModel.fetch() + } + } + } + .popup(isPresented: $viewModel.isShowPopup, type: .toast, position: .top, autohideIn: 2) { + GeometryReader { geo in + HStack { + Spacer() + Text(viewModel.errorMessage) + .padding(.vertical, 13.3) + .frame(width: geo.size.width - 66.7, alignment: .center) + .font(.custom(Font.medium.rawValue, size: 12)) + .background(Color.button) + .foregroundColor(Color.white) + .multilineTextAlignment(.center) + .cornerRadius(20) + .padding(.top, 66.7) + Spacer() + } + } + } + } +} + +#Preview { + OriginalTabView() +} diff --git a/SodaLive/Sources/Chat/Original/OriginalWorkApi.swift b/SodaLive/Sources/Chat/Original/OriginalWorkApi.swift new file mode 100644 index 0000000..45539e5 --- /dev/null +++ b/SodaLive/Sources/Chat/Original/OriginalWorkApi.swift @@ -0,0 +1,59 @@ +// +// OriginalWorkApi.swift +// SodaLive +// +// Created by klaus on 9/15/25. +// + +import Foundation +import Moya + +enum OriginalWorkApi { + case getOriginalWorks(page: Int, size: Int) + case getOriginalDetail(id: Int) + case getOriginalWorkCharacters(id: Int, page: Int, size: Int) +} + +extension OriginalWorkApi: TargetType { + var baseURL: URL { URL(string: BASE_URL)! } + + var path: String { + switch self { + case .getOriginalWorks: + return "/api/chat/original/list" + + case .getOriginalDetail(let id): + return "/api/chat/original/\(id)" + + case .getOriginalWorkCharacters(let id, _, _): + return "/api/chat/original/\(id)/characters" + } + } + + var method: Moya.Method { + return .get + } + + var task: Moya.Task { + switch self { + case .getOriginalWorks(let page, let size): + return .requestParameters( + parameters: ["page": page, "size": size], + encoding: URLEncoding.queryString + ) + + case .getOriginalDetail: + return .requestPlain + + case .getOriginalWorkCharacters(_, let page, let size): + return .requestParameters( + parameters: ["page": page, "size": size], + encoding: URLEncoding.queryString + ) + } + } + + var headers: [String : String]? { + ["Authorization": "Bearer \(UserDefaults.string(forKey: UserDefaultsKey.token))"] + } +} diff --git a/SodaLive/Sources/Chat/Original/OriginalWorkListResponse.swift b/SodaLive/Sources/Chat/Original/OriginalWorkListResponse.swift new file mode 100644 index 0000000..5d779e2 --- /dev/null +++ b/SodaLive/Sources/Chat/Original/OriginalWorkListResponse.swift @@ -0,0 +1,18 @@ +// +// OriginalWorkListResponse.swift +// SodaLive +// +// Created by klaus on 9/15/25. +// + +struct OriginalWorkListResponse: Decodable { + let totalCount: Int + let content: [OriginalWorkListItemResponse] +} + +struct OriginalWorkListItemResponse: Decodable { + let id: Int + let imageUrl: String? + let title: String + let contentType: String +} diff --git a/SodaLive/Sources/Chat/Original/OriginalWorkRepository.swift b/SodaLive/Sources/Chat/Original/OriginalWorkRepository.swift new file mode 100644 index 0000000..8669c09 --- /dev/null +++ b/SodaLive/Sources/Chat/Original/OriginalWorkRepository.swift @@ -0,0 +1,27 @@ +// +// OriginalWorkRepository.swift +// SodaLive +// +// Created by klaus on 9/15/25. +// + +import Foundation +import CombineMoya +import Combine +import Moya + +class OriginalWorkRepository { + private let api = MoyaProvider() + + func getOriginalWorks(page: Int) -> AnyPublisher { + return api.requestPublisher(.getOriginalWorks(page: page, size: 20)) + } + + func getOriginalDetail(id: Int) -> AnyPublisher { + return api.requestPublisher(.getOriginalDetail(id: id)) + } + + func getOriginalWorkCharacters(id: Int, page: Int) -> AnyPublisher { + return api.requestPublisher(.getOriginalWorkCharacters(id: id, page: page, size: 20)) + } +} diff --git a/SodaLive/Sources/Chat/Original/OriginalWorkViewModel.swift b/SodaLive/Sources/Chat/Original/OriginalWorkViewModel.swift new file mode 100644 index 0000000..204dd09 --- /dev/null +++ b/SodaLive/Sources/Chat/Original/OriginalWorkViewModel.swift @@ -0,0 +1,116 @@ +// +// OriginalWorkViewModel.swift +// SodaLive +// +// Created by klaus on 9/15/25. +// + +import Foundation +import Combine +import Moya + +final class OriginalWorkViewModel: ObservableObject { + // MARK: - Outputs + @Published private(set) var totalCount: Int = 0 + @Published private(set) var items = [OriginalWorkListItemResponse]() + @Published var isLoading: Bool = false + @Published var isLoadingMore: Bool = false + @Published var errorMessage: String = "" + @Published var isShowPopup: Bool = false + + // MARK: - Private + private let repository = OriginalWorkRepository() + private var subscription = Set() + private var currentPage: Int = 0 + private var hasMorePages: Bool = true + + // MARK: - API + func fetch() { + // 초기 로드 + currentPage = 0 + hasMorePages = true + items.removeAll() + request(page: currentPage) + } + + func loadMoreIfNeeded(currentIndex: Int) { + guard hasMorePages, + !isLoading, + !isLoadingMore, + currentIndex >= items.count - 1 else { return } + loadMore() + } + + // MARK: - Private + private func loadMore() { + guard hasMorePages, !isLoadingMore else { return } + isLoadingMore = true + currentPage += 1 + request(page: currentPage, isLoadMore: true) + } + + private func request(page: Int, isLoadMore: Bool = false) { + if !isLoadMore { + isLoading = true + } + + repository.getOriginalWorks(page: page) + .receive(on: DispatchQueue.main) + .sink { [weak self] completion in + switch completion { + case .finished: + DEBUG_LOG("finish") + case .failure(let error): + ERROR_LOG(error.localizedDescription) + if isLoadMore { + self?.isLoadingMore = false + } else { + 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.totalCount = data.totalCount + if isLoadMore { + self.items.append(contentsOf: data.content) + self.isLoadingMore = false + } else { + self.items = data.content + self.isLoading = false + } + // hasMore 계산 (총 개수 대비 현재 로드 수) + if self.items.count >= self.totalCount || data.content.isEmpty { + self.hasMorePages = false + } + } else { + if let message = decoded.message { + self.errorMessage = message + } else { + self.errorMessage = "다시 시도해 주세요.\n계속 같은 문제가 발생할 경우 고객센터로 문의 주시기 바랍니다." + } + self.isShowPopup = true + if isLoadMore { + self.isLoadingMore = false + } else { + self.isLoading = false + } + } + } catch { + if isLoadMore { + self.isLoadingMore = false + } else { + self.isLoading = false + } + self.errorMessage = "다시 시도해 주세요.\n계속 같은 문제가 발생할 경우 고객센터로 문의 주시기 바랍니다." + self.isShowPopup = true + } + } + .store(in: &subscription) + } +}