diff --git a/CERTI-iOS/Domain/UseCases/PreviewUseCases/CertificationDetailPreviewUseCase.swift b/CERTI-iOS/Domain/UseCases/PreviewUseCases/CertificationDetailPreviewUseCase.swift new file mode 100644 index 00000000..70051153 --- /dev/null +++ b/CERTI-iOS/Domain/UseCases/PreviewUseCases/CertificationDetailPreviewUseCase.swift @@ -0,0 +1,40 @@ +// +// CertificationDetailPreviewUseCase.swift +// CERTI-iOS +// +// Created by 이상엽 on 11/16/25. +// + +struct PreviewFetchCertificationDetailUseCase: FetchCertificationDetailUseCase { + func execute(id: Int) async -> Result { + let dummy = CertificationDetailEntity( + certificationId: id, + certificationName: "GTQ 1급 (그래픽기술자격)", + tags: ["디자인", "그래픽"], + averagePeriod: "2개월", + charge: "35,000원", + agencyName: "한국생산성본부", + testType: "필기/실기", + description: "그래픽 편집 능력을 평가하는 자격으로, 포토샵 및 일러스트 사용 능력을 측정합니다.", + testDateInformation: "매월 정기 시행", + applicationMethod: "온라인 접수", + applicationUrl: "https://www.kpc.or.kr/gtq", + expirationPeriod: "영구" + ) + return .success(dummy) + } +} + +struct PreviewAddPreCertificationUseCase: AddPreCertificationUseCase { + func execute(certificationId: Int) async -> Result { + // Always succeed for preview + return .success(.success) + } +} + +struct PreviewAddAcquisitionUseCase: AddAcquisitionUseCase { + func execute(certificationId: Int) async -> Result { + // Always succeed for preview + return .success(true) + } +} diff --git a/CERTI-iOS/Global/Components/CertiTimePicker.swift b/CERTI-iOS/Global/Components/CertiTimePicker.swift new file mode 100644 index 00000000..e2c4ae7b --- /dev/null +++ b/CERTI-iOS/Global/Components/CertiTimePicker.swift @@ -0,0 +1,257 @@ +// +// CertiTimePicker.swift +// CERTI-iOS +// +// Created by 이상엽 on 11/21/25. +// + +import SwiftUI +import UIKit + +// MARK: - UIKit View + +final class CertiPickerView: UIPickerView { + + private var topLines: [UIView] = [] + private var bottomLines: [UIView] = [] + + private struct ComponentLayout { + let textWidth: CGFloat + let lineLocationValue: CGFloat + let lineWidth: CGFloat + } + + private let layouts: [ComponentLayout] = [ + .init(textWidth: 45, lineLocationValue: 5, lineWidth: 45), + .init(textWidth: 129, lineLocationValue: 0, lineWidth: 35), + .init(textWidth: 38, lineLocationValue: -7, lineWidth: 38) + ] + + private let lineSpacing: [CGFloat] = [12, 10] + + override init(frame: CGRect) { + super.init(frame: frame) + setupLines() + } + + required init?(coder: NSCoder) { + super.init(coder: coder) + setupLines() + } + + private func setupLines() { + for _ in 0..<3 { + let topLine = UIView() + topLine.backgroundColor = UIColor(Color.purpleblue) + addSubview(topLine) + topLines.append(topLine) + + let bottomLine = UIView() + bottomLine.backgroundColor = UIColor(Color.purpleblue) + addSubview(bottomLine) + bottomLines.append(bottomLine) + } + } + + override func layoutSubviews() { + super.layoutSubviews() + + hideIndicator() + layoutLines() + } +} + + +// MARK: - Layout Methods + +private extension CertiPickerView { + func hideIndicator() { + for sub in subviews { + let isIndicator = + sub.subviews.isEmpty && + sub.bounds.height > 5 && + sub.bounds.height < 60 + + if isIndicator { + sub.isHidden = true + sub.alpha = 0 + } + } + } + + func layoutLines() { + let totalContentWidth = + layouts.map { $0.textWidth }.reduce(0, +) + lineSpacing.reduce(0, +) + + var xOffset: CGFloat = (bounds.width - totalContentWidth) / 2 + + let rowHeight = rowSize(forComponent: 0).height + let lineHeight: CGFloat = 2 + let topLineY = bounds.midY - rowHeight / 2 + let bottomLineY = bounds.midY + rowHeight / 2 + + for i in 0.. Coordinator { + Coordinator(self) + } + + func makeUIView(context: Context) -> UIPickerView { + let picker = CertiPickerView() + picker.delegate = context.coordinator + picker.dataSource = context.coordinator + return picker + } + + func updateUIView(_ uiView: UIPickerView, context: Context) { + uiView.selectRow(isAM ? 0 : 1, inComponent: 0, animated: false) + uiView.selectRow(hour - 1, inComponent: 1, animated: false) + uiView.selectRow(minute / 5, inComponent: 2, animated: false) + } +} + +// MARK: - Coordinator + +extension CustomTimePicker { + class Coordinator: NSObject, UIPickerViewDelegate, UIPickerViewDataSource { + var parent: CustomTimePicker + + static let ampm: [String] = ["오전", "오후"] + static let hours: [Int] = Array(1...12) + static let minutes: [Int] = Array(stride(from: 0, to: 60, by: 5)) + static let hoursInfinite: [Int] = Array(repeating: hours, count: 100).flatMap { $0 } + static let minutesInfinite: [Int] = Array(repeating: minutes, count: 100).flatMap { $0 } + + init(_ parent: CustomTimePicker) { + self.parent = parent + } + + func numberOfComponents(in pickerView: UIPickerView) -> Int { + return 3 + } + + func pickerView(_ pickerView: UIPickerView, numberOfRowsInComponent component: Int) -> Int { + switch component { + case 0: return Self.ampm.count + case 1: return Self.hoursInfinite.count + case 2: return Self.minutesInfinite.count + default: return 0 + } + } + + func pickerView(_ pickerView: UIPickerView, + viewForRow row: Int, + forComponent component: Int, + reusing view: UIView?) -> UIView { + + let label = UILabel() + label.textAlignment = .center + label.font = UIFont(name: "Pretendard-SemiBold", size: 14) + label.textColor = UIColor(named: "grayscale600") + + switch component { + case 0: label.text = Self.ampm[row] + case 1: label.text = "\(Self.hoursInfinite[row % 12])" + case 2: label.text = String(format: "%02d", Self.minutesInfinite[row % 12]) + default: break + } + + return label + } + + func pickerView(_ pickerView: UIPickerView, widthForComponent component: Int) -> CGFloat { + switch component { + case 0: return 45 + case 1: return 129 + case 2: return 38 + default: return 50 + } + } + + func pickerView(_ pickerView: UIPickerView, rowHeightForComponent component: Int) -> CGFloat { + return 40 + } + + func pickerView(_ pickerView: UIPickerView, didSelectRow row: Int, inComponent component: Int) { + switch component { + case 0: + parent.isAM = (row == 0) + case 1: + parent.hour = Self.hoursInfinite[row % 12] + case 2: + parent.minute = Self.minutesInfinite[row % 12] + default: break + } + } + } +} + +// MARK: - SwiftUI Component + +struct CertiTimePicker: View { + @Binding var isAM: Bool + @Binding var hour: Int + @Binding var minute: Int + + var body: some View { + ZStack { + CustomTimePicker(isAM: $isAM, hour: $hour, minute: $minute) + .frame(height: 180) + + Text(":") + .applyCertiFont(.caption_semibold_14) + .foregroundColor(.grayscale600) + .offset(x: 49, y: 0) + } + } +} + +#Preview { + struct PreviewWrapper: View { + @State var isAM = true + @State var hour = 1 + @State var minute = 0 + + var body: some View { + CertiTimePicker(isAM: $isAM, hour: $hour, minute: $minute) + } + } + return PreviewWrapper() +} + diff --git a/CERTI-iOS/Global/Components/DatePickerBox.swift b/CERTI-iOS/Global/Components/DatePickerBox.swift new file mode 100644 index 00000000..b172438e --- /dev/null +++ b/CERTI-iOS/Global/Components/DatePickerBox.swift @@ -0,0 +1,103 @@ +// +// DatePickerBox.swift +// CERTI-iOS +// +// Created by 이상엽 on 12/2/25. +// + +import SwiftUI + +struct DatePickerBox: View { + @State private var isCalendarVisible: Bool = false + + @Binding var selectedDate: Date? + + private var dateFormatter: DateFormatter { + let formatter = DateFormatter() + formatter.dateFormat = "yyyy.MM.dd" + formatter.locale = Locale(identifier: "ko_KR") + return formatter + } + + var body: some View { + VStack(alignment: .leading, spacing: 0) { + Button { + withAnimation(.easeInOut(duration: 0.2)){ + isCalendarVisible.toggle() + } + } label: { + HStack(alignment: .center, spacing: 0) { + if let date = selectedDate { + Text(dateFormatter.string(from: date)) + .applyCertiFont(.caption_regular_14) + .foregroundStyle(.black) + } else { + Text("시험 날짜를 선택해주세요.") + .applyCertiFont(.caption_semibold_12) + .foregroundStyle(.grayscale300) + } + + Spacer() + + Image(.iconArrowdown24) + .foregroundStyle(.grayscale400) + } + .padding(.vertical, 11) + .padding(.leading, 12) + .padding(.trailing, 8) + } + .frame(height: 40) + .background(.clear) + .clipShape(RoundedRectangle(cornerRadius: 4)) + .overlay( + RoundedRectangle(cornerRadius: 4) + .stroke(.grayscale200, lineWidth: 1) + ) + .padding(.horizontal, 20) + + if isCalendarVisible { + DatePicker("", selection: Binding( + get: { selectedDate ?? Date() }, + set: { + selectedDate = $0 + withAnimation { + self.isCalendarVisible = false + } + } + ), displayedComponents: .date) + .datePickerStyle(.graphical) + .background( + RoundedRectangle(cornerRadius: 12) + .fill(.white) + .shadow(color: .black.opacity(0.08), radius: 12, x: 4, y: 4) + ) + .environment(\.locale, Locale(identifier: "ko_KR")) + .padding(.horizontal, 8) + .padding(.top, 11) + } + } + + } +} + +#Preview { + struct PreviewWrapper: View { + @State private var selectedDate: Date? = nil + + var body: some View { + VStack { + HStack(alignment: .center, spacing: 0) { + Image(.iconCheck24) + .frame(width: 24, height: 24) + + Text("시험 날짜") + .applyCertiFont(.body_semibold_16) + .foregroundStyle(.grayscale600) + .frame(height: 22) + } + DatePickerBox(selectedDate: $selectedDate) + } + } + } + return PreviewWrapper() +} diff --git a/CERTI-iOS/Global/Components/DropDownMenu.swift b/CERTI-iOS/Global/Components/DropDownMenu.swift new file mode 100644 index 00000000..e838e34b --- /dev/null +++ b/CERTI-iOS/Global/Components/DropDownMenu.swift @@ -0,0 +1,110 @@ +// +// DropDownMenu.swift +// CERTI-iOS +// +// Created by 이상엽 on 11/17/25. +// +import SwiftUI + + +struct DropdownMenu: View { + @State private var isOpen = false + + @Binding var selectedPlace: String? + + let options: [String] + let menuPlaceholder: String + + var body: some View { + Button { + withAnimation { + isOpen.toggle() + } + } label: { + HStack { + Text(selectedPlace ?? menuPlaceholder) + .applyCertiFont(.caption_semibold_12) + .foregroundColor(selectedPlace == nil ? .grayscale300 : .grayscale600) + .padding(.leading, 12) + .padding(.vertical, 11) + + Spacer() + + Image(.iconArrowdown24) + .frame(width: 24, height: 24) + .padding(.trailing, 8) + .padding(.vertical, 11) + } + .frame(width: 161, height: 40) + .background(RoundedRectangle(cornerRadius: 4).stroke(.grayscale200)) + } + .overlay( + Group { + if isOpen { + VStack(alignment: .center, spacing: 0) { + ForEach(options, id: \.self) { item in + Button { + selectedPlace = item + withAnimation { + isOpen = false + } + } label: { + Text(item) + .applyCertiFont(.caption_semibold_12) + .foregroundStyle(.grayscale600) + .frame(height: 18) + .padding(.vertical, 8) + .padding(.leading, 12) + .frame(maxWidth: .infinity, alignment: .leading) + } + .frame(width: 161, height: 34) + .background(RoundedRectangle(cornerRadius: 1).stroke(.grayscale100)) + .background(.white) + } + } + .background( + RoundedRectangle(cornerRadius: 4) + .stroke(.grayscale100) + ) + .offset(y: 40) + } + }, + alignment: .topLeading + ) + } +} + +#Preview { + struct PreviewWrapper: View { + @State private var selectedPlace: String? = nil + + var body: some View { + VStack { + HStack(alignment: .center, spacing: 0) { + Image(.iconCheck24) + .frame(width: 24, height: 24) + + Text("시험 장소") + .applyCertiFont(.body_semibold_16) + .foregroundStyle(.grayscale600) + .frame(height: 22) + } + + DropdownMenu(selectedPlace: $selectedPlace, options: ["서울", "경기", "인천", "강원", "충남", "충북"], menuPlaceholder: "시/도") + .zIndex(2) + + HStack(alignment: .center, spacing: 0) { + Image(.iconCheck24) + .frame(width: 24, height: 24) + + Text("시험 시간") + .applyCertiFont(.body_semibold_16) + .foregroundStyle(.grayscale600) + .frame(height: 22) + } + .zIndex(1) + } + } + } + return PreviewWrapper() +} diff --git a/CERTI-iOS/Presentation/CertificateDetail/View/CertificateDetailView.swift b/CERTI-iOS/Presentation/CertificateDetail/View/CertificateDetailView.swift index 06a15b93..9ebee3fc 100644 --- a/CERTI-iOS/Presentation/CertificateDetail/View/CertificateDetailView.swift +++ b/CERTI-iOS/Presentation/CertificateDetail/View/CertificateDetailView.swift @@ -15,7 +15,7 @@ struct CertificateDetailView: View { let onBack: () -> Void @State private var opacity: Double = 1.0 - + @State private var isShowingSheet = false var body: some View { @@ -308,10 +308,9 @@ struct CertificateDetailView: View { private var ToBeAcquiredButton: some View { Button { - Task { - await viewModel.appendPreCertification(certificationId: certificationId) - } - } label: { + isShowingSheet.toggle() + } + label: { ZStack { RoundedRectangle(cornerRadius: 12) .foregroundStyle(.bluewhite) @@ -328,6 +327,12 @@ struct CertificateDetailView: View { } .padding(.horizontal, 20) } + .sheet(isPresented: $isShowingSheet) { + CertificationDetailPlanModalView(viewModel: viewModel, certificationId: $certificationId, isShowingSheet: $isShowingSheet, certificationName: viewModel.certificateDetailModel.certificationName) + .presentationDetents([.height(663)]) + .presentationCornerRadius(40) + .presentationDragIndicator(.visible) + } .padding(.bottom, 12) } diff --git a/CERTI-iOS/Presentation/CertificateDetail/View/CertificationDetailPlanModalView.swift b/CERTI-iOS/Presentation/CertificateDetail/View/CertificationDetailPlanModalView.swift new file mode 100644 index 00000000..fcbd0844 --- /dev/null +++ b/CERTI-iOS/Presentation/CertificateDetail/View/CertificationDetailPlanModalView.swift @@ -0,0 +1,198 @@ +// +// CertificationDetailPlanModalView.swift +// CERTI-iOS +// +// Created by 이상엽 on 11/16/25. +// + +import SwiftUI + +struct CertificationDetailPlanModalView: View { + @ObservedObject var viewModel: CertificateDetailViewModel + + @EnvironmentObject var tabRouter: CertiTabCoordinator + + @Binding var certificationId: Int + @Binding var isShowingSheet: Bool + + let certificationName: String + + // TODO: - API 연결하면 지우기 + let placeMenuOptions = ["서울", "경기" ,"인천", "강원", "충남", "충북"] + let placeMenuOptions2 = ["강북구", "마포구" ,"용산구", "성북구"] + + var body: some View { + ScrollView(.vertical) { + headerView + dateView + placeView + timeView + } + .scrollIndicators(.hidden) + + Spacer() + + bottomButtonView + } +} + +extension CertificationDetailPlanModalView { + @ViewBuilder + private var headerView: some View { + VStack(alignment: .leading, spacing: 0) { + + Text("자격증 시험 정보를 입력해주세요") + .applyCertiFont(.body_bold_18) + .foregroundStyle(.grayscale600) + .frame(width: 229, height: 25) + .padding(.leading, 20) + .padding(.trailing, 126) + + Text(certificationName) + .applyCertiFont(.caption_semibold_14) + .foregroundStyle(.grayscale400) + .frame(width: 149, height: 20) + .padding(.leading, 20) + .padding(.trailing, 206) + } + .padding(.top, 60) + .frame(maxWidth: .infinity, maxHeight: .infinity) + } + + @ViewBuilder + private var dateView: some View { + VStack(alignment: .leading, spacing: 0) { + HStack(alignment: .center, spacing: 0) { + Image(.iconCheck24) + .frame(width: 24, height: 24) + + Text("시험 날짜") + .applyCertiFont(.body_semibold_16) + .foregroundStyle(.grayscale600) + .frame(height: 22) + } + .frame(width: 88, height: 24) + .padding(.leading, 20) + .padding(.trailing, 267) + + DatePickerBox(selectedDate: $viewModel.CertificationPlanDate) + .padding(.top, 12) + } + .padding(.top, 32) + } + + @ViewBuilder + private var placeView: some View { + VStack(alignment: .leading, spacing: 0) { + HStack(alignment: .center, spacing: 0) { + Image(.iconCheck24) + .frame(width: 24, height: 24) + + Text("시험 장소") + .applyCertiFont(.body_semibold_16) + .foregroundStyle(.grayscale600) + .frame(height: 22) + } + .frame(width: 88, height: 24) + .padding(.leading, 20) + .padding(.trailing, 267) + + HStack(alignment: .center, spacing: 0) { + DropdownMenu(selectedPlace: $viewModel.CertificationPlanPlaceDo, options: placeMenuOptions, menuPlaceholder: "시/도") + + Spacer() + + DropdownMenu(selectedPlace: $viewModel.CertificationPlanPlaceSi, options: placeMenuOptions2, menuPlaceholder: "구/시") + } + .padding(.top, 12) + .padding(.horizontal, 20) + + } + .padding(.top, 25) + .zIndex(2) + } + + @ViewBuilder + private var timeView: some View { + VStack(alignment: .center, spacing: 0) { + HStack(alignment: .center, spacing: 0) { + Image(.iconCheck24) + .frame(width: 24, height: 24) + + Text("시험 시간") + .applyCertiFont(.body_semibold_16) + .foregroundStyle(.grayscale600) + .frame(height: 22) + + Spacer() + } + .frame(height: 24) + .padding(.leading, 20) + + CertiTimePicker(isAM: $viewModel.isAM, hour: $viewModel.hour, minute: $viewModel.minute) + } + .padding(.top, 25) + .zIndex(1) + } + + @ViewBuilder + private var bottomButtonView: some View { + VStack(alignment: .center, spacing: 0) { + Button { + // TODO: - API 연결하기 + // Task { + // await viewModel.appendPreCertification(certificationId: certificationId) + // } + isShowingSheet.toggle() + } label: { + VStack(alignment: .center, spacing: 0) { + Text("나중에 입력하기") + .applyCertiFont(.caption_semibold_12) + .foregroundStyle(.grayscale300) + + Rectangle() + .frame(width: 97, height: 1) + .foregroundStyle(.grayscale200) + .padding(.top, 4) + } + } + + Button { + // TODO: - API 연결하기 + // Task { + // await viewModel.appendPreCertification(certificationId: certificationId) + // } + isShowingSheet.toggle() + } label: { + ZStack { + Rectangle() + .frame(width: 335, height: 56) + .foregroundStyle(.purpleblue) + .cornerRadius(12) + + Text("적용하기") + .applyCertiFont(.body_semibold_16) + .foregroundStyle(.white) + } + } + .padding(.top, 12) + } + } +} + +#Preview { + struct PreviewWrapper: View { + @State var certificationId = 1 + @State var isShowingSheet = true + + var body: some View { + CertificationDetailPlanModalView(viewModel: CertificateDetailViewModel( + fetchCertificationDetailUseCase: PreviewFetchCertificationDetailUseCase(), + addPreCertificationUseCase: PreviewAddPreCertificationUseCase(), + addAcquisitionUseCase: PreviewAddAcquisitionUseCase()), + certificationId: $certificationId, isShowingSheet: $isShowingSheet, certificationName: "GTQ 1급 (그래픽기술자격)") + } + } + return PreviewWrapper() +} + diff --git a/CERTI-iOS/Presentation/CertificateDetail/ViewModel/CertificateDetailViewModel.swift b/CERTI-iOS/Presentation/CertificateDetail/ViewModel/CertificateDetailViewModel.swift index c0260015..d825b76b 100644 --- a/CERTI-iOS/Presentation/CertificateDetail/ViewModel/CertificateDetailViewModel.swift +++ b/CERTI-iOS/Presentation/CertificateDetail/ViewModel/CertificateDetailViewModel.swift @@ -29,6 +29,12 @@ final class CertificateDetailViewModel: ObservableObject { @Published var showFailAcquired: Bool = false @Published var showFailToBeAcquired: Bool = false @Published var showCompleteModal = false + @Published var CertificationPlanDate: Date? = nil + @Published var CertificationPlanPlaceDo: String? = nil + @Published var CertificationPlanPlaceSi: String? = nil + @Published var isAM = true + @Published var hour = 1 + @Published var minute = 0 private let logger = Logger(subsystem: Bundle.main.bundleIdentifier ?? "CERTI", category: "CertificationDetail")