feat(gift): 보낸 선물 상세를 추가한다
This commit is contained in:
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user