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(@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선물 보내기 규정에 따라 선물 취소 및 사용한 캔이 모두 환불됩니다." } }