From 24d2508700e93bc89a41b7591f86e1a659f240b9 Mon Sep 17 00:00:00 2001 From: Yu Sung Date: Thu, 8 Oct 2026 14:13:35 +0900 Subject: [PATCH] =?UTF-8?q?feat(gift):=20=EC=84=A0=EB=AC=BC=20=EB=B0=9C?= =?UTF-8?q?=EC=86=A1=20=ED=91=9C=EC=8B=9C=EB=A5=BC=20=EB=B3=B4=EC=A0=95?= =?UTF-8?q?=ED=95=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../v2/ic_send_gift.imageset/Contents.json | 21 ++++++ .../v2/ic_send_gift.imageset/ic_send_gift.png | Bin 0 -> 815 bytes SodaLive/Sources/App/AppStep.swift | 2 +- SodaLive/Sources/ContentView.swift | 4 +- .../CreatorChannelHeaderSection.swift | 1 + .../CreatorChannelTalkActionSection.swift | 21 ++++-- .../V2/Gift/Components/GiftAgreementRow.swift | 3 +- .../V2/Gift/Constants/GiftConstants.swift | 1 + .../Sources/V2/Gift/Send/GiftSendView.swift | 12 ++-- work/tests/check-gift-phase11.sh | 68 ++++++++++++++++++ 10 files changed, 119 insertions(+), 14 deletions(-) create mode 100644 SodaLive/Resources/Assets.xcassets/v2/ic_send_gift.imageset/Contents.json create mode 100644 SodaLive/Resources/Assets.xcassets/v2/ic_send_gift.imageset/ic_send_gift.png create mode 100644 work/tests/check-gift-phase11.sh diff --git a/SodaLive/Resources/Assets.xcassets/v2/ic_send_gift.imageset/Contents.json b/SodaLive/Resources/Assets.xcassets/v2/ic_send_gift.imageset/Contents.json new file mode 100644 index 00000000..51f1c585 --- /dev/null +++ b/SodaLive/Resources/Assets.xcassets/v2/ic_send_gift.imageset/Contents.json @@ -0,0 +1,21 @@ +{ + "images" : [ + { + "idiom" : "universal", + "scale" : "1x" + }, + { + "idiom" : "universal", + "scale" : "2x" + }, + { + "filename" : "ic_send_gift.png", + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/SodaLive/Resources/Assets.xcassets/v2/ic_send_gift.imageset/ic_send_gift.png b/SodaLive/Resources/Assets.xcassets/v2/ic_send_gift.imageset/ic_send_gift.png new file mode 100644 index 0000000000000000000000000000000000000000..b0ec14b3baeacd5a39c0299879fad0c874864998 GIT binary patch literal 815 zcmV+~1JL}5P)_!|DivO5}js#}LcoP$bWS9#jqHoip1 z$EkME7Y>_r?>NHl7Lh3&KfE!z#dC06og?_575anSH+D()J$0@S5jz-iR5SGo@PX5{ zb~jXxdYs)$bpMl&>*7 zrgwNA7Iv?oYkx58G~K7MudVBMEg#1YyH^VIzwno+Jp#IS3C!a36e{6~0x-AJp;BC6 zeXpegrw^M2_Dx#KD*whbNxDLJg+s|kjc{)8Hpp|Xl601)cW#Y1)R@M$DEaWow2~kP zoH{<4mO?m6cx?=42*8#1o{B1Xpa9J6aFoR36L=^Ir%if|(}Wf?cNjqd_d0fIqJ_@d z6_E5(3r$)Zyv=0BWM2x?WLh_jF9ih4L+LC{hU`pcs_d(Vza7pO(ONhs9;uT9PMsWZ z>g0e^CkLE5IpEaE0jEyL2>^xpSXt!*BB0UTVJP8;f^hH2LCM(oE^X{{xRiE1_UL}l z-a)>Ar?&P-F4}vNe#lnn?z7cKUlEBVZr`pEnF_$;LR8_MC@}ACChcL3`g|LDK%B-S t9co$OeW1-d8uN63CinnLCX>l5o+r%kktL-Hu+jhk002ovPDHLkV1fw4YODYN literal 0 HcmV?d00001 diff --git a/SodaLive/Sources/App/AppStep.swift b/SodaLive/Sources/App/AppStep.swift index 26dfac63..759c3ba6 100644 --- a/SodaLive/Sources/App/AppStep.swift +++ b/SodaLive/Sources/App/AppStep.swift @@ -197,7 +197,7 @@ enum AppStep { case seriesMain - case giftSend(recipientMemberId: Int, recipientCreatorNickname: String) + case giftSend(recipientMemberId: Int, recipientCreatorNickname: String, recipientCreatorProfileImageUrl: String) case giftBoxList diff --git a/SodaLive/Sources/ContentView.swift b/SodaLive/Sources/ContentView.swift index 60fc5cc3..30a7ffa7 100644 --- a/SodaLive/Sources/ContentView.swift +++ b/SodaLive/Sources/ContentView.swift @@ -374,8 +374,8 @@ struct AppStepLayerView: View { case .seriesMain: SeriesMainView() - case .giftSend(let recipientMemberId, let recipientCreatorNickname): - GiftSendView(recipientMemberId: recipientMemberId, recipientCreatorNickname: recipientCreatorNickname) + case .giftSend(let recipientMemberId, let recipientCreatorNickname, let recipientCreatorProfileImageUrl): + GiftSendView(recipientMemberId: recipientMemberId, recipientCreatorNickname: recipientCreatorNickname, recipientCreatorProfileImageUrl: recipientCreatorProfileImageUrl) case .giftBoxList: GiftBoxListView() diff --git a/SodaLive/Sources/V2/CreatorChannel/Components/CreatorChannelHeaderSection.swift b/SodaLive/Sources/V2/CreatorChannel/Components/CreatorChannelHeaderSection.swift index feac2690..257c3ec2 100644 --- a/SodaLive/Sources/V2/CreatorChannel/Components/CreatorChannelHeaderSection.swift +++ b/SodaLive/Sources/V2/CreatorChannel/Components/CreatorChannelHeaderSection.swift @@ -57,6 +57,7 @@ struct CreatorChannelHeaderSection: View { CreatorChannelTalkActionSection( recipientMemberId: creator.creatorId, recipientCreatorNickname: creator.nickname, + recipientCreatorProfileImageUrl: creator.profileImageUrl, isAiChatAvailable: creator.isAiChatAvailable, isDmAvailable: creator.isDmAvailable, isOwnCreatorChannel: isOwnCreatorChannel, diff --git a/SodaLive/Sources/V2/CreatorChannel/Home/Components/CreatorChannelTalkActionSection.swift b/SodaLive/Sources/V2/CreatorChannel/Home/Components/CreatorChannelTalkActionSection.swift index 34dda731..9560b679 100644 --- a/SodaLive/Sources/V2/CreatorChannel/Home/Components/CreatorChannelTalkActionSection.swift +++ b/SodaLive/Sources/V2/CreatorChannel/Home/Components/CreatorChannelTalkActionSection.swift @@ -3,6 +3,7 @@ import SwiftUI struct CreatorChannelTalkActionSection: View { let recipientMemberId: Int let recipientCreatorNickname: String + let recipientCreatorProfileImageUrl: String let isAiChatAvailable: Bool let isDmAvailable: Bool let isOwnCreatorChannel: Bool @@ -66,16 +67,24 @@ struct CreatorChannelTalkActionSection: View { Button(action: { AppState.shared.setAppStep(step: .giftSend( recipientMemberId: recipientMemberId, - recipientCreatorNickname: recipientCreatorNickname + recipientCreatorNickname: recipientCreatorNickname, + recipientCreatorProfileImageUrl: recipientCreatorProfileImageUrl )) }) { - Text(I18n.CreatorChannelHome.sendGift) - .appFont(size: 16, weight: .medium) - .foregroundColor(.white) + HStack(spacing: SodaSpacing.s6) { + Image("ic_send_gift") + .resizable() + .frame(width: 20, height: 20) + .accessibilityHidden(true) + + Text(I18n.CreatorChannelHome.sendGift) + .appFont(size: 16, weight: .medium) + .foregroundColor(.black) + } .padding(SodaSpacing.s12) - .overlay( + .background( Capsule() - .stroke(Color.white.opacity(0.3), lineWidth: 1) + .fill(Color.white) ) } .buttonStyle(.plain) diff --git a/SodaLive/Sources/V2/Gift/Components/GiftAgreementRow.swift b/SodaLive/Sources/V2/Gift/Components/GiftAgreementRow.swift index b5de7d63..f66cf238 100644 --- a/SodaLive/Sources/V2/Gift/Components/GiftAgreementRow.swift +++ b/SodaLive/Sources/V2/Gift/Components/GiftAgreementRow.swift @@ -3,6 +3,7 @@ import SwiftUI struct GiftAgreementRow: View { let title: String @Binding var isAgreed: Bool + var policyURL: URL? = GiftConstants.termsURL var body: some View { HStack(alignment: .top, spacing: SodaSpacing.s4) { @@ -25,7 +26,7 @@ struct GiftAgreementRow: View { .buttonStyle(.plain) .accessibilityValue(isAgreed ? "동의함" : "동의 안 함") - if let url = GiftConstants.termsURL { + if let url = policyURL { Link(destination: url) { Text("(필수)") .underline() diff --git a/SodaLive/Sources/V2/Gift/Constants/GiftConstants.swift b/SodaLive/Sources/V2/Gift/Constants/GiftConstants.swift index 3f6c0e12..c541a4bf 100644 --- a/SodaLive/Sources/V2/Gift/Constants/GiftConstants.swift +++ b/SodaLive/Sources/V2/Gift/Constants/GiftConstants.swift @@ -2,6 +2,7 @@ import Foundation enum GiftConstants { static let termsURL = URL(string: "https://app.notion.com/p/3eb27b359673802b937ae741ab7b4ad3") + static let damagePolicyURL = URL(string: "https://app.notion.com/p/3f227b3596738018ba5dea2e5205293b") static let addressSearchAvailable = false static let inquiryStep: AppStep = .serviceCenter static let feedbackStep: AppStep = .serviceCenter diff --git a/SodaLive/Sources/V2/Gift/Send/GiftSendView.swift b/SodaLive/Sources/V2/Gift/Send/GiftSendView.swift index b658f599..e0582be6 100644 --- a/SodaLive/Sources/V2/Gift/Send/GiftSendView.swift +++ b/SodaLive/Sources/V2/Gift/Send/GiftSendView.swift @@ -1,10 +1,12 @@ import SwiftUI struct GiftSendView: View { + private let recipientCreatorProfileImageUrl: String @StateObject private var viewModel: GiftSendViewModel @State private var hasAppeared = false - init(recipientMemberId: Int, recipientCreatorNickname: String) { + init(recipientMemberId: Int, recipientCreatorNickname: String, recipientCreatorProfileImageUrl: String) { + self.recipientCreatorProfileImageUrl = recipientCreatorProfileImageUrl _viewModel = StateObject(wrappedValue: GiftSendViewModel( recipientMemberId: recipientMemberId, recipientCreatorNickname: recipientCreatorNickname, @@ -12,7 +14,7 @@ struct GiftSendView: View { AppState.shared.replaceCurrentAppStep( step: .giftDetail(applicationNo: applicationNo), ifCurrentStep: { step in - guard case .giftSend(let memberId, let nickname) = step else { return false } + guard case .giftSend(let memberId, let nickname, _) = step else { return false } return memberId == recipientMemberId && nickname == recipientCreatorNickname } ) @@ -77,7 +79,8 @@ struct GiftSendView: View { VStack(alignment: .leading, spacing: SodaSpacing.s14) { sectionTitle("받는 크리에이터") HStack(spacing: SodaSpacing.s12) { - Circle().fill(Color.gray800).frame(width: 38, height: 38) + DownsampledKFImage(url: URL(string: recipientCreatorProfileImageUrl), size: CGSize(width: 38, height: 38)) + .clipShape(Circle()) .accessibilityHidden(true) Text(viewModel.recipientCreatorNickname) .appFont(size: 16, weight: .bold) @@ -155,7 +158,8 @@ struct GiftSendView: View { } .disabled(viewModel.categories.isEmpty) if viewModel.selectedCategory?.requiresDamageWaiver == true { - GiftAgreementRow(title: "파손 및 분실 면책 사항에 동의합니다.", isAgreed: $viewModel.damageWaiverAgreed) + GiftAgreementRow(title: "파손 및 분실 면책 사항에 동의합니다.", isAgreed: $viewModel.damageWaiverAgreed, + policyURL: GiftConstants.damagePolicyURL) } } } diff --git a/work/tests/check-gift-phase11.sh b/work/tests/check-gift-phase11.sh new file mode 100644 index 00000000..baa79574 --- /dev/null +++ b/work/tests/check-gift-phase11.sh @@ -0,0 +1,68 @@ +#!/usr/bin/env bash +set -euo pipefail + +root="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +ruby - "$root" "${1:-all}" <<'RUBY' +root, mode = ARGV +abort 'Usage: check-gift-phase11.sh [profile|policy|button]' unless %w[all profile policy button].include?(mode) +source_root = File.join(root, 'SodaLive/Sources') +read = lambda do |path| + File.read(File.join(source_root, path)) + .gsub(%r{"(?:\\.|[^"\\])*"|//[^\n]*|/\*.*?\*/}m) { |token| token.start_with?('"') ? token : '' } + .gsub(/\s+/, ' ').gsub(/\s*([(),:])\s*/, '\1').strip +end +check = lambda do |condition, message| + abort "RED gift-phase11 #{mode}: #{message}" unless condition +end +send_view = read.call('V2/Gift/Send/GiftSendView.swift') + +if %w[all profile].include?(mode) + mode = 'profile' + step = read.call('App/AppStep.swift') + header = read.call('V2/CreatorChannel/Components/CreatorChannelHeaderSection.swift') + action = read.call('V2/CreatorChannel/Home/Components/CreatorChannelTalkActionSection.swift') + content = read.call('ContentView.swift') + check.call(step.include?('case giftSend(recipientMemberId:Int,recipientCreatorNickname:String,recipientCreatorProfileImageUrl:String)'), 'AppStep.giftSend must carry recipientCreatorProfileImageUrl') + check.call(header.match?(/CreatorChannelTalkActionSection\(recipientMemberId:creator.creatorId,recipientCreatorNickname:creator.nickname,recipientCreatorProfileImageUrl:creator.profileImageUrl,/), 'header must forward creator.profileImageUrl') + check.call(action.include?('let recipientCreatorProfileImageUrl:String') && action.include?('.giftSend(recipientMemberId:recipientMemberId,recipientCreatorNickname:recipientCreatorNickname,recipientCreatorProfileImageUrl:recipientCreatorProfileImageUrl)'), 'action must forward profile URL to AppStep') + check.call(content.include?('case .giftSend(let recipientMemberId,let recipientCreatorNickname,let recipientCreatorProfileImageUrl):') && content.include?('GiftSendView(recipientMemberId:recipientMemberId,recipientCreatorNickname:recipientCreatorNickname,recipientCreatorProfileImageUrl:recipientCreatorProfileImageUrl)'), 'ContentView must forward profile URL to GiftSendView') + check.call(send_view.include?('let recipientCreatorProfileImageUrl:String') && send_view.include?('init(recipientMemberId:Int,recipientCreatorNickname:String,recipientCreatorProfileImageUrl:String)') && send_view.include?('self.recipientCreatorProfileImageUrl = recipientCreatorProfileImageUrl'), 'GiftSendView must retain display-only profile URL') + check.call(send_view.include?('DownsampledKFImage(url:URL(string:recipientCreatorProfileImageUrl),size:CGSize(width:38,height:38)).clipShape(Circle()).accessibilityHidden(true)'), 'recipient must show circular accessible-hidden 38pt profile image') + check.call(send_view.include?('guard case .giftSend(let memberId,let nickname,_)= step else { return false } return memberId == recipientMemberId && nickname == recipientCreatorNickname'), 'replacement guard must compare memberId/nickname and ignore only image') + model_init = send_view.split('GiftSendViewModel(', 2).last.split('onCreated:', 2).first + check.call(!model_init.include?('recipientCreatorProfileImageUrl'), 'profile URL must not enter ViewModel initializer') + Dir.glob(File.join(source_root, '**/*{ViewModel,Api,Repository,Request}*.swift')).each do |path| + check.call(!File.read(path).include?('recipientCreatorProfileImageUrl'), 'profile URL must remain outside ViewModel/API/request layers') + end + puts 'PASS gift-phase11 profile: creator image routing, 38pt display, guarded replacement, display-only boundary' +end + +if %w[all policy].include?(ARGV[1]) + mode = 'policy' + constants = read.call('V2/Gift/Constants/GiftConstants.swift') + agreement = read.call('V2/Gift/Components/GiftAgreementRow.swift') + check.call(constants.include?('static let damagePolicyURL = URL(string:"https://app.notion.com/p/3f227b3596738018ba5dea2e5205293b")'), 'damagePolicyURL must use the approved damage-waiver URL') + check.call(constants.include?('static let termsURL = URL(string:"https://app.notion.com/p/3eb27b359673802b937ae741ab7b4ad3")'), 'existing terms URL must remain unchanged') + check.call(agreement.include?('var policyURL:URL? = GiftConstants.termsURL') && agreement.include?('if let url = policyURL { Link(destination:url)'), 'agreement must default to termsURL and link its policyURL') + rows = Dir.glob(File.join(source_root, 'V2/Gift/**/*.swift')).flat_map do |path| + read.call(path.delete_prefix(source_root + '/')).scan(/GiftAgreementRow\(([^)]*)\)/).flatten + end + damage_rows, other_rows = rows.partition { |row| row.include?('isAgreed:$viewModel.damageWaiverAgreed') } + check.call(damage_rows.length == 1 && damage_rows.first.end_with?('policyURL:GiftConstants.damagePolicyURL'), 'only damage waiver must select damagePolicyURL') + check.call(other_rows.length == 4 && other_rows.none? { |row| row.include?('policyURL:') }, 'existing sender/recipient agreements must keep default terms URL') + puts 'PASS gift-phase11 policy: isolated damage-waiver URL, unchanged default agreement links' +end + +if %w[all button].include?(ARGV[1]) + mode = 'button' + action = read.call('V2/CreatorChannel/Home/Components/CreatorChannelTalkActionSection.swift') + predicate = action[/private var shouldShowGiftButton:Bool \{[^}]*\}/] + check.call(predicate == 'private var shouldShowGiftButton:Bool { let normalizedCountryCode = UserDefaults .string(forKey:.countryCode).trimmingCharacters(in:.whitespacesAndNewlines).uppercased()let isKoreanCountry = normalizedCountryCode.isEmpty || normalizedCountryCode == "KR" return isOwnCreatorChannel == false && isDmAvailable && isKoreanCountry && LanguageHeaderProvider.current == "ko" }', 'gift button visibility predicate must remain unchanged') + button = action[/if shouldShowGiftButton \{ (.*?) Spacer\(\)/, 1] + check.call(button && button.start_with?('Button(action:{ AppState.shared.setAppStep(step:.giftSend(recipientMemberId:recipientMemberId,recipientCreatorNickname:recipientCreatorNickname,recipientCreatorProfileImageUrl:recipientCreatorProfileImageUrl))}){ '), 'gift button action must preserve all three recipient values') + label = button.split('}){ ', 2).last + check.call(label.start_with?('HStack(spacing:SodaSpacing.s6){ Image("ic_send_gift").resizable().frame(width:20,height:20).accessibilityHidden(true)Text(I18n.CreatorChannelHome.sendGift)'), 'gift button must place the accessible-hidden 20pt icon before text in an s6 HStack') + check.call(label == 'HStack(spacing:SodaSpacing.s6){ Image("ic_send_gift").resizable().frame(width:20,height:20).accessibilityHidden(true)Text(I18n.CreatorChannelHome.sendGift).appFont(size:16,weight:.medium).foregroundColor(.black)} .padding(SodaSpacing.s12).background(Capsule().fill(Color.white))} .buttonStyle(.plain)}', 'gift button must preserve font/padding and use black text on a white Capsule') + puts 'PASS gift-phase11 button: icon-before-text, 20pt hidden icon, black text, white Capsule, unchanged visibility/action' +end +RUBY