From e7a011c51907570d1c0603ce2819aef6b5cded23 Mon Sep 17 00:00:00 2001 From: Yu Sung Date: Tue, 7 Jul 2026 15:04:09 +0900 Subject: [PATCH] =?UTF-8?q?feat(creator):=20=EB=8B=B5=EA=B8=80=20=EC=83=81?= =?UTF-8?q?=EC=84=B8=20=EC=BB=B4=ED=8F=AC=EB=84=8C=ED=8A=B8=EB=A5=BC=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80=ED=95=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../ic_arrow_up_gray.imageset/Contents.json | 21 ++ .../ic_arrow_up_gray.png | Bin 0 -> 293 bytes .../ic_arrow_up_white.imageset/Contents.json | 21 ++ .../ic_arrow_up_white.png | Bin 0 -> 243 bytes SodaLive/Sources/I18n/I18n.swift | 7 + ...CreatorChannelReplyDetailActionPopup.swift | 39 ++++ .../CreatorChannelReplyDetailFeedView.swift | 214 ++++++++++++++++++ .../CreatorChannelReplyDetailInputBar.swift | 58 +++++ .../CreatorChannelReplyDetailTitleBar.swift | 19 ++ 9 files changed, 379 insertions(+) create mode 100644 SodaLive/Resources/Assets.xcassets/v2/ic_arrow_up_gray.imageset/Contents.json create mode 100644 SodaLive/Resources/Assets.xcassets/v2/ic_arrow_up_gray.imageset/ic_arrow_up_gray.png create mode 100644 SodaLive/Resources/Assets.xcassets/v2/ic_arrow_up_white.imageset/Contents.json create mode 100644 SodaLive/Resources/Assets.xcassets/v2/ic_arrow_up_white.imageset/ic_arrow_up_white.png create mode 100644 SodaLive/Sources/V2/CreatorChannel/ReplyDetail/Components/CreatorChannelReplyDetailActionPopup.swift create mode 100644 SodaLive/Sources/V2/CreatorChannel/ReplyDetail/Components/CreatorChannelReplyDetailFeedView.swift create mode 100644 SodaLive/Sources/V2/CreatorChannel/ReplyDetail/Components/CreatorChannelReplyDetailInputBar.swift create mode 100644 SodaLive/Sources/V2/CreatorChannel/ReplyDetail/Components/CreatorChannelReplyDetailTitleBar.swift diff --git a/SodaLive/Resources/Assets.xcassets/v2/ic_arrow_up_gray.imageset/Contents.json b/SodaLive/Resources/Assets.xcassets/v2/ic_arrow_up_gray.imageset/Contents.json new file mode 100644 index 00000000..7f5cd768 --- /dev/null +++ b/SodaLive/Resources/Assets.xcassets/v2/ic_arrow_up_gray.imageset/Contents.json @@ -0,0 +1,21 @@ +{ + "images" : [ + { + "filename" : "ic_arrow_up_gray.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "idiom" : "universal", + "scale" : "2x" + }, + { + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/SodaLive/Resources/Assets.xcassets/v2/ic_arrow_up_gray.imageset/ic_arrow_up_gray.png b/SodaLive/Resources/Assets.xcassets/v2/ic_arrow_up_gray.imageset/ic_arrow_up_gray.png new file mode 100644 index 0000000000000000000000000000000000000000..590d7e5e0add6b20333a1e147ca2a6b03554c276 GIT binary patch literal 293 zcmeAS@N?(olHy`uVBq!ia0vp^A|TAc1|)ksWqE-VXMsm#F#`j)FbFd;%$g$s6l5$8 za(7}_cTVOdki(Mh=MST#2P~b zc;7DM{j$H^ZrcIL$8-KayIHbMY<(Ydui<(D?!fG~zfCljUWqPu>iA|-VamBj@kdW& i(K*o%(@ni1Pcv{oarRleV?_zjtqh*7elF{r5}E)P5@zfG literal 0 HcmV?d00001 diff --git a/SodaLive/Resources/Assets.xcassets/v2/ic_arrow_up_white.imageset/Contents.json b/SodaLive/Resources/Assets.xcassets/v2/ic_arrow_up_white.imageset/Contents.json new file mode 100644 index 00000000..6c1edfcb --- /dev/null +++ b/SodaLive/Resources/Assets.xcassets/v2/ic_arrow_up_white.imageset/Contents.json @@ -0,0 +1,21 @@ +{ + "images" : [ + { + "filename" : "ic_arrow_up_white.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "idiom" : "universal", + "scale" : "2x" + }, + { + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/SodaLive/Resources/Assets.xcassets/v2/ic_arrow_up_white.imageset/ic_arrow_up_white.png b/SodaLive/Resources/Assets.xcassets/v2/ic_arrow_up_white.imageset/ic_arrow_up_white.png new file mode 100644 index 0000000000000000000000000000000000000000..7ef9eaf337ad4db9a7b04d078796417e4ddc2fa6 GIT binary patch literal 243 zcmeAS@N?(olHy`uVBq!ia0vp^A|TAc1|)ksWqE-VXMsm#F#`j)FbFd;%$g$s6l5$8 za(7}_cTVOdki(Mh=M*BZ6y!Cc2d&@jU#Kq+8 zLGcYIF15OHn@7$znPhoRsqpG0XEj@nrR5R~KBT*&1iCzcPinZeW5&t;ucLK6T`!BQLm literal 0 HcmV?d00001 diff --git a/SodaLive/Sources/I18n/I18n.swift b/SodaLive/Sources/I18n/I18n.swift index d25269e6..f35adfd8 100644 --- a/SodaLive/Sources/I18n/I18n.swift +++ b/SodaLive/Sources/I18n/I18n.swift @@ -3431,6 +3431,13 @@ If you block this user, the following features will be restricted. } } + + enum CreatorChannelReplyDetail { + static var replyPlaceholder: String { + pick(ko: "답글을 입력하세요", en: "Enter a reply", ja: "返信を入力してください") + } + } + enum CreatorChannelFanTalk { static var send: String { pick(ko: "보내기", en: "Send", ja: "送信") } static var exitModalTitle: String { pick(ko: "입력 종료", en: "Discard input", ja: "入力終了") } diff --git a/SodaLive/Sources/V2/CreatorChannel/ReplyDetail/Components/CreatorChannelReplyDetailActionPopup.swift b/SodaLive/Sources/V2/CreatorChannel/ReplyDetail/Components/CreatorChannelReplyDetailActionPopup.swift new file mode 100644 index 00000000..369c7e50 --- /dev/null +++ b/SodaLive/Sources/V2/CreatorChannel/ReplyDetail/Components/CreatorChannelReplyDetailActionPopup.swift @@ -0,0 +1,39 @@ +import SwiftUI + +struct CreatorChannelReplyDetailActionPopup: View { + let canEdit: Bool + let canDelete: Bool + let onTapEdit: () -> Void + let onTapDelete: () -> Void + + var body: some View { + VStack(alignment: .leading, spacing: 0) { + if canEdit { + actionButton(title: I18n.Explorer.edit, action: onTapEdit) + } + + if canDelete { + actionButton(title: I18n.Common.delete, action: onTapDelete) + } + } + .background(Color.gray900) + .cornerRadius(14) + .overlay( + RoundedRectangle(cornerRadius: 14, style: .continuous) + .stroke(Color.gray700, lineWidth: 1) + ) + } + + private func actionButton(title: String, action: @escaping () -> Void) -> some View { + Button(action: action) { + Text(title) + .appFont(size: 14, weight: .medium) + .foregroundColor(.white) + .lineLimit(1) + .frame(minWidth: 80, alignment: .leading) + .padding(.horizontal, SodaSpacing.s12) + .padding(.vertical, SodaSpacing.s8) + } + .buttonStyle(.plain) + } +} diff --git a/SodaLive/Sources/V2/CreatorChannel/ReplyDetail/Components/CreatorChannelReplyDetailFeedView.swift b/SodaLive/Sources/V2/CreatorChannel/ReplyDetail/Components/CreatorChannelReplyDetailFeedView.swift new file mode 100644 index 00000000..1ef61d9d --- /dev/null +++ b/SodaLive/Sources/V2/CreatorChannel/ReplyDetail/Components/CreatorChannelReplyDetailFeedView.swift @@ -0,0 +1,214 @@ +import SwiftUI + +struct CreatorChannelReplyDetailFeedView: View { + let parentItem: CreatorChannelReplyDetailDisplayItem + let replies: [CreatorChannelReplyDetailDisplayItem] + let selectedActionItem: CreatorChannelReplyDetailDisplayItem? + let canEdit: (CreatorChannelReplyDetailDisplayItem) -> Bool + let canDelete: (CreatorChannelReplyDetailDisplayItem) -> Bool + let onTapAction: (CreatorChannelReplyDetailDisplayItem) -> Void + let onTapEdit: (CreatorChannelReplyDetailDisplayItem) -> Void + let onTapDelete: (CreatorChannelReplyDetailDisplayItem) -> Void + let onReplyAppear: (CreatorChannelReplyDetailDisplayItem) -> Void + + @State private var itemFrames = [CreatorChannelReplyDetailItemKey: CGRect]() + + var body: some View { + ZStack(alignment: .topTrailing) { + ScrollView(.vertical, showsIndicators: false) { + VStack(alignment: .leading, spacing: 0) { + parentView + + if replies.isEmpty == false { + repliesView + .padding(.top, SodaSpacing.s12) + .padding(.leading, 50) + } + + Spacer(minLength: SodaSpacing.s20) + } + .padding(.horizontal, SodaSpacing.s14) + .padding(.top, SodaSpacing.s16) + .padding(.bottom, SodaSpacing.s20) + } + + actionPopupLayer + .zIndex(1) + } + .coordinateSpace(name: Self.coordinateSpaceName) + .onPreferenceChange(CreatorChannelReplyDetailItemFramePreferenceKey.self) { frames in + itemFrames.merge(frames) { _, new in new } + } + } + + @ViewBuilder + private var actionPopupLayer: some View { + if let selectedActionItem, + let itemFrame = itemFrames[CreatorChannelReplyDetailItemKey(item: selectedActionItem)] { + CreatorChannelReplyDetailActionPopup( + canEdit: canEdit(selectedActionItem), + canDelete: canDelete(selectedActionItem), + onTapEdit: { onTapEdit(selectedActionItem) }, + onTapDelete: { onTapDelete(selectedActionItem) } + ) + .padding(.top, itemFrame.minY + 32) + .padding(.trailing, SodaSpacing.s14) + } + } + + private var parentView: some View { + VStack(alignment: .leading, spacing: SodaSpacing.s12) { + itemHeader(item: parentItem, profileSize: 42, showsSecret: true) + + Text(parentItem.content) + .appFont(size: 16, weight: .regular) + .foregroundColor(.white) + .fixedSize(horizontal: false, vertical: true) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.leading, 50) + .background(alignment: .topLeading) { + if replies.isEmpty == false { + GeometryReader { proxy in + CreatorChannelReplyDetailConnector() + .stroke(Color.gray800, style: StrokeStyle(lineWidth: 2, lineCap: .round, lineJoin: .round)) + .frame(height: proxy.size.height + SodaSpacing.s12 + SodaSpacing.s20) + } + } + } + } + } + + private var repliesView: some View { + VStack(alignment: .leading, spacing: SodaSpacing.s8) { + ForEach(replies) { reply in + replyCard(reply) + .onAppear { onReplyAppear(reply) } + } + } + } + + private func replyCard(_ item: CreatorChannelReplyDetailDisplayItem) -> some View { + VStack(alignment: .leading, spacing: SodaSpacing.s8) { + itemHeader(item: item, profileSize: 20, showsSecret: false) + + Text(item.content) + .appFont(size: 16, weight: .regular) + .foregroundColor(.white) + .fixedSize(horizontal: false, vertical: true) + .frame(maxWidth: .infinity, alignment: .leading) + } + .padding(SodaSpacing.s12) + .frame(maxWidth: .infinity, alignment: .leading) + .background(Color.gray900) + .clipShape(RoundedRectangle(cornerRadius: SodaSpacing.s14, style: .continuous)) + } + + private func itemHeader( + item: CreatorChannelReplyDetailDisplayItem, + profileSize: CGFloat, + showsSecret: Bool + ) -> some View { + HStack(alignment: .center, spacing: SodaSpacing.s8) { + DownsampledKFImage( + url: URL(string: item.profileImageUrl), + size: CGSize(width: profileSize, height: profileSize) + ) + .background(Color.white.opacity(0.6)) + .clipShape(Circle()) + + Text(item.nickname) + .appFont(size: profileSize > 20 ? 14 : 13, weight: .medium) + .foregroundColor(.white) + .lineLimit(1) + + Text(item.relativeTimeText) + .appFont(size: 14, weight: .regular) + .foregroundColor(Color.gray500) + .lineLimit(1) + + if showsSecret && item.isSecret { + Text(I18n.Explorer.secretComment) + .appFont(size: 11, weight: .medium) + .foregroundColor(Color.grayee) + .padding(.horizontal, 4) + .padding(.vertical, 2) + .background(Color.soda400.opacity(0.2)) + .cornerRadius(3) + } + + Spacer(minLength: 0) + + if canEdit(item) || canDelete(item) { + Button(action: { onTapAction(item) }) { + Image("ic_new_more") + .resizable() + .frame(width: 24, height: 24) + } + .buttonStyle(.plain) + } + } + .trackReplyDetailItemFrame(item) + } + + fileprivate static let coordinateSpaceName = "CreatorChannelReplyDetailFeedView" +} + +private struct CreatorChannelReplyDetailItemKey: Hashable { + let id: Int + let source: Int + + init(item: CreatorChannelReplyDetailDisplayItem) { + id = item.id + switch item.source { + case .community: + source = 0 + case .fanTalk: + source = 1 + } + } +} + +private struct CreatorChannelReplyDetailItemFramePreferenceKey: PreferenceKey { + static var defaultValue: [CreatorChannelReplyDetailItemKey: CGRect] = [:] + + static func reduce( + value: inout [CreatorChannelReplyDetailItemKey: CGRect], + nextValue: () -> [CreatorChannelReplyDetailItemKey: CGRect] + ) { + value.merge(nextValue()) { _, new in new } + } +} + +private extension View { + func trackReplyDetailItemFrame(_ item: CreatorChannelReplyDetailDisplayItem) -> some View { + background { + GeometryReader { proxy in + Color.clear.preference( + key: CreatorChannelReplyDetailItemFramePreferenceKey.self, + value: [CreatorChannelReplyDetailItemKey(item: item): proxy.frame(in: .named(CreatorChannelReplyDetailFeedView.coordinateSpaceName))] + ) + } + } + } +} + +private struct CreatorChannelReplyDetailConnector: Shape { + func path(in rect: CGRect) -> Path { + var path = Path() + let avatarCenterX: CGFloat = 21 + let replyCardStartX: CGFloat = 50 + let connectorGap: CGFloat = 14 + let connectorEndX = replyCardStartX - connectorGap + let startY: CGFloat = 0 + let endY = rect.height - 8 + let bendY = max(startY, endY - 14) + + path.move(to: CGPoint(x: avatarCenterX, y: startY)) + path.addLine(to: CGPoint(x: avatarCenterX, y: bendY)) + path.addQuadCurve( + to: CGPoint(x: connectorEndX, y: endY), + control: CGPoint(x: avatarCenterX, y: endY) + ) + return path + } +} diff --git a/SodaLive/Sources/V2/CreatorChannel/ReplyDetail/Components/CreatorChannelReplyDetailInputBar.swift b/SodaLive/Sources/V2/CreatorChannel/ReplyDetail/Components/CreatorChannelReplyDetailInputBar.swift new file mode 100644 index 00000000..801016d1 --- /dev/null +++ b/SodaLive/Sources/V2/CreatorChannel/ReplyDetail/Components/CreatorChannelReplyDetailInputBar.swift @@ -0,0 +1,58 @@ +import SwiftUI + +struct CreatorChannelReplyDetailInputBar: View { + @Binding var text: String + let isSendEnabled: Bool + let isEditing: Bool + let onTapSend: () -> Void + let onTapCancelEditing: () -> Void + + var body: some View { + VStack(spacing: 0) { + Rectangle() + .fill(Color.gray700) + .frame(height: 1) + + HStack(spacing: SodaSpacing.s8) { + if isEditing { + Button(action: onTapCancelEditing) { + Image("ic_close_white") + .resizable() + .scaledToFit() + .frame(width: 20, height: 20) + } + .buttonStyle(.plain) + } + + TextField(I18n.CreatorChannelReplyDetail.replyPlaceholder, text: $text) + .autocapitalization(.none) + .disableAutocorrection(true) + .appFont(size: 15, weight: .regular) + .foregroundColor(.white) + .accentColor(Color.soda400) + .padding(.horizontal, SodaSpacing.s14) + .frame(height: 40) + .background(Color.gray900) + .clipShape(RoundedRectangle(cornerRadius: 20, style: .continuous)) + + Button(action: { + guard isSendEnabled else { return } + onTapSend() + }) { + Image(isSendEnabled ? "ic_arrow_up_white" : "ic_arrow_up_gray") + .resizable() + .scaledToFit() + .frame(width: 20, height: 20) + .frame(width: 36, height: 36) + .background(isSendEnabled ? Color.soda400 : Color.gray900) + .clipShape(Circle()) + } + .buttonStyle(.plain) + .disabled(isSendEnabled == false) + } + .padding(.horizontal, SodaSpacing.s14) + .frame(height: 60) + .background(Color.black) + } + } +} diff --git a/SodaLive/Sources/V2/CreatorChannel/ReplyDetail/Components/CreatorChannelReplyDetailTitleBar.swift b/SodaLive/Sources/V2/CreatorChannel/ReplyDetail/Components/CreatorChannelReplyDetailTitleBar.swift new file mode 100644 index 00000000..ecea8e25 --- /dev/null +++ b/SodaLive/Sources/V2/CreatorChannel/ReplyDetail/Components/CreatorChannelReplyDetailTitleBar.swift @@ -0,0 +1,19 @@ +import SwiftUI + +struct CreatorChannelReplyDetailTitleBar: View { + let onTapBack: () -> Void + + var body: some View { + TitleBar { + Button(action: onTapBack) { + Image("ic_new_bar_back") + .resizable() + .scaledToFit() + .frame(width: 24, height: 24) + } + .buttonStyle(.plain) + } trailing: { + EmptyView() + } + } +}