feat(explorer): 채널 후원 목록/등록 기능을 추가한다

This commit is contained in:
Yu Sung
2026-02-25 20:57:23 +09:00
parent e9bd1e7396
commit 32d1d970e4
17 changed files with 853 additions and 58 deletions

View File

@@ -0,0 +1,79 @@
import SwiftUI
struct ChannelDonationAllView: View {
let creatorId: Int
@StateObject private var viewModel = ChannelDonationViewModel()
var body: some View {
BaseView(isLoading: $viewModel.isLoading) {
VStack(spacing: 0) {
DetailNavigationBar(title: I18n.MemberChannel.channelDonationAllTitle)
HStack(alignment: .center, spacing: 0) {
Text(I18n.MemberChannel.totalLabel)
.appFont(size: 14.7, weight: .medium)
.foregroundColor(Color(hex: "eeeeee"))
Text("\(viewModel.totalCount)")
.appFont(size: 12, weight: .medium)
.foregroundColor(Color(hex: "80d8ff"))
.padding(.leading, 6.7)
Text(I18n.MemberChannel.countUnit)
.appFont(size: 12, weight: .medium)
.foregroundColor(Color(hex: "777777"))
Spacer()
}
.padding(.top, 13.3)
.padding(.horizontal, 13.3)
Rectangle()
.frame(width: screenSize().width - 26.7, height: 1)
.foregroundColor(Color(hex: "595959"))
.padding(.top, 6.7)
ScrollView(.vertical, showsIndicators: false) {
if viewModel.donationItems.isEmpty {
Text(I18n.MemberChannel.channelDonationEmpty)
.appFont(size: 16, weight: .regular)
.foregroundColor(Color(hex: "CFD8DC"))
.padding(.top, 40)
} else {
LazyVStack(spacing: 12) {
ForEach(0..<viewModel.donationItems.count, id: \.self) { index in
let item = viewModel.donationItems[index]
ChannelDonationItemView(
item: item,
previewLimit: 30,
isShowFullMessageOnTap: true
)
}
}
.padding(.vertical, 16.7)
.padding(.horizontal, 13.3)
}
}
}
.onAppear {
viewModel.setCreatorId(creatorId)
}
.popup(isPresented: $viewModel.isShowPopup, type: .toast, position: .bottom, autohideIn: 2) {
HStack {
Spacer()
Text(viewModel.errorMessage)
.padding(.vertical, 13.3)
.frame(width: screenSize().width - 66.7, alignment: .center)
.appFont(size: 12, weight: .medium)
.background(Color.button)
.foregroundColor(Color.white)
.multilineTextAlignment(.leading)
.cornerRadius(20)
.padding(.bottom, 66.7)
Spacer()
}
}
}
}
}

View File

