feat(explorer): 채널 후원 목록/등록 기능을 추가한다
This commit is contained in:
@@ -4139,22 +4139,6 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"목" : {
|
|
||||||
"localizations" : {
|
|
||||||
"en" : {
|
|
||||||
"stringUnit" : {
|
|
||||||
"state" : "translated",
|
|
||||||
"value" : "Thu"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"ja" : {
|
|
||||||
"stringUnit" : {
|
|
||||||
"state" : "translated",
|
|
||||||
"value" : "木"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"모집완료" : {
|
"모집완료" : {
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
"en" : {
|
"en" : {
|
||||||
@@ -4187,6 +4171,22 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"목" : {
|
||||||
|
"localizations" : {
|
||||||
|
"en" : {
|
||||||
|
"stringUnit" : {
|
||||||
|
"state" : "translated",
|
||||||
|
"value" : "Thu"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"ja" : {
|
||||||
|
"stringUnit" : {
|
||||||
|
"state" : "translated",
|
||||||
|
"value" : "木"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"무료" : {
|
"무료" : {
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
"en" : {
|
"en" : {
|
||||||
@@ -6955,22 +6955,6 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"일" : {
|
|
||||||
"localizations" : {
|
|
||||||
"en" : {
|
|
||||||
"stringUnit" : {
|
|
||||||
"state" : "translated",
|
|
||||||
"value" : "Sun"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"ja" : {
|
|
||||||
"stringUnit" : {
|
|
||||||
"state" : "translated",
|
|
||||||
"value" : "日"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"이벤트" : {
|
"이벤트" : {
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
"en" : {
|
"en" : {
|
||||||
@@ -7163,6 +7147,22 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"일" : {
|
||||||
|
"localizations" : {
|
||||||
|
"en" : {
|
||||||
|
"stringUnit" : {
|
||||||
|
"state" : "translated",
|
||||||
|
"value" : "Sun"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"ja" : {
|
||||||
|
"stringUnit" : {
|
||||||
|
"state" : "translated",
|
||||||
|
"value" : "日"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"일간 랭킹" : {
|
"일간 랭킹" : {
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
"en" : {
|
"en" : {
|
||||||
@@ -8638,22 +8638,6 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"캐릭터 정보" : {
|
|
||||||
"localizations" : {
|
|
||||||
"en" : {
|
|
||||||
"stringUnit" : {
|
|
||||||
"state" : "translated",
|
|
||||||
"value" : "Character info"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"ja" : {
|
|
||||||
"stringUnit" : {
|
|
||||||
"state" : "translated",
|
|
||||||
"value" : "キャラクター情報"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"캔" : {
|
"캔" : {
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
"en" : {
|
"en" : {
|
||||||
@@ -8670,6 +8654,22 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"캐릭터 정보" : {
|
||||||
|
"localizations" : {
|
||||||
|
"en" : {
|
||||||
|
"stringUnit" : {
|
||||||
|
"state" : "translated",
|
||||||
|
"value" : "Character info"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"ja" : {
|
||||||
|
"stringUnit" : {
|
||||||
|
"state" : "translated",
|
||||||
|
"value" : "キャラクター情報"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"캔 충전" : {
|
"캔 충전" : {
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
"en" : {
|
"en" : {
|
||||||
@@ -9614,7 +9614,18 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"함께 보낼 %@메시지 입력(최대 %lld자)" : {
|
||||||
|
"localizations" : {
|
||||||
|
"ko" : {
|
||||||
|
"stringUnit" : {
|
||||||
|
"state" : "new",
|
||||||
|
"value" : "함께 보낼 %1$@메시지 입력(최대 %2$lld자)"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"함께 보낼 %@메시지 입력(최대 1000자)" : {
|
"함께 보낼 %@메시지 입력(최대 1000자)" : {
|
||||||
|
"extractionState" : "stale",
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
"en" : {
|
"en" : {
|
||||||
"stringUnit" : {
|
"stringUnit" : {
|
||||||
|
|||||||
@@ -75,7 +75,9 @@ enum AppStep {
|
|||||||
case followerList(userId: Int)
|
case followerList(userId: Int)
|
||||||
|
|
||||||
case userProfileDonationAll(userId: Int)
|
case userProfileDonationAll(userId: Int)
|
||||||
|
|
||||||
|
case channelDonationAll(creatorId: Int)
|
||||||
|
|
||||||
case userProfileFanTalkAll(userId: Int)
|
case userProfileFanTalkAll(userId: Int)
|
||||||
|
|
||||||
case createLive(
|
case createLive(
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ enum DateParser {
|
|||||||
{ ISO8601.fractional.date(from: $0) },
|
{ ISO8601.fractional.date(from: $0) },
|
||||||
{ ISO8601.basic.date(from: $0) },
|
{ ISO8601.basic.date(from: $0) },
|
||||||
{ DF.rfc3339.date(from: $0) },
|
{ DF.rfc3339.date(from: $0) },
|
||||||
|
{ DF.isoLocalDateTime.date(from: $0) },
|
||||||
{ DF.basic.date(from: $0) }
|
{ DF.basic.date(from: $0) }
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -56,5 +57,13 @@ enum DateParser {
|
|||||||
f.dateFormat = "yyyy-MM-dd HH:mm:ss"
|
f.dateFormat = "yyyy-MM-dd HH:mm:ss"
|
||||||
return f
|
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
|
||||||
|
}()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,9 +22,22 @@ struct LiveRoomDonationDialogView: View {
|
|||||||
|
|
||||||
@Binding var isShowing: Bool
|
@Binding var isShowing: Bool
|
||||||
let isAudioContentDonation: Bool
|
let isAudioContentDonation: Bool
|
||||||
|
let messageLimit: Int
|
||||||
let onClickDonation: (Int, String, Bool) -> Void
|
let onClickDonation: (Int, String, Bool) -> Void
|
||||||
|
|
||||||
@StateObject var keyboardHandler = KeyboardHandler()
|
@StateObject var keyboardHandler = KeyboardHandler()
|
||||||
|
|
||||||
|
init(
|
||||||
|
isShowing: Binding<Bool>,
|
||||||
|
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 {
|
var body: some View {
|
||||||
ZStack {
|
ZStack {
|
||||||
@@ -204,7 +217,7 @@ struct LiveRoomDonationDialogView: View {
|
|||||||
.stroke(Color.graybb, lineWidth: 1)
|
.stroke(Color.graybb, lineWidth: 1)
|
||||||
)
|
)
|
||||||
|
|
||||||
TextField("함께 보낼 \(isSecret ? "비밀 " : "")메시지 입력(최대 1000자)", text: $donationMessage)
|
TextField("함께 보낼 \(isSecret ? "비밀 " : "")메시지 입력(최대 \(messageLimit)자)", text: $donationMessage)
|
||||||
.appFont(size: 13.3, weight: .medium)
|
.appFont(size: 13.3, weight: .medium)
|
||||||
.foregroundColor(Color.grayee)
|
.foregroundColor(Color.grayee)
|
||||||
.padding(13.3)
|
.padding(13.3)
|
||||||
@@ -286,8 +299,8 @@ struct LiveRoomDonationDialogView: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func limitText() {
|
func limitText() {
|
||||||
if donationMessage.count > 1000 {
|
if donationMessage.count > messageLimit {
|
||||||
donationMessage = String(donationMessage.prefix(1000))
|
donationMessage = String(donationMessage.prefix(messageLimit))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -168,7 +168,10 @@ struct ContentView: View {
|
|||||||
|
|
||||||
case .userProfileDonationAll(let userId):
|
case .userProfileDonationAll(let userId):
|
||||||
UserProfileDonationAllView(userId: userId)
|
UserProfileDonationAllView(userId: userId)
|
||||||
|
|
||||||
|
case .channelDonationAll(let creatorId):
|
||||||
|
ChannelDonationAllView(creatorId: creatorId)
|
||||||
|
|
||||||
case .userProfileFanTalkAll(let userId):
|
case .userProfileFanTalkAll(let userId):
|
||||||
UserProfileFanTalkAllView(userId: userId)
|
UserProfileFanTalkAllView(userId: userId)
|
||||||
|
|
||||||
|
|||||||
@@ -20,6 +20,8 @@ enum ExplorerApi {
|
|||||||
case modifyCheers(request: PutModifyCheersRequest)
|
case modifyCheers(request: PutModifyCheersRequest)
|
||||||
case writeCreatorNotice(request: PostCreatorNoticeRequest)
|
case writeCreatorNotice(request: PostCreatorNoticeRequest)
|
||||||
case getCreatorProfileDonationRanking(userId: Int, page: Int, size: Int, period: DonationRankingPeriod?)
|
case getCreatorProfileDonationRanking(userId: Int, page: Int, size: Int, period: DonationRankingPeriod?)
|
||||||
|
case getChannelDonationList(creatorId: Int)
|
||||||
|
case postChannelDonation(request: PostChannelDonationRequest)
|
||||||
}
|
}
|
||||||
|
|
||||||
extension ExplorerApi: TargetType {
|
extension ExplorerApi: TargetType {
|
||||||
@@ -61,15 +63,18 @@ extension ExplorerApi: TargetType {
|
|||||||
|
|
||||||
case .writeCreatorNotice:
|
case .writeCreatorNotice:
|
||||||
return "/explorer/profile/notice"
|
return "/explorer/profile/notice"
|
||||||
|
|
||||||
|
case .getChannelDonationList, .postChannelDonation:
|
||||||
|
return "/explorer/profile/channel-donation"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
var method: Moya.Method {
|
var method: Moya.Method {
|
||||||
switch self {
|
switch self {
|
||||||
case .getExplorer, .searchChannel, .getCreatorProfile, .getCreatorDetail, .getFollowerList, .getCreatorProfileCheers, .getCreatorProfileDonationRanking, .getCreatorRank:
|
case .getExplorer, .searchChannel, .getCreatorProfile, .getCreatorDetail, .getFollowerList, .getCreatorProfileCheers, .getCreatorProfileDonationRanking, .getCreatorRank, .getChannelDonationList:
|
||||||
return .get
|
return .get
|
||||||
|
|
||||||
case .writeCheers, .writeCreatorNotice:
|
case .writeCheers, .writeCreatorNotice, .postChannelDonation:
|
||||||
return .post
|
return .post
|
||||||
|
|
||||||
case .modifyCheers:
|
case .modifyCheers:
|
||||||
@@ -115,7 +120,13 @@ extension ExplorerApi: TargetType {
|
|||||||
|
|
||||||
case .writeCreatorNotice(let request):
|
case .writeCreatorNotice(let request):
|
||||||
return .requestJSONEncodable(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):
|
case .getCreatorProfileDonationRanking(_, let page, let size, let period):
|
||||||
var parameters = [
|
var parameters = [
|
||||||
"page": page - 1,
|
"page": page - 1,
|
||||||
|
|||||||
@@ -70,4 +70,12 @@ final class ExplorerRepository {
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func getChannelDonationList(creatorId: Int) -> AnyPublisher<Response, MoyaError> {
|
||||||
|
return api.requestPublisher(.getChannelDonationList(creatorId: creatorId))
|
||||||
|
}
|
||||||
|
|
||||||
|
func postChannelDonation(request: PostChannelDonationRequest) -> AnyPublisher<Response, MoyaError> {
|
||||||
|
return api.requestPublisher(.postChannelDonation(request: request))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -19,8 +19,8 @@ struct GetCreatorProfileResponse: Decodable {
|
|||||||
let notice: String
|
let notice: String
|
||||||
let communityPostList: [GetCommunityPostListResponse]
|
let communityPostList: [GetCommunityPostListResponse]
|
||||||
let cheers: GetCheersResponse
|
let cheers: GetCheersResponse
|
||||||
let activitySummary: GetCreatorActivitySummary
|
|
||||||
let seriesList: [SeriesListItem]
|
let seriesList: [SeriesListItem]
|
||||||
|
let channelDonationList: [GetChannelDonationListItem]
|
||||||
let isBlock: Bool
|
let isBlock: Bool
|
||||||
let isCreatorRole: Bool
|
let isCreatorRole: Bool
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ struct UserProfileView: View {
|
|||||||
|
|
||||||
@Environment(\.presentationMode) var presentationMode: Binding<PresentationMode>
|
@Environment(\.presentationMode) var presentationMode: Binding<PresentationMode>
|
||||||
@StateObject var viewModel = UserProfileViewModel()
|
@StateObject var viewModel = UserProfileViewModel()
|
||||||
|
@StateObject private var channelDonationViewModel = ChannelDonationViewModel()
|
||||||
@StateObject private var keyboardHandler = KeyboardHandler()
|
@StateObject private var keyboardHandler = KeyboardHandler()
|
||||||
|
|
||||||
@State private var memberId: Int = 0
|
@State private var memberId: Int = 0
|
||||||
@@ -22,6 +23,7 @@ struct UserProfileView: View {
|
|||||||
@State private var isShowRouletteSettings: Bool = false
|
@State private var isShowRouletteSettings: Bool = false
|
||||||
@State private var isShowMenuSettings: Bool = false
|
@State private var isShowMenuSettings: Bool = false
|
||||||
@State private var isShowCreatorDetailDialog: Bool = false
|
@State private var isShowCreatorDetailDialog: Bool = false
|
||||||
|
@State private var isShowChannelDonationDialog: Bool = false
|
||||||
|
|
||||||
@State private var maxCommunityPostHeight: CGFloat? = nil
|
@State private var maxCommunityPostHeight: CGFloat? = nil
|
||||||
|
|
||||||
@@ -191,6 +193,15 @@ struct UserProfileView: View {
|
|||||||
.setAppStep(step: .contentDetail(contentId: item.contentId))
|
.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 {
|
if creatorProfile.creator.creatorId == UserDefaults.int(forKey: .userId) || creatorProfile.liveRoomList.count > 0 {
|
||||||
VStack(alignment: .leading, spacing: 14) {
|
VStack(alignment: .leading, spacing: 14) {
|
||||||
@@ -425,6 +436,25 @@ struct UserProfileView: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
ZStack {
|
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 {
|
if viewModel.isShowPaymentDialog {
|
||||||
LivePaymentDialog(
|
LivePaymentDialog(
|
||||||
title: viewModel.paymentDialogTitle,
|
title: viewModel.paymentDialogTitle,
|
||||||
@@ -581,6 +611,21 @@ struct UserProfileView: View {
|
|||||||
Spacer()
|
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)
|
.ignoresSafeArea(.container, edges: .all)
|
||||||
.sheet(
|
.sheet(
|
||||||
isPresented: $viewModel.isShowShareView,
|
isPresented: $viewModel.isShowShareView,
|
||||||
|
|||||||
@@ -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 {
|
static func monthsAgo(_ months: Int) -> String {
|
||||||
pick(
|
pick(
|
||||||
ko: "\(months)개월 전",
|
ko: "\(months)개월 전",
|
||||||
@@ -876,6 +900,14 @@ enum I18n {
|
|||||||
|
|
||||||
static var cheersDeleteTitle: String { pick(ko: "응원글 삭제", en: "Delete Cheer", ja: "応援削除") }
|
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 liveHeader: String { pick(ko: "라이브", en: "Live", ja: "ライブ") }
|
||||||
static var rouletteSettings: String { pick(ko: "룰렛 설정", en: "Roulette settings", ja: "ルーレット設定") }
|
static var rouletteSettings: String { pick(ko: "룰렛 설정", en: "Roulette settings", ja: "ルーレット設定") }
|
||||||
static var menuSettings: String { pick(ko: "메뉴 설정", en: "Menu settings", ja: "メニュー設定") }
|
static var menuSettings: String { pick(ko: "메뉴 설정", en: "Menu settings", ja: "メニュー設定") }
|
||||||
|
|||||||
158
docs/20260225_채널후원구현.md
Normal file
158
docs/20260225_채널후원구현.md
Normal file
@@ -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`) 정상 적용
|
||||||
Reference in New Issue
Block a user