From b0336902e67c5aeb523cf97789e719546092b0b3 Mon Sep 17 00:00:00 2001 From: Yu Sung Date: Thu, 8 Oct 2026 12:32:59 +0900 Subject: [PATCH] =?UTF-8?q?feat(gift):=20=EC=88=98=EB=A0=B9=EC=A7=80=20?= =?UTF-8?q?=EC=9E=85=EB=A0=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 --- .../GiftRecipientAddressFormView.swift | 112 +++++++++ .../GiftRecipientAddressViewModel.swift | 98 ++++++++ work/tests/GiftRecipientAddressCheck.swift | 220 ++++++++++++++++++ work/tests/check-gift-recipient-address.sh | 15 ++ 4 files changed, 445 insertions(+) create mode 100644 SodaLive/Sources/V2/Gift/RecipientAddress/GiftRecipientAddressFormView.swift create mode 100644 SodaLive/Sources/V2/Gift/RecipientAddress/GiftRecipientAddressViewModel.swift create mode 100644 work/tests/GiftRecipientAddressCheck.swift create mode 100644 work/tests/check-gift-recipient-address.sh diff --git a/SodaLive/Sources/V2/Gift/RecipientAddress/GiftRecipientAddressFormView.swift b/SodaLive/Sources/V2/Gift/RecipientAddress/GiftRecipientAddressFormView.swift new file mode 100644 index 00000000..d016d9ea --- /dev/null +++ b/SodaLive/Sources/V2/Gift/RecipientAddress/GiftRecipientAddressFormView.swift @@ -0,0 +1,112 @@ +import SwiftUI + +struct GiftRecipientAddressFormView: View { + let model: ReceivedGiftDetailDisplayModel + @ObservedObject var viewModel: GiftRecipientAddressViewModel + + var body: some View { + ScrollView { + VStack(alignment: .leading, spacing: SodaSpacing.s32) { + privacyBanner + GiftInfoSectionView(title: "전달 예정 선물") { + if let nickname = model.senderNickname { + GiftInfoRow(title: "보낸 사람", value: nickname) + } + GiftInfoRow(title: "선물 사이즈", value: model.sizeName) + GiftInfoRow(title: "카테고리", value: model.categoryName) + } + addressSection + agreementsSection + } + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.horizontal, SodaSpacing.s14) + .padding(.top, SodaSpacing.s24) + .padding(.bottom, SodaSpacing.s32) + } + .scrollDismissesKeyboard(.interactively) + .safeAreaInset(edge: .bottom, spacing: 0) { + Button { + viewModel.submit() + } label: { + SelectedButtonView(title: "배송지 입력 완료", isActive: viewModel.canSubmit, isSelected: false) + } + .disabled(!viewModel.canSubmit) + .padding(SodaSpacing.s14) + .background(Color.black) + } + .disabled(viewModel.isSubmitting || viewModel.hasSubmitted) + } + + private var privacyBanner: some View { + Text("필요 정보 외의 용도로 사용하지 않으며, 전달이 완료되면 파기됩니다") + .appFont(.caption3) + .foregroundColor(.gray500) + .fixedSize(horizontal: false, vertical: true) + .padding(SodaSpacing.s14) + .frame(maxWidth: .infinity, alignment: .leading) + .background(Color.gray900) + .clipShape(RoundedRectangle(cornerRadius: SodaRadius.r14)) + } + + private var addressSection: some View { + VStack(alignment: .leading, spacing: SodaSpacing.s20) { + VStack(alignment: .leading, spacing: SodaSpacing.s4) { + Text("배송지 입력") + .appFont(.heading3) + .foregroundColor(.white) + .accessibilityAddTraits(.isHeader) + Text("제출 후 수정할 수 없으니 입력한 정보를 다시 한번 확인해 주세요") + .appFont(.caption3) + .foregroundColor(.gray500) + .fixedSize(horizontal: false, vertical: true) + } + field("이름", helper: "입력한 이름으로 선물이 전달돼요") { + GiftContactTextField(text: $viewModel.recipientName, rule: .name) + } + field("휴대폰 번호", helper: "문제 발생 시 연락이 가능한 번호를 입력해 주세요") { + GiftContactTextField(text: $viewModel.recipientPhoneNumber, rule: .phoneNumber) + } + SodaAddressInputView(zipCode: $viewModel.recipientZipCode, address: $viewModel.recipientAddress, + addressDetail: $viewModel.recipientAddressDetail) + } + .frame(maxWidth: .infinity, alignment: .leading) + } + + private var agreementsSection: some View { + VStack(alignment: .leading, spacing: SodaSpacing.s16) { + Text("이용 약관 동의") + .appFont(.heading3) + .foregroundColor(.white) + .accessibilityAddTraits(.isHeader) + GiftAgreementRow(title: "소다라이브 크리에이터 선물 받기 이용약관에 동의합니다.", + isAgreed: $viewModel.recipientTermsAgreed) + GiftAgreementRow(title: "선물 받기를 위한 개인정보 수집·이용에 동의합니다.", + isAgreed: $viewModel.recipientPrivacyAgreed) + } + .frame(maxWidth: .infinity, alignment: .leading) + } + + private func field( + _ title: String, + helper: String, + @ViewBuilder content: () -> Content + ) -> some View { + VStack(alignment: .leading, spacing: SodaSpacing.s4) { + Text(title) + .appFont(.body5) + .foregroundColor(.grayee) + content() + .appFont(.body3) + .foregroundColor(.white) + .padding(SodaSpacing.s14) + .background(Color.gray900) + .cornerRadius(SodaRadius.r14) + .accessibilityLabel(title) + Text(helper) + .appFont(.caption3) + .foregroundColor(.gray500) + .fixedSize(horizontal: false, vertical: true) + } + .frame(maxWidth: .infinity, alignment: .leading) + } +} diff --git a/SodaLive/Sources/V2/Gift/RecipientAddress/GiftRecipientAddressViewModel.swift b/SodaLive/Sources/V2/Gift/RecipientAddress/GiftRecipientAddressViewModel.swift new file mode 100644 index 00000000..d7c7622f --- /dev/null +++ b/SodaLive/Sources/V2/Gift/RecipientAddress/GiftRecipientAddressViewModel.swift @@ -0,0 +1,98 @@ +import Combine +import Foundation + +final class GiftRecipientAddressViewModel: ObservableObject { + @Published var recipientName: String = "" + @Published var recipientPhoneNumber: String = "" + @Published var recipientZipCode: String = "" + @Published var recipientAddress: String = "" + @Published var recipientAddressDetail: String = "" + @Published var recipientTermsAgreed: Bool = false + @Published var recipientPrivacyAgreed: Bool = false + + @Published private(set) var isSubmitting = false + @Published var errorMessage = "" + @Published var isShowPopup = false + @Published var isShowSuccessToast = false + let successMessage = "배송지 입력 완료" + + private let applicationNo: String + private let onSubmitted: () -> Void + private let repository = GiftRepository() + private var mutationSubscription: AnyCancellable? + @Published private(set) var hasSubmitted = false + private var hasNotifiedSubmission = false + + init(applicationNo: String, onSubmitted: @escaping () -> Void) { + self.applicationNo = applicationNo + self.onSubmitted = onSubmitted + } + + var canSubmit: Bool { + !isSubmitting && !hasSubmitted && recipientTermsAgreed && recipientPrivacyAgreed + && GiftContactInputRule.name.acceptsSubmission(recipientName) + && GiftContactInputRule.phoneNumber.acceptsSubmission(recipientPhoneNumber) + && [recipientZipCode, recipientAddress].allSatisfy { + !$0.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + } + } + + func submit() { + guard canSubmit else { return } + + isSubmitting = true + errorMessage = "" + isShowPopup = false + + let request = SubmitGiftRecipientAddressRequest( + recipientName: recipientName.trimmingCharacters(in: .whitespacesAndNewlines), + recipientPhoneNumber: recipientPhoneNumber.trimmingCharacters(in: .whitespacesAndNewlines), + recipientZipCode: recipientZipCode.trimmingCharacters(in: .whitespacesAndNewlines), + recipientAddress: recipientAddress.trimmingCharacters(in: .whitespacesAndNewlines), + recipientAddressDetail: recipientAddressDetail.trimmingCharacters(in: .whitespacesAndNewlines), + recipientTermsAgreed: recipientTermsAgreed, + recipientPrivacyAgreed: recipientPrivacyAgreed + ) + mutationSubscription = repository.submitRecipientAddress(applicationNo: applicationNo, request: request) + .receive(on: DispatchQueue.main) + .sink { [weak self] result in + guard let self, !self.hasSubmitted else { return } + if case .failure(let error) = result { + self.finishFailure(data: error.response?.data) + } + } receiveValue: { [weak self] response in + guard let self, !self.hasSubmitted else { return } + do { + let decoded = try JSONDecoder().decode(ApiResponseWithoutData.self, from: response.data) + guard decoded.success else { + self.finishFailure(data: response.data) + return + } + self.hasSubmitted = true + self.isSubmitting = false + self.isShowSuccessToast = true + } catch { + self.finishFailure(data: response.data) + } + } + } + + func successToastDidDismiss() { + guard hasSubmitted, !isShowSuccessToast, !hasNotifiedSubmission else { return } + + hasNotifiedSubmission = true + onSubmitted() + } + + private func finishFailure(data: Data?) { + struct ErrorResponse: Decodable { + let message: String? + } + + let decoded = data.flatMap { try? JSONDecoder().decode(ErrorResponse.self, from: $0) } + let message = decoded?.message?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + isSubmitting = false + errorMessage = message.isEmpty ? I18n.Common.commonError : message + isShowPopup = true + } +} diff --git a/work/tests/GiftRecipientAddressCheck.swift b/work/tests/GiftRecipientAddressCheck.swift new file mode 100644 index 00000000..360c723f --- /dev/null +++ b/work/tests/GiftRecipientAddressCheck.swift @@ -0,0 +1,220 @@ +import Combine +import Foundation + +struct Response { + let data: Data +} + +struct MoyaError: Error { + let response: Response? +} + +enum I18n { + enum Common { + static let commonError = "common error" + } +} + +final class GiftRepository { + static var requests: [(String, SubmitGiftRecipientAddressRequest)] = [] + static var pending: [PassthroughSubject] = [] + + func submitRecipientAddress( + applicationNo: String, + request: SubmitGiftRecipientAddressRequest + ) -> AnyPublisher { + Self.requests.append((applicationNo, request)) + let subject = PassthroughSubject() + Self.pending.append(subject) + return subject.eraseToAnyPublisher() + } +} + +@main +enum GiftRecipientAddressCheck { + static var assertions = 0 + static let fields: [ReferenceWritableKeyPath] = [ + \.recipientName, \.recipientPhoneNumber, \.recipientZipCode, \.recipientAddress, + \.recipientAddressDetail + ] + static let raw = ["Name", "01012345678", " zip \n", " Address \t", " Detail \n"] + + static func check(_ condition: Bool, _ message: String) { + guard condition else { + print("FAIL: \(message)") + exit(1) + } + assertions += 1 + } + + static func valid(_ callback: @escaping () -> Void = {}) -> GiftRecipientAddressViewModel { + GiftRepository.requests = [] + GiftRepository.pending = [] + let model = GiftRecipientAddressViewModel(applicationNo: " application/007 ", onSubmitted: callback) + for (field, value) in zip(fields, raw) { model[keyPath: field] = value } + model.recipientTermsAgreed = true + model.recipientPrivacyAgreed = true + return model + } + + static func retained(_ model: GiftRecipientAddressViewModel) { + for (field, value) in zip(fields, raw) { + check(model[keyPath: field] == value, "raw field preserved: \(field)") + } + check(model.recipientTermsAgreed, "terms retained") + check(model.recipientPrivacyAgreed, "privacy retained") + } + + static func drain() { + RunLoop.main.run(until: Date().addingTimeInterval(0.02)) + } + + static func response(_ json: String) -> Response { + Response(data: Data(json.utf8)) + } + + static func succeed() { + GiftRepository.pending.last!.send(response("{\"success\":true}")) + GiftRepository.pending.last!.send(completion: .finished) + drain() + } + + static func main() { + let initial = GiftRecipientAddressViewModel(applicationNo: "initial", onSubmitted: {}) + check(!initial.canSubmit, "empty form invalid") + check(!initial.isSubmitting && !initial.isShowPopup && !initial.isShowSuccessToast, "initial flags") + check(initial.errorMessage.isEmpty, "initial error empty") + check(!initial.hasSubmitted, "initial submission state false") + initial.successToastDidDismiss() + for name in ["Name1", "", "ABCDEFGHIJKLM"] { + let model = valid() + model.recipientName = name + check(!model.canSubmit, "invalid name rejected: \(name)") + } + for phone in ["phone", "", "0", "0101234567", "010123456789", "01112345678", "010-1234-5678", "01012345678"] { + let model = valid() + model.recipientPhoneNumber = phone + check(!model.canSubmit, "invalid phone rejected: \(phone)") + } + for name in ["가", "홍길동", "ABCDEFGHIJKL"] { + for phone in ["01012345678"] { + let model = valid() + model.recipientName = name + model.recipientPhoneNumber = phone + check(model.canSubmit, "valid contact boundaries accepted: \(name), \(phone)") + } + } + for field in fields.prefix(4) { + for blank in ["", " \t\n"] { + let model = valid() + model[keyPath: field] = blank + check(!model.canSubmit, "blank required field invalid") + model.submit() + check(GiftRepository.requests.isEmpty, "invalid field sends no POST") + } + } + for agreement in [\GiftRecipientAddressViewModel.recipientTermsAgreed, \.recipientPrivacyAgreed] { + let model = valid() + model[keyPath: agreement] = false + check(!model.canSubmit, "agreement required") + model.submit() + check(GiftRepository.requests.isEmpty, "missing agreement sends no POST") + } + for detail in ["", " \t\n"] { + let model = valid() + model.recipientAddressDetail = detail + check(model.canSubmit, "optional detail accepts blank") + model.submit() + check(GiftRepository.requests.last?.1.recipientAddressDetail == "", "blank detail mapped empty") + check(model.recipientAddressDetail == detail, "blank detail raw preserved") + } + + var callbacks = 0 + var successModel: GiftRecipientAddressViewModel! + successModel = valid { + callbacks += 1 + if callbacks == 1 { successModel.successToastDidDismiss() } + } + check(successModel.canSubmit, "valid contact and arbitrary zip accepted") + var submittedStates: [Bool] = [] + let submissionObservation = successModel.$hasSubmitted.sink { submittedStates.append($0) } + defer { submissionObservation.cancel() } + check(submittedStates == [false], "observable initial submission state") + successModel.successToastDidDismiss() + check(callbacks == 0, "dismiss before success ignored") + successModel.submit() + check(successModel.isSubmitting && !successModel.canSubmit, "synchronous submit lock") + successModel.submit() + check(GiftRepository.requests.count == 1, "double tap one POST") + let (applicationNo, request) = GiftRepository.requests[0] + check(applicationNo == " application/007 ", "exact route applicationNo") + check(request.recipientName == "Name", "map valid name") + check(request.recipientPhoneNumber == "01012345678", "map valid phone") + check(request.recipientZipCode == "zip", "trim zip without format validation") + check(request.recipientAddress == "Address", "trim address") + check(request.recipientAddressDetail == "Detail", "trim detail") + check(request.recipientTermsAgreed && request.recipientPrivacyAgreed, "map agreements") + retained(successModel) + succeed() + check(successModel.hasSubmitted, "successful POST marks submitted immediately") + check(submittedStates == [false, true], "successful submission published") + check(successModel.isShowSuccessToast, "success shows toast") + check(successModel.successMessage == "배송지 입력 완료", "success copy") + check(callbacks == 0, "success does not callback before dismissal") + successModel.successToastDidDismiss() + check(callbacks == 0, "visible toast blocks dismissal callback") + successModel.submit() + check(GiftRepository.requests.count == 1 && !successModel.canSubmit, "success blocks POST") + successModel.isShowSuccessToast = false + successModel.successToastDidDismiss() + successModel.successToastDidDismiss() + check(callbacks == 1, "callback exactly once including reentrant dismissal") + check(successModel.hasSubmitted, "submitted remains true after dismissal") + successModel.submit() + check(GiftRepository.requests.count == 1 && !successModel.canSubmit, "permanent success lock") + retained(successModel) + + let failures: [(Bool, String?, String)] = [ + (true, nil, I18n.Common.commonError), + (true, "{\"message\":\" server transport \"}", "server transport"), + (true, "{\"message\":\" \\n\"}", I18n.Common.commonError), + (false, "{\"success\":false,\"message\":\" server rejected \"}", "server rejected"), + (false, "{\"success\":false,\"message\":\" \\t\"}", I18n.Common.commonError), + (false, "{\"success\":false}", I18n.Common.commonError), + (false, "malformed JSON", I18n.Common.commonError), + (false, "{\"message\":\" decode rejected \"}", "decode rejected") + ] + for (transport, json, expected) in failures { + var failureCallbacks = 0 + let model = valid { failureCallbacks += 1 } + model.submit() + if transport { + GiftRepository.pending[0].send(completion: .failure(MoyaError(response: json.map(response)))) + } else { + GiftRepository.pending[0].send(response(json!)) + GiftRepository.pending[0].send(completion: .finished) + } + drain() + check(!model.hasSubmitted, "every failure leaves submission state false") + check(!model.isSubmitting && model.canSubmit, "failure unlocks") + check(model.isShowPopup && model.errorMessage == expected, "server or fallback error") + check(!model.isShowSuccessToast && failureCallbacks == 0, "failure never succeeds") + model.successToastDidDismiss() + check(failureCallbacks == 0, "failure dismissal ignored") + retained(model) + model.submit() + check(GiftRepository.requests.count == 2, "retry allowed") + check(model.errorMessage.isEmpty && !model.isShowPopup, "retry clears stale error") + succeed() + check(model.hasSubmitted, "retry success marks submitted immediately") + check(model.isShowSuccessToast && failureCallbacks == 0, "retry success waits for toast") + model.isShowSuccessToast = false + model.successToastDidDismiss() + model.successToastDidDismiss() + check(failureCallbacks == 1, "retry callback once") + check(model.hasSubmitted, "retry submitted remains true after dismissal") + retained(model) + } + print("PASS: \(assertions) assertions") + } +} diff --git a/work/tests/check-gift-recipient-address.sh b/work/tests/check-gift-recipient-address.sh new file mode 100644 index 00000000..9d7f21dd --- /dev/null +++ b/work/tests/check-gift-recipient-address.sh @@ -0,0 +1,15 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +TEMP_DIR="$(mktemp -d)" +trap 'rm -rf "$TEMP_DIR"' EXIT + +swiftc -module-cache-path "$TEMP_DIR/module-cache" \ + "$ROOT/SodaLive/Sources/Common/ApiResponseWithoutData.swift" \ + "$ROOT/SodaLive/Sources/V2/Gift/Models/GiftModels.swift" \ + "$ROOT/SodaLive/Sources/V2/Gift/Components/GiftContactInputRule.swift" \ + "$ROOT/SodaLive/Sources/V2/Gift/RecipientAddress/GiftRecipientAddressViewModel.swift" \ + "$ROOT/work/tests/GiftRecipientAddressCheck.swift" \ + -o "$TEMP_DIR/check-gift-recipient-address" +"$TEMP_DIR/check-gift-recipient-address"