@@ -0,0 +1,136 @@
import SwiftUI
import Kingfisher
struct ChannelDonationItemView: View {
let item: GetChannelDonationListItem
let previewLimit: Int?
let isShowFullMessageOnTap: Bool
@State private var isExpanded = false
init(
item: GetChannelDonationListItem,
previewLimit: Int? = nil,
isShowFullMessageOnTap: Bool = false
) {
self.item = item
self.previewLimit = previewLimit
self.isShowFullMessageOnTap = isShowFullMessageOnTap
}
private var donationBackgroundColor: Color {
if item.isSecret {
return Color(hex: "59548f").opacity(0.8)
}
if item.can >= 10000 {
return Color(hex: "c25264").opacity(0.8)
}
if item.can >= 5000 {
return Color(hex: "d85e37").opacity(0.8)
}
if item.can >= 1000 {
return Color(hex: "d38c38").opacity(0.8)
}
if item.can >= 500 {
return Color(hex: "c25264").opacity(0.8)
}
if item.can >= 100 {
return Color(hex: "4d6aa4").opacity(0.8)
}
if item.can >= 50 {
return Color(hex: "2d7390").opacity(0.8)
}
return Color(hex: "548f7d").opacity(0.8)
}
var body: some View {
VStack(alignment: .leading, spacing: 10) {
HStack(spacing: 11) {
KFImage(URL(string: item.profileUrl))
.cancelOnDisappear(true)
.downsampling(size: CGSize(width: 40, height: 40))
.resizable()
.scaledToFill()
.frame(width: 40, height: 40)
.clipShape(Circle())
VStack(alignment: .leading, spacing: 2) {
Text(item.nickname)
.appFont(size: 18, weight: .bold)
.foregroundColor(.white)
.lineLimit(1)
Text(item.relativeTimeText())
.appFont(size: 14, weight: .regular)
.foregroundColor(Color(hex: "78909C"))
}
Spacer()
}
highlightedMessageText(displayMessage)
.appFont(size: 16, weight: .regular)
.lineLimit(isExpanded ? nil : 2)
.multilineTextAlignment(.leading)
.frame(maxWidth: .infinity, alignment: .leading)
}
.padding(16)
.frame(maxWidth: .infinity, alignment: .leading)
.background(donationBackgroundColor)
.cornerRadius(16)
.onTapGesture {
guard isShowFullMessageOnTap else { return }
guard isTruncated else { return }
isExpanded = true
}
}
}
private extension ChannelDonationItemView {
var normalizedMessage: String {
item.message.trimmingCharacters(in: .whitespacesAndNewlines)
}
var displayMessage: String {
guard !isExpanded else { return normalizedMessage }
guard let previewLimit else { return normalizedMessage }
if normalizedMessage.count > previewLimit {
return String(normalizedMessage.prefix(previewLimit)) + "..."
}
return normalizedMessage
}
var isTruncated: Bool {
guard let previewLimit else { return false }
return normalizedMessage.count > previewLimit
}
func highlightedMessageText(_ message: String) -> Text {
let plainCanToken = "\(item.can)"
let commaCanToken = "\(item.can.comma())"
let range = message.range(of: commaCanToken)
?? message.range(of: plainCanToken)
guard let range else {
return Text(message).foregroundColor(Color(hex: "CFD8DC"))
}
let prefixText = String(message[..<range.lowerBound])
let canText = String(message[range])
let suffixText = String(message[range.upperBound...])
return Text(prefixText).foregroundColor(Color(hex: "CFD8DC"))
+ Text(canText).foregroundColor(Color(hex: "FDCA2F"))
+ Text(suffixText).foregroundColor(Color(hex: "CFD8DC"))
}
}

View File

@@ -0,0 +1,131 @@
import Foundation
import Combine
final class ChannelDonationViewModel: ObservableObject {
private var repository = ExplorerRepository()
private var subscription = Set<AnyCancellable>()
@Published var errorMessage = ""
@Published var isShowPopup = false
@Published var isLoading = false
@Published var totalCount = 0
@Published var donationItems: [GetChannelDonationListItem] = []
private var creatorId = 0
func setCreatorId(_ creatorId: Int, shouldFetch: Bool = true) {
guard creatorId > 0 else { return }
if self.creatorId != creatorId {
self.creatorId = creatorId
}
if shouldFetch {
getChannelDonationList()
}
}
func getChannelDonationList() {
guard creatorId > 0, !isLoading else { return }
isLoading = true
repository.getChannelDonationList(creatorId: creatorId)
.sink { [weak self] result in
guard let self = self else { return }
switch result {
case .finished:
DEBUG_LOG("finish")
case .failure(let error):
ERROR_LOG(error.localizedDescription)
self.isLoading = false
self.errorMessage = I18n.Common.commonError
self.isShowPopup = true
}
} receiveValue: { [weak self] response in
guard let self = self else { return }
self.isLoading = false
do {
let jsonDecoder = JSONDecoder()
let decoded = try jsonDecoder.decode(ApiResponse<GetChannelDonationListResponse>.self, from: response.data)
if let data = decoded.data, decoded.success {
self.totalCount = data.totalCount
self.donationItems = data.items
} else {
self.errorMessage = decoded.message ?? I18n.Common.commonError
self.isShowPopup = true
}
} catch {
self.errorMessage = I18n.Common.commonError
self.isShowPopup = true
}
}
.store(in: &subscription)
}
func postChannelDonation(
can: Int,
message: String,
isSecret: Bool,
reloadAfterSuccess: Bool = true,
onSuccess: (() -> Void)? = nil
) {
guard creatorId > 0, !isLoading else { return }
isLoading = true
let request = PostChannelDonationRequest(
creatorId: creatorId,
can: can,
isSecret: isSecret,
message: message
)
repository.postChannelDonation(request: request)
.sink { [weak self] result in
guard let self = self else { return }
switch result {
case .finished:
DEBUG_LOG("finish")
case .failure(let error):
ERROR_LOG(error.localizedDescription)
self.isLoading = false
self.errorMessage = I18n.Common.commonError
self.isShowPopup = true
}
} receiveValue: { [weak self] response in
guard let self = self else { return }
do {
let jsonDecoder = JSONDecoder()
let decoded = try jsonDecoder.decode(ApiResponseWithoutData.self, from: response.data)
self.isLoading = false
if decoded.success {
if reloadAfterSuccess {
self.getChannelDonationList()
} else {
onSuccess?()
}
} else {
self.errorMessage = decoded.message ?? I18n.Common.commonError
self.isShowPopup = true
}
} catch {
self.isLoading = false
self.errorMessage = I18n.Common.commonError
self.isShowPopup = true
}
}
.store(in: &subscription)
}
}

