From 32d1d970e472c36c3f829420cf2d47554c9e8111 Mon Sep 17 00:00:00 2001 From: Yu Sung Date: Wed, 25 Feb 2026 20:57:23 +0900 Subject: [PATCH] =?UTF-8?q?feat(explorer):=20=EC=B1=84=EB=84=90=20?= =?UTF-8?q?=ED=9B=84=EC=9B=90=20=EB=AA=A9=EB=A1=9D/=EB=93=B1=EB=A1=9D=20?= =?UTF-8?q?=EA=B8=B0=EB=8A=A5=EC=9D=84=20=EC=B6=94=EA=B0=80=ED=95=9C?= =?UTF-8?q?=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- SodaLive/Resources/Localizable.xcstrings | 107 ++++++------ SodaLive/Sources/App/AppStep.swift | 4 +- SodaLive/Sources/Common/DateParser.swift | 9 + .../Detail/LiveRoomDonationDialogView.swift | 19 ++- SodaLive/Sources/ContentView.swift | 5 +- SodaLive/Sources/Explorer/ExplorerApi.swift | 19 ++- .../Sources/Explorer/ExplorerRepository.swift | 8 + .../ChannelDonationAllView.swift | 79 +++++++++ .../ChannelDonationItemView.swift | 136 +++++++++++++++ .../ChannelDonationViewModel.swift | 131 +++++++++++++++ .../GetChannelDonationListResponse.swift | 73 ++++++++ .../PostChannelDonationRequest.swift | 14 ++ .../UserProfileChannelDonationView.swift | 70 ++++++++ .../Profile/GetCreatorProfileResponse.swift | 2 +- .../Explorer/Profile/UserProfileView.swift | 45 +++++ SodaLive/Sources/I18n/I18n.swift | 32 ++++ docs/20260225_채널후원구현.md | 158 ++++++++++++++++++ 17 files changed, 853 insertions(+), 58 deletions(-) create mode 100644 SodaLive/Sources/Explorer/Profile/ChannelDonation/ChannelDonationAllView.swift create mode 100644 SodaLive/Sources/Explorer/Profile/ChannelDonation/ChannelDonationItemView.swift create mode 100644 SodaLive/Sources/Explorer/Profile/ChannelDonation/ChannelDonationViewModel.swift create mode 100644 SodaLive/Sources/Explorer/Profile/ChannelDonation/GetChannelDonationListResponse.swift create mode 100644 SodaLive/Sources/Explorer/Profile/ChannelDonation/PostChannelDonationRequest.swift create mode 100644 SodaLive/Sources/Explorer/Profile/ChannelDonation/UserProfileChannelDonationView.swift create mode 100644 docs/20260225_채널후원구현.md diff --git a/SodaLive/Resources/Localizable.xcstrings b/SodaLive/Resources/Localizable.xcstrings index 04d97c0..a4b61ac 100644 --- a/SodaLive/Resources/Localizable.xcstrings +++ b/SodaLive/Resources/Localizable.xcstrings @@ -4139,22 +4139,6 @@ } } }, - "목" : { - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Thu" - } - }, - "ja" : { - "stringUnit" : { - "state" : "translated", - "value" : "木" - } - } - } - }, "모집완료" : { "localizations" : { "en" : { @@ -4187,6 +4171,22 @@ } } }, + "목" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Thu" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "木" + } + } + } + }, "무료" : { "localizations" : { "en" : { @@ -6955,22 +6955,6 @@ } } }, - "일" : { - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Sun" - } - }, - "ja" : { - "stringUnit" : { - "state" : "translated", - "value" : "日" - } - } - } - }, "이벤트" : { "localizations" : { "en" : { @@ -7163,6 +7147,22 @@ } } }, + "일" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Sun" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "日" + } + } + } + }, "일간 랭킹" : { "localizations" : { "en" : { @@ -8638,22 +8638,6 @@ } } }, - "캐릭터 정보" : { - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Character info" - } - }, - "ja" : { - "stringUnit" : { - "state" : "translated", - "value" : "キャラクター情報" - } - } - } - }, "캔" : { "localizations" : { "en" : { @@ -8670,6 +8654,22 @@ } } }, + "캐릭터 정보" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Character info" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "キャラクター情報" + } + } + } + }, "캔 충전" : { "localizations" : { "en" : { @@ -9614,7 +9614,18 @@ } } }, + "함께 보낼 %@메시지 입력(최대 %lld자)" : { + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "new", + "value" : "함께 보낼 %1$@메시지 입력(최대 %2$lld자)" + } + } + } + }, "함께 보낼 %@메시지 입력(최대 1000자)" : { + "extractionState" : "stale", "localizations" : { "en" : { "stringUnit" : { diff --git a/SodaLive/Sources/App/AppStep.swift b/SodaLive/Sources/App/AppStep.swift index 8dc53c6..cfa2fd1 100644 --- a/SodaLive/Sources/App/AppStep.swift +++ b/SodaLive/Sources/App/AppStep.swift @@ -75,7 +75,9 @@ enum AppStep { case followerList(userId: Int) case userProfileDonationAll(userId: Int) - + + case channelDonationAll(creatorId: Int) + case userProfileFanTalkAll(userId: Int) case createLive( diff --git a/SodaLive/Sources/Common/DateParser.swift b/SodaLive/Sources/Common/DateParser.swift index 13d17ae..268a3f4 100644 --- a/SodaLive/Sources/Common/DateParser.swift +++ b/SodaLive/Sources/Common/DateParser.swift @@ -21,6 +21,7 @@ enum DateParser { { ISO8601.fractional.date(from: $0) }, { ISO8601.basic.date(from: $0) }, { DF.rfc3339.date(from: $0) }, + { DF.isoLocalDateTime.date(from: $0) }, { DF.basic.date(from: $0) } ] @@ -56,5 +57,13 @@ enum DateParser { f.dateFormat = "yyyy-MM-dd HH:mm:ss" return f }() + + static let isoLocalDateTime: DateFormatter = { + let f = DateFormatter() + f.locale = Locale(identifier: "en_US_POSIX") + f.timeZone = TimeZone(secondsFromGMT: 0) + f.dateFormat = "yyyy-MM-dd'T'HH:mm:ss" + return f + }() } } diff --git a/SodaLive/Sources/Content/Detail/LiveRoomDonationDialogView.swift b/SodaLive/Sources/Content/Detail/LiveRoomDonationDialogView.swift index 55012b3..a84a396 100644 --- a/SodaLive/Sources/Content/Detail/LiveRoomDonationDialogView.swift +++ b/SodaLive/Sources/Content/Detail/LiveRoomDonationDialogView.swift @@ -22,9 +22,22 @@ struct LiveRoomDonationDialogView: View { @Binding var isShowing: Bool let isAudioContentDonation: Bool + let messageLimit: Int let onClickDonation: (Int, String, Bool) -> Void @StateObject var keyboardHandler = KeyboardHandler() + + init( + isShowing: Binding, + isAudioContentDonation: Bool, + messageLimit: Int = 1000, + onClickDonation: @escaping (Int, String, Bool) -> Void + ) { + self._isShowing = isShowing + self.isAudioContentDonation = isAudioContentDonation + self.messageLimit = messageLimit + self.onClickDonation = onClickDonation + } var body: some View { ZStack { @@ -204,7 +217,7 @@ struct LiveRoomDonationDialogView: View { .stroke(Color.graybb, lineWidth: 1) ) - TextField("함께 보낼 \(isSecret ? "비밀 " : "")메시지 입력(최대 1000자)", text: $donationMessage) + TextField("함께 보낼 \(isSecret ? "비밀 " : "")메시지 입력(최대 \(messageLimit)자)", text: $donationMessage) .appFont(size: 13.3, weight: .medium) .foregroundColor(Color.grayee) .padding(13.3) @@ -286,8 +299,8 @@ struct LiveRoomDonationDialogView: View { } func limitText() { - if donationMessage.count > 1000 { - donationMessage = String(donationMessage.prefix(1000)) + if donationMessage.count > messageLimit { + donationMessage = String(donationMessage.prefix(messageLimit)) } } } diff --git a/SodaLive/Sources/ContentView.swift b/SodaLive/Sources/ContentView.swift index e07ebb8..acbef0c 100644 --- a/SodaLive/Sources/ContentView.swift +++ b/SodaLive/Sources/ContentView.swift @@ -168,7 +168,10 @@ struct ContentView: View { case .userProfileDonationAll(let userId): UserProfileDonationAllView(userId: userId) - + + case .channelDonationAll(let creatorId): + ChannelDonationAllView(creatorId: creatorId) + case .userProfileFanTalkAll(let userId): UserProfileFanTalkAllView(userId: userId) diff --git a/SodaLive/Sources/Explorer/ExplorerApi.swift b/SodaLive/Sources/Explorer/ExplorerApi.swift index e9c9f76..e444d13 100644 --- a/SodaLive/Sources/Explorer/ExplorerApi.swift +++ b/SodaLive/Sources/Explorer/ExplorerApi.swift @@ -20,6 +20,8 @@ enum ExplorerApi { case modifyCheers(request: PutModifyCheersRequest) case writeCreatorNotice(request: PostCreatorNoticeRequest) case getCreatorProfileDonationRanking(userId: Int, page: Int, size: Int, period: DonationRankingPeriod?) + case getChannelDonationList(creatorId: Int) + case postChannelDonation(request: PostChannelDonationRequest) } extension ExplorerApi: TargetType { @@ -61,15 +63,18 @@ extension ExplorerApi: TargetType { case .writeCreatorNotice: return "/explorer/profile/notice" + + case .getChannelDonationList, .postChannelDonation: + return "/explorer/profile/channel-donation" } } var method: Moya.Method { switch self { - case .getExplorer, .searchChannel, .getCreatorProfile, .getCreatorDetail, .getFollowerList, .getCreatorProfileCheers, .getCreatorProfileDonationRanking, .getCreatorRank: + case .getExplorer, .searchChannel, .getCreatorProfile, .getCreatorDetail, .getFollowerList, .getCreatorProfileCheers, .getCreatorProfileDonationRanking, .getCreatorRank, .getChannelDonationList: return .get - - case .writeCheers, .writeCreatorNotice: + + case .writeCheers, .writeCreatorNotice, .postChannelDonation: return .post case .modifyCheers: @@ -115,7 +120,13 @@ extension ExplorerApi: TargetType { case .writeCreatorNotice(let request): return .requestJSONEncodable(request) - + + case .getChannelDonationList(let creatorId): + return .requestParameters(parameters: ["creatorId": creatorId], encoding: URLEncoding.queryString) + + case .postChannelDonation(let request): + return .requestJSONEncodable(request) + case .getCreatorProfileDonationRanking(_, let page, let size, let period): var parameters = [ "page": page - 1, diff --git a/SodaLive/Sources/Explorer/ExplorerRepository.swift b/SodaLive/Sources/Explorer/ExplorerRepository.swift index cc82bc6..a188a14 100644 --- a/SodaLive/Sources/Explorer/ExplorerRepository.swift +++ b/SodaLive/Sources/Explorer/ExplorerRepository.swift @@ -70,4 +70,12 @@ final class ExplorerRepository { ) ) } + + func getChannelDonationList(creatorId: Int) -> AnyPublisher { + return api.requestPublisher(.getChannelDonationList(creatorId: creatorId)) + } + + func postChannelDonation(request: PostChannelDonationRequest) -> AnyPublisher { + return api.requestPublisher(.postChannelDonation(request: request)) + } } diff --git a/SodaLive/Sources/Explorer/Profile/ChannelDonation/ChannelDonationAllView.swift b/SodaLive/Sources/Explorer/Profile/ChannelDonation/ChannelDonationAllView.swift new file mode 100644 index 0000000..fcee2f7 --- /dev/null +++ b/SodaLive/Sources/Explorer/Profile/ChannelDonation/ChannelDonationAllView.swift @@ -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..= 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[..() + + @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.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) + } +} diff --git a/SodaLive/Sources/Explorer/Profile/ChannelDonation/GetChannelDonationListResponse.swift b/SodaLive/Sources/Explorer/Profile/ChannelDonation/GetChannelDonationListResponse.swift new file mode 100644 index 0000000..f075354 --- /dev/null +++ b/SodaLive/Sources/Explorer/Profile/ChannelDonation/GetChannelDonationListResponse.swift @@ -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)" + } +} diff --git a/SodaLive/Sources/Explorer/Profile/ChannelDonation/PostChannelDonationRequest.swift b/SodaLive/Sources/Explorer/Profile/ChannelDonation/PostChannelDonationRequest.swift new file mode 100644 index 0000000..1208aee --- /dev/null +++ b/SodaLive/Sources/Explorer/Profile/ChannelDonation/PostChannelDonationRequest.swift @@ -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" +} diff --git a/SodaLive/Sources/Explorer/Profile/ChannelDonation/UserProfileChannelDonationView.swift b/SodaLive/Sources/Explorer/Profile/ChannelDonation/UserProfileChannelDonationView.swift new file mode 100644 index 0000000..78f1400 --- /dev/null +++ b/SodaLive/Sources/Explorer/Profile/ChannelDonation/UserProfileChannelDonationView.swift @@ -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.. @StateObject var viewModel = UserProfileViewModel() + @StateObject private var channelDonationViewModel = ChannelDonationViewModel() @StateObject private var keyboardHandler = KeyboardHandler() @State private var memberId: Int = 0 @@ -22,6 +23,7 @@ struct UserProfileView: View { @State private var isShowRouletteSettings: Bool = false @State private var isShowMenuSettings: Bool = false @State private var isShowCreatorDetailDialog: Bool = false + @State private var isShowChannelDonationDialog: Bool = false @State private var maxCommunityPostHeight: CGFloat? = nil @@ -191,6 +193,15 @@ struct UserProfileView: View { .setAppStep(step: .contentDetail(contentId: item.contentId)) } } + + UserProfileChannelDonationView( + creatorId: creatorProfile.creator.creatorId, + donationItems: creatorProfile.channelDonationList, + onTapDonationButton: { + channelDonationViewModel.setCreatorId(creatorProfile.creator.creatorId, shouldFetch: false) + isShowChannelDonationDialog = true + } + ) if creatorProfile.creator.creatorId == UserDefaults.int(forKey: .userId) || creatorProfile.liveRoomList.count > 0 { VStack(alignment: .leading, spacing: 14) { @@ -425,6 +436,25 @@ struct UserProfileView: View { } ZStack { + if isShowChannelDonationDialog { + LiveRoomDonationDialogView( + isShowing: $isShowChannelDonationDialog, + isAudioContentDonation: false, + messageLimit: 100, + onClickDonation: { can, message, isSecret in + channelDonationViewModel.postChannelDonation( + can: can, + message: message, + isSecret: isSecret, + reloadAfterSuccess: false, + onSuccess: { + viewModel.getCreatorProfile(userId: userId) + } + ) + } + ) + } + if viewModel.isShowPaymentDialog { LivePaymentDialog( title: viewModel.paymentDialogTitle, @@ -581,6 +611,21 @@ struct UserProfileView: View { Spacer() } } + .popup(isPresented: $channelDonationViewModel.isShowPopup, type: .toast, position: .bottom, autohideIn: 2) { + HStack { + Spacer() + Text(channelDonationViewModel.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() + } + } .ignoresSafeArea(.container, edges: .all) .sheet( isPresented: $viewModel.isShowShareView, diff --git a/SodaLive/Sources/I18n/I18n.swift b/SodaLive/Sources/I18n/I18n.swift index 1e8bd88..c8c8c4d 100644 --- a/SodaLive/Sources/I18n/I18n.swift +++ b/SodaLive/Sources/I18n/I18n.swift @@ -201,6 +201,30 @@ enum I18n { ) } + static func minutesAgoCompact(_ minutes: Int) -> String { + pick( + ko: "\(minutes)분전", + en: "\(minutes)m ago", + ja: "\(minutes)分前" + ) + } + + static func hoursAgoCompact(_ hours: Int) -> String { + pick( + ko: "\(hours)시간전", + en: "\(hours)h ago", + ja: "\(hours)時間前" + ) + } + + static func daysAgoCompact(_ days: Int) -> String { + pick( + ko: "\(days)일전", + en: "\(days)d ago", + ja: "\(days)日前" + ) + } + static func monthsAgo(_ months: Int) -> String { pick( ko: "\(months)개월 전", @@ -876,6 +900,14 @@ enum I18n { static var cheersDeleteTitle: String { pick(ko: "응원글 삭제", en: "Delete Cheer", ja: "応援削除") } + static var channelDonationHeader: String { pick(ko: "채널 후원", en: "Channel Donation", ja: "チャンネル支援") } + static var channelDonationButton: String { pick(ko: "채널 후원하기", en: "Donate to Channel", ja: "チャンネルを支援する") } + static var channelDonationEmpty: String { pick(ko: "채널 후원이 없습니다.", en: "No channel donations.", ja: "チャンネル支援はありません。") } + static var channelDonationAllTitle: String { pick(ko: "채널 후원 전체보기", en: "All Channel Donations", ja: "チャンネル支援一覧") } + static var totalLabel: String { pick(ko: "전체", en: "Total", ja: "全体") } + static var countUnit: String { pick(ko: "개", en: "items", ja: "件") } + static var channelDonationDefaultMessage: String { pick(ko: "을 후원했습니다.", en: " donated.", ja: "を支援しました。") } + static var liveHeader: String { pick(ko: "라이브", en: "Live", ja: "ライブ") } static var rouletteSettings: String { pick(ko: "룰렛 설정", en: "Roulette settings", ja: "ルーレット設定") } static var menuSettings: String { pick(ko: "메뉴 설정", en: "Menu settings", ja: "メニュー設定") } diff --git a/docs/20260225_채널후원구현.md b/docs/20260225_채널후원구현.md new file mode 100644 index 0000000..af8095d --- /dev/null +++ b/docs/20260225_채널후원구현.md @@ -0,0 +1,158 @@ +# 20260225 채널후원 구현 + +## 구현 체크리스트 +- [x] 기존 라이브 후원하기 UI/프로필 섹션 패턴 기준 정리 +- [x] `ExplorerApi`에 채널 후원 GET/POST 엔드포인트 추가 (`/explorer/profile/channel-donation`) +- [x] `ExplorerRepository`에 채널 후원 목록 조회/등록 메서드 추가 +- [x] `Sources/Explorer/Profile/ChannelDonation`에 화면/뷰모델/아이템 파일 추가 +- [x] 크리에이터 채널(`UserProfileView`)에 채널 후원 섹션 추가 (제목 + 전체보기 + 가로 리스트) +- [x] 채널 후원하기 버튼 UI 적용 (배경 `#525252`, radius 16, 좌측 선물 아이콘) +- [x] 후원 아이템 UI 적용 (프로필/닉네임/시간/내용, 캔 텍스트 색상 구분) +- [x] `createdAt` UTC -> 기기 타임존 상대시간(`OO분전/OO시간전/OO일전`) 변환 적용 +- [x] 채널 후원 전체보기 별도 페이지 추가 및 라우팅 연결 +- [x] 채널 후원 UI 문자열 국제화(`I18n`) 적용 +- [x] 검증 수행 (`lsp_diagnostics`, 빌드, 테스트) 및 결과 기록 + +## 검증 기록 +- 무엇: 채널 후원 API/섹션/전체보기/아이템 UI/시간 변환/국제화 적용 후 컴파일 검증 + 왜: 요청 기능이 실제 스킴에서 정상 컴파일되는지 확인 필요 + 어떻게: `xcodebuild -workspace "SodaLive.xcworkspace" -scheme "SodaLive" -configuration Debug build` + 결과: `** BUILD SUCCEEDED **` + +- 무엇: 개발 스킴 회귀 확인 + 왜: 공통 코드 변경이 dev 스킴에도 영향이 없는지 확인 필요 + 어떻게: `xcodebuild -workspace "SodaLive.xcworkspace" -scheme "SodaLive-dev" -configuration Debug build` + 결과: `** BUILD SUCCEEDED **` + +- 무엇: 테스트 액션 실행 가능 여부 확인 + 왜: 저장소 검증 절차에 테스트 명령이 포함됨 + 어떻게: `xcodebuild -workspace "SodaLive.xcworkspace" -scheme "SodaLive" test` 및 `xcodebuild -workspace "SodaLive.xcworkspace" -scheme "SodaLive-dev" test` + 결과: 두 스킴 모두 `Scheme ... is not currently configured for the test action.`로 실행 불가(테스트 액션 미구성) + +## 추가 요구사항 반영 체크리스트 (2차) +- [x] 채널 후원 섹션을 최신 콘텐츠 바로 아래로 이동 +- [x] 후원 데이터가 없을 때 크리에이터 채널 섹션의 `전체보기` 숨김 +- [x] 크리에이터 채널 섹션에서 후원 개수 미표시, 전체보기 페이지에서만 개수 표시 +- [x] 크리에이터 채널 섹션은 별도 채널후원 GET 호출 없이 `GetCreatorProfileResponse.channelDonationList` 사용 +- [x] 문구를 `OO캔을 후원했습니다.` 형태로 통일하고 `OO캔`만 강조 색상 적용 +- [x] 채널 후원 메시지 입력 길이 제한을 100자로 적용(라이브 후원 기본 제한은 유지) +- [x] 채널 후원 아이템 배경색 규칙을 라이브룸 후원 아이템(캔 수/비밀후원)과 동일하게 적용 +- [x] 후원하기 UI를 중앙 고정 오버레이가 아닌 라이브룸과 동일한 하단 시트 형태로 표시 +- [x] 국제화 문자열(`I18n.MemberChannel.*`) 반영 유지 + +- 무엇: `LiveRoomDonationDialogView`의 `messageLimit` 초기화 충돌 컴파일 오류 수정 + 왜: 채널 전용 100자 제한 전달을 위해 init 파라미터를 추가한 뒤 immutable 재할당 오류가 발생함 + 어떻게: `let messageLimit: Int = 1000` -> `let messageLimit: Int`로 변경하고 init에서만 초기화 + 결과: 컴파일 오류(`immutable value 'self.messageLimit' may only be initialized once`) 해소 + +- 무엇: 2차 반영 이후 기본 스킴 빌드 재검증 + 왜: 후원 다이얼로그/프로필 섹션 변경 회귀 여부 확인 + 어떻게: `xcodebuild -workspace "SodaLive.xcworkspace" -scheme "SodaLive" -configuration Debug build` + 결과: `** BUILD SUCCEEDED **` + +- 무엇: 2차 반영 이후 dev 스킴 빌드 재검증 + 왜: 공통 코드 변경이 `SodaLive-dev`에도 정상 적용되는지 확인 + 어떻게: `xcodebuild -workspace "SodaLive.xcworkspace" -scheme "SodaLive-dev" -configuration Debug build` + 결과: `** BUILD SUCCEEDED **` + +- 무엇: 2차 반영 이후 테스트 액션 재확인 + 왜: 변경 이후 테스트 실행 가능 상태 확인 + 어떻게: `xcodebuild -workspace "SodaLive.xcworkspace" -scheme "SodaLive" test` 및 `xcodebuild -workspace "SodaLive.xcworkspace" -scheme "SodaLive-dev" test` + 결과: 두 스킴 모두 `Scheme ... is not currently configured for the test action.`로 실행 불가(테스트 액션 미구성) + +## 추가 요구사항 반영 체크리스트 (3차) +- [x] 채널 후원 다이얼로그를 `fullScreenCover`가 아닌 라이브룸(`LiveRoomViewV2`)과 동일한 조건부 오버레이 표시 방식으로 변경 +- [x] 채널 후원 아이템 메시지는 서버 응답 원문(`item.message`)을 그대로 사용하고, 해당 메시지 내 `OO캔`만 하이라이트 +- [x] 채널 후원 아이템 메시지를 30자로 말줄임(`...`) 표시 +- [x] 채널 후원 전체보기 페이지에서만 아이템 탭 시 전체 메시지 표시(크리에이터 채널 페이지는 탭 동작 없음) + +- 무엇: 채널 후원 다이얼로그 표시 방식 수정 + 왜: 라이브룸과 동일한 하단 시트형 노출 방식으로 통일 필요 + 어떻게: `UserProfileChannelDonationView`에서 `.fullScreenCover` 제거 후 `if isShowDonationDialog { LiveRoomDonationDialogView(...) }` 조건부 오버레이로 변경 + 결과: 라이브룸과 동일한 표시 패턴으로 동작 + +- 무엇: 채널 후원 메시지 렌더링 규칙 수정 + 왜: `OO캔` 색상 분리 과정에서 서버 메시지 원문이 제거되던 문제 복구 필요 + 어떻게: `ChannelDonationItemView`에서 고정 문구 합성 제거, `item.message` 원문 기반 렌더링 + 메시지 내 `\(item.can)캔` 부분만 `#FDCA2F` 하이라이트 적용 + 결과: 서버 메시지 원문 유지 + `OO캔`만 강조 표시 + +- 무엇: 30자 말줄임 및 전체보기 탭 확장 동작 적용 + 왜: 100자 메시지 도입 후 카드 높이/가독성 요구사항 충족 필요 + 어떻게: `ChannelDonationItemView(previewLimit: 30)` 적용, 전체보기(`ChannelDonationAllView`)는 `isShowFullMessageOnTap: true`로 탭 시 전체 메시지 다이얼로그 표시, 크리에이터 채널(`UserProfileChannelDonationView`)은 `isShowFullMessageOnTap: false` 유지 + 결과: 크리에이터 채널은 30자 고정 표시, 전체보기는 탭으로 전체 메시지 확인 가능 + +- 무엇: 3차 반영 이후 기본 스킴 빌드 재검증 + 왜: 채널 후원 뷰/아이템 렌더링 로직 변경 후 컴파일 회귀 확인 + 어떻게: `xcodebuild -workspace "SodaLive.xcworkspace" -scheme "SodaLive" -configuration Debug build` + 결과: `** BUILD SUCCEEDED **` + +- 무엇: 3차 반영 이후 dev 스킴 빌드 재검증 + 왜: 동일 변경이 `SodaLive-dev`에도 정상 반영되는지 확인 + 어떻게: `xcodebuild -workspace "SodaLive.xcworkspace" -scheme "SodaLive-dev" -configuration Debug build` + 결과: `** BUILD SUCCEEDED **` + +- 무엇: 3차 반영 이후 테스트 액션 재확인 + 왜: 변경 이후 테스트 실행 가능 상태 확인 + 어떻게: `xcodebuild -workspace "SodaLive.xcworkspace" -scheme "SodaLive" test` 및 `xcodebuild -workspace "SodaLive.xcworkspace" -scheme "SodaLive-dev" test` + 결과: 두 스킴 모두 `Scheme ... is not currently configured for the test action.`로 실행 불가(테스트 액션 미구성) + +## 추가 요구사항 반영 체크리스트 (4차) +- [x] 스크롤 뷰 영향이 있는 인라인 오버레이 방식 제거 +- [x] `fullScreenCover` 없이 모달 표시로 복원 +- [x] 후원 UI가 화면 전체가 아닌 모달 영역 기준으로 표시되도록 변경 + +- 무엇: 채널 후원 다이얼로그 표시 방식 재수정 + 왜: 라이브룸 방식 인라인 오버레이 적용 시 스크롤 컨텍스트 영향으로 UI 표시가 어긋남 + 어떻게: `UserProfileChannelDonationView`에서 인라인 `if isShowDonationDialog` 오버레이 제거 후 `.sheet(isPresented:)`로 전환 + 결과: `fullScreenCover` 없이 별도 모달 레이어에서 안정적으로 표시 + +- 무엇: 4차 반영 이후 기본 스킴 빌드 재검증 + 왜: 표시 방식 변경 후 컴파일 회귀 확인 + 어떻게: `xcodebuild -workspace "SodaLive.xcworkspace" -scheme "SodaLive" -configuration Debug build` + 결과: `** BUILD SUCCEEDED **` + +## 추가 요구사항 반영 체크리스트 (5차) +- [x] 채널 후원 전체 리스트에서 말줄임 메시지 탭 시 AlertDialog 대신 아이템 내부에서 전체 내용 확장 + +- 무엇: 전체보기 메시지 확장 동작 변경 + 왜: 탭 시 팝업 표시가 아닌 아이템 내부 확장 표시 요구 + 어떻게: `ChannelDonationItemView`에서 `SodaDialog` 오버레이 제거, `isExpanded` 상태로 말줄임 텍스트를 인라인 전체 텍스트로 확장 + 결과: 전체보기 페이지에서 탭 시 해당 아이템 내에서 전체 메시지가 그대로 표시 + +## 추가 요구사항 반영 체크리스트 (6차) +- [x] `LiveRoomDonationDialogView` 표시 책임을 `UserProfileChannelDonationView`에서 `UserProfileView`로 이동 +- [x] 라이브룸(`LiveRoomViewV2`)과 동일하게 상위 View의 `ZStack` 조건부 렌더링으로 후원 다이얼로그 표시 + +- 무엇: 채널 후원 다이얼로그 표시 위치/책임 변경 + 왜: 하위 섹션 뷰가 아닌 프로필 루트 뷰에서 라이브룸과 동일 패턴으로 제어하기 위함 + 어떻게: `UserProfileChannelDonationView`는 `onTapDonationButton` 콜백만 전달하도록 단순화하고, `UserProfileView`에서 `isShowChannelDonationDialog` 상태와 `ChannelDonationViewModel`로 `LiveRoomDonationDialogView`를 `if` 조건부 렌더링 + 결과: `UserProfileView`가 후원 다이얼로그 표시와 후원 API 호출을 직접 제어 + +- 무엇: 6차 반영 이후 기본 스킴 빌드 재검증 + 왜: 다이얼로그 표시 책임 이전 후 컴파일 회귀 확인 + 어떻게: `xcodebuild -workspace "SodaLive.xcworkspace" -scheme "SodaLive" -configuration Debug build` + 결과: `** BUILD SUCCEEDED **` + +## 추가 요구사항 반영 체크리스트 (7차) +- [x] 채널 후원 아이템 시간 표시를 `GetCommunityPostListResponse` 상대시간 규칙으로 정렬 + +- 무엇: 채널 후원 시간 문자열 계산 로직 수정 + 왜: 기존 채널 후원 시간 표시가 커뮤니티와 다르게 표시되어 사용자 기대와 불일치 + 어떻게: `GetChannelDonationListItem.relativeTimeText`에서 `DateParser.parse(createdAt)` 기반으로 파싱하고, `GetCommunityPostListResponse.relativeTimeText`와 동일하게 연/월/방금 전/분/시간/일 단위로 계산 + 결과: 채널 후원 시간 표시가 커뮤니티 포스트와 동일 기준으로 노출 + +## 추가 요구사항 반영 체크리스트 (8차) +- [x] 서버 `ISO_LOCAL_DATE_TIME`(`yyyy-MM-dd'T'HH:mm:ss`) 응답 포맷 파싱 지원 + +- 무엇: 공통 날짜 파서 포맷 확장 + 왜: 서버가 Kotlin `ISO_LOCAL_DATE_TIME`으로 시간을 내려줄 때 기존 파서가 실패해 상대 시간 계산이 깨짐 + 어떻게: `DateParser`의 파서 체인에 `yyyy-MM-dd'T'HH:mm:ss` 포맷(`DF.isoLocalDateTime`) 추가 + 결과: 채널 후원 `createdAt`이 `ISO_LOCAL_DATE_TIME`이어도 커뮤니티 게시물과 동일한 상대 시간으로 정상 표시 + +## 추가 요구사항 반영 체크리스트 (9차) +- [x] 채널 후원 메시지 내 `OO캔` 하이라이트가 천 단위 comma(`1,000캔`)에도 적용되도록 수정 + +- 무엇: 채널 후원 메시지 하이라이트 토큰 탐색 로직 수정 + 왜: 기존 로직이 `\(item.can)캔`만 찾고 있어 서버 메시지에 comma가 포함되면 강조 색상이 적용되지 않음 + 어떻게: `ChannelDonationItemView.highlightedMessageText`에서 `\(item.can.comma())캔` 우선, 실패 시 `\(item.can)캔` 순서로 range 탐색 + 결과: `1,000캔`/`1000캔` 모두 `OO캔` 구간에 강조 색상(`FDCA2F`) 정상 적용