From 68fd9ee3ad24327fd86a18d64e2bcce72dde07eb Mon Sep 17 00:00:00 2001 From: Yu Sung Date: Thu, 20 Nov 2025 14:52:28 +0900 Subject: [PATCH] =?UTF-8?q?feat(content-all):=20theme,=20=EC=A0=95?= =?UTF-8?q?=EB=A0=AC(=EC=B5=9C=EC=8B=A0=EC=88=9C/=EC=9D=B8=EA=B8=B0?= =?UTF-8?q?=EC=88=9C)=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Sources/Content/All/ContentAllView.swift | 39 ++++++++++ .../Content/All/ContentAllViewModel.swift | 71 ++++++++++++++++++- SodaLive/Sources/Content/ContentApi.swift | 31 +++++++- .../Sources/Content/ContentRepository.swift | 17 ++++- 4 files changed, 152 insertions(+), 6 deletions(-) diff --git a/SodaLive/Sources/Content/All/ContentAllView.swift b/SodaLive/Sources/Content/All/ContentAllView.swift index 203616c..0b627fa 100644 --- a/SodaLive/Sources/Content/All/ContentAllView.swift +++ b/SodaLive/Sources/Content/All/ContentAllView.swift @@ -20,6 +20,44 @@ struct ContentAllView: View { VStack(spacing: 0) { DetailNavigationBar(title: isFree ? "무료 콘텐츠 전체" : isPointAvailableOnly ? "포인트 대여 전체" : "콘텐츠 전체") + if !viewModel.themeList.isEmpty { + ContentMainContentThemeView( + themeList: viewModel.themeList, + selectTheme: viewModel.selectTheme, + selectedTheme: $viewModel.selectedTheme + ) + } + + HStack(spacing: 12) { + Spacer() + + Text("최신순") + .font(.custom(Font.preMedium.rawValue, size: 16)) + .foregroundColor( + Color(hex: "e2e2e2") + .opacity(viewModel.sort == .NEWEST ? 1 : 0.5) + ) + .onTapGesture { + if viewModel.sort != .NEWEST { + viewModel.sort = .NEWEST + } + } + + Text("인기순") + .font(.custom(Font.preMedium.rawValue, size: 16)) + .foregroundColor( + Color(hex: "e2e2e2") + .opacity(viewModel.sort == .POPULARITY ? 1 : 0.5) + ) + .onTapGesture { + if viewModel.sort != .POPULARITY { + viewModel.sort = .POPULARITY + } + } + } + .padding(.vertical, 12) + .padding(.horizontal, 24) + ScrollView(.vertical, showsIndicators: false) { let horizontalPadding: CGFloat = 24 let gridSpacing: CGFloat = 16 @@ -95,6 +133,7 @@ struct ContentAllView: View { .onAppear { viewModel.isFree = isFree viewModel.isPointAvailableOnly = isPointAvailableOnly + viewModel.getThemeList() viewModel.fetchData() } } diff --git a/SodaLive/Sources/Content/All/ContentAllViewModel.swift b/SodaLive/Sources/Content/All/ContentAllViewModel.swift index a396102..04664ce 100644 --- a/SodaLive/Sources/Content/All/ContentAllViewModel.swift +++ b/SodaLive/Sources/Content/All/ContentAllViewModel.swift @@ -9,6 +9,10 @@ import Foundation import Combine final class ContentAllViewModel: ObservableObject { + enum Sort: String { + case NEWEST, POPULARITY + } + private let repository = ContentRepository() private var subscription = Set() @@ -16,11 +20,30 @@ final class ContentAllViewModel: ObservableObject { @Published var isShowPopup = false @Published var isLoading = false + @Published var themeList = [String]() @Published var contentList = [AudioContentMainItem]() + @Published var sort = Sort.NEWEST { + didSet { + page = 1 + isLast = false + contentList.removeAll() + fetchData() + } + } + + @Published var selectedTheme = "전체" { + didSet { + page = 1 + isLast = false + contentList.removeAll() + fetchData() + } + } + var page = 1 var isLast = false - private let pageSize = 10 + private let pageSize = 20 var isFree: Bool = false var isPointAvailableOnly: Bool = false @@ -29,7 +52,7 @@ final class ContentAllViewModel: ObservableObject { if !isLast && !isLoading { isLoading = true - repository.getAllAudioContents(page: page, size: pageSize, isFree: isFree, isPointAvailableOnly: isPointAvailableOnly) + repository.getAllAudioContents(page: page, size: pageSize, isFree: isFree, isPointAvailableOnly: isPointAvailableOnly, sortType: sort, theme: selectedTheme == "전체" ? nil : selectedTheme) .sink { result in switch result { case .finished: @@ -75,4 +98,48 @@ final class ContentAllViewModel: ObservableObject { .store(in: &subscription) } } + + func getThemeList() { + repository.getAudioContentActiveThemeList(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<[String]>.self, from: responseData) + self.isLoading = false + + if let data = decoded.data, decoded.success { + self.themeList = ["전체"] + data + } 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) + } + + func selectTheme(theme: String) { + if selectedTheme != theme { + selectedTheme = theme + } + } } diff --git a/SodaLive/Sources/Content/ContentApi.swift b/SodaLive/Sources/Content/ContentApi.swift index 6cf9e3d..09f57b8 100644 --- a/SodaLive/Sources/Content/ContentApi.swift +++ b/SodaLive/Sources/Content/ContentApi.swift @@ -68,7 +68,8 @@ enum ContentApi { 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?) + case getAllAudioContents(isAdultContentVisible: Bool, contentType: ContentType, page: Int, size: Int, isFree: Bool?, isPointAvailableOnly: Bool?, sortType: ContentAllViewModel.Sort = .NEWEST, theme: String? = nil) + case getAudioContentActiveThemeList(isAdultContentVisible: Bool, contentType: ContentType, isFree: Bool?, isPointAvailableOnly: Bool?) } extension ContentApi: TargetType { @@ -233,6 +234,9 @@ extension ContentApi: TargetType { case .getAllAudioContents: return "/audio-content/all" + + case .getAudioContentActiveThemeList: + return "/audio-content/theme/active" } } @@ -259,7 +263,7 @@ extension ContentApi: TargetType { case .registerComment, .orderAudioContent, .addAllPlaybackTracking, .uploadAudioContent, .donation, .pinContent: return .post - case .getAllAudioContents: + case .getAllAudioContents, .getAudioContentActiveThemeList: return .get case .deleteAudioContent: @@ -550,10 +554,11 @@ extension ContentApi: TargetType { return .requestParameters(parameters: parameters, encoding: URLEncoding.queryString) - case .getAllAudioContents(let isAdultContentVisible, let contentType, let page, let size, let isFree, let isPointAvailableOnly): + case .getAllAudioContents(let isAdultContentVisible, let contentType, let page, let size, let isFree, let isPointAvailableOnly, let sortType, let theme): var parameters = [ "isAdultContentVisible": isAdultContentVisible, "contentType": contentType, + "sort-type": sortType, "page": page - 1, "size": size ] as [String : Any] @@ -566,6 +571,26 @@ extension ContentApi: TargetType { parameters["isPointAvailableOnly"] = isPointAvailableOnly } + if let theme = theme { + parameters["theme"] = theme + } + + return .requestParameters(parameters: parameters, encoding: URLEncoding.queryString) + + case .getAudioContentActiveThemeList(let isAdultContentVisible, let contentType, let isFree, let isPointAvailableOnly): + var parameters = [ + "isAdultContentVisible": isAdultContentVisible, + "contentType": contentType, + ] 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 22ddb1b..7aad1a0 100644 --- a/SodaLive/Sources/Content/ContentRepository.swift +++ b/SodaLive/Sources/Content/ContentRepository.swift @@ -200,7 +200,9 @@ final class ContentRepository { page: Int, size: Int, isFree: Bool? = nil, - isPointAvailableOnly: Bool? = nil + isPointAvailableOnly: Bool? = nil, + sortType: ContentAllViewModel.Sort, + theme: String? ) -> AnyPublisher { return api.requestPublisher( .getAllAudioContents( @@ -209,6 +211,19 @@ final class ContentRepository { page: page, size: size, isFree: isFree, + isPointAvailableOnly: isPointAvailableOnly, + sortType: sortType, + theme: theme + ) + ) + } + + func getAudioContentActiveThemeList(isFree: Bool, isPointAvailableOnly: Bool) -> AnyPublisher { + return api.requestPublisher( + .getAudioContentActiveThemeList( + isAdultContentVisible: UserDefaults.isAdultContentVisible(), + contentType: ContentType(rawValue: UserDefaults.string(forKey: .contentPreference)) ?? ContentType.ALL, + isFree: isFree, isPointAvailableOnly: isPointAvailableOnly ) )