From c4a77425142392eff8845641983dc31519ead7a6 Mon Sep 17 00:00:00 2001 From: Yu Sung Date: Fri, 14 Nov 2025 04:21:39 +0900 Subject: [PATCH] =?UTF-8?q?feat(audio-content-all):=20=EB=AC=B4=EB=A3=8C?= =?UTF-8?q?=20=EC=BD=98=ED=85=90=EC=B8=A0,=20=ED=8F=AC=EC=9D=B8=ED=8A=B8?= =?UTF-8?q?=20=EB=8C=80=EC=97=AC=20=EC=BD=98=ED=85=90=EC=B8=A0=20=EC=A0=84?= =?UTF-8?q?=EC=B2=B4=EB=B3=B4=EA=B8=B0=20=ED=8E=98=EC=9D=B4=EC=A7=80=20UI/?= =?UTF-8?q?API=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 + .../Sources/Content/All/ContentAllView.swift | 107 ++++++++++++++++++ .../Content/All/ContentAllViewModel.swift | 78 +++++++++++++ SodaLive/Sources/Content/ContentApi.swift | 26 +++++ .../Sources/Content/ContentRepository.swift | 18 +++ SodaLive/Sources/ContentView.swift | 3 + SodaLive/Sources/Home/HomeTabView.swift | 20 ++++ 7 files changed, 254 insertions(+) create mode 100644 SodaLive/Sources/Content/All/ContentAllView.swift create mode 100644 SodaLive/Sources/Content/All/ContentAllViewModel.swift diff --git a/SodaLive/Sources/App/AppStep.swift b/SodaLive/Sources/App/AppStep.swift index 1172de0..25bd234 100644 --- a/SodaLive/Sources/App/AppStep.swift +++ b/SodaLive/Sources/App/AppStep.swift @@ -169,4 +169,6 @@ enum AppStep { case newCharacterAll case originalWorkDetail(originalId: Int) + + case contentAll(isFree: Bool = false, isPointOnly: Bool = false) } diff --git a/SodaLive/Sources/Content/All/ContentAllView.swift b/SodaLive/Sources/Content/All/ContentAllView.swift new file mode 100644 index 0000000..203616c --- /dev/null +++ b/SodaLive/Sources/Content/All/ContentAllView.swift @@ -0,0 +1,107 @@ +// +// ContentAllView.swift +// SodaLive +// +// Created by klaus on 11/14/25. +// + +import SwiftUI + +struct ContentAllView: View { + + @StateObject var viewModel = ContentAllViewModel() + + var isFree: Bool = false + var isPointAvailableOnly: Bool = false + + var body: some View { + NavigationView { + BaseView(isLoading: $viewModel.isLoading) { + VStack(spacing: 0) { + DetailNavigationBar(title: isFree ? "무료 콘텐츠 전체" : isPointAvailableOnly ? "포인트 대여 전체" : "콘텐츠 전체") + + ScrollView(.vertical, showsIndicators: false) { + let horizontalPadding: CGFloat = 24 + let gridSpacing: CGFloat = 16 + let itemSize = (screenSize().width - (horizontalPadding * 2) - gridSpacing) / 2 + + LazyVGrid( + columns: Array( + repeating: GridItem( + .flexible(), + spacing: gridSpacing, + alignment: .topLeading + ), + count: 2 + ), + alignment: .leading, + spacing: gridSpacing + ) { + ForEach(viewModel.contentList.indices, id: \.self) { idx in + let item = viewModel.contentList[idx] + + NavigationLink { + ContentDetailView(contentId: item.contentId) + } label: { + VStack(alignment: .leading, spacing: 0) { + ZStack(alignment: .top) { + DownsampledKFImage( + url: URL(string: item.coverImageUrl), + size: CGSize(width: itemSize, height: itemSize) + ) + .cornerRadius(16) + + HStack(alignment: .top, spacing: 0) { + Spacer() + + if item.isPointAvailable { + Image("ic_point") + .padding(.top, 6) + .padding(.trailing, 6) + } + } + } + + Text(item.title) + .font(.custom(Font.preRegular.rawValue, size: 18)) + .foregroundColor(.white) + .multilineTextAlignment(.leading) + .fixedSize(horizontal: false, vertical: true) + .lineLimit(1) + .padding(.horizontal, 6) + .padding(.top, 8) + + + Text(item.creatorNickname) + .font(.custom(Font.preRegular.rawValue, size: 14)) + .foregroundColor(Color(hex: "78909C")) + .lineLimit(1) + .padding(.horizontal, 6) + .padding(.top, 4) + } + .frame(width: itemSize) + .contentShape(Rectangle()) + .onAppear { + if idx == viewModel.contentList.count - 1 { + viewModel.fetchData() + } + } + } + } + } + .padding(horizontalPadding) + } + } + .onAppear { + viewModel.isFree = isFree + viewModel.isPointAvailableOnly = isPointAvailableOnly + viewModel.fetchData() + } + } + } + } +} + +#Preview { + ContentAllView() +} diff --git a/SodaLive/Sources/Content/All/ContentAllViewModel.swift b/SodaLive/Sources/Content/All/ContentAllViewModel.swift new file mode 100644 index 0000000..a396102 --- /dev/null +++ b/SodaLive/Sources/Content/All/ContentAllViewModel.swift @@ -0,0 +1,78 @@ +// +// ContentAllViewModel.swift +// SodaLive +// +// Created by klaus on 11/14/25. +// + +import Foundation +import Combine + +final class ContentAllViewModel: ObservableObject { + private let repository = ContentRepository() + private var subscription = Set() + + @Published var errorMessage = "" + @Published var isShowPopup = false + @Published var isLoading = false + + @Published var contentList = [AudioContentMainItem]() + + var page = 1 + var isLast = false + private let pageSize = 10 + + var isFree: Bool = false + var isPointAvailableOnly: Bool = false + + func fetchData() { + if !isLast && !isLoading { + isLoading = true + + repository.getAllAudioContents(page: page, size: pageSize, isFree: isFree, isPointAvailableOnly: isPointAvailableOnly) + .sink { result in + switch result { + case .finished: + DEBUG_LOG("finish") + case .failure(let error): + ERROR_LOG(error.localizedDescription) + } + } receiveValue: { [unowned self] response in + self.isLoading = false + let responseData = response.data + + do { + let jsonDecoder = JSONDecoder() + let decoded = try jsonDecoder.decode(ApiResponse<[AudioContentMainItem]>.self, from: responseData) + self.isLoading = false + + if let data = decoded.data, decoded.success { + if page == 1 { + contentList.removeAll() + } + + if !data.isEmpty { + page += 1 + self.contentList.append(contentsOf: data) + } else { + isLast = true + } + } else { + if let message = decoded.message { + self.errorMessage = message + } else { + self.errorMessage = "다시 시도해 주세요.\n계속 같은 문제가 발생할 경우 고객센터로 문의 주시기 바랍니다." + } + + self.isShowPopup = true + } + } catch { + self.errorMessage = "다시 시도해 주세요.\n계속 같은 문제가 발생할 경우 고객센터로 문의 주시기 바랍니다." + self.isShowPopup = true + self.isLoading = false + } + } + .store(in: &subscription) + } + } +} diff --git a/SodaLive/Sources/Content/ContentApi.swift b/SodaLive/Sources/Content/ContentApi.swift index cd26cfa..6cf9e3d 100644 --- a/SodaLive/Sources/Content/ContentApi.swift +++ b/SodaLive/Sources/Content/ContentApi.swift @@ -67,6 +67,8 @@ enum ContentApi { case getIntroduceCreatorList(isAdultContentVisible: Bool, contentType: ContentType, page: Int, size: Int) case getNewFreeContentOfTheme(theme: String, isAdultContentVisible: Bool, contentType: ContentType, page: Int, size: Int) case getPopularFreeContentByCreator(creatorId: Int, isAdultContentVisible: Bool, contentType: ContentType) + + case getAllAudioContents(isAdultContentVisible: Bool, contentType: ContentType, page: Int, size: Int, isFree: Bool?, isPointAvailableOnly: Bool?) } extension ContentApi: TargetType { @@ -228,6 +230,9 @@ extension ContentApi: TargetType { case .getPopularFreeContentByCreator: return "/v2/audio-content/main/free/popular-content-by-creator" + + case .getAllAudioContents: + return "/audio-content/all" } } @@ -254,6 +259,9 @@ extension ContentApi: TargetType { case .registerComment, .orderAudioContent, .addAllPlaybackTracking, .uploadAudioContent, .donation, .pinContent: return .post + case .getAllAudioContents: + return .get + case .deleteAudioContent: return .delete } @@ -540,6 +548,24 @@ extension ContentApi: TargetType { "size": size ] as [String : Any] + return .requestParameters(parameters: parameters, encoding: URLEncoding.queryString) + + case .getAllAudioContents(let isAdultContentVisible, let contentType, let page, let size, let isFree, let isPointAvailableOnly): + var parameters = [ + "isAdultContentVisible": isAdultContentVisible, + "contentType": contentType, + "page": page - 1, + "size": size + ] as [String : Any] + + if let isFree = isFree { + parameters["isFree"] = isFree + } + + if let isPointAvailableOnly = isPointAvailableOnly { + parameters["isPointAvailableOnly"] = isPointAvailableOnly + } + return .requestParameters(parameters: parameters, encoding: URLEncoding.queryString) } } diff --git a/SodaLive/Sources/Content/ContentRepository.swift b/SodaLive/Sources/Content/ContentRepository.swift index 3854545..22ddb1b 100644 --- a/SodaLive/Sources/Content/ContentRepository.swift +++ b/SodaLive/Sources/Content/ContentRepository.swift @@ -195,4 +195,22 @@ final class ContentRepository { func getCreatorRanking() -> AnyPublisher { return explorerApi.requestPublisher(.getCreatorRank) } + + func getAllAudioContents( + page: Int, + size: Int, + isFree: Bool? = nil, + isPointAvailableOnly: Bool? = nil + ) -> AnyPublisher { + return api.requestPublisher( + .getAllAudioContents( + isAdultContentVisible: UserDefaults.isAdultContentVisible(), + contentType: ContentType(rawValue: UserDefaults.string(forKey: .contentPreference)) ?? ContentType.ALL, + page: page, + size: size, + isFree: isFree, + isPointAvailableOnly: isPointAvailableOnly + ) + ) + } } diff --git a/SodaLive/Sources/ContentView.swift b/SodaLive/Sources/ContentView.swift index cadc5e8..10a2f80 100644 --- a/SodaLive/Sources/ContentView.swift +++ b/SodaLive/Sources/ContentView.swift @@ -262,6 +262,9 @@ struct ContentView: View { case .originalWorkDetail(let originalId): OriginalWorkDetailView(originalId: originalId) + case .contentAll(let isFree, let isPointOnly): + ContentAllView(isFree: isFree, isPointAvailableOnly: isPointOnly) + default: EmptyView() .frame(width: 0, height: 0, alignment: .topLeading) diff --git a/SodaLive/Sources/Home/HomeTabView.swift b/SodaLive/Sources/Home/HomeTabView.swift index 9a1a261..d74e872 100644 --- a/SodaLive/Sources/Home/HomeTabView.swift +++ b/SodaLive/Sources/Home/HomeTabView.swift @@ -289,6 +289,16 @@ struct HomeTabView: View { Text(" 콘텐츠") .font(.custom(Font.preBold.rawValue, size: 24)) .foregroundColor(.white) + + Spacer() + + Text("전체보기") + .font(.custom(Font.preRegular.rawValue, size: 14)) + .foregroundColor(.init(hex: "78909C")) + .onTapGesture { + AppState.shared + .setAppStep(step: .contentAll(isFree: true, isPointOnly: false)) + } } .padding(.horizontal, 24) @@ -313,6 +323,16 @@ struct HomeTabView: View { Text(" 대여 콘텐츠") .font(.custom(Font.preBold.rawValue, size: 24)) .foregroundColor(.white) + + Spacer() + + Text("전체보기") + .font(.custom(Font.preRegular.rawValue, size: 14)) + .foregroundColor(.init(hex: "78909C")) + .onTapGesture { + AppState.shared + .setAppStep(step: .contentAll(isFree: false, isPointOnly: true)) + } } .padding(.horizontal, 24)