feat(creator): 채널 후원 섹션을 연결한다

This commit is contained in:
Yu Sung
2026-07-03 11:45:53 +09:00
parent f514a7ac40
commit c136f5bbc3
6 changed files with 358 additions and 10 deletions

View File

@@ -4,8 +4,10 @@ struct CreatorChannelView: View {
let creatorId: Int
@StateObject private var viewModel = CreatorChannelViewModel()
@StateObject private var channelDonationViewModel = ChannelDonationViewModel()
@Environment(\.dismiss) private var dismiss
@State private var tabBarMinY: CGFloat = .greatestFiniteMagnitude
@State private var isShowChannelDonationDialog = false
private let titleBarHeight: CGFloat = 56
private let tabBarHeight: CGFloat = 52
@@ -65,6 +67,28 @@ struct CreatorChannelView: View {
if viewModel.isLoading {
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)
@@ -74,6 +98,7 @@ struct CreatorChannelView: View {
}
}
.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 {
@@ -131,7 +156,8 @@ struct CreatorChannelView: View {
CreatorChannelHomeView(
response: response,
onSelectTab: selectTab,
onTapContent: showContentDetail
onTapContent: showContentDetail,
onTapDonate: showDonationDialog
)
} else {
CreatorChannelPlaceholderTabView(title: viewModel.selectedTab.title)
@@ -158,6 +184,12 @@ struct CreatorChannelView: View {
private func showContentDetail(_ contentId: Int) {
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 {
@@ -194,7 +226,36 @@ struct CreatorChannelView_Previews: PreviewProvider {
isOwned: 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: [],
schedules: [],
audioContents: [],

View File

@@ -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)
}
}

View File

@@ -5,17 +5,20 @@ struct CreatorChannelHomeView: View {
let onSelectTab: (CreatorChannelTab) -> Void
let onTapLive: (Int) -> Void
let onTapContent: (Int) -> Void
let onTapDonate: () -> Void
init(
response: CreatorChannelHomeResponse,
onSelectTab: @escaping (CreatorChannelTab) -> Void,
onTapLive: @escaping (Int) -> Void = { _ in },
onTapContent: @escaping (Int) -> Void = { _ in }
onTapContent: @escaping (Int) -> Void = { _ in },
onTapDonate: @escaping () -> Void = {}
) {
self.response = response
self.onSelectTab = onSelectTab
self.onTapLive = onTapLive
self.onTapContent = onTapContent
self.onTapDonate = onTapDonate
}
var body: some View {
@@ -29,6 +32,12 @@ struct CreatorChannelHomeView: View {
latestAudioContent: response.latestAudioContent,
onTapContent: onTapContent
)
CreatorChannelDonationSection(
channelDonations: response.channelDonations,
onSelectTab: onSelectTab,
onTapDonate: onTapDonate
)
}
.frame(maxWidth: .infinity, alignment: .leading)
.background(Color.black)