feat(content): 전체 탭을 추가한다
This commit is contained in:
@@ -7,6 +7,24 @@
|
|||||||
objects = {
|
objects = {
|
||||||
|
|
||||||
/* Begin PBXBuildFile section */
|
/* Begin PBXBuildFile section */
|
||||||
|
C0A711000000000000000001 /* MainContentAllTabResponse.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0A710000000000000000001 /* MainContentAllTabResponse.swift */; };
|
||||||
|
C0A712000000000000000001 /* MainContentAllTabResponse.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0A710000000000000000001 /* MainContentAllTabResponse.swift */; };
|
||||||
|
C0A713000000000000000001 /* MainContentAllTabResponse.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0A710000000000000000001 /* MainContentAllTabResponse.swift */; };
|
||||||
|
C0A711000000000000000002 /* MainContentAllApi.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0A710000000000000000002 /* MainContentAllApi.swift */; };
|
||||||
|
C0A712000000000000000002 /* MainContentAllApi.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0A710000000000000000002 /* MainContentAllApi.swift */; };
|
||||||
|
C0A713000000000000000002 /* MainContentAllApi.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0A710000000000000000002 /* MainContentAllApi.swift */; };
|
||||||
|
C0A711000000000000000003 /* MainContentAllRepository.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0A710000000000000000003 /* MainContentAllRepository.swift */; };
|
||||||
|
C0A712000000000000000003 /* MainContentAllRepository.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0A710000000000000000003 /* MainContentAllRepository.swift */; };
|
||||||
|
C0A713000000000000000003 /* MainContentAllRepository.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0A710000000000000000003 /* MainContentAllRepository.swift */; };
|
||||||
|
C0A711000000000000000004 /* MainContentAllViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0A710000000000000000004 /* MainContentAllViewModel.swift */; };
|
||||||
|
C0A712000000000000000004 /* MainContentAllViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0A710000000000000000004 /* MainContentAllViewModel.swift */; };
|
||||||
|
C0A713000000000000000004 /* MainContentAllViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0A710000000000000000004 /* MainContentAllViewModel.swift */; };
|
||||||
|
C0A711000000000000000005 /* MainContentAllView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0A710000000000000000005 /* MainContentAllView.swift */; };
|
||||||
|
C0A712000000000000000005 /* MainContentAllView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0A710000000000000000005 /* MainContentAllView.swift */; };
|
||||||
|
C0A713000000000000000005 /* MainContentAllView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0A710000000000000000005 /* MainContentAllView.swift */; };
|
||||||
|
C0A711000000000000000006 /* MainContentSeriesThumbnailCard.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0A710000000000000000006 /* MainContentSeriesThumbnailCard.swift */; };
|
||||||
|
C0A712000000000000000006 /* MainContentSeriesThumbnailCard.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0A710000000000000000006 /* MainContentSeriesThumbnailCard.swift */; };
|
||||||
|
C0A713000000000000000006 /* MainContentSeriesThumbnailCard.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0A710000000000000000006 /* MainContentSeriesThumbnailCard.swift */; };
|
||||||
C0A701000000000000000001 /* MainContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0A700000000000000000001 /* MainContentView.swift */; };
|
C0A701000000000000000001 /* MainContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0A700000000000000000001 /* MainContentView.swift */; };
|
||||||
C0A701000000000000000002 /* MainContentTab.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0A700000000000000000002 /* MainContentTab.swift */; };
|
C0A701000000000000000002 /* MainContentTab.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0A700000000000000000002 /* MainContentTab.swift */; };
|
||||||
C0A701000000000000000003 /* MainContentRecommendationView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0A700000000000000000003 /* MainContentRecommendationView.swift */; };
|
C0A701000000000000000003 /* MainContentRecommendationView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0A700000000000000000003 /* MainContentRecommendationView.swift */; };
|
||||||
@@ -2078,6 +2096,12 @@
|
|||||||
401A3FB52FEEAFA000A5BBA3 /* MainHomeRecommendationView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainHomeRecommendationView.swift; sourceTree = "<group>"; };
|
401A3FB52FEEAFA000A5BBA3 /* MainHomeRecommendationView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainHomeRecommendationView.swift; sourceTree = "<group>"; };
|
||||||
401A3FB62FEEAFA000A5BBA3 /* MainHomeRankingView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainHomeRankingView.swift; sourceTree = "<group>"; };
|
401A3FB62FEEAFA000A5BBA3 /* MainHomeRankingView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainHomeRankingView.swift; sourceTree = "<group>"; };
|
||||||
401A3FB72FEEAFA000A5BBA3 /* MainHomeFollowingView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainHomeFollowingView.swift; sourceTree = "<group>"; };
|
401A3FB72FEEAFA000A5BBA3 /* MainHomeFollowingView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainHomeFollowingView.swift; sourceTree = "<group>"; };
|
||||||
|
C0A710000000000000000001 /* MainContentAllTabResponse.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainContentAllTabResponse.swift; sourceTree = "<group>"; };
|
||||||
|
C0A710000000000000000002 /* MainContentAllApi.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainContentAllApi.swift; sourceTree = "<group>"; };
|
||||||
|
C0A710000000000000000003 /* MainContentAllRepository.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainContentAllRepository.swift; sourceTree = "<group>"; };
|
||||||
|
C0A710000000000000000004 /* MainContentAllViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainContentAllViewModel.swift; sourceTree = "<group>"; };
|
||||||
|
C0A710000000000000000005 /* MainContentAllView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainContentAllView.swift; sourceTree = "<group>"; };
|
||||||
|
C0A710000000000000000006 /* MainContentSeriesThumbnailCard.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainContentSeriesThumbnailCard.swift; sourceTree = "<group>"; };
|
||||||
C0A700000000000000000001 /* MainContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainContentView.swift; sourceTree = "<group>"; };
|
C0A700000000000000000001 /* MainContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainContentView.swift; sourceTree = "<group>"; };
|
||||||
C0A700000000000000000002 /* MainContentTab.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainContentTab.swift; sourceTree = "<group>"; };
|
C0A700000000000000000002 /* MainContentTab.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainContentTab.swift; sourceTree = "<group>"; };
|
||||||
C0A700000000000000000003 /* MainContentRecommendationView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainContentRecommendationView.swift; sourceTree = "<group>"; };
|
C0A700000000000000000003 /* MainContentRecommendationView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainContentRecommendationView.swift; sourceTree = "<group>"; };
|
||||||
@@ -3155,10 +3179,48 @@
|
|||||||
path = Like;
|
path = Like;
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
};
|
};
|
||||||
|
C0A710000000000000000100 /* All */ = {
|
||||||
|
isa = PBXGroup;
|
||||||
|
children = (
|
||||||
|
C0A710000000000000000103 /* Components */,
|
||||||
|
C0A710000000000000000101 /* Models */,
|
||||||
|
C0A710000000000000000102 /* Repository */,
|
||||||
|
C0A710000000000000000004 /* MainContentAllViewModel.swift */,
|
||||||
|
C0A710000000000000000005 /* MainContentAllView.swift */,
|
||||||
|
);
|
||||||
|
path = All;
|
||||||
|
sourceTree = "<group>";
|
||||||
|
};
|
||||||
|
C0A710000000000000000101 /* Models */ = {
|
||||||
|
isa = PBXGroup;
|
||||||
|
children = (
|
||||||
|
C0A710000000000000000001 /* MainContentAllTabResponse.swift */,
|
||||||
|
);
|
||||||
|
path = Models;
|
||||||
|
sourceTree = "<group>";
|
||||||
|
};
|
||||||
|
C0A710000000000000000102 /* Repository */ = {
|
||||||
|
isa = PBXGroup;
|
||||||
|
children = (
|
||||||
|
C0A710000000000000000002 /* MainContentAllApi.swift */,
|
||||||
|
C0A710000000000000000003 /* MainContentAllRepository.swift */,
|
||||||
|
);
|
||||||
|
path = Repository;
|
||||||
|
sourceTree = "<group>";
|
||||||
|
};
|
||||||
|
C0A710000000000000000103 /* Components */ = {
|
||||||
|
isa = PBXGroup;
|
||||||
|
children = (
|
||||||
|
C0A710000000000000000006 /* MainContentSeriesThumbnailCard.swift */,
|
||||||
|
);
|
||||||
|
path = Components;
|
||||||
|
sourceTree = "<group>";
|
||||||
|
};
|
||||||
C0A700000000000000000101 /* Content */ = {
|
C0A700000000000000000101 /* Content */ = {
|
||||||
isa = PBXGroup;
|
isa = PBXGroup;
|
||||||
children = (
|
children = (
|
||||||
C0A700000000000000000102 /* Recommendation */,
|
C0A700000000000000000102 /* Recommendation */,
|
||||||
|
C0A710000000000000000100 /* All */,
|
||||||
C0A700000000000000000001 /* MainContentView.swift */,
|
C0A700000000000000000001 /* MainContentView.swift */,
|
||||||
C0A700000000000000000002 /* MainContentTab.swift */,
|
C0A700000000000000000002 /* MainContentTab.swift */,
|
||||||
);
|
);
|
||||||
@@ -6334,6 +6396,12 @@
|
|||||||
401A3FD12FEEB00500A5BBA3 /* MainHomeAiCharacterSection.swift in Sources */,
|
401A3FD12FEEB00500A5BBA3 /* MainHomeAiCharacterSection.swift in Sources */,
|
||||||
401A3FE02FEEB00900A5BBA3 /* MainHomeBusinessInfoSection.swift in Sources */,
|
401A3FE02FEEB00900A5BBA3 /* MainHomeBusinessInfoSection.swift in Sources */,
|
||||||
C0A704000000000000000003 /* AudioContentListRow.swift in Sources */,
|
C0A704000000000000000003 /* AudioContentListRow.swift in Sources */,
|
||||||
|
C0A711000000000000000001 /* MainContentAllTabResponse.swift in Sources */,
|
||||||
|
C0A711000000000000000002 /* MainContentAllApi.swift in Sources */,
|
||||||
|
C0A711000000000000000003 /* MainContentAllRepository.swift in Sources */,
|
||||||
|
C0A711000000000000000004 /* MainContentAllViewModel.swift in Sources */,
|
||||||
|
C0A711000000000000000005 /* MainContentAllView.swift in Sources */,
|
||||||
|
C0A711000000000000000006 /* MainContentSeriesThumbnailCard.swift in Sources */,
|
||||||
C0A701000000000000000001 /* MainContentView.swift in Sources */,
|
C0A701000000000000000001 /* MainContentView.swift in Sources */,
|
||||||
C0A701000000000000000002 /* MainContentTab.swift in Sources */,
|
C0A701000000000000000002 /* MainContentTab.swift in Sources */,
|
||||||
C0A701000000000000000003 /* MainContentRecommendationView.swift in Sources */,
|
C0A701000000000000000003 /* MainContentRecommendationView.swift in Sources */,
|
||||||
@@ -7190,6 +7258,12 @@
|
|||||||
401A3FD92FEEB00700A5BBA3 /* MainHomeCheerCreatorSection.swift in Sources */,
|
401A3FD92FEEB00700A5BBA3 /* MainHomeCheerCreatorSection.swift in Sources */,
|
||||||
401A3FDD2FEEB00800A5BBA3 /* MainHomePopularCommunitySection.swift in Sources */,
|
401A3FDD2FEEB00800A5BBA3 /* MainHomePopularCommunitySection.swift in Sources */,
|
||||||
401A3FE12FEEB00900A5BBA3 /* MainHomeBusinessInfoSection.swift in Sources */,
|
401A3FE12FEEB00900A5BBA3 /* MainHomeBusinessInfoSection.swift in Sources */,
|
||||||
|
C0A712000000000000000001 /* MainContentAllTabResponse.swift in Sources */,
|
||||||
|
C0A712000000000000000002 /* MainContentAllApi.swift in Sources */,
|
||||||
|
C0A712000000000000000003 /* MainContentAllRepository.swift in Sources */,
|
||||||
|
C0A712000000000000000004 /* MainContentAllViewModel.swift in Sources */,
|
||||||
|
C0A712000000000000000005 /* MainContentAllView.swift in Sources */,
|
||||||
|
C0A712000000000000000006 /* MainContentSeriesThumbnailCard.swift in Sources */,
|
||||||
C0A702000000000000000001 /* MainContentView.swift in Sources */,
|
C0A702000000000000000001 /* MainContentView.swift in Sources */,
|
||||||
C0A702000000000000000002 /* MainContentTab.swift in Sources */,
|
C0A702000000000000000002 /* MainContentTab.swift in Sources */,
|
||||||
C0A702000000000000000003 /* MainContentRecommendationView.swift in Sources */,
|
C0A702000000000000000003 /* MainContentRecommendationView.swift in Sources */,
|
||||||
@@ -8086,6 +8160,12 @@
|
|||||||
401A3FDA2FEEB00700A5BBA3 /* MainHomeCheerCreatorSection.swift in Sources */,
|
401A3FDA2FEEB00700A5BBA3 /* MainHomeCheerCreatorSection.swift in Sources */,
|
||||||
401A3FDE2FEEB00800A5BBA3 /* MainHomePopularCommunitySection.swift in Sources */,
|
401A3FDE2FEEB00800A5BBA3 /* MainHomePopularCommunitySection.swift in Sources */,
|
||||||
401A3FE22FEEB00900A5BBA3 /* MainHomeBusinessInfoSection.swift in Sources */,
|
401A3FE22FEEB00900A5BBA3 /* MainHomeBusinessInfoSection.swift in Sources */,
|
||||||
|
C0A713000000000000000001 /* MainContentAllTabResponse.swift in Sources */,
|
||||||
|
C0A713000000000000000002 /* MainContentAllApi.swift in Sources */,
|
||||||
|
C0A713000000000000000003 /* MainContentAllRepository.swift in Sources */,
|
||||||
|
C0A713000000000000000004 /* MainContentAllViewModel.swift in Sources */,
|
||||||
|
C0A713000000000000000005 /* MainContentAllView.swift in Sources */,
|
||||||
|
C0A713000000000000000006 /* MainContentSeriesThumbnailCard.swift in Sources */,
|
||||||
C0A703000000000000000001 /* MainContentView.swift in Sources */,
|
C0A703000000000000000001 /* MainContentView.swift in Sources */,
|
||||||
C0A703000000000000000002 /* MainContentTab.swift in Sources */,
|
C0A703000000000000000002 /* MainContentTab.swift in Sources */,
|
||||||
C0A703000000000000000003 /* MainContentRecommendationView.swift in Sources */,
|
C0A703000000000000000003 /* MainContentRecommendationView.swift in Sources */,
|
||||||
|
|||||||
@@ -8,7 +8,7 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
import Combine
|
import Combine
|
||||||
|
|
||||||
enum SeriesPublishedDaysOfWeek: String, Encodable {
|
enum SeriesPublishedDaysOfWeek: String, Encodable, Decodable, CaseIterable, Hashable {
|
||||||
case SUN, MON, TUE, WED, THU, FRI, SAT, RANDOM
|
case SUN, MON, TUE, WED, THU, FRI, SAT, RANDOM
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -3049,6 +3049,57 @@ If you block this user, the following features will be restricted.
|
|||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
enum MainContentRanking {
|
||||||
|
static var tabTitle: String {
|
||||||
|
pick(ko: "랭킹", en: "Ranking", ja: "ランキング")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
enum MainContentAll {
|
||||||
|
static var tabTitle: String {
|
||||||
|
pick(ko: "전체", en: "All", ja: "すべて")
|
||||||
|
}
|
||||||
|
|
||||||
|
static var rangeLabel: String {
|
||||||
|
pick(ko: "전체", en: "All", ja: "すべて")
|
||||||
|
}
|
||||||
|
|
||||||
|
static var emptyStateMessage: String {
|
||||||
|
pick(
|
||||||
|
ko: "콘텐츠가 아직 없어요.",
|
||||||
|
en: "No content yet.",
|
||||||
|
ja: "コンテンツはまだありません。"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
static var loadFailedMessage: String {
|
||||||
|
pick(
|
||||||
|
ko: "콘텐츠를 불러오지 못했습니다.\n잠시 후 다시 시도해 주세요.",
|
||||||
|
en: "Unable to load content.\nPlease try again later.",
|
||||||
|
ja: "コンテンツを読み込めませんでした。\nしばらくしてからもう一度お試しください。"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
enum TypeChip {
|
||||||
|
static var audio: String { pick(ko: "오디오", en: "Audio", ja: "オーディオ") }
|
||||||
|
static var series: String { pick(ko: "시리즈", en: "Series", ja: "シリーズ") }
|
||||||
|
static var original: String { pick(ko: "오리지널", en: "Original", ja: "オリジナル") }
|
||||||
|
static var free: String { pick(ko: "무료", en: "Free", ja: "無料") }
|
||||||
|
static var point: String { pick(ko: "포인트", en: "Point", ja: "ポイント") }
|
||||||
|
}
|
||||||
|
|
||||||
|
enum DayOfWeek {
|
||||||
|
static var mon: String { pick(ko: "월", en: "MON", ja: "月") }
|
||||||
|
static var tue: String { pick(ko: "화", en: "TUE", ja: "火") }
|
||||||
|
static var wed: String { pick(ko: "수", en: "WED", ja: "水") }
|
||||||
|
static var thu: String { pick(ko: "목", en: "THU", ja: "木") }
|
||||||
|
static var fri: String { pick(ko: "금", en: "FRI", ja: "金") }
|
||||||
|
static var sat: String { pick(ko: "토", en: "SAT", ja: "土") }
|
||||||
|
static var sun: String { pick(ko: "일", en: "SUN", ja: "日") }
|
||||||
|
static var random: String { pick(ko: "기타", en: "OTHER", ja: "その他") }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
enum Home {
|
enum Home {
|
||||||
static var liveNowSectionTitle: String {
|
static var liveNowSectionTitle: String {
|
||||||
pick(ko: "지금 라이브중", en: "Live now", ja: "ライブ配信中")
|
pick(ko: "지금 라이브중", en: "Live now", ja: "ライブ配信中")
|
||||||
|
|||||||
@@ -189,6 +189,20 @@ extension AudioContentThumbnailCardItem {
|
|||||||
isOriginalSeries: audioCard.isOriginalSeries
|
isOriginalSeries: audioCard.isOriginalSeries
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
init(mainContentAudio: MainContentAudioResponse) {
|
||||||
|
self.init(
|
||||||
|
id: mainContentAudio.audioContentId,
|
||||||
|
title: mainContentAudio.title,
|
||||||
|
subtitle: mainContentAudio.creatorNickname,
|
||||||
|
imageUrl: mainContentAudio.imageUrl,
|
||||||
|
price: mainContentAudio.price,
|
||||||
|
isAdult: mainContentAudio.isAdult,
|
||||||
|
isPointAvailable: mainContentAudio.isPointAvailable,
|
||||||
|
isFirstContent: mainContentAudio.isFirstContent,
|
||||||
|
isOriginalSeries: mainContentAudio.isOriginalSeries
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
struct AudioContentThumbnailCard_Previews: PreviewProvider {
|
struct AudioContentThumbnailCard_Previews: PreviewProvider {
|
||||||
|
|||||||
@@ -0,0 +1,78 @@
|
|||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
struct MainContentSeriesThumbnailCard: View {
|
||||||
|
let item: MainContentSeriesResponse
|
||||||
|
let width: CGFloat
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(alignment: .leading, spacing: SodaSpacing.s8) {
|
||||||
|
thumbnail
|
||||||
|
|
||||||
|
labelContent
|
||||||
|
}
|
||||||
|
.frame(width: width, alignment: .leading)
|
||||||
|
}
|
||||||
|
|
||||||
|
private var thumbnail: some View {
|
||||||
|
ZStack(alignment: .topLeading) {
|
||||||
|
DownsampledKFImage(
|
||||||
|
url: item.coverImageUrl.flatMap(URL.init(string:)),
|
||||||
|
size: CGSize(width: width, height: width * 1.41)
|
||||||
|
)
|
||||||
|
.background(Color.gray800)
|
||||||
|
|
||||||
|
if item.isOriginal {
|
||||||
|
originalTag
|
||||||
|
}
|
||||||
|
|
||||||
|
HStack(spacing: 0) {
|
||||||
|
Spacer(minLength: 0)
|
||||||
|
|
||||||
|
if item.isAdult {
|
||||||
|
CreatorChannelAudioAdultTag()
|
||||||
|
.padding(.top, SodaSpacing.s6)
|
||||||
|
.padding(.trailing, SodaSpacing.s6)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.frame(width: width, height: width * 1.41)
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: SodaSpacing.s14, style: .continuous))
|
||||||
|
}
|
||||||
|
|
||||||
|
private var originalTag: some View {
|
||||||
|
HStack(spacing: SodaSpacing.s4) {
|
||||||
|
Image("ic_series_original")
|
||||||
|
.resizable()
|
||||||
|
.scaledToFit()
|
||||||
|
.frame(width: 14, height: 14)
|
||||||
|
|
||||||
|
Image("img_new_only")
|
||||||
|
.resizable()
|
||||||
|
.scaledToFit()
|
||||||
|
.frame(height: 14)
|
||||||
|
}
|
||||||
|
.padding(.horizontal, SodaSpacing.s8)
|
||||||
|
.padding(.vertical, 5)
|
||||||
|
.background(Color.gray900)
|
||||||
|
}
|
||||||
|
|
||||||
|
private var labelContent: some View {
|
||||||
|
VStack(alignment: .leading, spacing: 2) {
|
||||||
|
Text(item.title)
|
||||||
|
.appFont(size: 16, weight: .bold)
|
||||||
|
.foregroundColor(Color.white)
|
||||||
|
.lineLimit(1)
|
||||||
|
.truncationMode(.tail)
|
||||||
|
|
||||||
|
Text(item.creatorNickname)
|
||||||
|
.appFont(size: 12, weight: .medium)
|
||||||
|
.foregroundColor(Color.gray500)
|
||||||
|
.lineLimit(1)
|
||||||
|
.truncationMode(.tail)
|
||||||
|
}
|
||||||
|
.frame(width: width - (SodaSpacing.s4 * 2), alignment: .leading)
|
||||||
|
.padding(.horizontal, SodaSpacing.s4)
|
||||||
|
.frame(width: width, alignment: .leading)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
200
SodaLive/Sources/V2/Main/Content/All/MainContentAllView.swift
Normal file
200
SodaLive/Sources/V2/Main/Content/All/MainContentAllView.swift
Normal file
@@ -0,0 +1,200 @@
|
|||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
struct MainContentAllView: View {
|
||||||
|
@ObservedObject var viewModel: MainContentAllViewModel
|
||||||
|
let onTapContent: (Int) -> Void
|
||||||
|
let onTapSeries: (Int) -> Void
|
||||||
|
|
||||||
|
@State private var isSortPopupPresented = false
|
||||||
|
|
||||||
|
private let columns = [
|
||||||
|
GridItem(.flexible(), spacing: SodaSpacing.s4),
|
||||||
|
GridItem(.flexible(), spacing: SodaSpacing.s4),
|
||||||
|
GridItem(.flexible(), spacing: SodaSpacing.s4)
|
||||||
|
]
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
ZStack(alignment: .topTrailing) {
|
||||||
|
Color.black
|
||||||
|
.ignoresSafeArea()
|
||||||
|
|
||||||
|
content
|
||||||
|
|
||||||
|
if isSortPopupPresented {
|
||||||
|
Color.black
|
||||||
|
.opacity(0.001)
|
||||||
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||||
|
.onTapGesture {
|
||||||
|
isSortPopupPresented = false
|
||||||
|
}
|
||||||
|
|
||||||
|
CreatorChannelSortContextPopup(
|
||||||
|
selectedSort: viewModel.selectedSort,
|
||||||
|
sorts: viewModel.allowedSorts
|
||||||
|
) { sort in
|
||||||
|
viewModel.selectSort(sort)
|
||||||
|
isSortPopupPresented = false
|
||||||
|
}
|
||||||
|
.padding(.top, viewModel.selectedType == .series ? 156 : 104)
|
||||||
|
.padding(.trailing, SodaSpacing.s14)
|
||||||
|
.zIndex(1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.onAppear {
|
||||||
|
viewModel.fetchFirstPageIfNeeded()
|
||||||
|
}
|
||||||
|
.onChange(of: viewModel.selectedSort) { _ in
|
||||||
|
isSortPopupPresented = false
|
||||||
|
}
|
||||||
|
.onChange(of: viewModel.selectedType) { _ in
|
||||||
|
isSortPopupPresented = false
|
||||||
|
}
|
||||||
|
.onDisappear {
|
||||||
|
isSortPopupPresented = false
|
||||||
|
}
|
||||||
|
.sodaToast(
|
||||||
|
isPresented: $viewModel.isShowPopup,
|
||||||
|
message: viewModel.errorMessage,
|
||||||
|
autohideIn: 2
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private var content: some View {
|
||||||
|
VStack(spacing: 0) {
|
||||||
|
typeFilter
|
||||||
|
|
||||||
|
if viewModel.selectedType == .series {
|
||||||
|
dayOfWeekFilter
|
||||||
|
}
|
||||||
|
|
||||||
|
sortBar
|
||||||
|
|
||||||
|
if viewModel.isLoading && viewModel.hasLoaded == false {
|
||||||
|
ProgressView()
|
||||||
|
.progressViewStyle(CircularProgressViewStyle(tint: .white))
|
||||||
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||||
|
} else if viewModel.shouldShowEmptyState {
|
||||||
|
MainContentAudioEmptyStateView(message: I18n.MainContentAll.emptyStateMessage)
|
||||||
|
} else {
|
||||||
|
ScrollView(showsIndicators: false) {
|
||||||
|
gridContent
|
||||||
|
.padding(.horizontal, SodaSpacing.s14)
|
||||||
|
.padding(.top, SodaSpacing.s8)
|
||||||
|
.padding(.bottom, SodaSpacing.s20)
|
||||||
|
|
||||||
|
if viewModel.isLoadingNextPage {
|
||||||
|
ProgressView()
|
||||||
|
.progressViewStyle(CircularProgressViewStyle(tint: .white))
|
||||||
|
.padding(.vertical, SodaSpacing.s20)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var typeFilter: some View {
|
||||||
|
CapsuleTabBar(
|
||||||
|
items: MainContentAllType.allCases,
|
||||||
|
selectedItem: Binding(
|
||||||
|
get: { viewModel.selectedType },
|
||||||
|
set: { viewModel.selectType($0) }
|
||||||
|
),
|
||||||
|
title: { $0.title }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private var dayOfWeekFilter: some View {
|
||||||
|
HStack(spacing: 0) {
|
||||||
|
ForEach(SeriesPublishedDaysOfWeek.allFilterCases, id: \.self) { dayOfWeek in
|
||||||
|
Button {
|
||||||
|
viewModel.selectDayOfWeek(dayOfWeek)
|
||||||
|
} label: {
|
||||||
|
Text(dayOfWeek.shortTitle)
|
||||||
|
.appFont(size: 14, weight: .medium)
|
||||||
|
.foregroundColor(viewModel.selectedDayOfWeek == dayOfWeek ? Color.black : Color.white)
|
||||||
|
.lineLimit(1)
|
||||||
|
.frame(minWidth: 20, minHeight: 20)
|
||||||
|
.padding(SodaSpacing.s8)
|
||||||
|
.background(viewModel.selectedDayOfWeek == dayOfWeek ? Color.white : Color.gray900)
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: SodaSpacing.s8, style: .continuous))
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.background(Color.gray900)
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: SodaSpacing.s8, style: .continuous))
|
||||||
|
.padding(.vertical, SodaSpacing.s8)
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
.frame(height: 52)
|
||||||
|
.background(Color.black)
|
||||||
|
}
|
||||||
|
|
||||||
|
private var sortBar: some View {
|
||||||
|
CreatorChannelSortBar(
|
||||||
|
selectedSort: viewModel.selectedSort,
|
||||||
|
totalCount: nil,
|
||||||
|
onTapSort: {
|
||||||
|
isSortPopupPresented.toggle()
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@ViewBuilder
|
||||||
|
private var gridContent: some View {
|
||||||
|
if viewModel.selectedType.usesAudioItems {
|
||||||
|
LazyVGrid(columns: columns, alignment: .leading, spacing: SodaSpacing.s20) {
|
||||||
|
ForEach(viewModel.audios) { audio in
|
||||||
|
Button {
|
||||||
|
onTapContent(audio.audioContentId)
|
||||||
|
} label: {
|
||||||
|
GeometryReader { proxy in
|
||||||
|
AudioContentThumbnailCard(
|
||||||
|
item: AudioContentThumbnailCardItem(mainContentAudio: audio),
|
||||||
|
width: proxy.size.width,
|
||||||
|
titleTypography: .body1,
|
||||||
|
subtitleTypography: .caption2,
|
||||||
|
labelHorizontalPadding: SodaSpacing.s4
|
||||||
|
)
|
||||||
|
}
|
||||||
|
.aspectRatio(0.78, contentMode: .fit)
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
.onAppear {
|
||||||
|
viewModel.fetchNextPageIfNeeded(currentAudio: audio)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
LazyVGrid(columns: columns, alignment: .leading, spacing: SodaSpacing.s20) {
|
||||||
|
ForEach(viewModel.series) { series in
|
||||||
|
Button {
|
||||||
|
onTapSeries(series.seriesId)
|
||||||
|
} label: {
|
||||||
|
GeometryReader { proxy in
|
||||||
|
MainContentSeriesThumbnailCard(
|
||||||
|
item: series,
|
||||||
|
width: proxy.size.width
|
||||||
|
)
|
||||||
|
}
|
||||||
|
.aspectRatio(0.55, contentMode: .fit)
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
.onAppear {
|
||||||
|
viewModel.fetchNextPageIfNeeded(currentSeries: series)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct MainContentAllView_Previews: PreviewProvider {
|
||||||
|
static var previews: some View {
|
||||||
|
MainContentAllView(
|
||||||
|
viewModel: MainContentAllViewModel(),
|
||||||
|
onTapContent: { _ in },
|
||||||
|
onTapSeries: { _ in }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,233 @@
|
|||||||
|
import Foundation
|
||||||
|
import Combine
|
||||||
|
|
||||||
|
final class MainContentAllViewModel: ObservableObject {
|
||||||
|
private let repository = MainContentAllRepository()
|
||||||
|
private var subscription = Set<AnyCancellable>()
|
||||||
|
private let pageSize = 20
|
||||||
|
private var latestRequestId = 0
|
||||||
|
|
||||||
|
@Published var selectedType: MainContentAllType = .audio
|
||||||
|
@Published var selectedSort: ContentSort = .latest
|
||||||
|
@Published var selectedDayOfWeek: SeriesPublishedDaysOfWeek = .currentDeviceDay
|
||||||
|
@Published var response: MainContentAllTabResponse?
|
||||||
|
@Published var audios = [MainContentAudioResponse]()
|
||||||
|
@Published var series = [MainContentSeriesResponse]()
|
||||||
|
@Published var page = 0
|
||||||
|
@Published var size = 20
|
||||||
|
@Published var hasNext = false
|
||||||
|
@Published var hasLoaded = false
|
||||||
|
@Published var isLoading = false
|
||||||
|
@Published var isLoadingNextPage = false
|
||||||
|
@Published var errorMessage = ""
|
||||||
|
@Published var isShowPopup = false
|
||||||
|
|
||||||
|
var allowedSorts: [ContentSort] {
|
||||||
|
selectedType == .free ? [.latest, .popular] : ContentSort.allCases
|
||||||
|
}
|
||||||
|
|
||||||
|
var shouldShowEmptyState: Bool {
|
||||||
|
guard hasLoaded, isLoading == false else { return false }
|
||||||
|
return selectedType.usesAudioItems ? audios.isEmpty : series.isEmpty
|
||||||
|
}
|
||||||
|
|
||||||
|
func fetchFirstPageIfNeeded() {
|
||||||
|
guard hasLoaded == false else { return }
|
||||||
|
fetchFirstPage()
|
||||||
|
}
|
||||||
|
|
||||||
|
func fetchFirstPage() {
|
||||||
|
fetchContents(page: 0, isNextPage: false)
|
||||||
|
}
|
||||||
|
|
||||||
|
func selectType(_ type: MainContentAllType) {
|
||||||
|
guard selectedType != type else { return }
|
||||||
|
|
||||||
|
selectedType = type
|
||||||
|
if allowedSorts.contains(selectedSort) == false {
|
||||||
|
selectedSort = .latest
|
||||||
|
}
|
||||||
|
clearItems()
|
||||||
|
fetchFirstPage()
|
||||||
|
}
|
||||||
|
|
||||||
|
func selectSort(_ sort: ContentSort) {
|
||||||
|
guard allowedSorts.contains(sort), selectedSort != sort else { return }
|
||||||
|
|
||||||
|
selectedSort = sort
|
||||||
|
clearItems()
|
||||||
|
fetchFirstPage()
|
||||||
|
}
|
||||||
|
|
||||||
|
func selectDayOfWeek(_ dayOfWeek: SeriesPublishedDaysOfWeek) {
|
||||||
|
guard selectedType == .series, selectedDayOfWeek != dayOfWeek else { return }
|
||||||
|
|
||||||
|
selectedDayOfWeek = dayOfWeek
|
||||||
|
clearItems()
|
||||||
|
fetchFirstPage()
|
||||||
|
}
|
||||||
|
|
||||||
|
func fetchNextPageIfNeeded(currentAudio: MainContentAudioResponse) {
|
||||||
|
guard audios.last?.audioContentId == currentAudio.audioContentId else { return }
|
||||||
|
fetchNextPageIfPossible()
|
||||||
|
}
|
||||||
|
|
||||||
|
func fetchNextPageIfNeeded(currentSeries: MainContentSeriesResponse) {
|
||||||
|
guard series.last?.seriesId == currentSeries.seriesId else { return }
|
||||||
|
fetchNextPageIfPossible()
|
||||||
|
}
|
||||||
|
|
||||||
|
private func fetchNextPageIfPossible() {
|
||||||
|
guard hasNext, isLoading == false, isLoadingNextPage == false else { return }
|
||||||
|
fetchContents(page: page + 1, isNextPage: true)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func fetchContents(page requestPage: Int, isNextPage: Bool) {
|
||||||
|
latestRequestId += 1
|
||||||
|
let requestId = latestRequestId
|
||||||
|
|
||||||
|
if isNextPage {
|
||||||
|
isLoadingNextPage = true
|
||||||
|
} else {
|
||||||
|
isLoading = true
|
||||||
|
}
|
||||||
|
|
||||||
|
repository.getContents(
|
||||||
|
type: selectedType,
|
||||||
|
sort: selectedSort,
|
||||||
|
page: requestPage,
|
||||||
|
size: pageSize,
|
||||||
|
dayOfWeek: selectedDayOfWeek
|
||||||
|
)
|
||||||
|
.sink { [weak self] result in
|
||||||
|
guard let self else { return }
|
||||||
|
guard requestId == self.latestRequestId else { return }
|
||||||
|
|
||||||
|
switch result {
|
||||||
|
case .finished:
|
||||||
|
DEBUG_LOG("finish")
|
||||||
|
case .failure(let error):
|
||||||
|
ERROR_LOG(error.localizedDescription)
|
||||||
|
self.applyFailureState()
|
||||||
|
}
|
||||||
|
} receiveValue: { [weak self] response in
|
||||||
|
guard let self else { return }
|
||||||
|
guard requestId == self.latestRequestId else { return }
|
||||||
|
|
||||||
|
do {
|
||||||
|
let jsonDecoder = JSONDecoder()
|
||||||
|
let decoded = try jsonDecoder.decode(ApiResponse<MainContentAllTabResponse>.self, from: response.data)
|
||||||
|
|
||||||
|
if let data = decoded.data, decoded.success {
|
||||||
|
self.applySuccessState(data, isNextPage: isNextPage)
|
||||||
|
} else {
|
||||||
|
self.errorMessage = decoded.message ?? I18n.MainContentAll.loadFailedMessage
|
||||||
|
self.isShowPopup = true
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
ERROR_LOG(error.localizedDescription)
|
||||||
|
self.errorMessage = I18n.MainContentAll.loadFailedMessage
|
||||||
|
self.isShowPopup = true
|
||||||
|
}
|
||||||
|
|
||||||
|
self.hasLoaded = true
|
||||||
|
self.isLoading = false
|
||||||
|
self.isLoadingNextPage = false
|
||||||
|
}
|
||||||
|
.store(in: &subscription)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func applySuccessState(_ data: MainContentAllTabResponse, isNextPage: Bool) {
|
||||||
|
response = data
|
||||||
|
selectedType = data.type
|
||||||
|
selectedSort = data.sort
|
||||||
|
if let dayOfWeek = data.dayOfWeek {
|
||||||
|
selectedDayOfWeek = dayOfWeek
|
||||||
|
}
|
||||||
|
page = data.page
|
||||||
|
size = data.size
|
||||||
|
hasNext = data.hasNext
|
||||||
|
|
||||||
|
if data.type.usesAudioItems {
|
||||||
|
if isNextPage {
|
||||||
|
audios.append(contentsOf: data.audios)
|
||||||
|
} else {
|
||||||
|
audios = data.audios
|
||||||
|
series = []
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if isNextPage {
|
||||||
|
series.append(contentsOf: data.series)
|
||||||
|
} else {
|
||||||
|
series = data.series
|
||||||
|
audios = []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func applyFailureState() {
|
||||||
|
errorMessage = I18n.MainContentAll.loadFailedMessage
|
||||||
|
isShowPopup = true
|
||||||
|
hasLoaded = true
|
||||||
|
isLoading = false
|
||||||
|
isLoadingNextPage = false
|
||||||
|
}
|
||||||
|
|
||||||
|
private func clearItems() {
|
||||||
|
audios = []
|
||||||
|
series = []
|
||||||
|
response = nil
|
||||||
|
page = 0
|
||||||
|
hasNext = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
extension SeriesPublishedDaysOfWeek {
|
||||||
|
static var currentDeviceDay: SeriesPublishedDaysOfWeek {
|
||||||
|
switch Calendar.current.component(.weekday, from: Date()) {
|
||||||
|
case 1:
|
||||||
|
return .SUN
|
||||||
|
case 2:
|
||||||
|
return .MON
|
||||||
|
case 3:
|
||||||
|
return .TUE
|
||||||
|
case 4:
|
||||||
|
return .WED
|
||||||
|
case 5:
|
||||||
|
return .THU
|
||||||
|
case 6:
|
||||||
|
return .FRI
|
||||||
|
case 7:
|
||||||
|
return .SAT
|
||||||
|
default:
|
||||||
|
return .SUN
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static var allFilterCases: [SeriesPublishedDaysOfWeek] {
|
||||||
|
[.MON, .TUE, .WED, .THU, .FRI, .SAT, .SUN, .RANDOM]
|
||||||
|
}
|
||||||
|
|
||||||
|
var shortTitle: String {
|
||||||
|
switch self {
|
||||||
|
case .SUN:
|
||||||
|
return I18n.MainContentAll.DayOfWeek.sun
|
||||||
|
case .MON:
|
||||||
|
return I18n.MainContentAll.DayOfWeek.mon
|
||||||
|
case .TUE:
|
||||||
|
return I18n.MainContentAll.DayOfWeek.tue
|
||||||
|
case .WED:
|
||||||
|
return I18n.MainContentAll.DayOfWeek.wed
|
||||||
|
case .THU:
|
||||||
|
return I18n.MainContentAll.DayOfWeek.thu
|
||||||
|
case .FRI:
|
||||||
|
return I18n.MainContentAll.DayOfWeek.fri
|
||||||
|
case .SAT:
|
||||||
|
return I18n.MainContentAll.DayOfWeek.sat
|
||||||
|
case .RANDOM:
|
||||||
|
return I18n.MainContentAll.DayOfWeek.random
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
enum MainContentAllType: String, Decodable, Encodable, CaseIterable, Hashable {
|
||||||
|
case audio = "AUDIO"
|
||||||
|
case series = "SERIES"
|
||||||
|
case original = "ORIGINAL"
|
||||||
|
case free = "FREE"
|
||||||
|
case point = "POINT"
|
||||||
|
|
||||||
|
var title: String {
|
||||||
|
switch self {
|
||||||
|
case .audio:
|
||||||
|
return I18n.MainContentAll.TypeChip.audio
|
||||||
|
case .series:
|
||||||
|
return I18n.MainContentAll.TypeChip.series
|
||||||
|
case .original:
|
||||||
|
return I18n.MainContentAll.TypeChip.original
|
||||||
|
case .free:
|
||||||
|
return I18n.MainContentAll.TypeChip.free
|
||||||
|
case .point:
|
||||||
|
return I18n.MainContentAll.TypeChip.point
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var usesAudioItems: Bool {
|
||||||
|
switch self {
|
||||||
|
case .audio, .free, .point:
|
||||||
|
return true
|
||||||
|
case .series, .original:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct MainContentAllTabResponse: Decodable {
|
||||||
|
let type: MainContentAllType
|
||||||
|
let totalCount: Int
|
||||||
|
let audios: [MainContentAudioResponse]
|
||||||
|
let series: [MainContentSeriesResponse]
|
||||||
|
let sort: ContentSort
|
||||||
|
let dayOfWeek: SeriesPublishedDaysOfWeek?
|
||||||
|
let page: Int
|
||||||
|
let size: Int
|
||||||
|
let hasNext: Bool
|
||||||
|
}
|
||||||
|
|
||||||
|
struct MainContentAudioResponse: Decodable, Identifiable {
|
||||||
|
let audioContentId: Int
|
||||||
|
let title: String
|
||||||
|
let imageUrl: String?
|
||||||
|
let price: Int
|
||||||
|
let isAdult: Bool
|
||||||
|
let isPointAvailable: Bool
|
||||||
|
let isFirstContent: Bool
|
||||||
|
let isOriginalSeries: Bool
|
||||||
|
let creatorNickname: String
|
||||||
|
|
||||||
|
var id: Int { audioContentId }
|
||||||
|
}
|
||||||
|
|
||||||
|
struct MainContentSeriesResponse: Decodable, Identifiable {
|
||||||
|
let seriesId: Int
|
||||||
|
let title: String
|
||||||
|
let coverImageUrl: String?
|
||||||
|
let creatorNickname: String
|
||||||
|
let isOriginal: Bool
|
||||||
|
let isAdult: Bool
|
||||||
|
|
||||||
|
var id: Int { seriesId }
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
import Foundation
|
||||||
|
import Moya
|
||||||
|
|
||||||
|
enum MainContentAllApi {
|
||||||
|
case getContents(
|
||||||
|
type: MainContentAllType,
|
||||||
|
sort: ContentSort,
|
||||||
|
page: Int,
|
||||||
|
size: Int,
|
||||||
|
dayOfWeek: SeriesPublishedDaysOfWeek
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
extension MainContentAllApi: TargetType {
|
||||||
|
var baseURL: URL {
|
||||||
|
return URL(string: BASE_URL)!
|
||||||
|
}
|
||||||
|
|
||||||
|
var path: String {
|
||||||
|
switch self {
|
||||||
|
case .getContents:
|
||||||
|
return "/api/v2/audio/contents"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var method: Moya.Method {
|
||||||
|
switch self {
|
||||||
|
case .getContents:
|
||||||
|
return .get
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var task: Moya.Task {
|
||||||
|
switch self {
|
||||||
|
case .getContents(let type, let sort, let page, let size, let dayOfWeek):
|
||||||
|
var parameters: [String: Any] = [
|
||||||
|
"type": type.rawValue,
|
||||||
|
"sort": sort.rawValue,
|
||||||
|
"page": page,
|
||||||
|
"size": size
|
||||||
|
]
|
||||||
|
|
||||||
|
if type == .series {
|
||||||
|
parameters["dayOfWeek"] = dayOfWeek.rawValue
|
||||||
|
}
|
||||||
|
|
||||||
|
return .requestParameters(parameters: parameters, encoding: URLEncoding.queryString)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var headers: [String: String]? {
|
||||||
|
return ["Authorization": "Bearer \(UserDefaults.string(forKey: UserDefaultsKey.token))"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
import Foundation
|
||||||
|
import Combine
|
||||||
|
import CombineMoya
|
||||||
|
import Moya
|
||||||
|
|
||||||
|
final class MainContentAllRepository {
|
||||||
|
private let api = MoyaProvider<MainContentAllApi>()
|
||||||
|
|
||||||
|
func getContents(
|
||||||
|
type: MainContentAllType,
|
||||||
|
sort: ContentSort,
|
||||||
|
page: Int,
|
||||||
|
size: Int,
|
||||||
|
dayOfWeek: SeriesPublishedDaysOfWeek
|
||||||
|
) -> AnyPublisher<Response, MoyaError> {
|
||||||
|
return api.requestPublisher(
|
||||||
|
.getContents(
|
||||||
|
type: type,
|
||||||
|
sort: sort,
|
||||||
|
page: page,
|
||||||
|
size: size,
|
||||||
|
dayOfWeek: dayOfWeek
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -2,11 +2,17 @@ import Foundation
|
|||||||
|
|
||||||
enum MainContentTab: CaseIterable, Hashable {
|
enum MainContentTab: CaseIterable, Hashable {
|
||||||
case recommendation
|
case recommendation
|
||||||
|
case ranking
|
||||||
|
case all
|
||||||
|
|
||||||
var title: String {
|
var title: String {
|
||||||
switch self {
|
switch self {
|
||||||
case .recommendation:
|
case .recommendation:
|
||||||
return I18n.MainContentRecommendation.tabTitle
|
return I18n.MainContentRecommendation.tabTitle
|
||||||
|
case .ranking:
|
||||||
|
return I18n.MainContentRanking.tabTitle
|
||||||
|
case .all:
|
||||||
|
return I18n.MainContentAll.tabTitle
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ struct MainContentView: View {
|
|||||||
let onTapSeries: (Int) -> Void
|
let onTapSeries: (Int) -> Void
|
||||||
|
|
||||||
@State private var selectedTab: MainContentTab = .recommendation
|
@State private var selectedTab: MainContentTab = .recommendation
|
||||||
|
@StateObject private var allViewModel = MainContentAllViewModel()
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
VStack(spacing: 0) {
|
VStack(spacing: 0) {
|
||||||
@@ -46,6 +47,14 @@ struct MainContentView: View {
|
|||||||
onTapBanner: onTapBanner,
|
onTapBanner: onTapBanner,
|
||||||
onTapSeries: onTapSeries
|
onTapSeries: onTapSeries
|
||||||
)
|
)
|
||||||
|
case .ranking:
|
||||||
|
MainPlaceholderTabView(title: MainContentTab.ranking.title)
|
||||||
|
case .all:
|
||||||
|
MainContentAllView(
|
||||||
|
viewModel: allViewModel,
|
||||||
|
onTapContent: onTapContent,
|
||||||
|
onTapSeries: onTapSeries
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
97
docs/20260706_메인_콘텐츠_내부_전체_탭/plan-task.md
Normal file
97
docs/20260706_메인_콘텐츠_내부_전체_탭/plan-task.md
Normal file
@@ -0,0 +1,97 @@
|
|||||||
|
# 메인 콘텐츠 탭 내부 전체 탭 구현 계획
|
||||||
|
|
||||||
|
## 기준 문서
|
||||||
|
|
||||||
|
- PRD: `docs/20260706_메인_콘텐츠_내부_전체_탭/prd.md`
|
||||||
|
- 문서 정책: `docs/agent-guides/documentation-policy.md`
|
||||||
|
- 코드 스타일: `docs/agent-guides/code-style.md`
|
||||||
|
- 빌드/검증: `docs/agent-guides/build-test-verification.md`
|
||||||
|
|
||||||
|
## 구현할 UI의 Figma URL
|
||||||
|
|
||||||
|
- 오디오 선택 상태: `https://www.figma.com/design/HmN1yNdJ3EIpqknFL0Hkab/-%EA%B3%B5%EC%9C%A0%EC%9A%A9-%EB%B3%B4%EC%9D%B4%EC%8A%A4%EC%98%A8-UI-UX-%EA%B8%B0%ED%9A%8D%EB%AC%B8%EC%84%9C?node-id=35-5857&m=dev`
|
||||||
|
- 시리즈 선택 상태: `https://www.figma.com/design/HmN1yNdJ3EIpqknFL0Hkab/-%EA%B3%B5%EC%9C%A0%EC%9A%A9-%EB%B3%B4%EC%9D%B4%EC%8A%A4%EC%98%A8-UI-UX-%EA%B8%B0%ED%9A%8D%EB%AC%B8%EC%84%9C?node-id=24-6909&m=dev`
|
||||||
|
- 오리지널 선택 상태: `https://www.figma.com/design/HmN1yNdJ3EIpqknFL0Hkab/-%EA%B3%B5%EC%9C%A0%EC%9A%A9-%EB%B3%B4%EC%9D%B4%EC%8A%A4%EC%98%A8-UI-UX-%EA%B8%B0%ED%9A%8D%EB%AC%B8%EC%84%9C?node-id=24-9105&m=dev`
|
||||||
|
|
||||||
|
## 확정 요구사항 요약
|
||||||
|
|
||||||
|
- API endpoint는 `GET /api/v2/audio/contents`를 사용한다.
|
||||||
|
- 기본 query는 `type=AUDIO`, `sort=LATEST`, `page=0`, `size=20`이다.
|
||||||
|
- `dayOfWeek`는 `type == SERIES`일 때만 전송한다.
|
||||||
|
- Figma 타입 chip의 `전체`는 구현하지 않는다.
|
||||||
|
- Figma sort menu의 `추천순`은 구현하지 않는다.
|
||||||
|
- `FREE` 타입의 sort menu는 `최신순`, `인기순`만 제공한다.
|
||||||
|
- response의 `totalCount`는 UI에 표시하지 않는다.
|
||||||
|
- 내부 `추천/랭킹/전체` 탭 전환 시 `전체` 탭 상태를 유지한다.
|
||||||
|
|
||||||
|
## TASK 체크리스트
|
||||||
|
|
||||||
|
### Phase 1: 전체 탭 기준 구조와 API 모델
|
||||||
|
|
||||||
|
- [x] **Task 1.1: 내부 전체 탭 진입 구조와 상태 유지 설계**
|
||||||
|
- 대상 파일:
|
||||||
|
- 수정: `SodaLive/Sources/V2/Main/Content/MainContentTab.swift`
|
||||||
|
- 수정: `SodaLive/Sources/V2/Main/Content/MainContentView.swift`
|
||||||
|
- 검증 기준:
|
||||||
|
- 내부 `전체` 탭이 추가되고, `추천/랭킹/전체` 탭 전환 시 `전체` 탭 상태가 유지된다.
|
||||||
|
|
||||||
|
- [x] **Task 1.2: API 모델과 repository 추가**
|
||||||
|
- 대상 파일:
|
||||||
|
- 생성: `SodaLive/Sources/V2/Main/Content/All/Models/MainContentAllTabResponse.swift`
|
||||||
|
- 생성: `SodaLive/Sources/V2/Main/Content/All/Repository/MainContentAllApi.swift`
|
||||||
|
- 생성: `SodaLive/Sources/V2/Main/Content/All/Repository/MainContentAllRepository.swift`
|
||||||
|
- 검증 기준:
|
||||||
|
- `GET /api/v2/audio/contents`와 query parameter 기본값, `SERIES` 전용 `dayOfWeek` 전송 조건이 반영된다.
|
||||||
|
|
||||||
|
### Phase 2: 필터, 정렬, 페이징
|
||||||
|
|
||||||
|
- [x] **Task 2.1: 타입 chip, 요일 필터, sort menu 구현**
|
||||||
|
- 대상 파일:
|
||||||
|
- 생성: `SodaLive/Sources/V2/Main/Content/All/MainContentAllView.swift`
|
||||||
|
- 생성: `SodaLive/Sources/V2/Main/Content/All/MainContentAllViewModel.swift`
|
||||||
|
- 수정: `SodaLive/Sources/I18n/I18n.swift`
|
||||||
|
- 검증 기준:
|
||||||
|
- 타입 chip에 `전체`가 없고, `추천순` sort가 없으며, `FREE` 타입은 `LATEST`, `POPULAR`만 제공한다.
|
||||||
|
- `기타` 요일은 en `OTHER`, ja `その他`로 표시된다.
|
||||||
|
|
||||||
|
- [x] **Task 2.2: 스크롤 페이징 구현**
|
||||||
|
- 대상 파일:
|
||||||
|
- 수정: `SodaLive/Sources/V2/Main/Content/All/MainContentAllViewModel.swift`
|
||||||
|
- 수정: `SodaLive/Sources/V2/Main/Content/All/MainContentAllView.swift`
|
||||||
|
- 검증 기준:
|
||||||
|
- `hasNext == true`일 때 다음 page가 append되고, 타입/정렬/요일 변경 시 page 0부터 다시 로드된다.
|
||||||
|
|
||||||
|
### Phase 3: 콘텐츠 grid와 navigation
|
||||||
|
|
||||||
|
- [x] **Task 3.1: 오디오/무료/포인트 grid 구현**
|
||||||
|
- 대상 파일:
|
||||||
|
- 확인/수정: `SodaLive/Sources/V2/Component/AudioContent/AudioContentThumbnailCard.swift`
|
||||||
|
- 수정: `SodaLive/Sources/V2/Main/Content/All/MainContentAllView.swift`
|
||||||
|
- 검증 기준:
|
||||||
|
- `AUDIO`, `FREE`, `POINT`는 response `audios`를 표시하고 `totalCount`는 노출하지 않는다.
|
||||||
|
|
||||||
|
- [x] **Task 3.2: 시리즈/오리지널 grid 구현**
|
||||||
|
- 대상 파일:
|
||||||
|
- 생성: `SodaLive/Sources/V2/Main/Content/All/Components/MainContentSeriesThumbnailCard.swift`
|
||||||
|
- 수정: `SodaLive/Sources/V2/Main/Content/All/MainContentAllView.swift`
|
||||||
|
- 검증 기준:
|
||||||
|
- `SERIES`, `ORIGINAL`은 response `series`를 표시하고, `SERIES`에서만 요일 필터와 `dayOfWeek` 요청이 동작한다.
|
||||||
|
|
||||||
|
- [x] **Task 3.3: 상세 이동 연결**
|
||||||
|
- 대상 파일:
|
||||||
|
- 수정: `SodaLive/Sources/V2/Main/Content/All/MainContentAllView.swift`
|
||||||
|
- 수정: `SodaLive/Sources/V2/Main/MainView.swift`
|
||||||
|
- 검증 기준:
|
||||||
|
- 오디오는 `audioContentId`로 오디오 상세, 시리즈는 `seriesId`로 시리즈 상세에 진입한다.
|
||||||
|
|
||||||
|
## 검증 기록
|
||||||
|
|
||||||
|
- 2026-07-06: 사용자 요청에 따라 구현 대상 Figma URL을 본 문서에 기록했다.
|
||||||
|
- 2026-07-06: `MainContentTab`에 `추천/랭킹/전체` 탭을 구성하고 `MainContentView`가 `MainContentAllViewModel`을 `@StateObject`로 보유하도록 구현해 `전체` 탭 상태 유지 구조를 반영했다.
|
||||||
|
- 2026-07-06: `MainContentAllTabResponse`, `MainContentAllApi`, `MainContentAllRepository`를 추가해 `GET /api/v2/audio/contents`와 `SERIES` 전용 `dayOfWeek` query 전송 조건을 반영했다.
|
||||||
|
- 2026-07-06: `MainContentAllViewModel`과 `MainContentAllView`에 타입 chip, `SERIES` 전용 요일 필터, sort menu, page 0 reload, `hasNext` 기반 append paging을 구현했다.
|
||||||
|
- 2026-07-06: `AudioContentThumbnailCardItem(mainContentAudio:)` adapter와 `MainContentSeriesThumbnailCard`를 추가해 `AUDIO/FREE/POINT`는 `audios`, `SERIES/ORIGINAL`은 `series` grid를 표시하도록 구현했다.
|
||||||
|
- 2026-07-06: 오디오/시리즈 상세 이동은 기존 `MainContentView` callback과 `MainView`의 기존 handler를 재사용해 연결했으므로 `MainView.swift` 직접 수정은 발생하지 않았다.
|
||||||
|
- 2026-07-06: `I18n.MainContentAll`/`I18n.MainContentRanking` 문자열을 추가했고, `RANDOM` 짧은 요일 표기는 en `OTHER`, ja `その他`로 반영했다.
|
||||||
|
- 2026-07-06: `xcodebuild -workspace "SodaLive.xcworkspace" -list`로 테스트 타깃이 별도 노출되지 않음을 확인했다.
|
||||||
|
- 2026-07-06: `xcodebuild -workspace "SodaLive.xcworkspace" -scheme "SodaLive-dev" -configuration Debug -sdk iphonesimulator build` 실행 결과 `** BUILD SUCCEEDED **`를 확인했다.
|
||||||
311
docs/20260706_메인_콘텐츠_내부_전체_탭/prd.md
Normal file
311
docs/20260706_메인_콘텐츠_내부_전체_탭/prd.md
Normal file
@@ -0,0 +1,311 @@
|
|||||||
|
# PRD: 메인 콘텐츠 탭 내부 전체 탭
|
||||||
|
|
||||||
|
## 1. Overview
|
||||||
|
메인 하단 `콘텐츠` 탭 안의 내부 `전체` 탭에서 `GET /api/v2/audio/contents` 응답을 사용해 오디오/시리즈/오리지널/무료/포인트 콘텐츠 목록을 제공한다.
|
||||||
|
|
||||||
|
상단 title bar와 내부 `추천/랭킹/전체` tab bar는 기존 콘텐츠 탭 구조를 유지한다. 내부 `전체` 탭 화면에서는 콘텐츠 타입 chip, 요일 필터, 정렬 필터, 콘텐츠 grid를 구성하고, 목록에는 스크롤 페이징을 적용한다. Figma 타입 chip에는 `전체`가 보이지만 API `type` enum에 대응 값이 없으므로 `전체` chip은 구현 대상에서 제외한다.
|
||||||
|
|
||||||
|
Figma 참조:
|
||||||
|
- 오디오 선택 상태: `35:5857`, `https://www.figma.com/design/HmN1yNdJ3EIpqknFL0Hkab/-%EA%B3%B5%EC%9C%A0%EC%9A%A9-%EB%B3%B4%EC%9D%B4%EC%8A%A4%EC%98%A8-UI-UX-%EA%B8%B0%ED%9A%8D%EB%AC%B8%EC%84%9C?node-id=35-5857&m=dev`
|
||||||
|
- 시리즈 선택 상태: `24:6909`, `https://www.figma.com/design/HmN1yNdJ3EIpqknFL0Hkab/-%EA%B3%B5%EC%9C%A0%EC%9A%A9-%EB%B3%B4%EC%9D%B4%EC%8A%A4%EC%98%A8-UI-UX-%EA%B8%B0%ED%9A%8D%EB%AC%B8%EC%84%9C?node-id=24-6909&m=dev`
|
||||||
|
- 오리지널 선택 상태: `24:9105`, `https://www.figma.com/design/HmN1yNdJ3EIpqknFL0Hkab/-%EA%B3%B5%EC%9C%A0%EC%9A%A9-%EB%B3%B4%EC%9D%B4%EC%8A%A4%EC%98%A8-UI-UX-%EA%B8%B0%ED%9A%8D%EB%AC%B8%EC%84%9C?node-id=24-9105&m=dev`
|
||||||
|
|
||||||
|
## 2. Problem
|
||||||
|
- 현재 V2 메인 콘텐츠 탭은 내부 `추천` 탭 중심으로 구성되어 있고, 내부 `전체` 탭에서 타입별 콘텐츠 목록을 API 기반으로 탐색하는 요구사항이 분리되어 있지 않다.
|
||||||
|
- 같은 endpoint가 `type`에 따라 `audios` 또는 `series` 배열을 내려주므로, 선택 타입별 데이터 매핑 규칙을 명확히 해야 한다.
|
||||||
|
- `SERIES` 타입에서만 `dayOfWeek` query parameter를 보내야 하므로, 요일 필터 UI와 API 요청 조건을 분리해야 한다.
|
||||||
|
- 기존 `SeriesPublishedDaysOfWeek`는 legacy 영역에 있고, 짧은 요일 다국어 표기 중 `기타`의 영어/일본어 요구사항이 기존 `Random` 표기와 다르다.
|
||||||
|
|
||||||
|
## 3. Goals
|
||||||
|
- 메인 콘텐츠 탭 내부 `전체` 탭을 추가한다.
|
||||||
|
- 콘텐츠 타입 기본 선택값은 `AUDIO`로 둔다.
|
||||||
|
- Figma 타입 chip 중 `전체` chip은 구현하지 않고 `오디오`, `시리즈`, `오리지널`, `무료`, `포인트`만 제공한다.
|
||||||
|
- `GET /api/v2/audio/contents`를 호출하고 `type`, `sort`, `page`, `size`, `dayOfWeek` query parameter를 요구사항대로 전송한다.
|
||||||
|
- 기본 query 값은 `page = 0`, `size = 20`, `sort = LATEST`, `type = AUDIO`로 둔다.
|
||||||
|
- `dayOfWeek` 기본값은 현재 디바이스에 설정된 요일로 둔다.
|
||||||
|
- `dayOfWeek`는 `type == SERIES`일 때만 요청 query에 포함한다.
|
||||||
|
- `AUDIO`, `FREE`, `POINT` 선택 시 response의 `audios`를 표시한다.
|
||||||
|
- `SERIES`, `ORIGINAL` 선택 시 response의 `series`를 표시한다.
|
||||||
|
- 목록 하단 도달 시 `hasNext == true`이면 다음 page를 로드해 append한다.
|
||||||
|
- 내부 `추천/랭킹/전체` 탭 전환 시 `전체` 탭의 선택 타입, 정렬, 요일, 로드된 목록 상태를 유지한다.
|
||||||
|
- response의 `totalCount`는 모델에는 반영하되 UI에는 표시하지 않는다.
|
||||||
|
- Figma 기준 오디오/무료/포인트는 `AudioContentThumbnailCard` 재사용을 우선한다.
|
||||||
|
- Figma 기준 시리즈/오리지널은 기존 시리즈 카드 UI 후보를 확인하고, 응답 모델에 맞는 전용 adapter 또는 전용 thumbnail card를 사용한다.
|
||||||
|
- 요일 짧은 표기에서 `기타`는 영어 `OTHER`, 일본어 `その他`로 표시한다.
|
||||||
|
|
||||||
|
## 4. Non-Goals
|
||||||
|
- 실제 구현과 Xcode 프로젝트 수정은 이번 범위에 포함하지 않는다.
|
||||||
|
- 추천 탭의 `GET /api/v2/audio/recommendations` 구현 범위는 변경하지 않는다.
|
||||||
|
- 랭킹 탭 API/화면은 이번 범위에 포함하지 않는다.
|
||||||
|
- Figma 타입 chip의 `전체` chip은 구현하지 않는다.
|
||||||
|
- Figma sort menu의 `추천순`은 구현하지 않는다.
|
||||||
|
- response의 `totalCount`를 화면에 표시하지 않는다.
|
||||||
|
- Figma에 보이는 localhost asset URL을 앱 코드에 직접 사용하지 않는다.
|
||||||
|
- API 명세에 없는 신규 `type` 값을 임의로 추가하지 않는다.
|
||||||
|
- 외부 라이브러리를 추가하지 않는다.
|
||||||
|
- `Pods/**`, `generated/**`, `build/**`는 수정하지 않는다.
|
||||||
|
|
||||||
|
## 5. Target Users
|
||||||
|
- 메인 콘텐츠 탭에서 전체 콘텐츠를 타입별로 빠르게 탐색하려는 사용자
|
||||||
|
- 오디오/무료/포인트 콘텐츠를 썸네일 grid로 훑고 상세로 이동하려는 사용자
|
||||||
|
- 시리즈와 오리지널 시리즈를 구분해 탐색하려는 사용자
|
||||||
|
- 요일별 시리즈를 현재 디바이스 요일 기준으로 바로 확인하려는 사용자
|
||||||
|
|
||||||
|
## 6. User Stories
|
||||||
|
- 사용자는 콘텐츠 탭의 내부 `전체` 탭에 진입하면 기본으로 오디오 목록을 보고 싶다.
|
||||||
|
- 사용자는 타입 chip을 눌러 오디오, 시리즈, 오리지널, 무료, 포인트 목록을 전환하고 싶다.
|
||||||
|
- 사용자는 시리즈 타입에서 현재 요일의 시리즈가 기본으로 선택되어 있기를 기대한다.
|
||||||
|
- 사용자는 시리즈 타입에서 요일을 바꾸면 해당 요일 시리즈 목록이 새로 로드되기를 기대한다.
|
||||||
|
- 사용자는 정렬 메뉴에서 최신순 등 정렬 기준을 바꾸고 첫 페이지부터 다시 보고 싶다.
|
||||||
|
- 사용자는 스크롤을 내리면 다음 페이지가 자연스럽게 이어서 로드되기를 기대한다.
|
||||||
|
- 사용자는 `추천/랭킹/전체` 탭을 오가도 `전체` 탭에서 보던 필터와 목록 상태가 유지되기를 기대한다.
|
||||||
|
|
||||||
|
## 7. Core Requirements
|
||||||
|
|
||||||
|
### 7.1 API
|
||||||
|
- Method: `GET`
|
||||||
|
- Path: `/api/v2/audio/contents`
|
||||||
|
- 인증: 기존 V2 API 인증 헤더 패턴을 따른다.
|
||||||
|
- 응답 래퍼: 기존 관례대로 `ApiResponse<MainContentAllTabResponse>` 디코딩을 우선한다.
|
||||||
|
|
||||||
|
Query parameter:
|
||||||
|
- `type: MainContentAllType`
|
||||||
|
- `sort: ContentSort`
|
||||||
|
- `page: Int`
|
||||||
|
- `size: Int`
|
||||||
|
- `dayOfWeek: SeriesPublishedDaysOfWeek`
|
||||||
|
|
||||||
|
기본값:
|
||||||
|
- `type = AUDIO`
|
||||||
|
- `sort = LATEST`
|
||||||
|
- `page = 0`
|
||||||
|
- `size = 20`
|
||||||
|
- `dayOfWeek = Calendar.current` 기준 현재 디바이스 요일
|
||||||
|
|
||||||
|
전송 조건:
|
||||||
|
- `dayOfWeek`는 `type == SERIES`일 때만 query에 포함한다.
|
||||||
|
- `type`이 `AUDIO`, `ORIGINAL`, `FREE`, `POINT`이면 `dayOfWeek`를 보내지 않는다.
|
||||||
|
- 타입, 정렬, 요일 변경 시 `page = 0`부터 다시 요청한다.
|
||||||
|
- 스크롤 페이징 요청은 현재 filter 상태를 유지한 채 `page + 1`로 요청한다.
|
||||||
|
|
||||||
|
### 7.2 Type enum
|
||||||
|
```kotlin
|
||||||
|
enum class MainContentAllType {
|
||||||
|
AUDIO,
|
||||||
|
SERIES,
|
||||||
|
ORIGINAL,
|
||||||
|
FREE,
|
||||||
|
POINT
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Swift 모델은 기존 enum 스타일에 맞춰 `String`, `Decodable`, `Encodable`, `CaseIterable`, `Hashable` 채택을 우선한다.
|
||||||
|
|
||||||
|
### 7.3 Response model
|
||||||
|
```kotlin
|
||||||
|
data class MainContentAllTabResponse(
|
||||||
|
val type: MainContentAllType,
|
||||||
|
val totalCount: Int,
|
||||||
|
val audios: List<MainContentAudioResponse>,
|
||||||
|
val series: List<MainContentSeriesResponse>,
|
||||||
|
val sort: ContentSort,
|
||||||
|
val dayOfWeek: SeriesPublishedDaysOfWeek?,
|
||||||
|
val page: Int,
|
||||||
|
val size: Int,
|
||||||
|
@JsonProperty("hasNext")
|
||||||
|
val hasNext: Boolean
|
||||||
|
)
|
||||||
|
|
||||||
|
data class MainContentAudioResponse(
|
||||||
|
val audioContentId: Long,
|
||||||
|
val title: String,
|
||||||
|
val imageUrl: String?,
|
||||||
|
val price: Int,
|
||||||
|
@JsonProperty("isAdult")
|
||||||
|
val isAdult: Boolean,
|
||||||
|
@JsonProperty("isPointAvailable")
|
||||||
|
val isPointAvailable: Boolean,
|
||||||
|
@JsonProperty("isFirstContent")
|
||||||
|
val isFirstContent: Boolean,
|
||||||
|
@JsonProperty("isOriginalSeries")
|
||||||
|
val isOriginalSeries: Boolean,
|
||||||
|
val creatorNickname: String
|
||||||
|
)
|
||||||
|
|
||||||
|
data class MainContentSeriesResponse(
|
||||||
|
val seriesId: Long,
|
||||||
|
val title: String,
|
||||||
|
val coverImageUrl: String?,
|
||||||
|
val creatorNickname: String,
|
||||||
|
@JsonProperty("isOriginal")
|
||||||
|
val isOriginal: Boolean,
|
||||||
|
@JsonProperty("isAdult")
|
||||||
|
val isAdult: Boolean
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
Swift 모델은 기존 V2 관례에 맞춰 `Decodable` 구조체로 선언한다. `Long` 대응 id는 프로젝트 기존 모델 관례에 맞춰 Swift `Int`를 우선 사용한다.
|
||||||
|
|
||||||
|
### 7.4 Type별 data mapping
|
||||||
|
- `AUDIO`: response `audios`를 오디오 grid로 표시한다.
|
||||||
|
- `FREE`: response `audios`를 오디오 grid로 표시한다.
|
||||||
|
- `POINT`: response `audios`를 오디오 grid로 표시한다.
|
||||||
|
- `SERIES`: response `series`를 시리즈 grid로 표시하고, 요일 필터를 노출한다.
|
||||||
|
- `ORIGINAL`: response `series`를 시리즈 grid로 표시하고, 요일 필터는 노출하지 않는다.
|
||||||
|
- API enum에 `ALL`이 없으므로 Figma의 `전체` type chip은 구현하지 않는다.
|
||||||
|
- response의 `totalCount`는 pagination 판단이나 디버깅 보조 값으로만 유지하고, 화면에는 표시하지 않는다.
|
||||||
|
|
||||||
|
### 7.5 Paging
|
||||||
|
- `page = 0` 응답은 기존 목록을 교체한다.
|
||||||
|
- `page > 0` 응답은 현재 선택 타입에 맞는 목록 뒤에 append한다.
|
||||||
|
- `hasNext == false`이면 추가 요청을 막는다.
|
||||||
|
- 이미 다음 페이지 로딩 중이면 중복 요청을 막는다.
|
||||||
|
- 타입, 정렬, 요일 변경 시 기존 목록을 비우고 첫 페이지를 다시 요청한다.
|
||||||
|
- API 실패 시 기존 목록이 있으면 유지하고 toast 또는 error message를 표시한다.
|
||||||
|
- 첫 페이지 실패 또는 결과 없음은 empty state를 표시한다.
|
||||||
|
|
||||||
|
### 7.6 Sort
|
||||||
|
- 기본 정렬은 `ContentSort.latest` / API 값 `LATEST`이다.
|
||||||
|
- 정렬 menu는 Figma처럼 sort bar 우측에 표시한다.
|
||||||
|
- 정렬 변경 시 첫 페이지부터 다시 요청한다.
|
||||||
|
- `추천순`은 구현하지 않는다.
|
||||||
|
- `FREE` 타입에서는 `최신순`, `인기순`만 제공한다.
|
||||||
|
- `AUDIO`, `SERIES`, `ORIGINAL`, `POINT` 타입에서 제공할 sort 후보:
|
||||||
|
- `LATEST`
|
||||||
|
- `POPULAR`
|
||||||
|
- `PRICE_HIGH`
|
||||||
|
- `PRICE_LOW`
|
||||||
|
- `FREE` 타입에서 제공할 sort 후보:
|
||||||
|
- `LATEST`
|
||||||
|
- `POPULAR`
|
||||||
|
- 현재 선택된 sort가 타입 변경 후 허용되지 않는 값이면 `LATEST`로 초기화하고 첫 페이지부터 다시 요청한다.
|
||||||
|
|
||||||
|
### 7.7 Day of week
|
||||||
|
- `SeriesPublishedDaysOfWeek` 기존 enum을 사용한다.
|
||||||
|
- 현재 디바이스 요일 기본값은 `Calendar.current.component(.weekday, from: Date())`를 기준으로 매핑한다.
|
||||||
|
- Sunday: `.SUN`
|
||||||
|
- Monday: `.MON`
|
||||||
|
- Tuesday: `.TUE`
|
||||||
|
- Wednesday: `.WED`
|
||||||
|
- Thursday: `.THU`
|
||||||
|
- Friday: `.FRI`
|
||||||
|
- Saturday: `.SAT`
|
||||||
|
- `RANDOM`은 Figma의 `기타` 표시와 매핑한다.
|
||||||
|
- 요일 필터는 `SERIES` 타입에서만 노출한다.
|
||||||
|
- `ORIGINAL` 타입은 response `series`를 쓰지만 `dayOfWeek`를 보내지 않고 요일 필터도 노출하지 않는다.
|
||||||
|
|
||||||
|
짧은 요일 다국어 표기:
|
||||||
|
- `MON`: ko `월`, en `MON`, ja `月`
|
||||||
|
- `TUE`: ko `화`, en `TUE`, ja `火`
|
||||||
|
- `WED`: ko `수`, en `WED`, ja `水`
|
||||||
|
- `THU`: ko `목`, en `THU`, ja `木`
|
||||||
|
- `FRI`: ko `금`, en `FRI`, ja `金`
|
||||||
|
- `SAT`: ko `토`, en `SAT`, ja `土`
|
||||||
|
- `SUN`: ko `일`, en `SUN`, ja `日`
|
||||||
|
- `RANDOM`: ko `기타`, en `OTHER`, ja `その他`
|
||||||
|
|
||||||
|
### 7.8 UI layout
|
||||||
|
- 내부 tab bar에서 `전체`가 선택된 상태로 표시된다.
|
||||||
|
- 콘텐츠 타입 chip은 Figma 기준 수평 스크롤 형태를 따른다.
|
||||||
|
- 콘텐츠 타입 chip은 `오디오`, `시리즈`, `오리지널`, `무료`, `포인트`만 표시한다.
|
||||||
|
- Figma에 보이는 `전체` chip은 표시하지 않는다.
|
||||||
|
- 콘텐츠 타입 chip 기본 선택은 `오디오`이다.
|
||||||
|
- sort bar는 좌측에 현재 범위 label, 우측에 현재 sort label과 펼침 아이콘을 둔다.
|
||||||
|
- sort menu에는 `추천순`을 표시하지 않는다.
|
||||||
|
- 오디오/무료/포인트 grid는 Figma 오디오 상태와 동일한 3열 정사각형 썸네일 grid를 따른다.
|
||||||
|
- 시리즈/오리지널 grid는 Figma 시리즈/오리지널 상태와 동일한 3열 세로형 썸네일 grid를 따른다.
|
||||||
|
- response의 `totalCount` 또는 전체 개수 문구는 표시하지 않는다.
|
||||||
|
- 하단 main tab bar와 mini player 영역은 기존 `MainView` 구조를 유지한다.
|
||||||
|
- 빈 목록, 로딩, pagination loading 상태는 기존 V2 콘텐츠/추천 탭 패턴을 따른다.
|
||||||
|
|
||||||
|
### 7.9 Tab state preservation
|
||||||
|
- 내부 `추천/랭킹/전체` 탭 전환 시 `전체` 탭 ViewModel 상태를 유지한다.
|
||||||
|
- 유지 대상은 선택된 콘텐츠 타입, sort, dayOfWeek, 현재 page, hasNext, 로드된 `audios`/`series` 목록이다.
|
||||||
|
- `전체` 탭으로 돌아왔을 때 사용자가 명시적으로 filter를 변경하지 않았다면 첫 페이지를 자동 재요청하지 않는다.
|
||||||
|
|
||||||
|
### 7.10 Navigation
|
||||||
|
- 오디오 item 탭은 `audioContentId`로 기존 content detail 진입 흐름을 사용한다.
|
||||||
|
- 시리즈 item 탭은 `seriesId`로 기존 series detail 진입 흐름을 사용한다.
|
||||||
|
- 로그인/성인 콘텐츠 guard가 기존 상세 진입 흐름에 있다면 재사용한다.
|
||||||
|
|
||||||
|
## 8. UX / UI Expectations
|
||||||
|
- 전체 배경은 기존 V2 dark UI 기준을 따른다.
|
||||||
|
- 신규 사용자 노출 문자열은 `I18n`에 ko/en/ja로 추가한다.
|
||||||
|
- Figma localhost asset URL은 앱 코드에 사용하지 않는다.
|
||||||
|
- 타입 chip의 `전체`와 sort menu의 `추천순`은 표시하지 않는다.
|
||||||
|
- 전체 개수 또는 `totalCount`는 표시하지 않는다.
|
||||||
|
- 오디오 카드의 title은 `title`, subtitle은 `creatorNickname`을 표시한다.
|
||||||
|
- 시리즈 카드의 title은 `title`, subtitle은 `creatorNickname`을 표시한다.
|
||||||
|
- `MainContentAudioResponse.isOriginalSeries == true`이면 오디오 original tag를 표시한다.
|
||||||
|
- `MainContentAudioResponse.isFirstContent == true`이면 first tag를 표시한다.
|
||||||
|
- `MainContentAudioResponse.isPointAvailable == true`이면 point tag를 표시한다.
|
||||||
|
- `MainContentAudioResponse.price == 0`이면 free tag를 표시한다.
|
||||||
|
- `isAdult == true`이면 adult shield tag를 표시한다.
|
||||||
|
- `MainContentSeriesResponse.isOriginal == true`이면 `ic_series_original` + `img_new_only` 조합의 `ONLY` tag를 표시한다.
|
||||||
|
- 텍스트가 길면 Figma 기준 line limit로 말줄임 처리한다.
|
||||||
|
|
||||||
|
## 9. 재사용 가능한 V2 위젯 후보
|
||||||
|
- `SodaLive/Sources/V2/Main/Content/MainContentView.swift`
|
||||||
|
- 콘텐츠 탭 root와 title bar, 내부 tab bar 구조를 확장할 후보.
|
||||||
|
- `SodaLive/Sources/V2/Main/Content/MainContentTab.swift`
|
||||||
|
- 내부 `전체` 탭 case 추가 후보.
|
||||||
|
- `SodaLive/Sources/V2/Component/HomeTitleBar.swift`
|
||||||
|
- 콘텐츠 탭 title bar 재사용 후보.
|
||||||
|
- `SodaLive/Sources/V2/Component/TextTabBar.swift`
|
||||||
|
- 내부 `추천/랭킹/전체` tab bar 재사용 후보.
|
||||||
|
- `SodaLive/Sources/V2/Component/AudioContent/AudioContentThumbnailCard.swift`
|
||||||
|
- `AUDIO`, `FREE`, `POINT` 타입 카드의 1차 재사용 후보.
|
||||||
|
- `AudioContentThumbnailCardItem`에 `MainContentAudioResponse` adapter init 추가를 검토한다.
|
||||||
|
- `SodaLive/Sources/V2/CreatorChannel/Series/Components/CreatorChannelSeriesListItem.swift`
|
||||||
|
- `ic_series_original` + `img_new_only` tag와 세로형 시리즈 썸네일 스타일 참고 후보.
|
||||||
|
- 현재 `CreatorChannelSeriesResponse` 전용이고 요일/진행/소장률 필드를 요구하므로 그대로 재사용하기보다 `MainContentSeriesResponse` 전용 thumbnail card 또는 adapter를 우선 검토한다.
|
||||||
|
- `SodaLive/Sources/V2/CreatorChannel/Models/ContentSort.swift`
|
||||||
|
- `sort` query와 sort menu label 재사용 후보.
|
||||||
|
- `SodaLive/Sources/Home/HomeTabViewModel.swift`
|
||||||
|
- 기존 `SeriesPublishedDaysOfWeek` enum 위치 확인 대상.
|
||||||
|
- `SodaLive/Sources/Content/Series/Main/DayOfWeek/SeriesMainDayOfWeekView.swift`
|
||||||
|
- 현재 디바이스 요일 매핑과 요일 selector 구현 참고 대상.
|
||||||
|
|
||||||
|
## 10. Technical Constraints
|
||||||
|
- 신규 구현이 이어질 경우 기능 변경은 `SodaLive/Sources/V2/Main/Content/**` 하위에서 해결한다.
|
||||||
|
- API, Repository, ViewModel, Model은 내부 전체 탭 전용 경로로 분리한다.
|
||||||
|
- 여러 화면에서 재사용할 명확한 근거가 있는 UI만 `SodaLive/Sources/V2/Component/**`로 승격한다.
|
||||||
|
- 기존 legacy `SodaLive/Sources/Content/**` 화면을 리팩터링하지 않는다.
|
||||||
|
- 기존 `MainContentRecommendation` API/ViewModel을 직접 확장하지 않는다.
|
||||||
|
- 외부 라이브러리는 추가하지 않는다.
|
||||||
|
- `Pods/**`, `generated/**`, `build/**`는 수정하지 않는다.
|
||||||
|
|
||||||
|
## 11. Success Criteria
|
||||||
|
- 콘텐츠 탭 내부 tab bar에 `전체` 탭이 추가되고 선택 가능하다.
|
||||||
|
- 내부 `전체` 탭 최초 진입 시 `GET /api/v2/audio/contents?type=AUDIO&sort=LATEST&page=0&size=20` 요청이 발생한다.
|
||||||
|
- 최초 진입 요청에는 `dayOfWeek`가 포함되지 않는다.
|
||||||
|
- `SERIES` 타입 선택 시 현재 디바이스 요일이 선택되고, `dayOfWeek` query가 포함된다.
|
||||||
|
- `SERIES` 외 타입 요청에는 `dayOfWeek`가 포함되지 않는다.
|
||||||
|
- `AUDIO`, `FREE`, `POINT` 선택 시 response `audios`가 `AudioContentThumbnailCard` 기반 grid로 표시된다.
|
||||||
|
- `SERIES`, `ORIGINAL` 선택 시 response `series`가 세로형 시리즈 grid로 표시된다.
|
||||||
|
- Figma에 보이는 타입 chip `전체`는 표시되지 않는다.
|
||||||
|
- sort menu에 `추천순`이 표시되지 않는다.
|
||||||
|
- `FREE` 타입의 sort menu에는 `최신순`, `인기순`만 표시된다.
|
||||||
|
- response의 `totalCount` 또는 전체 개수 문구가 표시되지 않는다.
|
||||||
|
- 내부 `추천/랭킹/전체` 탭 전환 후 `전체` 탭으로 돌아오면 선택 타입, 정렬, 요일, 로드된 목록이 유지된다.
|
||||||
|
- `hasNext == true`인 상태에서 목록 하단 도달 시 다음 page가 요청되고 기존 목록 뒤에 append된다.
|
||||||
|
- 타입, 정렬, 요일 변경 시 page가 0으로 초기화되고 목록이 새 응답으로 교체된다.
|
||||||
|
- `기타` 요일은 영어에서 `OTHER`, 일본어에서 `その他`로 표시된다.
|
||||||
|
- 오디오 item 탭 시 오디오 상세로 이동하고, 시리즈 item 탭 시 시리즈 상세로 이동한다.
|
||||||
|
|
||||||
|
## 12. Open Questions
|
||||||
|
- 현재 확정된 요구사항 기준으로 미결정 사항은 없다.
|
||||||
|
|
||||||
|
## 13. Decisions
|
||||||
|
- Figma 타입 chip의 `전체`는 구현 대상에서 제외한다.
|
||||||
|
- Figma sort menu의 `추천순`은 구현하지 않는다.
|
||||||
|
- 내부 `추천/랭킹/전체` 탭 전환 시 `전체` 탭 상태를 유지한다.
|
||||||
|
- `FREE` 타입의 sort menu는 `LATEST`, `POPULAR`만 제공한다.
|
||||||
|
- response의 `totalCount`는 UI에 표시하지 않는다.
|
||||||
|
|
||||||
|
## 14. Verification Notes
|
||||||
|
- 2026-07-06: `docs/agent-guides/documentation-policy.md`를 확인해 PRD-only 작업은 필요한 최소 문서만 작성해도 됨을 확인했다.
|
||||||
|
- 2026-07-06: 기존 `docs/20260705_메인_콘텐츠_내부_추천_탭/prd.md`, `plan-task.md`를 확인해 이번 작업이 추천 탭이 아닌 내부 전체 탭 별도 범위임을 확인했다.
|
||||||
|
- 2026-07-06: Figma `get_design_context`와 `get_screenshot`으로 `35:5857`, `24:6909`, `24:9105`를 확인했다.
|
||||||
|
- 2026-07-06: `AudioContentThumbnailCard`, `CreatorChannelSeriesListItem`, `ContentSort`, `SeriesPublishedDaysOfWeek`, `SeriesMainDayOfWeekView`, `I18n.Series`를 확인해 재사용 후보와 제약을 정리했다.
|
||||||
|
- 2026-07-06: 사용자 결정 사항을 반영해 `전체` chip 제외, `추천순` 제외, 탭 전환 상태 유지, `FREE` sort 제한, `totalCount` 미표시를 확정했다.
|
||||||
Reference in New Issue
Block a user