feat(explorer): 채널 후원 목록/등록 기능을 추가한다
This commit is contained in:
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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"))
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)"
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user