From bb96007df5a2e0da460c9b007b224f540324f412 Mon Sep 17 00:00:00 2001 From: sangyup Date: Sun, 16 Nov 2025 22:57:36 +0900 Subject: [PATCH 01/13] =?UTF-8?q?[Feat]=20#185=20-=20CertificationDetailPr?= =?UTF-8?q?eviewUseCase=20=EC=83=9D=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../CertificationDetailPreviewUseCase.swift | 40 +++++++++++++++++++ 1 file changed, 40 insertions(+) create mode 100644 CERTI-iOS/Domain/UseCases/PreviewUseCases/CertificationDetailPreviewUseCase.swift diff --git a/CERTI-iOS/Domain/UseCases/PreviewUseCases/CertificationDetailPreviewUseCase.swift b/CERTI-iOS/Domain/UseCases/PreviewUseCases/CertificationDetailPreviewUseCase.swift new file mode 100644 index 0000000..7005115 --- /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) + } +} From a352144dc1249e39b49d13c29387b9b16681087e Mon Sep 17 00:00:00 2001 From: sangyup Date: Sun, 16 Nov 2025 22:58:42 +0900 Subject: [PATCH 02/13] [Style] #185 - CertificationDetailPlanModalView UI headerView, dateView --- .../CertificationDetailPlanModalView.swift | 179 ++++++++++++++++++ .../CertificateDetailViewModel.swift | 1 + 2 files changed, 180 insertions(+) create mode 100644 CERTI-iOS/Presentation/CertificateDetail/View/CertificationDetailPlanModalView.swift diff --git a/CERTI-iOS/Presentation/CertificateDetail/View/CertificationDetailPlanModalView.swift b/CERTI-iOS/Presentation/CertificateDetail/View/CertificationDetailPlanModalView.swift new file mode 100644 index 0000000..ea08a30 --- /dev/null +++ b/CERTI-iOS/Presentation/CertificateDetail/View/CertificationDetailPlanModalView.swift @@ -0,0 +1,179 @@ +// +// CertificationDetailPlanModalView.swift +// CERTI-iOS +// +// Created by 이상엽 on 11/16/25. +// + +import SwiftUI + +struct CertificationDetailPlanModalView: View { + @ObservedObject var viewModel: CertificateDetailViewModel + + @EnvironmentObject var tabRouter: CertiTabCoordinator + + @State private var isCalendarVisible: Bool = false + + private var dateFormatter: DateFormatter { + let formatter = DateFormatter() + formatter.dateFormat = "yyyy.MM.dd" + formatter.locale = Locale(identifier: "ko_KR") + return formatter + } + + let certificationName: String + + var body: some View { + ScrollView(.vertical) { + headerView + dateView + placeView + timeView + } + .scrollIndicators(.hidden) + //.background(.yellow) + } +} + +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) + + } + .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) + + VStack(alignment: .leading, spacing: 0) { + Button { + withAnimation(.easeInOut(duration: 0.2)){ + isCalendarVisible.toggle() + } + } label: { + HStack(alignment: .center, spacing: 0) { + if viewModel.CertificationPlanDate != nil { + Text(dateFormatter.string(from: viewModel.CertificationPlanDate!)) + .applyCertiFont(.caption_regular_14) + .foregroundStyle(.black) + } else { + Text("시험 날짜를 선택해주세요.") + .applyCertiFont(.caption_semibold_14) + .foregroundStyle(.grayscale300) + } + + Spacer() + + Image(.iconArrowdown24) + .foregroundStyle(.grayscale400) + } + .padding(.vertical, 11) + .padding(.leading, 12) + .padding(.trailing, 8) + } + .background(.grayscale0) + .clipShape(RoundedRectangle(cornerRadius: 8)) + .overlay( + RoundedRectangle(cornerRadius: 8) + .stroke(.grayscale200, lineWidth: 1) + ) + .padding(.horizontal, 20) + + if isCalendarVisible { + DatePicker("", selection: Binding( + get: { self.viewModel.CertificationPlanDate ?? Date() }, + set: { + self.viewModel.CertificationPlanDate = $0 + withAnimation { + self.isCalendarVisible = false + } + } + ), displayedComponents: .date) + .datePickerStyle(.graphical) + .environment(\.locale, Locale(identifier: "ko_KR")) + .background(Color.white) + .padding(.horizontal, 8) + .padding(.top, 11) + .clipShape(RoundedRectangle(cornerRadius: 12)) + .shadow(color: .black.opacity(0.08), radius: 20, x: 4, y: 4) + + } + } + .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) + } + } + + @ViewBuilder + private var timeView: 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) + } + } +} +#Preview { + CertificationDetailPlanModalView(viewModel: CertificateDetailViewModel( + fetchCertificationDetailUseCase: PreviewFetchCertificationDetailUseCase(), + addPreCertificationUseCase: PreviewAddPreCertificationUseCase(), + addAcquisitionUseCase: PreviewAddAcquisitionUseCase()), + certificationName: "GTQ 1급 (그래픽기술자격)") +} + diff --git a/CERTI-iOS/Presentation/CertificateDetail/ViewModel/CertificateDetailViewModel.swift b/CERTI-iOS/Presentation/CertificateDetail/ViewModel/CertificateDetailViewModel.swift index c026001..d9fd9ae 100644 --- a/CERTI-iOS/Presentation/CertificateDetail/ViewModel/CertificateDetailViewModel.swift +++ b/CERTI-iOS/Presentation/CertificateDetail/ViewModel/CertificateDetailViewModel.swift @@ -29,6 +29,7 @@ final class CertificateDetailViewModel: ObservableObject { @Published var showFailAcquired: Bool = false @Published var showFailToBeAcquired: Bool = false @Published var showCompleteModal = false + @Published var CertificationPlanDate: Date? = nil private let logger = Logger(subsystem: Bundle.main.bundleIdentifier ?? "CERTI", category: "CertificationDetail") From a1012f79e817483b8392bec919039dadc012493e Mon Sep 17 00:00:00 2001 From: sangyup Date: Fri, 21 Nov 2025 16:54:45 +0900 Subject: [PATCH 03/13] [Style] #185 - dropdown menu component --- .../Components/DropDownMenu.swift | 102 ++++++++++++++++++ .../CertificationDetailPlanModalView.swift | 30 +++++- .../CertificateDetailViewModel.swift | 1 + 3 files changed, 129 insertions(+), 4 deletions(-) create mode 100644 CERTI-iOS/Presentation/CertificateDetail/Components/DropDownMenu.swift diff --git a/CERTI-iOS/Presentation/CertificateDetail/Components/DropDownMenu.swift b/CERTI-iOS/Presentation/CertificateDetail/Components/DropDownMenu.swift new file mode 100644 index 0000000..5bab248 --- /dev/null +++ b/CERTI-iOS/Presentation/CertificateDetail/Components/DropDownMenu.swift @@ -0,0 +1,102 @@ +// +// DropDownMenu.swift +// CERTI-iOS +// +// Created by 이상엽 on 11/17/25. +// +import SwiftUI + + +struct DropdownMenu: View { + @State private var isOpen = false + @State private var selected: String? = nil + + let options: [String] + let menuPlaceholder: String + + var body: some View { + Button { + withAnimation { + isOpen.toggle() + } + } label: { + HStack { + Text(selected ?? menuPlaceholder) + .applyCertiFont(.caption_semibold_12) + .foregroundColor(selected == 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 { + selected = 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 { + VStack { + HStack(alignment: .center, spacing: 0) { + Image(.iconCheck24) + .frame(width: 24, height: 24) + + Text("시험 장소") + .applyCertiFont(.body_semibold_16) + .foregroundStyle(.grayscale600) + .frame(height: 22) + } + + DropdownMenu(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) + } +} diff --git a/CERTI-iOS/Presentation/CertificateDetail/View/CertificationDetailPlanModalView.swift b/CERTI-iOS/Presentation/CertificateDetail/View/CertificationDetailPlanModalView.swift index ea08a30..33d9601 100644 --- a/CERTI-iOS/Presentation/CertificateDetail/View/CertificationDetailPlanModalView.swift +++ b/CERTI-iOS/Presentation/CertificateDetail/View/CertificationDetailPlanModalView.swift @@ -22,6 +22,9 @@ struct CertificationDetailPlanModalView: View { } let certificationName: String + let placeMenuOptions = ["서울", "경기" ,"인천", "강원", "충남", "충북"] + let placeMenuOptions2 = ["강북구", "마포구" ,"용산구", "성북구"] + var body: some View { ScrollView(.vertical) { @@ -87,7 +90,7 @@ extension CertificationDetailPlanModalView { .foregroundStyle(.black) } else { Text("시험 날짜를 선택해주세요.") - .applyCertiFont(.caption_semibold_14) + .applyCertiFont(.caption_semibold_12) .foregroundStyle(.grayscale300) } @@ -100,10 +103,11 @@ extension CertificationDetailPlanModalView { .padding(.leading, 12) .padding(.trailing, 8) } - .background(.grayscale0) - .clipShape(RoundedRectangle(cornerRadius: 8)) + .frame(height: 40) + .background(.clear) + .clipShape(RoundedRectangle(cornerRadius: 4)) .overlay( - RoundedRectangle(cornerRadius: 8) + RoundedRectangle(cornerRadius: 4) .stroke(.grayscale200, lineWidth: 1) ) .padding(.horizontal, 20) @@ -148,7 +152,20 @@ extension CertificationDetailPlanModalView { .frame(width: 88, height: 24) .padding(.leading, 20) .padding(.trailing, 267) + + HStack(alignment: .center, spacing: 0) { + DropdownMenu(options: placeMenuOptions, menuPlaceholder: "시/도") + + Spacer() + + DropdownMenu(options: placeMenuOptions2, menuPlaceholder: "구/시") + } + .padding(.top, 12) + .padding(.horizontal, 20) + } + .padding(.top, 25) + .zIndex(2) } @ViewBuilder @@ -166,9 +183,14 @@ extension CertificationDetailPlanModalView { .frame(width: 88, height: 24) .padding(.leading, 20) .padding(.trailing, 267) + + } + .padding(.top, 25) + .zIndex(1) } } + #Preview { CertificationDetailPlanModalView(viewModel: CertificateDetailViewModel( fetchCertificationDetailUseCase: PreviewFetchCertificationDetailUseCase(), diff --git a/CERTI-iOS/Presentation/CertificateDetail/ViewModel/CertificateDetailViewModel.swift b/CERTI-iOS/Presentation/CertificateDetail/ViewModel/CertificateDetailViewModel.swift index d9fd9ae..25ebdc7 100644 --- a/CERTI-iOS/Presentation/CertificateDetail/ViewModel/CertificateDetailViewModel.swift +++ b/CERTI-iOS/Presentation/CertificateDetail/ViewModel/CertificateDetailViewModel.swift @@ -30,6 +30,7 @@ final class CertificateDetailViewModel: ObservableObject { @Published var showFailToBeAcquired: Bool = false @Published var showCompleteModal = false @Published var CertificationPlanDate: Date? = nil + @Published var CertificationPlanPlace: String? = nil private let logger = Logger(subsystem: Bundle.main.bundleIdentifier ?? "CERTI", category: "CertificationDetail") From f88c750c2a43d896bce08a8e783f4d27c8ebcb23 Mon Sep 17 00:00:00 2001 From: sangyup Date: Wed, 26 Nov 2025 17:51:42 +0900 Subject: [PATCH 04/13] =?UTF-8?q?[Style]=20#185=20-=20CertiTimePicker=20?= =?UTF-8?q?=EC=BB=B4=ED=8F=AC=EB=84=8C=ED=8A=B8,?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Components/CertiTimePicker.swift | 234 ++++++++++++++++++ 1 file changed, 234 insertions(+) create mode 100644 CERTI-iOS/Presentation/CertificateDetail/Components/CertiTimePicker.swift diff --git a/CERTI-iOS/Presentation/CertificateDetail/Components/CertiTimePicker.swift b/CERTI-iOS/Presentation/CertificateDetail/Components/CertiTimePicker.swift new file mode 100644 index 0000000..40c1ab1 --- /dev/null +++ b/CERTI-iOS/Presentation/CertificateDetail/Components/CertiTimePicker.swift @@ -0,0 +1,234 @@ +// +// CertiTimePicker.swift +// CERTI-iOS +// +// Created by 이상엽 on 11/21/25. +// + +import SwiftUI +import UIKit + +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: - Private 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, +) + + // 왼쪽 여백: picker 중앙에 맞추기 위한 offset + 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) + } + + class Coordinator: NSObject, UIPickerViewDelegate, UIPickerViewDataSource { + var parent: CertiTimePicker + + 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: CertiTimePicker) { + self.parent = parent + } + + func numberOfComponents(in pickerView: UIPickerView) -> Int { 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) ?? UIFont.systemFont(ofSize: 14, weight: .semibold) + label.textColor = .black + + 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 + } + } + } +} + +#Preview { + @State var isAM = true + @State var hour = 1 + @State var minute = 0 + ZStack { + CertiTimePicker(isAM: $isAM, hour: $hour, minute: $minute) + .frame(height: 180) + + Text(":") + .applyCertiFont(.caption_semibold_14) + .offset(x: 49, y: 0) + } +} + From 4c072c8f3181eb80d5ab0a2159b3665c6ca2dfd5 Mon Sep 17 00:00:00 2001 From: sangyup Date: Mon, 1 Dec 2025 15:39:25 +0900 Subject: [PATCH 05/13] =?UTF-8?q?[Style]=20#185=20-=20CertificationDetailP?= =?UTF-8?q?lanModalView=20UI=20=EC=99=84=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Components/CertiTimePicker.swift | 43 +++++++++++------- .../CertificationDetailPlanModalView.swift | 44 ++++++++++++++++++- 2 files changed, 71 insertions(+), 16 deletions(-) diff --git a/CERTI-iOS/Presentation/CertificateDetail/Components/CertiTimePicker.swift b/CERTI-iOS/Presentation/CertificateDetail/Components/CertiTimePicker.swift index 40c1ab1..b87cd78 100644 --- a/CERTI-iOS/Presentation/CertificateDetail/Components/CertiTimePicker.swift +++ b/CERTI-iOS/Presentation/CertificateDetail/Components/CertiTimePicker.swift @@ -124,7 +124,7 @@ private extension CertiPickerView { } } -struct CertiTimePicker: UIViewRepresentable { +struct CustomTimePicker: UIViewRepresentable { @Binding var isAM: Bool @Binding var hour: Int @Binding var minute: Int @@ -147,7 +147,7 @@ struct CertiTimePicker: UIViewRepresentable { } class Coordinator: NSObject, UIPickerViewDelegate, UIPickerViewDataSource { - var parent: CertiTimePicker + var parent: CustomTimePicker static let ampm: [String] = ["오전", "오후"] static let hours: [Int] = Array(1...12) @@ -155,12 +155,13 @@ struct CertiTimePicker: UIViewRepresentable { static let hoursInfinite: [Int] = Array(repeating: hours, count: 100).flatMap { $0 } static let minutesInfinite: [Int] = Array(repeating: minutes, count: 100).flatMap { $0 } - - init(_ parent: CertiTimePicker) { + init(_ parent: CustomTimePicker) { self.parent = parent } - func numberOfComponents(in pickerView: UIPickerView) -> Int { 3 } + func numberOfComponents(in pickerView: UIPickerView) -> Int { + return 3 + } func pickerView(_ pickerView: UIPickerView, numberOfRowsInComponent component: Int) -> Int { switch component { @@ -178,8 +179,8 @@ struct CertiTimePicker: UIViewRepresentable { let label = UILabel() label.textAlignment = .center - label.font = UIFont(name: "Pretendard-SemiBold", size: 14) ?? UIFont.systemFont(ofSize: 14, weight: .semibold) - label.textColor = .black + label.font = UIFont(name: "Pretendard-SemiBold", size: 14) + label.textColor = UIColor(named: "grayscale600") switch component { case 0: label.text = Self.ampm[row] @@ -218,17 +219,29 @@ struct CertiTimePicker: UIViewRepresentable { } } +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 { @State var isAM = true @State var hour = 1 @State var minute = 0 - ZStack { - CertiTimePicker(isAM: $isAM, hour: $hour, minute: $minute) - .frame(height: 180) - - Text(":") - .applyCertiFont(.caption_semibold_14) - .offset(x: 49, y: 0) - } + + CertiTimePicker(isAM: $isAM, hour: $hour, minute: $minute) } diff --git a/CERTI-iOS/Presentation/CertificateDetail/View/CertificationDetailPlanModalView.swift b/CERTI-iOS/Presentation/CertificateDetail/View/CertificationDetailPlanModalView.swift index 33d9601..f09c76d 100644 --- a/CERTI-iOS/Presentation/CertificateDetail/View/CertificationDetailPlanModalView.swift +++ b/CERTI-iOS/Presentation/CertificateDetail/View/CertificationDetailPlanModalView.swift @@ -13,6 +13,9 @@ struct CertificationDetailPlanModalView: View { @EnvironmentObject var tabRouter: CertiTabCoordinator @State private var isCalendarVisible: Bool = false + @State var isAM = true + @State var hour = 1 + @State var minute = 0 private var dateFormatter: DateFormatter { let formatter = DateFormatter() @@ -35,6 +38,8 @@ struct CertificationDetailPlanModalView: View { } .scrollIndicators(.hidden) //.background(.yellow) + Spacer() + bottomButtonView } } @@ -184,11 +189,48 @@ extension CertificationDetailPlanModalView { .padding(.leading, 20) .padding(.trailing, 267) - + CertiTimePicker(isAM: $isAM, hour: $hour, minute: $minute) + .padding(.horizontal, 20) } .padding(.top, 25) .zIndex(1) } + + @ViewBuilder + private var bottomButtonView: some View { + VStack(alignment: .center, spacing: 0) { + Button { + print("나중에 입력하기 클릭") + } 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 { + print("적용하기 클릭") + } label: { + ZStack { + Rectangle() + .frame(width: 335, height: 56) + .foregroundStyle(.purpleblue) + .cornerRadius(12) + + Text("적용하기") + .applyCertiFont(.body_semibold_16) + .foregroundStyle(.white) + } + } + .padding(.top, 12) + } + } } #Preview { From eb86ab6d9ec8b96b38a13f73f0e098b30e92e355 Mon Sep 17 00:00:00 2001 From: sangyup Date: Tue, 2 Dec 2025 13:32:02 +0900 Subject: [PATCH 06/13] =?UTF-8?q?[Style]=20#185=20-=20Sheet=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../View/CertificateDetailView.swift | 15 ++++--- .../CertificationDetailPlanModalView.swift | 41 +++++++++++++------ 2 files changed, 38 insertions(+), 18 deletions(-) diff --git a/CERTI-iOS/Presentation/CertificateDetail/View/CertificateDetailView.swift b/CERTI-iOS/Presentation/CertificateDetail/View/CertificateDetailView.swift index 06a15b9..9ebee3f 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 index f09c76d..50f3e9f 100644 --- a/CERTI-iOS/Presentation/CertificateDetail/View/CertificationDetailPlanModalView.swift +++ b/CERTI-iOS/Presentation/CertificateDetail/View/CertificationDetailPlanModalView.swift @@ -12,6 +12,9 @@ struct CertificationDetailPlanModalView: View { @EnvironmentObject var tabRouter: CertiTabCoordinator + @Binding var certificationId: Int + @Binding var isShowingSheet: Bool + @State private var isCalendarVisible: Bool = false @State var isAM = true @State var hour = 1 @@ -37,8 +40,9 @@ struct CertificationDetailPlanModalView: View { timeView } .scrollIndicators(.hidden) - //.background(.yellow) + Spacer() + bottomButtonView } } @@ -63,6 +67,7 @@ extension CertificationDetailPlanModalView { .padding(.trailing, 206) } + .padding(.top, 60) .frame(maxWidth: .infinity, maxHeight: .infinity) } @@ -128,13 +133,14 @@ extension CertificationDetailPlanModalView { } ), 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")) - .background(Color.white) .padding(.horizontal, 8) .padding(.top, 11) - .clipShape(RoundedRectangle(cornerRadius: 12)) - .shadow(color: .black.opacity(0.08), radius: 20, x: 4, y: 4) - } } .padding(.top, 12) @@ -175,7 +181,7 @@ extension CertificationDetailPlanModalView { @ViewBuilder private var timeView: some View { - VStack(alignment: .leading, spacing: 0) { + VStack(alignment: .center, spacing: 0) { HStack(alignment: .center, spacing: 0) { Image(.iconCheck24) .frame(width: 24, height: 24) @@ -184,13 +190,13 @@ extension CertificationDetailPlanModalView { .applyCertiFont(.body_semibold_16) .foregroundStyle(.grayscale600) .frame(height: 22) + + Spacer() } - .frame(width: 88, height: 24) + .frame(height: 24) .padding(.leading, 20) - .padding(.trailing, 267) - + CertiTimePicker(isAM: $isAM, hour: $hour, minute: $minute) - .padding(.horizontal, 20) } .padding(.top, 25) .zIndex(1) @@ -200,7 +206,10 @@ extension CertificationDetailPlanModalView { private var bottomButtonView: some View { VStack(alignment: .center, spacing: 0) { Button { - print("나중에 입력하기 클릭") +// Task { +// await viewModel.appendPreCertification(certificationId: certificationId) +// } + isShowingSheet.toggle() } label: { VStack(alignment: .center, spacing: 0) { Text("나중에 입력하기") @@ -215,7 +224,10 @@ extension CertificationDetailPlanModalView { } Button { - print("적용하기 클릭") +// Task { +// await viewModel.appendPreCertification(certificationId: certificationId) +// } + isShowingSheet.toggle() } label: { ZStack { Rectangle() @@ -234,10 +246,13 @@ extension CertificationDetailPlanModalView { } #Preview { + @State var certificationId = 1 + @State var isShowingSheet = true + CertificationDetailPlanModalView(viewModel: CertificateDetailViewModel( fetchCertificationDetailUseCase: PreviewFetchCertificationDetailUseCase(), addPreCertificationUseCase: PreviewAddPreCertificationUseCase(), addAcquisitionUseCase: PreviewAddAcquisitionUseCase()), - certificationName: "GTQ 1급 (그래픽기술자격)") + certificationId: $certificationId, isShowingSheet: $isShowingSheet, certificationName: "GTQ 1급 (그래픽기술자격)") } From 29d01c6c57000fe218142588cb589c1a912e28d3 Mon Sep 17 00:00:00 2001 From: sangyup Date: Tue, 2 Dec 2025 14:53:27 +0900 Subject: [PATCH 07/13] =?UTF-8?q?[Chore]=20#185=20-=20DropDownMenu=20?= =?UTF-8?q?=EB=B3=80=EC=88=98=EB=AA=85=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../ViewModel/CertificateDetailViewModel.swift | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/CERTI-iOS/Presentation/CertificateDetail/ViewModel/CertificateDetailViewModel.swift b/CERTI-iOS/Presentation/CertificateDetail/ViewModel/CertificateDetailViewModel.swift index 25ebdc7..277aaf7 100644 --- a/CERTI-iOS/Presentation/CertificateDetail/ViewModel/CertificateDetailViewModel.swift +++ b/CERTI-iOS/Presentation/CertificateDetail/ViewModel/CertificateDetailViewModel.swift @@ -30,7 +30,8 @@ final class CertificateDetailViewModel: ObservableObject { @Published var showFailToBeAcquired: Bool = false @Published var showCompleteModal = false @Published var CertificationPlanDate: Date? = nil - @Published var CertificationPlanPlace: String? = nil + @Published var CertificationPlanPlaceDo: String? = nil + @Published var CertificationPlanPlaceSi: String? = nil private let logger = Logger(subsystem: Bundle.main.bundleIdentifier ?? "CERTI", category: "CertificationDetail") From f313081e4c4d7e5363b53b1e563d66351b0a05db Mon Sep 17 00:00:00 2001 From: sangyup Date: Tue, 2 Dec 2025 14:54:13 +0900 Subject: [PATCH 08/13] =?UTF-8?q?[Feat]=20#185=20-=20=EC=BB=B4=ED=8F=AC?= =?UTF-8?q?=EB=84=8C=ED=8A=B8=20=EB=B6=84=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 날짜, 시간, 장소 --- .../Components/CertiTimePicker.swift | 7 +- .../Components/DatePickerBox.swift | 104 ++++++++++++++++++ .../Components/DropDownMenu.swift | 62 ++++++----- .../CertificationDetailPlanModalView.swift | 81 +++----------- 4 files changed, 153 insertions(+), 101 deletions(-) create mode 100644 CERTI-iOS/Presentation/CertificateDetail/Components/DatePickerBox.swift diff --git a/CERTI-iOS/Presentation/CertificateDetail/Components/CertiTimePicker.swift b/CERTI-iOS/Presentation/CertificateDetail/Components/CertiTimePicker.swift index b87cd78..7bbc7fa 100644 --- a/CERTI-iOS/Presentation/CertificateDetail/Components/CertiTimePicker.swift +++ b/CERTI-iOS/Presentation/CertificateDetail/Components/CertiTimePicker.swift @@ -60,7 +60,7 @@ final class CertiPickerView: UIPickerView { } -// MARK: - Private Layout Methods +// MARK: - Layout Methods private extension CertiPickerView { func hideIndicator() { @@ -78,11 +78,9 @@ private extension CertiPickerView { } func layoutLines() { - // 전체 폭 계산 let totalContentWidth = layouts.map { $0.textWidth }.reduce(0, +) + lineSpacing.reduce(0, +) - // 왼쪽 여백: picker 중앙에 맞추기 위한 offset var xOffset: CGFloat = (bounds.width - totalContentWidth) / 2 let rowHeight = rowSize(forComponent: 0).height @@ -93,11 +91,9 @@ private extension CertiPickerView { for i in 0..( + 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/Presentation/CertificateDetail/Components/DropDownMenu.swift b/CERTI-iOS/Presentation/CertificateDetail/Components/DropDownMenu.swift index 5bab248..8373990 100644 --- a/CERTI-iOS/Presentation/CertificateDetail/Components/DropDownMenu.swift +++ b/CERTI-iOS/Presentation/CertificateDetail/Components/DropDownMenu.swift @@ -9,7 +9,8 @@ import SwiftUI struct DropdownMenu: View { @State private var isOpen = false - @State private var selected: String? = nil + + @Binding var selectedPlace: String? let options: [String] let menuPlaceholder: String @@ -21,9 +22,9 @@ struct DropdownMenu: View { } } label: { HStack { - Text(selected ?? menuPlaceholder) + Text(selectedPlace ?? menuPlaceholder) .applyCertiFont(.caption_semibold_12) - .foregroundColor(selected == nil ? .grayscale300 : .grayscale600) + .foregroundColor(selectedPlace == nil ? .grayscale300 : .grayscale600) .padding(.leading, 12) .padding(.vertical, 11) @@ -43,7 +44,7 @@ struct DropdownMenu: View { VStack(alignment: .center, spacing: 0) { ForEach(options, id: \.self) { item in Button { - selected = item + selectedPlace = item withAnimation { isOpen = false } @@ -74,29 +75,36 @@ struct DropdownMenu: View { } #Preview { - VStack { - HStack(alignment: .center, spacing: 0) { - Image(.iconCheck24) - .frame(width: 24, height: 24) - - Text("시험 장소") - .applyCertiFont(.body_semibold_16) - .foregroundStyle(.grayscale600) - .frame(height: 22) - } - - DropdownMenu(options: ["서울", "경기", "인천", "강원", "충남", "충북"], menuPlaceholder: "시/도") - .zIndex(2) + struct PreviewWrapper: View { + @State private var selectedPlace: String? = nil - HStack(alignment: .center, spacing: 0) { - Image(.iconCheck24) - .frame(width: 24, height: 24) - - Text("시험 시간") - .applyCertiFont(.body_semibold_16) - .foregroundStyle(.grayscale600) - .frame(height: 22) + 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) + } } - .zIndex(1) - } + } + return PreviewWrapper() } diff --git a/CERTI-iOS/Presentation/CertificateDetail/View/CertificationDetailPlanModalView.swift b/CERTI-iOS/Presentation/CertificateDetail/View/CertificationDetailPlanModalView.swift index 50f3e9f..63da5bd 100644 --- a/CERTI-iOS/Presentation/CertificateDetail/View/CertificationDetailPlanModalView.swift +++ b/CERTI-iOS/Presentation/CertificateDetail/View/CertificationDetailPlanModalView.swift @@ -15,22 +15,15 @@ struct CertificationDetailPlanModalView: View { @Binding var certificationId: Int @Binding var isShowingSheet: Bool - @State private var isCalendarVisible: Bool = false @State var isAM = true @State var hour = 1 @State var minute = 0 - private var dateFormatter: DateFormatter { - let formatter = DateFormatter() - formatter.dateFormat = "yyyy.MM.dd" - formatter.locale = Locale(identifier: "ko_KR") - return formatter - } - let certificationName: String + + // TODO: - API 연결하면 지우기 let placeMenuOptions = ["서울", "경기" ,"인천", "강원", "충남", "충북"] let placeMenuOptions2 = ["강북구", "마포구" ,"용산구", "성북구"] - var body: some View { ScrollView(.vertical) { @@ -87,62 +80,7 @@ extension CertificationDetailPlanModalView { .padding(.leading, 20) .padding(.trailing, 267) - VStack(alignment: .leading, spacing: 0) { - Button { - withAnimation(.easeInOut(duration: 0.2)){ - isCalendarVisible.toggle() - } - } label: { - HStack(alignment: .center, spacing: 0) { - if viewModel.CertificationPlanDate != nil { - Text(dateFormatter.string(from: viewModel.CertificationPlanDate!)) - .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: { self.viewModel.CertificationPlanDate ?? Date() }, - set: { - self.viewModel.CertificationPlanDate = $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) - } - } + DatePickerBox(selectedDate: $viewModel.CertificationPlanDate) .padding(.top, 12) } .padding(.top, 32) @@ -165,11 +103,11 @@ extension CertificationDetailPlanModalView { .padding(.trailing, 267) HStack(alignment: .center, spacing: 0) { - DropdownMenu(options: placeMenuOptions, menuPlaceholder: "시/도") + DropdownMenu(selectedPlace: $viewModel.CertificationPlanPlaceDo, options: placeMenuOptions, menuPlaceholder: "시/도") Spacer() - DropdownMenu(options: placeMenuOptions2, menuPlaceholder: "구/시") + DropdownMenu(selectedPlace: $viewModel.CertificationPlanPlaceSi, options: placeMenuOptions2, menuPlaceholder: "구/시") } .padding(.top, 12) .padding(.horizontal, 20) @@ -206,6 +144,7 @@ extension CertificationDetailPlanModalView { private var bottomButtonView: some View { VStack(alignment: .center, spacing: 0) { Button { + // TODO: - API 연결하기 // Task { // await viewModel.appendPreCertification(certificationId: certificationId) // } @@ -224,6 +163,7 @@ extension CertificationDetailPlanModalView { } Button { + // TODO: - API 연결하기 // Task { // await viewModel.appendPreCertification(certificationId: certificationId) // } @@ -246,13 +186,18 @@ extension CertificationDetailPlanModalView { } #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() } From b17281ae9e7b24c7a3d7356888c7e2635acf241e Mon Sep 17 00:00:00 2001 From: sangyup Date: Tue, 2 Dec 2025 14:55:23 +0900 Subject: [PATCH 09/13] =?UTF-8?q?[Chore]=20#185=20-=20=ED=8C=8C=EC=9D=BC?= =?UTF-8?q?=20=EC=9C=84=EC=B9=98=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../CertificateDetail => Global}/Components/CertiTimePicker.swift | 0 .../CertificateDetail => Global}/Components/DatePickerBox.swift | 0 .../CertificateDetail => Global}/Components/DropDownMenu.swift | 0 3 files changed, 0 insertions(+), 0 deletions(-) rename CERTI-iOS/{Presentation/CertificateDetail => Global}/Components/CertiTimePicker.swift (100%) rename CERTI-iOS/{Presentation/CertificateDetail => Global}/Components/DatePickerBox.swift (100%) rename CERTI-iOS/{Presentation/CertificateDetail => Global}/Components/DropDownMenu.swift (100%) diff --git a/CERTI-iOS/Presentation/CertificateDetail/Components/CertiTimePicker.swift b/CERTI-iOS/Global/Components/CertiTimePicker.swift similarity index 100% rename from CERTI-iOS/Presentation/CertificateDetail/Components/CertiTimePicker.swift rename to CERTI-iOS/Global/Components/CertiTimePicker.swift diff --git a/CERTI-iOS/Presentation/CertificateDetail/Components/DatePickerBox.swift b/CERTI-iOS/Global/Components/DatePickerBox.swift similarity index 100% rename from CERTI-iOS/Presentation/CertificateDetail/Components/DatePickerBox.swift rename to CERTI-iOS/Global/Components/DatePickerBox.swift diff --git a/CERTI-iOS/Presentation/CertificateDetail/Components/DropDownMenu.swift b/CERTI-iOS/Global/Components/DropDownMenu.swift similarity index 100% rename from CERTI-iOS/Presentation/CertificateDetail/Components/DropDownMenu.swift rename to CERTI-iOS/Global/Components/DropDownMenu.swift From cea4d544b0294acf3185f232dedee0fc0dd50e4a Mon Sep 17 00:00:00 2001 From: sangyup Date: Tue, 2 Dec 2025 15:21:56 +0900 Subject: [PATCH 10/13] =?UTF-8?q?[Chore]=20#185=20-=20TimePicker=20?= =?UTF-8?q?=EA=B2=BD=EA=B3=A0=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Global/Components/CertiTimePicker.swift | 51 ++++++++++--------- 1 file changed, 28 insertions(+), 23 deletions(-) diff --git a/CERTI-iOS/Global/Components/CertiTimePicker.swift b/CERTI-iOS/Global/Components/CertiTimePicker.swift index 7bbc7fa..0b17270 100644 --- a/CERTI-iOS/Global/Components/CertiTimePicker.swift +++ b/CERTI-iOS/Global/Components/CertiTimePicker.swift @@ -26,7 +26,7 @@ final class CertiPickerView: UIPickerView { ] private let lineSpacing: [CGFloat] = [12, 10] - + override init(frame: CGRect) { super.init(frame: frame) setupLines() @@ -66,10 +66,10 @@ private extension CertiPickerView { func hideIndicator() { for sub in subviews { let isIndicator = - sub.subviews.isEmpty && - sub.bounds.height > 5 && - sub.bounds.height < 60 - + sub.subviews.isEmpty && + sub.bounds.height > 5 && + sub.bounds.height < 60 + if isIndicator { sub.isHidden = true sub.alpha = 0 @@ -79,7 +79,7 @@ private extension CertiPickerView { func layoutLines() { let totalContentWidth = - layouts.map { $0.textWidth }.reduce(0, +) + lineSpacing.reduce(0, +) + layouts.map { $0.textWidth }.reduce(0, +) + lineSpacing.reduce(0, +) var xOffset: CGFloat = (bounds.width - totalContentWidth) / 2 @@ -123,41 +123,41 @@ struct CustomTimePicker: UIViewRepresentable { @Binding var isAM: Bool @Binding var hour: Int @Binding var minute: Int - + func makeCoordinator() -> 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) } - + 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 @@ -166,17 +166,17 @@ struct CustomTimePicker: UIViewRepresentable { 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])" @@ -186,7 +186,7 @@ struct CustomTimePicker: UIViewRepresentable { return label } - + func pickerView(_ pickerView: UIPickerView, widthForComponent component: Int) -> CGFloat { switch component { case 0: return 45 @@ -233,10 +233,15 @@ struct CertiTimePicker: View { } #Preview { - @State var isAM = true - @State var hour = 1 - @State var minute = 0 - - CertiTimePicker(isAM: $isAM, hour: $hour, minute: $minute) + 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() } From 3d4bcfd72b16c059194812001c0619e50fec6484 Mon Sep 17 00:00:00 2001 From: sangyup Date: Tue, 2 Dec 2025 15:22:52 +0900 Subject: [PATCH 11/13] =?UTF-8?q?[Chore]=20#185=20-=20=EC=BD=94=EB=93=9C?= =?UTF-8?q?=20=EC=A0=95=EB=A0=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Global/Components/DatePickerBox.swift | 33 ++++++------ .../Global/Components/DropDownMenu.swift | 6 +-- .../CertificationDetailPlanModalView.swift | 50 +++++++++---------- .../CertificateDetailViewModel.swift | 3 ++ 4 files changed, 45 insertions(+), 47 deletions(-) diff --git a/CERTI-iOS/Global/Components/DatePickerBox.swift b/CERTI-iOS/Global/Components/DatePickerBox.swift index 163f448..b172438 100644 --- a/CERTI-iOS/Global/Components/DatePickerBox.swift +++ b/CERTI-iOS/Global/Components/DatePickerBox.swift @@ -11,13 +11,13 @@ 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 - } + 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) { @@ -65,18 +65,18 @@ struct DatePickerBox: View { } } ), 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) + .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) } } - + } } @@ -95,7 +95,6 @@ struct DatePickerBox: View { .foregroundStyle(.grayscale600) .frame(height: 22) } - DatePickerBox(selectedDate: $selectedDate) } } diff --git a/CERTI-iOS/Global/Components/DropDownMenu.swift b/CERTI-iOS/Global/Components/DropDownMenu.swift index 8373990..e838e34 100644 --- a/CERTI-iOS/Global/Components/DropDownMenu.swift +++ b/CERTI-iOS/Global/Components/DropDownMenu.swift @@ -11,10 +11,10 @@ struct DropdownMenu: View { @State private var isOpen = false @Binding var selectedPlace: String? - + let options: [String] let menuPlaceholder: String - + var body: some View { Button { withAnimation { @@ -105,6 +105,6 @@ struct DropdownMenu: View { .zIndex(1) } } - } + } return PreviewWrapper() } diff --git a/CERTI-iOS/Presentation/CertificateDetail/View/CertificationDetailPlanModalView.swift b/CERTI-iOS/Presentation/CertificateDetail/View/CertificationDetailPlanModalView.swift index 63da5bd..ef032ff 100644 --- a/CERTI-iOS/Presentation/CertificateDetail/View/CertificationDetailPlanModalView.swift +++ b/CERTI-iOS/Presentation/CertificateDetail/View/CertificationDetailPlanModalView.swift @@ -15,10 +15,6 @@ struct CertificationDetailPlanModalView: View { @Binding var certificationId: Int @Binding var isShowingSheet: Bool - @State var isAM = true - @State var hour = 1 - @State var minute = 0 - let certificationName: String // TODO: - API 연결하면 지우기 @@ -33,9 +29,9 @@ struct CertificationDetailPlanModalView: View { timeView } .scrollIndicators(.hidden) - + Spacer() - + bottomButtonView } } @@ -44,7 +40,7 @@ extension CertificationDetailPlanModalView { @ViewBuilder private var headerView: some View { VStack(alignment: .leading, spacing: 0) { - + Text("자격증 시험 정보를 입력해주세요") .applyCertiFont(.body_bold_18) .foregroundStyle(.grayscale600) @@ -58,7 +54,7 @@ extension CertificationDetailPlanModalView { .frame(width: 149, height: 20) .padding(.leading, 20) .padding(.trailing, 206) - + } .padding(.top, 60) .frame(maxWidth: .infinity, maxHeight: .infinity) @@ -81,7 +77,7 @@ extension CertificationDetailPlanModalView { .padding(.trailing, 267) DatePickerBox(selectedDate: $viewModel.CertificationPlanDate) - .padding(.top, 12) + .padding(.top, 12) } .padding(.top, 32) } @@ -111,7 +107,7 @@ extension CertificationDetailPlanModalView { } .padding(.top, 12) .padding(.horizontal, 20) - + } .padding(.top, 25) .zIndex(2) @@ -133,8 +129,8 @@ extension CertificationDetailPlanModalView { } .frame(height: 24) .padding(.leading, 20) - - CertiTimePicker(isAM: $isAM, hour: $hour, minute: $minute) + + CertiTimePicker(isAM: $viewModel.isAM, hour: $viewModel.hour, minute: $viewModel.minute) } .padding(.top, 25) .zIndex(1) @@ -144,10 +140,10 @@ extension CertificationDetailPlanModalView { private var bottomButtonView: some View { VStack(alignment: .center, spacing: 0) { Button { - // TODO: - API 연결하기 -// Task { -// await viewModel.appendPreCertification(certificationId: certificationId) -// } + // TODO: - API 연결하기 + // Task { + // await viewModel.appendPreCertification(certificationId: certificationId) + // } isShowingSheet.toggle() } label: { VStack(alignment: .center, spacing: 0) { @@ -163,10 +159,10 @@ extension CertificationDetailPlanModalView { } Button { - // TODO: - API 연결하기 -// Task { -// await viewModel.appendPreCertification(certificationId: certificationId) -// } + // TODO: - API 연결하기 + // Task { + // await viewModel.appendPreCertification(certificationId: certificationId) + // } isShowingSheet.toggle() } label: { ZStack { @@ -187,15 +183,15 @@ extension CertificationDetailPlanModalView { #Preview { struct PreviewWrapper: View { - @State var certificationId = 1 - @State var isShowingSheet = true + @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급 (그래픽기술자격)") + 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 277aaf7..d825b76 100644 --- a/CERTI-iOS/Presentation/CertificateDetail/ViewModel/CertificateDetailViewModel.swift +++ b/CERTI-iOS/Presentation/CertificateDetail/ViewModel/CertificateDetailViewModel.swift @@ -32,6 +32,9 @@ final class CertificateDetailViewModel: ObservableObject { @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") From b56a5685284f11df28a6f966d2b1bd01828e9e77 Mon Sep 17 00:00:00 2001 From: sangyup Date: Thu, 18 Dec 2025 17:09:53 +0900 Subject: [PATCH 12/13] =?UTF-8?q?[Chore]=20#185=20-=20Coordinator=20extens?= =?UTF-8?q?ion=20=EB=B6=84=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CERTI-iOS/Global/Components/CertiTimePicker.swift | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/CERTI-iOS/Global/Components/CertiTimePicker.swift b/CERTI-iOS/Global/Components/CertiTimePicker.swift index 0b17270..e2c4ae7 100644 --- a/CERTI-iOS/Global/Components/CertiTimePicker.swift +++ b/CERTI-iOS/Global/Components/CertiTimePicker.swift @@ -8,6 +8,8 @@ import SwiftUI import UIKit +// MARK: - UIKit View + final class CertiPickerView: UIPickerView { private var topLines: [UIView] = [] @@ -119,6 +121,8 @@ private extension CertiPickerView { } } +// MARK: - UIViewRepresentable + struct CustomTimePicker: UIViewRepresentable { @Binding var isAM: Bool @Binding var hour: Int @@ -140,7 +144,11 @@ struct CustomTimePicker: UIViewRepresentable { 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 @@ -214,6 +222,8 @@ struct CustomTimePicker: UIViewRepresentable { } } +// MARK: - SwiftUI Component + struct CertiTimePicker: View { @Binding var isAM: Bool @Binding var hour: Int From dec628503b0e675341c3d749d3a5608a2cffe36d Mon Sep 17 00:00:00 2001 From: sangyup Date: Thu, 18 Dec 2025 17:11:08 +0900 Subject: [PATCH 13/13] =?UTF-8?q?[Chore]=20#185=20-=20=EA=B3=B5=EB=B0=B1?= =?UTF-8?q?=20=EC=A7=80=EC=9A=B0=EA=B8=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../View/CertificationDetailPlanModalView.swift | 1 - 1 file changed, 1 deletion(-) diff --git a/CERTI-iOS/Presentation/CertificateDetail/View/CertificationDetailPlanModalView.swift b/CERTI-iOS/Presentation/CertificateDetail/View/CertificationDetailPlanModalView.swift index ef032ff..fcbd084 100644 --- a/CERTI-iOS/Presentation/CertificateDetail/View/CertificationDetailPlanModalView.swift +++ b/CERTI-iOS/Presentation/CertificateDetail/View/CertificationDetailPlanModalView.swift @@ -54,7 +54,6 @@ extension CertificationDetailPlanModalView { .frame(width: 149, height: 20) .padding(.leading, 20) .padding(.trailing, 206) - } .padding(.top, 60) .frame(maxWidth: .infinity, maxHeight: .infinity)