feat(gift): 수령지 입력을 추가한다

This commit is contained in:
Yu Sung
2026-10-08 12:32:59 +09:00
parent 52f96f29ca
commit b0336902e6
4 changed files with 445 additions and 0 deletions
@@ -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<Content: View>(
_ 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)
}
}
@@ -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
}
}
+220
View File
@@ -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<Response, MoyaError>] = []
func submitRecipientAddress(
applicationNo: String,
request: SubmitGiftRecipientAddressRequest
) -> AnyPublisher<Response, MoyaError> {
Self.requests.append((applicationNo, request))
let subject = PassthroughSubject<Response, MoyaError>()
Self.pending.append(subject)
return subject.eraseToAnyPublisher()
}
}
@main
enum GiftRecipientAddressCheck {
static var assertions = 0
static let fields: [ReferenceWritableKeyPath<GiftRecipientAddressViewModel, String>] = [
\.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")
}
}
@@ -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"