View File

@@ -0,0 +1,73 @@
//
// GetChannelDonationListResponse.swift
// SodaLive
//
// Created by klaus on 2/25/26.
//
import Foundation
struct GetChannelDonationListResponse: Decodable {
let totalCount: Int
let items: [GetChannelDonationListItem]
}
struct GetChannelDonationListItem: Decodable {
let id: Int
let memberId: Int
let nickname: String
let profileUrl: String
let can: Int
let isSecret: Bool
let message: String
let createdAt: String
}
extension GetChannelDonationListItem {
func relativeTimeText(now: Date = Date()) -> String {
guard let createdDate = DateParser.parse(createdAt) else {
return createdAt
}
let interval = max(0, now.timeIntervalSince(createdDate))
let calendar = Calendar.current
let ym = calendar.dateComponents([.year, .month],
from: createdDate,
to: now)
if let years = ym.year, years >= 1 {
return I18n.Time.yearsAgo(years)
}
if let months = ym.month, months >= 1 {
return I18n.Time.monthsAgo(months)
}
if interval < 60 {
return I18n.Time.justNow
}
if interval < 3600 {
let minutes = max(1, Int(interval / 60))
return I18n.Time.minutesAgo(minutes)
}
if interval < 86_400 {
let hours = max(1, Int(interval / 3600))
return I18n.Time.hoursAgo(hours)
}
let days = max(1, Int(interval / 86_400))
return I18n.Time.daysAgo(days)
}
var messageBodyText: String {
let trimmed = message.trimmingCharacters(in: .whitespacesAndNewlines)
if trimmed.isEmpty {
return I18n.MemberChannel.channelDonationDefaultMessage
}
return " \(trimmed)"
}
}

View File

@@ -0,0 +1,14 @@
//
// PostChannelDonationRequest.swift
// SodaLive
//
// Created by klaus on 2/25/26.
//
struct PostChannelDonationRequest: Encodable {
let creatorId: Int
let can: Int
var isSecret: Bool = false
var message: String = ""
var container: String = "ios"
}

View File

@@ -0,0 +1,70 @@
import SwiftUI
struct UserProfileChannelDonationView: View {
let creatorId: Int
let donationItems: [GetChannelDonationListItem]
let onTapDonationButton: () -> Void
var body: some View {
VStack(alignment: .leading, spacing: 14) {
HStack(spacing: 0) {
Text(I18n.MemberChannel.channelDonationHeader)
.appFont(size: 26, weight: .bold)
.foregroundColor(.white)
Spacer()
if !donationItems.isEmpty {
Text(I18n.Common.viewAll)
.appFont(size: 14, weight: .light)
.foregroundColor(Color(hex: "78909C"))
.onTapGesture {
AppState.shared.setAppStep(step: .channelDonationAll(creatorId: creatorId))
}
}
}
.padding(.horizontal, 24)
if donationItems.isEmpty {
Text(I18n.MemberChannel.channelDonationEmpty)
.appFont(size: 16, weight: .regular)
.foregroundColor(Color(hex: "CFD8DC"))
.padding(.horizontal, 24)
} else {
ScrollView(.horizontal, showsIndicators: false) {
HStack(alignment: .top, spacing: 14) {
ForEach(0..<donationItems.count, id: \.self) { index in
let item = donationItems[index]
ChannelDonationItemView(
item: item,
previewLimit: 30,
isShowFullMessageOnTap: false
)
.frame(width: screenSize().width - 104)
}
}
.padding(.horizontal, 24)
}
}
HStack(spacing: 6.7) {
Image("ic_donation_white")
.resizable()
.frame(width: 20, height: 20)
Text(I18n.MemberChannel.channelDonationButton)
.appFont(size: 16, weight: .bold)
.foregroundColor(.white)
}
.frame(maxWidth: .infinity)
.padding(.vertical, 16)
.background(Color(hex: "525252"))
.cornerRadius(16)
.padding(.horizontal, 24)
.onTapGesture {
onTapDonationButton()
}
}
}
}