feat(gift): 보낸 선물 상세를 추가한다

This commit is contained in:
Yu Sung
2026-10-08 12:32:58 +09:00
parent d73a69297b
commit cc0edc3770
3 changed files with 486 additions and 0 deletions
@@ -0,0 +1,149 @@
import SwiftUI
struct SentGiftDetailContentView: View {
let model: SentGiftDetailDisplayModel
var body: some View {
VStack(alignment: .leading, spacing: SodaSpacing.s32) {
hero
if model.status == .undeliverable {
undeliverableReason
} else if model.status != .canceled {
GiftProgressView(progress: model.progress)
}
if model.status == .received {
mailbox
}
giftInfo
sender
if model.status != .received {
mailbox
}
}
.frame(maxWidth: .infinity, alignment: .leading)
}
private var hero: some View {
VStack(alignment: .leading, spacing: SodaSpacing.s14) {
if let statusLabel {
Text(statusLabel)
.appFont(.caption2)
.foregroundColor(model.status == .undeliverable ? .red400 : .soda400)
.padding(.horizontal, SodaSpacing.s8)
.padding(.vertical, SodaSpacing.s4)
.background(Color.gray900)
.clipShape(RoundedRectangle(cornerRadius: SodaRadius.r4))
}
Text(heroTitle)
.appFont(.heading1)
.foregroundColor(.white)
.accessibilityAddTraits(.isHeader)
if let heroBody {
Text(heroBody)
.appFont(.body6)
.foregroundColor(.gray500)
}
}
.fixedSize(horizontal: false, vertical: true)
.frame(maxWidth: .infinity, alignment: .leading)
}
private var statusLabel: String? {
switch model.status {
case .trackingRegistered: return "발송 확인"
case .delivered: return "전달 완료"
case .undeliverable: return "전달 불가"
case .received, .arrivedAtMailbox, .inspectionCompleted, .canceled: return nil
}
}
private var heroTitle: String {
switch model.status {
case .received: return "선물 신청 완료!"
case .trackingRegistered: return "사서함으로 이동 중이에요"
case .delivered: return "크리에이터에게 선물을 전달했어요"
case .undeliverable: return "전달할 수 없는 품목입니다"
case .arrivedAtMailbox, .inspectionCompleted, .canceled: return model.statusName
}
}
private var heroBody: String? {
switch model.status {
case .received:
return "선물이 정상적으로 접수되었어요!"
case .undeliverable:
return "보내주신 선물은 소다라이브 선물 정책에 따라 크리에이터에게 전달할 수 없는 품목으로 확인되었습니다."
case .trackingRegistered, .arrivedAtMailbox, .inspectionCompleted, .delivered, .canceled:
return nil
}
}
private var giftInfo: some View {
GiftInfoSectionView(title: "선물 정보") {
if let nickname = model.recipientCreatorNickname {
GiftInfoRow(title: "크리에이터", value: nickname)
}
GiftInfoRow(title: "선물 사이즈", value: model.sizeName)
GiftInfoRow(title: "카테고리", value: model.categoryName)
GiftInfoRow(title: "신청번호", value: model.applicationNo)
if let paidCan = model.paidCan {
GiftInfoRow(title: "캔", value: String(paidCan))
}
if let tracking = model.tracking,
!tracking.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
GiftInfoRow(title: "운송장 번호", value: tracking)
}
}
}
@ViewBuilder
private var sender: some View {
if let sender = model.senderInfo {
GiftInfoSectionView(title: "보내는 사람") {
GiftInfoRow(title: "이름", value: sender.name)
GiftInfoRow(title: "휴대폰 번호", value: sender.phoneNumber)
GiftInfoRow(title: "주소", value: sender.address)
}
}
}
@ViewBuilder
private var mailbox: some View {
if let mailbox = model.mailbox {
GiftInfoSectionView(title: model.status == .received ? "보내실 주소" : "받는 사서함") {
GiftInfoRow(title: "사서함", value: mailbox.name)
GiftInfoRow(title: "주소", value: mailbox.address)
GiftInfoRow(title: "연락처", value: mailbox.phoneNumber)
}
}
}
private var undeliverableReason: some View {
GiftInfoSectionView(title: "사유") {
if let reason = model.undeliverableReason?.trimmingCharacters(in: .whitespacesAndNewlines),
!reason.isEmpty {
Text(reason)
.appFont(.body6)
.foregroundColor(.white)
.fixedSize(horizontal: false, vertical: true)
.frame(maxWidth: .infinity, alignment: .leading)
Divider()
.overlay(Color.gray800)
.accessibilityHidden(true)
}
Text("해당 선물은 정책에 따라 폐기되며 반송되지 않습니다.")
.appFont(.body6)
.foregroundColor(.red400)
.fixedSize(horizontal: false, vertical: true)
.frame(maxWidth: .infinity, alignment: .leading)
}
}
}
@@ -0,0 +1,181 @@
import SwiftUI
struct GiftDetailView: View {
@Environment(\.openURL) private var openURL
@StateObject private var viewModel: GiftDetailViewModel
@StateObject private var recipientAddressViewModel: GiftRecipientAddressViewModel
@State private var hasAppeared = false
@State private var isActive = false
init(applicationNo: String) {
let detailViewModel = GiftDetailViewModel(applicationNo: applicationNo) { canceledApplicationNo, completion in
AppState.shared.replaceCurrentAppStep(
step: .giftDetail(applicationNo: canceledApplicationNo),
ifCurrentStep: { step in
guard case .giftDetail(let currentApplicationNo) = step else { return false }
return currentApplicationNo == canceledApplicationNo
},
completion: completion
)
}
_viewModel = StateObject(wrappedValue: detailViewModel)
_recipientAddressViewModel = StateObject(wrappedValue: GiftRecipientAddressViewModel(applicationNo: applicationNo, onSubmitted: {
detailViewModel.fetch()
}))
}
var body: some View {
VStack(spacing: 0) {
DetailNavigationBar(title: navigationTitle)
.disabled(viewModel.isCancelSubmitting || isRecipientAddressBusy)
BaseView(isLoading: .constant(viewModel.isLoading || recipientAddressViewModel.isSubmitting)) {
ZStack {
switch viewModel.renderState {
case .sent(let model):
detailContent {
SentGiftDetailContentView(model: model)
}
.safeAreaInset(edge: .bottom, spacing: 0) {
GiftDetailActionBar(
model: model,
onCancel: viewModel.presentCancelDialog,
onPolicy: openGiftTerms
)
}
case .received(let model):
if model.recipientAddressRequired {
VStack(spacing: 0) {
GiftRecipientAddressFormView(model: model, viewModel: recipientAddressViewModel)
if recipientAddressViewModel.hasSubmitted && !viewModel.isLoading && !viewModel.errorMessage.isEmpty {
retryContent
}
}
} else {
detailContent {
ReceivedGiftDetailContentView(model: model)
}
.safeAreaInset(edge: .bottom, spacing: 0) {
if model.status == .undeliverable {
ReceivedGiftDetailActionBar(onPolicy: openGiftTerms)
}
}
}
case .canceled(let model):
detailContent {
GiftCancelCompleteView(model: model)
}
case nil:
Color.clear.accessibilityHidden(true)
}
if viewModel.renderState == nil && !viewModel.errorMessage.isEmpty {
retryContent
}
}
}
.disabled(isRecipientAddressBusy)
}
.background(Color.black.ignoresSafeArea())
.allowsHitTesting(!viewModel.isShowingCancelDialog && !viewModel.isCancelSubmitting)
.accessibilityHidden(viewModel.isShowingCancelDialog)
.overlay {
if viewModel.isShowingCancelDialog {
SodaV2ActionModal(
title: "신청 취소",
message: cancelMessage,
button1: SodaV2ActionModalButton(label: "취소하기", action: viewModel.confirmCancel),
button2: SodaV2ActionModalButton(label: "나가기", action: viewModel.dismissCancelDialog),
onDimmedTap: viewModel.dismissCancelDialog,
primaryTextColor: .red400
)
.disabled(viewModel.isCancelSubmitting)
.allowsHitTesting(!viewModel.isCancelSubmitting)
}
}
.overlay {
if viewModel.isCancelSubmitting {
LoadingView()
}
}
.sodaToast(isPresented: $viewModel.isShowPopup, message: viewModel.errorMessage)
.sodaToast(isPresented: $recipientAddressViewModel.isShowPopup, message: recipientAddressViewModel.errorMessage)
.sodaToast(isPresented: $recipientAddressViewModel.isShowSuccessToast, message: recipientAddressViewModel.successMessage)
.onChange(of: recipientAddressViewModel.isShowSuccessToast) { isPresented in
if !isPresented && isActive {
recipientAddressViewModel.successToastDidDismiss()
}
}
.onAppear {
isActive = true
recipientAddressViewModel.successToastDidDismiss()
guard !hasAppeared else { return }
hasAppeared = true
viewModel.fetch()
}
.onDisappear {
isActive = false
}
}
private func openGiftTerms() {
guard let url = GiftConstants.termsURL else { return }
openURL(url)
}
private var isRecipientAddressBusy: Bool {
recipientAddressViewModel.isSubmitting || recipientAddressViewModel.isShowSuccessToast
|| (recipientAddressViewModel.hasSubmitted && viewModel.isLoading)
}
private var retryContent: some View {
VStack(spacing: SodaSpacing.s16) {
Text(viewModel.errorMessage)
.appFont(.body3)
.foregroundColor(.gray500)
.multilineTextAlignment(.center)
.fixedSize(horizontal: false, vertical: true)
Button(action: viewModel.fetch) {
Text("다시 시도")
.appFont(.body2)
.foregroundColor(.white)
.padding(.horizontal, SodaSpacing.s16)
.frame(minHeight: SodaSpacing.s48)
.background(Color.gray800)
.clipShape(Capsule())
}
.buttonStyle(.plain)
.disabled(viewModel.isLoading)
}
.padding(SodaSpacing.s24)
}
private func detailContent<Content: View>(@ViewBuilder content: () -> Content) -> some View {
ScrollView {
LazyVStack(alignment: .leading, spacing: SodaSpacing.s32) {
content()
}
.padding(.horizontal, SodaSpacing.s14)
.padding(.top, SodaSpacing.s24)
.padding(.bottom, SodaSpacing.s32)
}
}
private var navigationTitle: String {
if case .canceled = viewModel.renderState {
return "취소 완료"
}
return "선물 상세"
}
private var cancelMessage: String {
let firstLine: String
if case .sent(let model) = viewModel.renderState,
let nickname = model.recipientCreatorNickname?.trimmingCharacters(in: .whitespacesAndNewlines),
!nickname.isEmpty {
firstLine = "\(nickname)에게 보내는 선물을 취소할까요?"
} else {
firstLine = "보내는 선물을 취소할까요?"
}
return firstLine + "\n선물 보내기 규정에 따라 선물 취소 및 사용한 캔이 모두 환불됩니다."
}
}
@@ -0,0 +1,156 @@
import Foundation
import Combine
enum GiftDetailRenderState {
case sent(SentGiftDetailDisplayModel)
case received(ReceivedGiftDetailDisplayModel)
case canceled(SentGiftDetailDisplayModel)
init?(detail: GiftDetailResponse) {
switch GiftDirection(rawValue: detail.direction) {
case .sent:
guard let model = GiftDisplayPolicy.sentDetail(from: detail) else { return nil }
self = model.status == .canceled ? .canceled(model) : .sent(model)
case .received:
guard let model = GiftDisplayPolicy.receivedDetail(from: detail) else { return nil }
self = .received(model)
case nil:
return nil
}
}
}
final class GiftDetailViewModel: ObservableObject {
private let applicationNo: String
private let onCanceled: (String, @escaping (Bool) -> Void) -> Void
private let repository = GiftRepository()
private var subscription: AnyCancellable?
private var cancelSubscription: AnyCancellable?
private var hasCompletedCancellation = false
@Published private(set) var renderState: GiftDetailRenderState?
@Published private(set) var isLoading = false
@Published private(set) var hasLoaded = false
@Published private(set) var isShowingCancelDialog = false
@Published private(set) var isCancelSubmitting = false
@Published private(set) var errorMessage = ""
@Published var isShowPopup = false
init(
applicationNo: String,
onCanceled: @escaping (String, @escaping (Bool) -> Void) -> Void = { _, completion in
completion(true)
}
) {
self.applicationNo = applicationNo
self.onCanceled = onCanceled
}
func presentCancelDialog() {
guard !isLoading, !isCancelSubmitting, !hasCompletedCancellation, canCancel else { return }
errorMessage = ""
isShowPopup = false
isShowingCancelDialog = true
}
func dismissCancelDialog() {
guard !isCancelSubmitting else { return }
isShowingCancelDialog = false
errorMessage = ""
isShowPopup = false
}
func confirmCancel() {
guard isShowingCancelDialog, !isLoading, !isCancelSubmitting, !hasCompletedCancellation, canCancel else { return }
isCancelSubmitting = true
errorMessage = ""
isShowPopup = false
cancelSubscription = repository.cancelGift(applicationNo: applicationNo)
.receive(on: DispatchQueue.main)
.sink { [weak self] result in
guard let self, !self.hasCompletedCancellation else { return }
if case .failure(let error) = result {
self.showCancelError(data: error.response?.data)
}
} receiveValue: { [weak self] response in
guard let self, !self.hasCompletedCancellation else { return }
do {
let decoded = try JSONDecoder().decode(ApiResponseWithoutData.self, from: response.data)
guard decoded.success else {
self.showCancelError(data: response.data)
return
}
self.hasCompletedCancellation = true
self.isShowingCancelDialog = false
self.onCanceled(self.applicationNo) { [weak self] didReplace in
guard let self, !didReplace else { return }
self.isCancelSubmitting = false
self.fetch()
}
} catch {
self.showCancelError(data: response.data)
}
}
}
func fetch() {
guard !isLoading, !isShowingCancelDialog, !isCancelSubmitting else { return }
isLoading = true
errorMessage = ""
isShowPopup = false
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 state = GiftDetailRenderState(detail: detail) else {
self.showError(data: response.data)
return
}
self.renderState = state
self.hasLoaded = true
} catch {
self.showError(data: response.data)
}
}
}
private var canCancel: Bool {
guard case .sent(let detail) = renderState else { return false }
return detail.status == .received
}
private func showCancelError(data: Data?) {
isCancelSubmitting = false
showError(data: data)
}
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
}
}