feat(gift): 수령지 입력을 추가한다
This commit is contained in:
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
Reference in New Issue
Block a user