feat(gift): 운송장 등록을 추가한다

This commit is contained in:
Yu Sung
2026-10-08 12:32:58 +09:00
parent 0751971bf3
commit 74bc1679b7
4 changed files with 967 additions and 0 deletions
@@ -0,0 +1,37 @@
import SwiftUI
struct GiftCourierPickerView: View {
@Binding var selectedCourier: String?
var body: some View {
VStack(alignment: .leading, spacing: SodaSpacing.s12) {
Text("택배사")
.appFont(.body5)
.foregroundColor(.white)
Menu {
ForEach(GiftConstants.courierCompanyNames, id: \.self) { courier in
Button(courier) {
selectedCourier = courier
}
}
} label: {
HStack(spacing: SodaSpacing.s12) {
Text(selectedCourier ?? "택배사 선택")
.fixedSize(horizontal: false, vertical: true)
.frame(maxWidth: .infinity, alignment: .leading)
Image(systemName: "chevron.down")
.accessibilityHidden(true)
}
.appFont(.body3)
.foregroundColor(selectedCourier == nil ? .gray500 : .white)
.padding(SodaSpacing.s14)
.frame(maxWidth: .infinity)
.background(Color.gray900)
.clipShape(RoundedRectangle(cornerRadius: SodaRadius.r14))
}
.accessibilityLabel("택배사")
.accessibilityValue(selectedCourier ?? "택배사 선택")
}
.frame(maxWidth: .infinity, alignment: .leading)
}
}
@@ -0,0 +1,293 @@
import SwiftUI
struct GiftTrackingRegistrationView: View {
@StateObject private var viewModel: GiftTrackingRegistrationViewModel
@State private var hasAppeared = false
@State private var isActive = false
@State private var isShowingCancelDialog = false
@FocusState private var isTrackingNumberFocused: Bool
init(applicationNo: String) {
_viewModel = StateObject(wrappedValue: GiftTrackingRegistrationViewModel(applicationNo: applicationNo, onUnavailable: { unavailableApplicationNo in
AppState.shared.replaceCurrentAppStep(
step: .giftDetail(applicationNo: unavailableApplicationNo),
ifCurrentStep: { step in
guard case .giftTrackingRegistration(let currentApplicationNo) = step else { return false }
return currentApplicationNo == unavailableApplicationNo
}
)
}, onMutationCompleted: { completedApplicationNo, completion in
AppState.shared.replaceCurrentAppStep(
step: .giftDetail(applicationNo: completedApplicationNo),
ifCurrentStep: { step in
guard case .giftTrackingRegistration(let currentApplicationNo) = step else { return false }
return currentApplicationNo == completedApplicationNo
},
completion: completion
)
}))
}
var body: some View {
VStack(spacing: 0) {
DetailNavigationBar(title: "운송장 등록")
.disabled(viewModel.isSubmitting)
BaseView(isLoading: .constant(viewModel.isLoading || viewModel.isSubmitting)) {
if let summary = viewModel.summary, let mailbox = viewModel.mailbox {
ScrollView {
LazyVStack(alignment: .leading, spacing: SodaSpacing.s24) {
summaryCard(summary)
VStack(alignment: .leading, spacing: SodaSpacing.s12) {
sectionHeader("보내실 주소", helper: "아래 주소로 발송한 뒤 운송장 번호를 등록해 주세요")
VStack(alignment: .leading, spacing: SodaSpacing.s14) {
GiftInfoRow(title: "사서함", value: mailbox.name)
GiftInfoRow(title: "주소", value: mailbox.address)
GiftInfoRow(title: "연락처", value: mailbox.phoneNumber)
}
.padding(SodaSpacing.s14)
.frame(maxWidth: .infinity, alignment: .leading)
.background(Color.gray900)
.clipShape(RoundedRectangle(cornerRadius: SodaRadius.r14))
}
sectionDivider
trackingSection
sectionDivider
guidanceSection
}
.padding(.horizontal, SodaSpacing.s14)
.padding(.top, SodaSpacing.s24)
.padding(.bottom, SodaSpacing.s32)
}
.scrollDismissesKeyboard(.interactively)
.safeAreaInset(edge: .bottom, spacing: 0) {
actionBar
}
} else if !viewModel.isLoading && !viewModel.errorMessage.isEmpty {
ScrollView {
VStack(spacing: SodaSpacing.s16) {
Text(viewModel.errorMessage)
.appFont(.body3)
.foregroundColor(.gray500)
.multilineTextAlignment(.center)
.fixedSize(horizontal: false, vertical: true)
if !viewModel.shouldReturnToDetail {
Button(action: viewModel.fetch) {
Text("다시 시도")
.appFont(.body2)
.foregroundColor(.white)
.padding(.horizontal, SodaSpacing.s16)
.frame(minHeight: SodaSpacing.s48)
.background(Color.gray800)
.clipShape(Capsule())
}
.buttonStyle(.plain)
}
}
.padding(SodaSpacing.s24)
.frame(maxWidth: .infinity)
}
}
}
.disabled(viewModel.isSubmitting)
}
.background(Color.black.ignoresSafeArea())
.alert("신청 취소", isPresented: $isShowingCancelDialog) {
Button("취소하기", role: .destructive) {
viewModel.confirmCancel()
}
.disabled(viewModel.isSubmitting)
Button("나가기", role: .cancel) {
isShowingCancelDialog = false
}
} message: {
Text(cancelMessage)
}
.sodaToast(isPresented: $viewModel.isShowPopup, message: viewModel.errorMessage)
.sodaToast(isPresented: $viewModel.isShowSuccessToast, message: viewModel.successMessage)
.onChange(of: viewModel.isShowPopup) { isPresented in
if !isPresented && isActive {
viewModel.errorToastDidDismiss()
}
}
.onChange(of: viewModel.isShowSuccessToast) { isPresented in
if !isPresented && isActive {
viewModel.successToastDidDismiss()
}
}
.onAppear {
isActive = true
viewModel.errorToastDidDismiss()
viewModel.successToastDidDismiss()
guard !hasAppeared else { return }
hasAppeared = true
viewModel.fetch()
}
.onDisappear {
isActive = false
}
}
private func summaryCard(_ summary: SentGiftDetailDisplayModel) -> some View {
VStack(alignment: .leading, spacing: SodaSpacing.s12) {
Text(summary.applicationNo)
.appFont(.caption3)
.foregroundColor(.gray500)
HStack(alignment: .top, spacing: SodaSpacing.s12) {
Circle()
.fill(Color.gray800)
.frame(width: 42, height: 42)
.accessibilityHidden(true)
VStack(alignment: .leading, spacing: SodaSpacing.s6) {
if let nickname = summary.recipientCreatorNickname {
Text(nickname)
.appFont(size: 14, weight: .semibold)
.foregroundColor(.white)
}
Text([summary.sizeName, summary.categoryName, summary.applicationDateText.map { "\($0) 신청" }]
.compactMap { $0 }.joined(separator: " · "))
.appFont(.caption3)
.foregroundColor(.gray500)
}
.fixedSize(horizontal: false, vertical: true)
.frame(maxWidth: .infinity, alignment: .leading)
}
}
.padding(SodaSpacing.s14)
.frame(maxWidth: .infinity, alignment: .leading)
.background(Color.gray900)
.clipShape(RoundedRectangle(cornerRadius: SodaRadius.r14))
}
private func sectionHeader(_ title: String, helper: String) -> some View {
VStack(alignment: .leading, spacing: SodaSpacing.s8) {
Text(title)
.appFont(.body1)
.foregroundColor(.white)
.accessibilityAddTraits(.isHeader)
Text(helper)
.appFont(.caption2)
.foregroundColor(.gray500)
}
.fixedSize(horizontal: false, vertical: true)
.frame(maxWidth: .infinity, alignment: .leading)
}
private var trackingSection: some View {
VStack(alignment: .leading, spacing: SodaSpacing.s12) {
sectionHeader("운송장 정보", helper: "등록 완료 후 수정이 불가능하니 신중히 적어주세요")
GiftCourierPickerView(selectedCourier: $viewModel.selectedCourier)
VStack(alignment: .leading, spacing: SodaSpacing.s12) {
Text("운송장 번호")
.appFont(.body5)
.foregroundColor(.white)
TextField("운송장 번호를 입력해 주세요", text: Binding(
get: { viewModel.trackingNumber },
set: { value in
guard GiftTrackingRegistrationViewModel.acceptsTrackingEditing(value) else { return }
viewModel.trackingNumber = value
}
))
.appFont(.body3)
.foregroundColor(.white)
.textInputAutocapitalization(.never)
.autocorrectionDisabled()
.keyboardType(.numberPad)
.focused($isTrackingNumberFocused)
.submitLabel(.done)
.onSubmit { isTrackingNumberFocused = false }
.padding(SodaSpacing.s14)
.frame(maxWidth: .infinity)
.background(Color.gray900)
.clipShape(RoundedRectangle(cornerRadius: SodaRadius.r14))
.accessibilityLabel("운송장 번호")
}
}
}
private var sectionDivider: some View {
Color.gray900
.frame(height: 7)
.padding(.horizontal, -SodaSpacing.s14)
.accessibilityHidden(true)
}
private var guidanceSection: some View {
VStack(alignment: .leading, spacing: SodaSpacing.s14) {
Text("등록 안내")
.appFont(.body1)
.foregroundColor(.white)
.accessibilityAddTraits(.isHeader)
VStack(alignment: .leading, spacing: SodaSpacing.s14) {
ForEach(registrationGuidance, id: \.self) { guidance in
HStack(alignment: .top, spacing: SodaSpacing.s6) {
Text("•").accessibilityHidden(true)
Text(guidance)
.fixedSize(horizontal: false, vertical: true)
.frame(maxWidth: .infinity, alignment: .leading)
}
.appFont(.caption2)
.foregroundColor(.gray500)
}
}
.padding(SodaSpacing.s14)
.frame(maxWidth: .infinity, alignment: .leading)
.background(Color.gray900)
.clipShape(RoundedRectangle(cornerRadius: SodaRadius.r14))
}
}
private var registrationGuidance: [String] {
[
"신청일로부터 3일 이내에 운송장 번호를 등록해 주세요.",
"3일 이내 운송장 번호를 등록하지 않은 경우, 선물 보내기가 자동으로 취소되며 사용한 캔이 모두 환불됩니다.",
"선물은 반드시 배송비 선불로 발송해 주세요. 착불로 발송된 선물은 수취가 거부됩니다.",
"진행 상태는 신청 상세에서 확인할 수 있습니다.",
"검수를 통과한 선물만 크리에이터에게 재배송됩니다."
]
}
private var actionBar: some View {
HStack(spacing: SodaSpacing.s14) {
Button {
guard viewModel.canCancel else { return }
isTrackingNumberFocused = false
isShowingCancelDialog = true
} label: {
Text("신청 취소")
.appFont(.body2)
.foregroundColor(viewModel.canCancel ? .white : .gray500)
.padding(.vertical, SodaSpacing.s16)
.frame(maxWidth: .infinity, minHeight: SodaSpacing.s48 + SodaSpacing.s4)
.overlay(Capsule().stroke(Color.white.opacity(0.3), lineWidth: 1))
}
.disabled(!viewModel.canCancel)
Button {
isTrackingNumberFocused = false
viewModel.registerTracking()
} label: {
Text("등록 완료")
.appFont(.body2)
.foregroundColor(viewModel.canSubmit ? .white : .gray500)
.padding(.vertical, SodaSpacing.s16)
.frame(maxWidth: .infinity, minHeight: SodaSpacing.s48 + SodaSpacing.s4)
.background(viewModel.canSubmit ? Color.button : Color.gray900)
.clipShape(Capsule())
}
.disabled(!viewModel.canSubmit)
}
.buttonStyle(.plain)
.padding(SodaSpacing.s14)
.background(Color.black)
}
private var cancelMessage: String {
let firstLine: String
if let nickname = viewModel.summary?.recipientCreatorNickname?.trimmingCharacters(in: .whitespacesAndNewlines),
!nickname.isEmpty {
firstLine = "\(nickname)에게 보내는 선물을 취소할까요?"
} else {
firstLine = "보내는 선물을 취소할까요?"
}
return firstLine + "\n선물 보내기 규정에 따라 선물 취소 및 사용한 캔이 모두 환불됩니다."
}
}
@@ -0,0 +1,254 @@
import Foundation
import Combine
final class GiftTrackingRegistrationViewModel: ObservableObject {
private let applicationNo: String
private let onUnavailable: (String) -> Void
private let onMutationCompleted: (String, @escaping (Bool) -> Void) -> Void
private let repository = GiftRepository()
private var subscription: AnyCancellable?
private var mutationSubscription: AnyCancellable?
private var hasCompletedMutation = false
private var hasStartedMutationNavigation = false
@Published private(set) var summary: SentGiftDetailDisplayModel?
@Published private(set) var mailbox: GiftAddressDisplayModel?
@Published private(set) var isLoading = false
@Published private(set) var isSubmitting = false
@Published private(set) var errorMessage = ""
@Published private(set) var successMessage = "운송장이 등록되었습니다."
@Published private(set) var shouldReturnToDetail = false
@Published var isShowPopup = false
@Published var isShowSuccessToast = false
@Published var selectedCourier: String?
@Published var trackingNumber = ""
static func acceptsTrackingEditing(_ value: String) -> Bool {
value.utf8.count <= 14 && value.utf8.allSatisfy { (48...57).contains($0) }
}
static func acceptsTrackingSubmission(_ value: String) -> Bool {
value.utf8.count >= 10 && acceptsTrackingEditing(value)
}
var canRegister: Bool {
mailbox != nil && !isLoading && !shouldReturnToDetail
}
var canSubmit: Bool {
guard let selectedCourier else { return false }
return canRegister
&& !isSubmitting
&& !hasCompletedMutation
&& !selectedCourier.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
&& Self.acceptsTrackingSubmission(trackingNumber)
}
var canCancel: Bool {
canRegister && summary?.status == .received && !isSubmitting && !hasCompletedMutation
}
init(
applicationNo: String,
onUnavailable: @escaping (String) -> Void = { _ in }
) {
self.applicationNo = applicationNo
self.onUnavailable = onUnavailable
self.onMutationCompleted = { _, completion in
completion(true)
}
}
init(
applicationNo: String,
onUnavailable: @escaping (String) -> Void,
onMutationCompleted: @escaping (String, @escaping (Bool) -> Void) -> Void
) {
self.applicationNo = applicationNo
self.onUnavailable = onUnavailable
self.onMutationCompleted = onMutationCompleted
}
func fetch() {
guard !isLoading, !isSubmitting, !shouldReturnToDetail else { return }
summary = nil
mailbox = nil
errorMessage = ""
isShowPopup = false
isLoading = true
subscription = repository.getGift(applicationNo: applicationNo)
.receive(on: DispatchQueue.main)
.sink { [weak self] result in
guard let self else { return }
if case .failure(let error) = result {
self.showError(data: error.response?.data)
}
self.isLoading = false
} receiveValue: { [weak self] response in
guard let self else { return }
do {
let decoded = try JSONDecoder().decode(ApiResponse<GiftDetailResponse>.self, from: response.data)
guard decoded.success, let detail = decoded.data else {
self.showError(data: response.data)
return
}
guard let sentDetail = GiftDisplayPolicy.sentDetail(from: detail) else {
self.showUnavailable(data: response.data)
return
}
guard sentDetail.status != .canceled else {
self.showUnavailable(data: response.data)
return
}
guard detail.trackingRequired else {
self.showUnavailable(data: response.data)
return
}
guard let mailbox = detail.mailbox else {
self.showMissingMailboxError()
return
}
self.summary = sentDetail
self.mailbox = GiftAddressDisplayModel(
name: mailbox.name,
phoneNumber: mailbox.phoneNumber,
address: mailbox.address
)
} catch {
self.showError(data: response.data)
}
}
}
func registerTracking() {
guard canSubmit, let selectedCourier else { return }
let request = RegisterGiftTrackingRequest(
courierCompanyName: selectedCourier,
trackingNumber: trackingNumber.trimmingCharacters(in: .whitespacesAndNewlines)
)
beginMutation()
mutationSubscription = repository.registerTracking(applicationNo: applicationNo, request: request)
.receive(on: DispatchQueue.main)
.sink { [weak self] result in
guard let self, !self.hasCompletedMutation else { return }
if case .failure(let error) = result {
self.finishMutationFailure(data: error.response?.data)
}
} receiveValue: { [weak self] response in
guard let self, !self.hasCompletedMutation else { return }
do {
let decoded = try JSONDecoder().decode(ApiResponseWithoutData.self, from: response.data)
guard decoded.success else {
self.finishMutationFailure(data: response.data)
return
}
self.finishRegistrationSuccess()
} catch {
self.finishMutationFailure(data: response.data)
}
}
}
func confirmCancel() {
guard canCancel else { return }
beginMutation()
mutationSubscription = repository.cancelGift(applicationNo: applicationNo)
.receive(on: DispatchQueue.main)
.sink { [weak self] result in
guard let self, !self.hasCompletedMutation else { return }
if case .failure(let error) = result {
self.finishMutationFailure(data: error.response?.data)
}
} receiveValue: { [weak self] response in
guard let self, !self.hasCompletedMutation else { return }
do {
let decoded = try JSONDecoder().decode(ApiResponseWithoutData.self, from: response.data)
guard decoded.success else {
self.finishMutationFailure(data: response.data)
return
}
self.finishCancellationSuccess()
} catch {
self.finishMutationFailure(data: response.data)
}
}
}
func successToastDidDismiss() {
guard hasCompletedMutation, !isShowSuccessToast else { return }
navigateAfterMutation()
}
func errorToastDidDismiss() {
guard !isShowPopup, shouldReturnToDetail else { return }
shouldReturnToDetail = false
onUnavailable(applicationNo)
}
private func beginMutation() {
isSubmitting = true
errorMessage = ""
isShowPopup = false
}
private func finishRegistrationSuccess() {
hasCompletedMutation = true
isShowSuccessToast = true
}
private func finishCancellationSuccess() {
hasCompletedMutation = true
navigateAfterMutation()
}
private func finishMutationFailure(data: Data?) {
isSubmitting = false
showError(data: data)
}
private func navigateAfterMutation() {
guard hasCompletedMutation, !hasStartedMutationNavigation else { return }
hasStartedMutationNavigation = true
onMutationCompleted(applicationNo) { [weak self] didReplace in
guard let self, !didReplace, self.isSubmitting else { return }
self.isSubmitting = false
self.fetch()
}
}
private func showUnavailable(data: Data?) {
showError(data: data)
shouldReturnToDetail = true
}
private func showMissingMailboxError() {
errorMessage = "받을 주소가 등록되어 있지 않습니다."
isShowPopup = true
}
private func showError(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) ?? ""
errorMessage = message.isEmpty ? I18n.Common.commonError : message
isShowPopup = true
}
}