diff --git a/SodaLive/Sources/V2/Gift/Detail/Components/SentGiftDetailContentView.swift b/SodaLive/Sources/V2/Gift/Detail/Components/SentGiftDetailContentView.swift new file mode 100644 index 00000000..cc8929cc --- /dev/null +++ b/SodaLive/Sources/V2/Gift/Detail/Components/SentGiftDetailContentView.swift @@ -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) + } + } +} diff --git a/SodaLive/Sources/V2/Gift/Detail/GiftDetailView.swift b/SodaLive/Sources/V2/Gift/Detail/GiftDetailView.swift new file mode 100644 index 00000000..040693fe --- /dev/null +++ b/SodaLive/Sources/V2/Gift/Detail/GiftDetailView.swift @@ -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(@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선물 보내기 규정에 따라 선물 취소 및 사용한 캔이 모두 환불됩니다." + } +} diff --git a/SodaLive/Sources/V2/Gift/Detail/GiftDetailViewModel.swift b/SodaLive/Sources/V2/Gift/Detail/GiftDetailViewModel.swift new file mode 100644 index 00000000..eca00a97 --- /dev/null +++ b/SodaLive/Sources/V2/Gift/Detail/GiftDetailViewModel.swift @@ -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.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 + } +}