feat(creator): 채널 후원 섹션을 연결한다
This commit is contained in:
@@ -28,6 +28,9 @@
|
|||||||
CC0101152FF0100000A5BBA3 /* CreatorChannelLatestAudioSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC0100152FF0100000A5BBA3 /* CreatorChannelLatestAudioSection.swift */; };
|
CC0101152FF0100000A5BBA3 /* CreatorChannelLatestAudioSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC0100152FF0100000A5BBA3 /* CreatorChannelLatestAudioSection.swift */; };
|
||||||
CC0102152FF0100000A5BBA3 /* CreatorChannelLatestAudioSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC0100152FF0100000A5BBA3 /* CreatorChannelLatestAudioSection.swift */; };
|
CC0102152FF0100000A5BBA3 /* CreatorChannelLatestAudioSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC0100152FF0100000A5BBA3 /* CreatorChannelLatestAudioSection.swift */; };
|
||||||
CC0103152FF0100000A5BBA3 /* CreatorChannelLatestAudioSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC0100152FF0100000A5BBA3 /* CreatorChannelLatestAudioSection.swift */; };
|
CC0103152FF0100000A5BBA3 /* CreatorChannelLatestAudioSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC0100152FF0100000A5BBA3 /* CreatorChannelLatestAudioSection.swift */; };
|
||||||
|
CC0101162FF0100000A5BBA3 /* CreatorChannelDonationSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC0100162FF0100000A5BBA3 /* CreatorChannelDonationSection.swift */; };
|
||||||
|
CC0102162FF0100000A5BBA3 /* CreatorChannelDonationSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC0100162FF0100000A5BBA3 /* CreatorChannelDonationSection.swift */; };
|
||||||
|
CC0103162FF0100000A5BBA3 /* CreatorChannelDonationSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC0100162FF0100000A5BBA3 /* CreatorChannelDonationSection.swift */; };
|
||||||
CC0101082FF0100000A5BBA3 /* CreatorChannelView.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC0100082FF0100000A5BBA3 /* CreatorChannelView.swift */; };
|
CC0101082FF0100000A5BBA3 /* CreatorChannelView.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC0100082FF0100000A5BBA3 /* CreatorChannelView.swift */; };
|
||||||
CC0102082FF0100000A5BBA3 /* CreatorChannelView.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC0100082FF0100000A5BBA3 /* CreatorChannelView.swift */; };
|
CC0102082FF0100000A5BBA3 /* CreatorChannelView.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC0100082FF0100000A5BBA3 /* CreatorChannelView.swift */; };
|
||||||
CC0103082FF0100000A5BBA3 /* CreatorChannelView.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC0100082FF0100000A5BBA3 /* CreatorChannelView.swift */; };
|
CC0103082FF0100000A5BBA3 /* CreatorChannelView.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC0100082FF0100000A5BBA3 /* CreatorChannelView.swift */; };
|
||||||
@@ -1754,6 +1757,7 @@
|
|||||||
CC0100132FF0100000A5BBA3 /* CreatorChannelTabBar.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CreatorChannelTabBar.swift; sourceTree = "<group>"; };
|
CC0100132FF0100000A5BBA3 /* CreatorChannelTabBar.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CreatorChannelTabBar.swift; sourceTree = "<group>"; };
|
||||||
CC0100142FF0100000A5BBA3 /* CreatorChannelCurrentLiveSection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CreatorChannelCurrentLiveSection.swift; sourceTree = "<group>"; };
|
CC0100142FF0100000A5BBA3 /* CreatorChannelCurrentLiveSection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CreatorChannelCurrentLiveSection.swift; sourceTree = "<group>"; };
|
||||||
CC0100152FF0100000A5BBA3 /* CreatorChannelLatestAudioSection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CreatorChannelLatestAudioSection.swift; sourceTree = "<group>"; };
|
CC0100152FF0100000A5BBA3 /* CreatorChannelLatestAudioSection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CreatorChannelLatestAudioSection.swift; sourceTree = "<group>"; };
|
||||||
|
CC0100162FF0100000A5BBA3 /* CreatorChannelDonationSection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CreatorChannelDonationSection.swift; sourceTree = "<group>"; };
|
||||||
0BBDC3ECBBE049EAA4057D3A /* PostChannelDonationRequest.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = PostChannelDonationRequest.swift; sourceTree = "<group>"; };
|
0BBDC3ECBBE049EAA4057D3A /* PostChannelDonationRequest.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = PostChannelDonationRequest.swift; sourceTree = "<group>"; };
|
||||||
0EC05859619BDC03056911E7 /* MainTab.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = MainTab.swift; sourceTree = "<group>"; };
|
0EC05859619BDC03056911E7 /* MainTab.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = MainTab.swift; sourceTree = "<group>"; };
|
||||||
189350EA101CF12EEBA6EC1D /* MainTabBarView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = MainTabBarView.swift; sourceTree = "<group>"; };
|
189350EA101CF12EEBA6EC1D /* MainTabBarView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = MainTabBarView.swift; sourceTree = "<group>"; };
|
||||||
@@ -2675,6 +2679,7 @@
|
|||||||
CC0100072FF0100000A5BBA3 /* CreatorChannelPlaceholderTabView.swift */,
|
CC0100072FF0100000A5BBA3 /* CreatorChannelPlaceholderTabView.swift */,
|
||||||
CC0100142FF0100000A5BBA3 /* CreatorChannelCurrentLiveSection.swift */,
|
CC0100142FF0100000A5BBA3 /* CreatorChannelCurrentLiveSection.swift */,
|
||||||
CC0100152FF0100000A5BBA3 /* CreatorChannelLatestAudioSection.swift */,
|
CC0100152FF0100000A5BBA3 /* CreatorChannelLatestAudioSection.swift */,
|
||||||
|
CC0100162FF0100000A5BBA3 /* CreatorChannelDonationSection.swift */,
|
||||||
);
|
);
|
||||||
path = Components;
|
path = Components;
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
@@ -5611,6 +5616,7 @@
|
|||||||
CC0101072FF0100000A5BBA3 /* CreatorChannelPlaceholderTabView.swift in Sources */,
|
CC0101072FF0100000A5BBA3 /* CreatorChannelPlaceholderTabView.swift in Sources */,
|
||||||
CC0101142FF0100000A5BBA3 /* CreatorChannelCurrentLiveSection.swift in Sources */,
|
CC0101142FF0100000A5BBA3 /* CreatorChannelCurrentLiveSection.swift in Sources */,
|
||||||
CC0101152FF0100000A5BBA3 /* CreatorChannelLatestAudioSection.swift in Sources */,
|
CC0101152FF0100000A5BBA3 /* CreatorChannelLatestAudioSection.swift in Sources */,
|
||||||
|
CC0101162FF0100000A5BBA3 /* CreatorChannelDonationSection.swift in Sources */,
|
||||||
CC0101082FF0100000A5BBA3 /* CreatorChannelView.swift in Sources */,
|
CC0101082FF0100000A5BBA3 /* CreatorChannelView.swift in Sources */,
|
||||||
CC0101112FF0100000A5BBA3 /* CreatorChannelTitleBar.swift in Sources */,
|
CC0101112FF0100000A5BBA3 /* CreatorChannelTitleBar.swift in Sources */,
|
||||||
CC0101122FF0100000A5BBA3 /* CreatorChannelHeaderSection.swift in Sources */,
|
CC0101122FF0100000A5BBA3 /* CreatorChannelHeaderSection.swift in Sources */,
|
||||||
@@ -6410,6 +6416,7 @@
|
|||||||
CC0102072FF0100000A5BBA3 /* CreatorChannelPlaceholderTabView.swift in Sources */,
|
CC0102072FF0100000A5BBA3 /* CreatorChannelPlaceholderTabView.swift in Sources */,
|
||||||
CC0102142FF0100000A5BBA3 /* CreatorChannelCurrentLiveSection.swift in Sources */,
|
CC0102142FF0100000A5BBA3 /* CreatorChannelCurrentLiveSection.swift in Sources */,
|
||||||
CC0102152FF0100000A5BBA3 /* CreatorChannelLatestAudioSection.swift in Sources */,
|
CC0102152FF0100000A5BBA3 /* CreatorChannelLatestAudioSection.swift in Sources */,
|
||||||
|
CC0102162FF0100000A5BBA3 /* CreatorChannelDonationSection.swift in Sources */,
|
||||||
CC0102082FF0100000A5BBA3 /* CreatorChannelView.swift in Sources */,
|
CC0102082FF0100000A5BBA3 /* CreatorChannelView.swift in Sources */,
|
||||||
CC0102112FF0100000A5BBA3 /* CreatorChannelTitleBar.swift in Sources */,
|
CC0102112FF0100000A5BBA3 /* CreatorChannelTitleBar.swift in Sources */,
|
||||||
CC0102122FF0100000A5BBA3 /* CreatorChannelHeaderSection.swift in Sources */,
|
CC0102122FF0100000A5BBA3 /* CreatorChannelHeaderSection.swift in Sources */,
|
||||||
@@ -7225,6 +7232,7 @@
|
|||||||
CC0103072FF0100000A5BBA3 /* CreatorChannelPlaceholderTabView.swift in Sources */,
|
CC0103072FF0100000A5BBA3 /* CreatorChannelPlaceholderTabView.swift in Sources */,
|
||||||
CC0103142FF0100000A5BBA3 /* CreatorChannelCurrentLiveSection.swift in Sources */,
|
CC0103142FF0100000A5BBA3 /* CreatorChannelCurrentLiveSection.swift in Sources */,
|
||||||
CC0103152FF0100000A5BBA3 /* CreatorChannelLatestAudioSection.swift in Sources */,
|
CC0103152FF0100000A5BBA3 /* CreatorChannelLatestAudioSection.swift in Sources */,
|
||||||
|
CC0103162FF0100000A5BBA3 /* CreatorChannelDonationSection.swift in Sources */,
|
||||||
CC0103082FF0100000A5BBA3 /* CreatorChannelView.swift in Sources */,
|
CC0103082FF0100000A5BBA3 /* CreatorChannelView.swift in Sources */,
|
||||||
CC0103112FF0100000A5BBA3 /* CreatorChannelTitleBar.swift in Sources */,
|
CC0103112FF0100000A5BBA3 /* CreatorChannelTitleBar.swift in Sources */,
|
||||||
CC0103122FF0100000A5BBA3 /* CreatorChannelHeaderSection.swift in Sources */,
|
CC0103122FF0100000A5BBA3 /* CreatorChannelHeaderSection.swift in Sources */,
|
||||||
|
|||||||
@@ -3165,7 +3165,7 @@ If you block this user, the following features will be restricted.
|
|||||||
enum CreatorChannelHome {
|
enum CreatorChannelHome {
|
||||||
static var currentLive: String { pick(ko: "현재 라이브", en: "Live now", ja: "現在ライブ") }
|
static var currentLive: String { pick(ko: "현재 라이브", en: "Live now", ja: "現在ライブ") }
|
||||||
static var latestAudio: String { pick(ko: "최신 오디오 콘텐츠", en: "Latest audio", ja: "最新オーディオ") }
|
static var latestAudio: String { pick(ko: "최신 오디오 콘텐츠", en: "Latest audio", ja: "最新オーディオ") }
|
||||||
static var donation: String { pick(ko: "채널 후원", en: "Channel donations", ja: "チャンネル応援") }
|
static var donation: String { pick(ko: "후원", en: "Donations", ja: "応援") }
|
||||||
static var notice: String { pick(ko: "공지", en: "Notice", ja: "お知らせ") }
|
static var notice: String { pick(ko: "공지", en: "Notice", ja: "お知らせ") }
|
||||||
static var schedule: String { pick(ko: "스케줄", en: "Schedule", ja: "スケジュール") }
|
static var schedule: String { pick(ko: "스케줄", en: "Schedule", ja: "スケジュール") }
|
||||||
static var audio: String { pick(ko: "오디오", en: "Audio", ja: "オーディオ") }
|
static var audio: String { pick(ko: "오디오", en: "Audio", ja: "オーディオ") }
|
||||||
|
|||||||
@@ -4,8 +4,10 @@ struct CreatorChannelView: View {
|
|||||||
let creatorId: Int
|
let creatorId: Int
|
||||||
|
|
||||||
@StateObject private var viewModel = CreatorChannelViewModel()
|
@StateObject private var viewModel = CreatorChannelViewModel()
|
||||||
|
@StateObject private var channelDonationViewModel = ChannelDonationViewModel()
|
||||||
@Environment(\.dismiss) private var dismiss
|
@Environment(\.dismiss) private var dismiss
|
||||||
@State private var tabBarMinY: CGFloat = .greatestFiniteMagnitude
|
@State private var tabBarMinY: CGFloat = .greatestFiniteMagnitude
|
||||||
|
@State private var isShowChannelDonationDialog = false
|
||||||
|
|
||||||
private let titleBarHeight: CGFloat = 56
|
private let titleBarHeight: CGFloat = 56
|
||||||
private let tabBarHeight: CGFloat = 52
|
private let tabBarHeight: CGFloat = 52
|
||||||
@@ -65,6 +67,28 @@ struct CreatorChannelView: View {
|
|||||||
if viewModel.isLoading {
|
if viewModel.isLoading {
|
||||||
LoadingView()
|
LoadingView()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if isShowChannelDonationDialog {
|
||||||
|
LiveRoomDonationDialogView(
|
||||||
|
isShowing: $isShowChannelDonationDialog,
|
||||||
|
isAudioContentDonation: false,
|
||||||
|
messageLimit: 100,
|
||||||
|
secretLabel: I18n.MemberChannel.secretDonationLabel,
|
||||||
|
secretMinimumCanMessage: I18n.MemberChannel.secretDonationMinimumCanMessage,
|
||||||
|
shouldPrefixSecretInMessagePlaceholder: false,
|
||||||
|
onClickDonation: { can, message, isSecret in
|
||||||
|
channelDonationViewModel.postChannelDonation(
|
||||||
|
can: can,
|
||||||
|
message: message,
|
||||||
|
isSecret: isSecret,
|
||||||
|
reloadAfterSuccess: false,
|
||||||
|
onSuccess: {
|
||||||
|
viewModel.fetchHome(creatorId: creatorId)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.navigationBarBackButtonHidden(true)
|
.navigationBarBackButtonHidden(true)
|
||||||
@@ -74,6 +98,7 @@ struct CreatorChannelView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
.sodaToast(isPresented: $viewModel.isShowPopup, message: viewModel.errorMessage, autohideIn: 1)
|
.sodaToast(isPresented: $viewModel.isShowPopup, message: viewModel.errorMessage, autohideIn: 1)
|
||||||
|
.sodaToast(isPresented: $channelDonationViewModel.isShowPopup, message: channelDonationViewModel.errorMessage, autohideIn: 2)
|
||||||
}
|
}
|
||||||
|
|
||||||
private func titleBar(backgroundProgress: CGFloat) -> some View {
|
private func titleBar(backgroundProgress: CGFloat) -> some View {
|
||||||
@@ -131,7 +156,8 @@ struct CreatorChannelView: View {
|
|||||||
CreatorChannelHomeView(
|
CreatorChannelHomeView(
|
||||||
response: response,
|
response: response,
|
||||||
onSelectTab: selectTab,
|
onSelectTab: selectTab,
|
||||||
onTapContent: showContentDetail
|
onTapContent: showContentDetail,
|
||||||
|
onTapDonate: showDonationDialog
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
CreatorChannelPlaceholderTabView(title: viewModel.selectedTab.title)
|
CreatorChannelPlaceholderTabView(title: viewModel.selectedTab.title)
|
||||||
@@ -158,6 +184,12 @@ struct CreatorChannelView: View {
|
|||||||
private func showContentDetail(_ contentId: Int) {
|
private func showContentDetail(_ contentId: Int) {
|
||||||
AppState.shared.setAppStep(step: .contentDetail(contentId: contentId))
|
AppState.shared.setAppStep(step: .contentDetail(contentId: contentId))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func showDonationDialog() {
|
||||||
|
guard let creatorId = viewModel.response?.creator.creatorId else { return }
|
||||||
|
channelDonationViewModel.setCreatorId(creatorId, shouldFetch: false)
|
||||||
|
isShowChannelDonationDialog = true
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
struct CreatorChannelView_Previews: PreviewProvider {
|
struct CreatorChannelView_Previews: PreviewProvider {
|
||||||
@@ -194,7 +226,36 @@ struct CreatorChannelView_Previews: PreviewProvider {
|
|||||||
isOwned: false,
|
isOwned: false,
|
||||||
isRented: false
|
isRented: false
|
||||||
),
|
),
|
||||||
channelDonations: [],
|
channelDonations: [
|
||||||
|
CreatorChannelDonationResponse(
|
||||||
|
nickname: "팬 이름 50",
|
||||||
|
profileImageUrl: "https://picsum.photos/120/120?1",
|
||||||
|
can: 50,
|
||||||
|
message: "크리에이터가 커뮤니티에 올린 글이 보이는 부분 크리에이터가 커뮤니티에 올린 글이 보이는 부분",
|
||||||
|
createdAtUtc: "2026-07-03T03:44:00Z"
|
||||||
|
),
|
||||||
|
CreatorChannelDonationResponse(
|
||||||
|
nickname: "팬 이름 100",
|
||||||
|
profileImageUrl: "https://picsum.photos/120/120?2",
|
||||||
|
can: 100,
|
||||||
|
message: "51~100캔 후원 컬러를 확인하는 Preview 샘플입니다.",
|
||||||
|
createdAtUtc: "2026-07-03T03:43:00Z"
|
||||||
|
),
|
||||||
|
CreatorChannelDonationResponse(
|
||||||
|
nickname: "팬 이름 500",
|
||||||
|
profileImageUrl: "https://picsum.photos/120/120?3",
|
||||||
|
can: 500,
|
||||||
|
message: "101~500캔 후원 컬러를 확인하는 Preview 샘플입니다.",
|
||||||
|
createdAtUtc: "2026-07-03T03:42:00Z"
|
||||||
|
),
|
||||||
|
CreatorChannelDonationResponse(
|
||||||
|
nickname: "팬 이름 501",
|
||||||
|
profileImageUrl: "https://picsum.photos/120/120?4",
|
||||||
|
can: 501,
|
||||||
|
message: "501캔 이상 후원 컬러를 확인하는 Preview 샘플입니다.",
|
||||||
|
createdAtUtc: "2026-07-03T03:41:00Z"
|
||||||
|
)
|
||||||
|
],
|
||||||
notices: [],
|
notices: [],
|
||||||
schedules: [],
|
schedules: [],
|
||||||
audioContents: [],
|
audioContents: [],
|
||||||
|
|||||||
@@ -0,0 +1,251 @@
|
|||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
struct CreatorChannelDonationSection: View {
|
||||||
|
let channelDonations: [CreatorChannelDonationResponse]
|
||||||
|
let onSelectTab: (CreatorChannelTab) -> Void
|
||||||
|
let onTapDonate: () -> Void
|
||||||
|
|
||||||
|
init(
|
||||||
|
channelDonations: [CreatorChannelDonationResponse],
|
||||||
|
onSelectTab: @escaping (CreatorChannelTab) -> Void,
|
||||||
|
onTapDonate: @escaping () -> Void = {}
|
||||||
|
) {
|
||||||
|
self.channelDonations = channelDonations
|
||||||
|
self.onSelectTab = onSelectTab
|
||||||
|
self.onTapDonate = onTapDonate
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(alignment: .leading, spacing: SodaSpacing.s14) {
|
||||||
|
SectionTitle(title: I18n.CreatorChannelHome.donation) {
|
||||||
|
onSelectTab(.donation)
|
||||||
|
}
|
||||||
|
|
||||||
|
if channelDonations.isEmpty {
|
||||||
|
CreatorChannelDonationEmptyCard(onTapDonate: onTapDonate)
|
||||||
|
.padding(.horizontal, SodaSpacing.s14)
|
||||||
|
} else {
|
||||||
|
ScrollView(.horizontal, showsIndicators: false) {
|
||||||
|
HStack(alignment: .top, spacing: SodaSpacing.s4) {
|
||||||
|
ForEach(channelDonations.indices, id: \.self) { index in
|
||||||
|
CreatorChannelDonationCard(donation: channelDonations[index])
|
||||||
|
.frame(width: 374)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(.leading, SodaSpacing.s14)
|
||||||
|
.padding(.trailing, SodaSpacing.s14)
|
||||||
|
}
|
||||||
|
|
||||||
|
Button(action: onTapDonate) {
|
||||||
|
HStack(spacing: SodaSpacing.s6) {
|
||||||
|
Image("ic_new_donation")
|
||||||
|
.resizable()
|
||||||
|
.renderingMode(.template)
|
||||||
|
.foregroundColor(.white)
|
||||||
|
.scaledToFit()
|
||||||
|
.frame(width: 20, height: 20)
|
||||||
|
|
||||||
|
Text(I18n.ContentDetail.DonationDialog.title)
|
||||||
|
.appFont(size: 16, weight: .medium)
|
||||||
|
.foregroundColor(.white)
|
||||||
|
.lineLimit(1)
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
.padding(SodaSpacing.s12)
|
||||||
|
.overlay(
|
||||||
|
Capsule()
|
||||||
|
.stroke(Color.white.opacity(0.3), lineWidth: 1)
|
||||||
|
)
|
||||||
|
.contentShape(Rectangle())
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
.padding(.horizontal, SodaSpacing.s14)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(.top, SodaSpacing.s20)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct CreatorChannelDonationEmptyCard: View {
|
||||||
|
let onTapDonate: () -> Void
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(alignment: .leading, spacing: 0) {
|
||||||
|
Text("처음으로 크리에이터를\n후원해 보세요!")
|
||||||
|
.appFont(size: 24, weight: .bold)
|
||||||
|
.foregroundColor(.white)
|
||||||
|
.lineSpacing(0)
|
||||||
|
.padding(.top, SodaSpacing.s20)
|
||||||
|
.padding(.leading, SodaSpacing.s20)
|
||||||
|
|
||||||
|
Spacer(minLength: 0)
|
||||||
|
|
||||||
|
Button(action: onTapDonate) {
|
||||||
|
HStack(spacing: SodaSpacing.s6) {
|
||||||
|
Image("ic_new_donation")
|
||||||
|
.resizable()
|
||||||
|
.renderingMode(.template)
|
||||||
|
.foregroundColor(.black)
|
||||||
|
.scaledToFit()
|
||||||
|
.frame(width: 20, height: 20)
|
||||||
|
|
||||||
|
Text(I18n.ContentDetail.DonationDialog.title)
|
||||||
|
.appFont(size: 16, weight: .medium)
|
||||||
|
.foregroundColor(.black)
|
||||||
|
.lineLimit(1)
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
.padding(SodaSpacing.s12)
|
||||||
|
.background(Color.white)
|
||||||
|
.clipShape(Capsule())
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
.padding(.horizontal, SodaSpacing.s20)
|
||||||
|
.padding(.bottom, SodaSpacing.s20)
|
||||||
|
}
|
||||||
|
.frame(height: 196)
|
||||||
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
.background(Color.gray900)
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: SodaSpacing.s14, style: .continuous))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct CreatorChannelDonationCard: View {
|
||||||
|
let donation: CreatorChannelDonationResponse
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(alignment: .leading, spacing: SodaSpacing.s14) {
|
||||||
|
header
|
||||||
|
|
||||||
|
Text(donation.messageText)
|
||||||
|
.appFont(size: 16, weight: .regular)
|
||||||
|
.foregroundColor(.white)
|
||||||
|
.lineLimit(2)
|
||||||
|
.truncationMode(.tail)
|
||||||
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
.padding(.horizontal, SodaSpacing.s14)
|
||||||
|
}
|
||||||
|
.padding(.bottom, SodaSpacing.s14)
|
||||||
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
.background(Color.gray900)
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: SodaSpacing.s14, style: .continuous))
|
||||||
|
}
|
||||||
|
|
||||||
|
private var header: some View {
|
||||||
|
HStack(alignment: .center, spacing: SodaSpacing.s8) {
|
||||||
|
DownsampledKFImage(
|
||||||
|
url: URL(string: donation.profileImageUrl),
|
||||||
|
size: CGSize(width: 42, height: 42)
|
||||||
|
)
|
||||||
|
.background(Color.white.opacity(0.6))
|
||||||
|
.clipShape(Circle())
|
||||||
|
|
||||||
|
VStack(alignment: .leading, spacing: 2) {
|
||||||
|
Text(donation.nickname)
|
||||||
|
.appFont(size: 14, weight: .medium)
|
||||||
|
.foregroundColor(.black)
|
||||||
|
.lineLimit(1)
|
||||||
|
.truncationMode(.tail)
|
||||||
|
|
||||||
|
Text(donation.relativeTimeText())
|
||||||
|
.appFont(size: 14, weight: .regular)
|
||||||
|
.foregroundColor(Color.gray700)
|
||||||
|
.lineLimit(1)
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
|
||||||
|
canBadge
|
||||||
|
}
|
||||||
|
.padding(SodaSpacing.s14)
|
||||||
|
.background(donation.tierColor)
|
||||||
|
}
|
||||||
|
|
||||||
|
private var canBadge: some View {
|
||||||
|
HStack(spacing: 2) {
|
||||||
|
Image("ic_bar_cash")
|
||||||
|
.resizable()
|
||||||
|
.scaledToFit()
|
||||||
|
.frame(width: 18, height: 18)
|
||||||
|
|
||||||
|
Text("\(donation.can)캔")
|
||||||
|
.appFont(size: 14, weight: .regular)
|
||||||
|
.foregroundColor(.white)
|
||||||
|
.lineLimit(1)
|
||||||
|
}
|
||||||
|
.padding(.horizontal, SodaSpacing.s6)
|
||||||
|
.padding(.vertical, SodaSpacing.s4)
|
||||||
|
.background(Color.gray900)
|
||||||
|
.clipShape(Capsule())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private extension CreatorChannelDonationResponse {
|
||||||
|
var messageText: String {
|
||||||
|
let trimmedMessage = message.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
return trimmedMessage.isEmpty ? "\(can)캔을 후원하였습니다" : trimmedMessage
|
||||||
|
}
|
||||||
|
|
||||||
|
func relativeTimeText(now: Date = Date()) -> String {
|
||||||
|
DateParser.relativeTimeText(fromUTC: createdAtUtc, fallback: createdAtUtc, now: now)
|
||||||
|
}
|
||||||
|
|
||||||
|
var tierColor: Color {
|
||||||
|
switch can {
|
||||||
|
case 1...50:
|
||||||
|
return Color(hex: "E2E2E2")
|
||||||
|
case 51...100:
|
||||||
|
return Color(hex: "73EE01")
|
||||||
|
case 101...500:
|
||||||
|
return Color(hex: "00EAFF")
|
||||||
|
default:
|
||||||
|
return Color(hex: "FF4C3C")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct CreatorChannelDonationSection_Previews: PreviewProvider {
|
||||||
|
static var previews: some View {
|
||||||
|
VStack(spacing: SodaSpacing.s20) {
|
||||||
|
CreatorChannelDonationSection(
|
||||||
|
channelDonations: [],
|
||||||
|
onSelectTab: { _ in }
|
||||||
|
)
|
||||||
|
|
||||||
|
CreatorChannelDonationSection(
|
||||||
|
channelDonations: [
|
||||||
|
CreatorChannelDonationResponse(
|
||||||
|
nickname: "팬 이름 501",
|
||||||
|
profileImageUrl: "https://picsum.photos/120/120?4",
|
||||||
|
can: 501,
|
||||||
|
message: "501캔 이상 후원 컬러를 확인하는 Preview 샘플입니다.",
|
||||||
|
createdAtUtc: "2026-07-03T03:41:00Z"
|
||||||
|
),
|
||||||
|
CreatorChannelDonationResponse(
|
||||||
|
nickname: "팬 이름 50",
|
||||||
|
profileImageUrl: "https://picsum.photos/120/120?1",
|
||||||
|
can: 50,
|
||||||
|
message: "크리에이터가 커뮤니티에 올린 글이 보이는 부분 크리에이터가 커뮤니티에 올린 글이 보이는 부분",
|
||||||
|
createdAtUtc: "2026-07-03T03:44:00Z"
|
||||||
|
),
|
||||||
|
CreatorChannelDonationResponse(
|
||||||
|
nickname: "팬 이름 100",
|
||||||
|
profileImageUrl: "https://picsum.photos/120/120?2",
|
||||||
|
can: 100,
|
||||||
|
message: "51~100캔 후원 컬러를 확인하는 Preview 샘플입니다.",
|
||||||
|
createdAtUtc: "2026-07-03T03:43:00Z"
|
||||||
|
),
|
||||||
|
CreatorChannelDonationResponse(
|
||||||
|
nickname: "팬 이름 500",
|
||||||
|
profileImageUrl: "https://picsum.photos/120/120?3",
|
||||||
|
can: 500,
|
||||||
|
message: "101~500캔 후원 컬러를 확인하는 Preview 샘플입니다.",
|
||||||
|
createdAtUtc: "2026-07-03T03:42:00Z"
|
||||||
|
)
|
||||||
|
],
|
||||||
|
onSelectTab: { _ in }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
.background(Color.black)
|
||||||
|
.previewLayout(.sizeThatFits)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,17 +5,20 @@ struct CreatorChannelHomeView: View {
|
|||||||
let onSelectTab: (CreatorChannelTab) -> Void
|
let onSelectTab: (CreatorChannelTab) -> Void
|
||||||
let onTapLive: (Int) -> Void
|
let onTapLive: (Int) -> Void
|
||||||
let onTapContent: (Int) -> Void
|
let onTapContent: (Int) -> Void
|
||||||
|
let onTapDonate: () -> Void
|
||||||
|
|
||||||
init(
|
init(
|
||||||
response: CreatorChannelHomeResponse,
|
response: CreatorChannelHomeResponse,
|
||||||
onSelectTab: @escaping (CreatorChannelTab) -> Void,
|
onSelectTab: @escaping (CreatorChannelTab) -> Void,
|
||||||
onTapLive: @escaping (Int) -> Void = { _ in },
|
onTapLive: @escaping (Int) -> Void = { _ in },
|
||||||
onTapContent: @escaping (Int) -> Void = { _ in }
|
onTapContent: @escaping (Int) -> Void = { _ in },
|
||||||
|
onTapDonate: @escaping () -> Void = {}
|
||||||
) {
|
) {
|
||||||
self.response = response
|
self.response = response
|
||||||
self.onSelectTab = onSelectTab
|
self.onSelectTab = onSelectTab
|
||||||
self.onTapLive = onTapLive
|
self.onTapLive = onTapLive
|
||||||
self.onTapContent = onTapContent
|
self.onTapContent = onTapContent
|
||||||
|
self.onTapDonate = onTapDonate
|
||||||
}
|
}
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
@@ -29,6 +32,12 @@ struct CreatorChannelHomeView: View {
|
|||||||
latestAudioContent: response.latestAudioContent,
|
latestAudioContent: response.latestAudioContent,
|
||||||
onTapContent: onTapContent
|
onTapContent: onTapContent
|
||||||
)
|
)
|
||||||
|
|
||||||
|
CreatorChannelDonationSection(
|
||||||
|
channelDonations: response.channelDonations,
|
||||||
|
onSelectTab: onSelectTab,
|
||||||
|
onTapDonate: onTapDonate
|
||||||
|
)
|
||||||
}
|
}
|
||||||
.frame(maxWidth: .infinity, alignment: .leading)
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
.background(Color.black)
|
.background(Color.black)
|
||||||
|
|||||||
@@ -367,19 +367,36 @@
|
|||||||
|
|
||||||
### Phase 7: 채널 후원 섹션
|
### Phase 7: 채널 후원 섹션
|
||||||
|
|
||||||
- [ ] **Task 7.1: 채널 후원 섹션 구현**
|
- [x] **Task 7.1: 채널 후원 섹션 구현**
|
||||||
- 대상 파일:
|
- 대상 파일:
|
||||||
- 생성: `SodaLive/Sources/V2/CreatorChannel/Home/Components/CreatorChannelDonationSection.swift`
|
- 생성: `SodaLive/Sources/V2/CreatorChannel/Home/Components/CreatorChannelDonationSection.swift`
|
||||||
- 수정: `SodaLive/Sources/V2/CreatorChannel/Home/CreatorChannelHomeView.swift`
|
- 수정: `SodaLive/Sources/V2/CreatorChannel/Home/CreatorChannelHomeView.swift`
|
||||||
- 확인: `SodaLive/Sources/App/AppStep.swift`
|
- 확인: `SodaLive/Sources/App/AppStep.swift`
|
||||||
- 확인: `SodaLive/Sources/ContentView.swift`
|
- 확인: `SodaLive/Sources/ContentView.swift`
|
||||||
|
- 디자인 기준:
|
||||||
|
- Figma 후원 아이템: `567:17927`, `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=567-17927&m=dev`
|
||||||
- 작업 내용:
|
- 작업 내용:
|
||||||
- `channelDonations.isEmpty`이면 섹션을 표시하지 않는다.
|
- `channelDonations.isEmpty`여도 섹션을 숨기지 않고 Figma `290:8820` 기준의 빈 후원 UI를 표시한다.
|
||||||
- 각 item의 `nickname`, `profileImageUrl`, `can`, `message`, `createdAtUtc`를 표시한다.
|
- 각 item은 Figma 후원 아이템처럼 외곽 card 배경을 `#202020`, corner radius 14, 내부 vertical spacing 14 기준으로 구성한다.
|
||||||
- 전체보기는 기존 `AppStep.channelDonationAll(creatorId:)`로 연결한다.
|
- item 상단의 팬 정보/후원 금액 영역은 `profileImageUrl`, `nickname`, `createdAtUtc`, `can`을 표시한다.
|
||||||
|
- `profileImageUrl`은 42x42 원형 이미지로 표시하고, 이미지가 없거나 로드 실패 시 기존 프로필 placeholder 패턴을 따른다.
|
||||||
|
- `nickname`은 14pt medium, `createdAtUtc` 상대 시간은 14pt regular로 표시한다.
|
||||||
|
- 후원 금액은 우측 pill에 기존 can icon과 `{can}캔` 형식으로 표시한다.
|
||||||
|
- 팬 이름과 후원 금액을 포함하는 상단 영역의 배경색은 후원 금액 `can`에 따라 다르게 적용한다.
|
||||||
|
- `1...50`: `#E2E2E2`
|
||||||
|
- `51...100`: `#73EE01`
|
||||||
|
- `101...500`: `#00EAFF`
|
||||||
|
- `501...`: `#FF4C3C`
|
||||||
|
- 상단 영역 배경이 밝은 색상이므로 `nickname`은 black, `createdAtUtc`는 기존 gray 700 계열로 표시하고, 금액 pill은 `#202020` 배경과 white text를 유지한다.
|
||||||
|
- `message`는 상단 영역 아래에 16pt regular, white text로 표시하고, 긴 문장은 화면 폭 안에서 줄바꿈한다.
|
||||||
|
- 전체보기는 기존 `AppStep.channelDonationAll(creatorId:)`로 이동하지 않고, 부모에서 주입받은 `onSelectTab(.donation)` closure로 현재 크리에이터 채널의 후원 tab을 선택한다.
|
||||||
|
- 빈 후원 UI는 section title 아래 `#202020` card, `처음으로 크리에이터를\n후원해 보세요!` 문구, white capsule `후원하기` 버튼, black `ic_new_donation` icon/text를 표시한다.
|
||||||
|
- 후원하기 버튼은 기존 `UserProfileView`에서 사용하던 `LiveRoomDonationDialogView`를 그대로 표시한다.
|
||||||
|
- Dialog 설정은 기존 채널 후원과 동일하게 `isAudioContentDonation: false`, `messageLimit: 100`, `I18n.MemberChannel.secretDonationLabel`, `I18n.MemberChannel.secretDonationMinimumCanMessage`, `shouldPrefixSecretInMessagePlaceholder: false`를 사용한다.
|
||||||
|
- 후원 전송은 기존 `ChannelDonationViewModel.postChannelDonation`을 재사용하고, 성공 시 현재 채널 홈 API를 다시 호출해 후원 섹션에 즉시 반영한다.
|
||||||
- 검증 기준:
|
- 검증 기준:
|
||||||
- 실행 명령: `rg "struct CreatorChannelDonationSection|channelDonations|CreatorChannelDonationResponse|channelDonationAll|createdAtUtc|can" SodaLive/Sources/V2/CreatorChannel/Home/Components/CreatorChannelDonationSection.swift SodaLive/Sources/V2/CreatorChannel/Home/CreatorChannelHomeView.swift`
|
- 실행 명령: `rg "struct CreatorChannelDonationSection|CreatorChannelDonationEmptyCard|처음으로 크리에이터를|ic_new_donation|LiveRoomDonationDialogView|ChannelDonationViewModel|postChannelDonation|fetchHome\(creatorId: creatorId\)|createdAtUtc|can|#E2E2E2|#73EE01|#00EAFF|#FF4C3C|onSelectTab|\\.donation|channelDonationAll" SodaLive/Sources/V2/CreatorChannel/CreatorChannelView.swift SodaLive/Sources/V2/CreatorChannel/Home/CreatorChannelHomeView.swift SodaLive/Sources/V2/CreatorChannel/Home/Components/CreatorChannelDonationSection.swift`
|
||||||
- 기대 결과: 빈 후원 섹션 숨김과 기존 후원 전체보기 route 연결이 확인된다.
|
- 기대 결과: 빈 후원 UI, 후원 금액별 상단 영역 컬러, 기존 후원 Dialog 재사용, 후원 성공 후 홈 API 재조회, 후원 tab 전체보기 연결이 확인되고 신규 구현 경로에서 `channelDonationAll` route를 사용하지 않는다.
|
||||||
|
|
||||||
### Phase 8: 공지 섹션
|
### Phase 8: 공지 섹션
|
||||||
|
|
||||||
@@ -620,6 +637,8 @@
|
|||||||
- 2026-07-02: 이미 완료된 Phase 1~2 기록은 보존하고, 후속 구조 정리를 위해 Task 2.4 `공통 CreatorChannel shell로 구조 정리`를 추가했다.
|
- 2026-07-02: 이미 완료된 Phase 1~2 기록은 보존하고, 후속 구조 정리를 위해 Task 2.4 `공통 CreatorChannel shell로 구조 정리`를 추가했다.
|
||||||
- 2026-07-02: 사용자 확인 사항을 반영해 title bar Figma URL 3종과 사용할 아이콘 asset(`ic_new_bar_back`, `ic_new_follow`, `ic_new_following`, `ic_new_more`, `ic_bar_bell`, `ic_bar_bell_fill`)을 Task 3.1 및 최종 회귀 검색 기준에 추가했다.
|
- 2026-07-02: 사용자 확인 사항을 반영해 title bar Figma URL 3종과 사용할 아이콘 asset(`ic_new_bar_back`, `ic_new_follow`, `ic_new_following`, `ic_new_more`, `ic_bar_bell`, `ic_bar_bell_fill`)을 Task 3.1 및 최종 회귀 검색 기준에 추가했다.
|
||||||
- 2026-07-03: Phase 5 현재 라이브 섹션을 구현했다. Figma `290:8950` 기준으로 LIVE pill, 시작 시간, 제목, 커버 이미지/placeholder, 유료 캔 배지, 성인 표시를 반영하고 `CreatorChannelHomeView`에 `currentLive` optional 기반으로 연결했다.
|
- 2026-07-03: Phase 5 현재 라이브 섹션을 구현했다. Figma `290:8950` 기준으로 LIVE pill, 시작 시간, 제목, 커버 이미지/placeholder, 유료 캔 배지, 성인 표시를 반영하고 `CreatorChannelHomeView`에 `currentLive` optional 기반으로 연결했다.
|
||||||
|
- 2026-07-03: Phase 7 채널 후원 섹션을 구현했다. Figma `296:14908`, 후원 아이템 `567:17927` 기준으로 후원 금액별 상단 컬러(`1...50` `#E2E2E2`, `51...100` `#73EE01`, `101...500` `#00EAFF`, `501...` `#FF4C3C`), `ic_new_donation` 후원하기 버튼, `.donation` tab 전체보기 전환, Preview 금액 구간별 샘플을 반영했다. `xcodebuild -workspace "SodaLive.xcworkspace" -scheme "SodaLive-dev" -configuration Debug build` 성공으로 신규 파일 target 포함과 컴파일을 검증했다.
|
||||||
|
- 2026-07-03: 사용자 요청에 따라 Phase 7 후원하기 버튼을 기존 `LiveRoomDonationDialogView`/`ChannelDonationViewModel.postChannelDonation` 흐름으로 연결하고, 성공 시 `CreatorChannelViewModel.fetchHome(creatorId:)`로 현재 채널 홈 데이터를 다시 불러와 후원 섹션에 즉시 반영하도록 계획을 갱신했다. 후원 내역이 없는 경우 Figma `290:8820` 기준의 빈 후원 UI를 표시하도록 Task 7.1에 반영했다.
|
||||||
- 2026-07-03: Phase 6 최신 오디오 콘텐츠 섹션을 구현했다. Figma `296:14898`, tag `567:18272`, `567:18274` 기준으로 88px 썸네일, 상단/하단 tag overlay, `New` label, 제목, 재생시간을 반영하고 `latestAudioContent` optional 기반 숨김 및 `AppStep.contentDetail(contentId:)` 상세 진입을 연결했다. `rg` 체크리스트와 `xcodebuild -workspace "SodaLive.xcworkspace" -scheme "SodaLive-dev" -configuration Debug build` 성공으로 검증했다.
|
- 2026-07-03: Phase 6 최신 오디오 콘텐츠 섹션을 구현했다. Figma `296:14898`, tag `567:18272`, `567:18274` 기준으로 88px 썸네일, 상단/하단 tag overlay, `New` label, 제목, 재생시간을 반영하고 `latestAudioContent` optional 기반 숨김 및 `AppStep.contentDetail(contentId:)` 상세 진입을 연결했다. `rg` 체크리스트와 `xcodebuild -workspace "SodaLive.xcworkspace" -scheme "SodaLive-dev" -configuration Debug build` 성공으로 검증했다.
|
||||||
- 2026-07-03: Phase 4를 완료해 `CreatorChannelView`에서 홈 탭은 `CreatorChannelHomeView`, 홈 외 탭은 `CreatorChannelPlaceholderTabView(title: selectedTab.title)`로 분기하고, 홈 섹션 전체보기용 tab 선택 closure가 `.audio`, `.series`, `.community`, `.fanTalk`, `.donation`으로 전환되도록 연결했다.
|
- 2026-07-03: Phase 4를 완료해 `CreatorChannelView`에서 홈 탭은 `CreatorChannelHomeView`, 홈 외 탭은 `CreatorChannelPlaceholderTabView(title: selectedTab.title)`로 분기하고, 홈 섹션 전체보기용 tab 선택 closure가 `.audio`, `.series`, `.community`, `.fanTalk`, `.donation`으로 전환되도록 연결했다.
|
||||||
- 2026-07-02: 기존 `UserProfile` 구현을 확인해 title bar 팔로우/팔로우 취소/알림 설정/알림 설정 취소를 `UserRepository.creatorFollow(creatorId:follow:notify:)`로 처리하고, `ic_bar_bell`/`ic_bar_bell_fill` 터치 시 각각 `notify: true`/`notify: false` 호출이 일어나도록 계획에 반영했다.
|
- 2026-07-02: 기존 `UserProfile` 구현을 확인해 title bar 팔로우/팔로우 취소/알림 설정/알림 설정 취소를 `UserRepository.creatorFollow(creatorId:follow:notify:)`로 처리하고, `ic_bar_bell`/`ic_bar_bell_fill` 터치 시 각각 `notify: true`/`notify: false` 호출이 일어나도록 계획에 반영했다.
|
||||||
|
|||||||
Reference in New Issue
Block a user