diff --git a/.gitignore b/.gitignore index b710499..f5d8d1d 100644 --- a/.gitignore +++ b/.gitignore @@ -21,3 +21,4 @@ xcuserdata/ *.xcodeproj/project.xcworkspace/xcuserdata/* # End of https://www.toptal.com/developers/gitignore/api/xcode +.DS_Store diff --git a/CodiCue.xcodeproj/project.pbxproj b/CodiCue.xcodeproj/project.pbxproj index ca873ee..7f4b8d0 100644 --- a/CodiCue.xcodeproj/project.pbxproj +++ b/CodiCue.xcodeproj/project.pbxproj @@ -281,6 +281,7 @@ DEVELOPMENT_TEAM = J9ZM2PBPP6; ENABLE_PREVIEWS = YES; GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_KEY_NSCameraUsageDescription = "AI 체형 분석 및 옷장 기능을 위해 카메라 권한이 필요합니다."; INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; INFOPLIST_KEY_UILaunchScreen_Generation = YES; @@ -312,6 +313,7 @@ DEVELOPMENT_TEAM = J9ZM2PBPP6; ENABLE_PREVIEWS = YES; GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_KEY_NSCameraUsageDescription = "AI 체형 분석 및 옷장 기능을 위해 카메라 권한이 필요합니다."; INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; INFOPLIST_KEY_UILaunchScreen_Generation = YES; diff --git a/CodiCue/BodyScan/BodyScanCameraView.swift b/CodiCue/BodyScan/BodyScanCameraView.swift new file mode 100644 index 0000000..fbdbce8 --- /dev/null +++ b/CodiCue/BodyScan/BodyScanCameraView.swift @@ -0,0 +1,188 @@ +// +// BodyScanCameraView.swift +// CodiCue +// +// Created by Yeeun on 9/23/25. +// + +import SwiftUI +import AVFoundation +import UIKit +import Combine + +// MARK: - Public SwiftUI Wrapper +struct BodyScanCameraView: View { + var onCapture: (UIImage?) -> Void + @Environment(\.dismiss) private var dismiss + + @StateObject private var camera = CameraService() + + var body: some View { + ZStack { + CameraPreview(session: camera.session) + .ignoresSafeArea() + + VStack { + HStack { + Button { + onCapture(nil) + dismiss() + } label: { + Image(systemName: "xmark.circle.fill") + .font(.system(size: 28, weight: .bold)) + .foregroundStyle(.white) + .shadow(radius: 4) + } + .padding(.leading, 16) + .padding(.top, 12) + Spacer() + } + Spacer() + Button { + camera.capturePhoto { image in + onCapture(image) + dismiss() + } + } label: { + ZStack { + Circle().fill(.white.opacity(0.2)).frame(width: 86, height: 86) + Circle().fill(.white).frame(width: 72, height: 72) + } + } + .padding(.bottom, 36) + } + } + .onAppear { + camera.configure() + camera.startRunning() + } + .onDisappear { + camera.stopRunning() + } + } +} + +// MARK: - Camera Service (AVCaptureSession) +final class CameraService: NSObject, ObservableObject { + let session = AVCaptureSession() + private let sessionQueue = DispatchQueue(label: "camera.session.queue") + + private var photoOutput = AVCapturePhotoOutput() + private var videoDeviceInput: AVCaptureDeviceInput? + private var captureCompletion: ((UIImage?) -> Void)? + + func configure() { + // 여러 번 호출돼도 한 번만 구성되도록 + guard session.inputs.isEmpty && session.outputs.isEmpty else { return } + + session.beginConfiguration() + session.sessionPreset = .photo + + // 카메라 접근 권한 + switch AVCaptureDevice.authorizationStatus(for: .video) { + case .authorized: + break + case .notDetermined: + AVCaptureDevice.requestAccess(for: .video) { _ in } + default: + break + } + + // 후면 카메라 + guard let device = AVCaptureDevice.default(.builtInWideAngleCamera, for: .video, position: .back), + let input = try? AVCaptureDeviceInput(device: device), + session.canAddInput(input) else { + session.commitConfiguration() + return + } + session.addInput(input) + self.videoDeviceInput = input + + // 사진 출력 + guard session.canAddOutput(photoOutput) else { + session.commitConfiguration() + return + } + session.addOutput(photoOutput) + photoOutput.isHighResolutionCaptureEnabled = true + + session.commitConfiguration() + } + + func startRunning() { + sessionQueue.async { + if !self.session.isRunning { + self.session.startRunning() + } + } + } + + func stopRunning() { + sessionQueue.async { + if self.session.isRunning { + self.session.stopRunning() + } + } + } + + func capturePhoto(completion: @escaping (UIImage?) -> Void) { + captureCompletion = completion + let settings = AVCapturePhotoSettings() + settings.isHighResolutionPhotoEnabled = true + photoOutput.capturePhoto(with: settings, delegate: self) + } +} + +extension CameraService: AVCapturePhotoCaptureDelegate { + func photoOutput(_ output: AVCapturePhotoOutput, + didFinishProcessingPhoto photo: AVCapturePhoto, + error: Error?) { + if let error = error { + print("capture error:", error) + captureCompletion?(nil) + captureCompletion = nil + return + } + guard let data = photo.fileDataRepresentation(), + var image = UIImage(data: data) else { + captureCompletion?(nil) + captureCompletion = nil + return + } + // 미리보기 레이어는 항상 Portrait 기준이 아닐 수 있으니 정방향으로 보정 + image = image.fixedOrientation() + captureCompletion?(image) + captureCompletion = nil + } +} + +// MARK: - Preview Layer (UIViewRepresentable) +struct CameraPreview: UIViewRepresentable { + let session: AVCaptureSession + + func makeUIView(context: Context) -> PreviewView { + let v = PreviewView() + v.videoPreviewLayer.session = session + v.videoPreviewLayer.videoGravity = .resizeAspectFill + return v + } + + func updateUIView(_ uiView: PreviewView, context: Context) { } +} + +final class PreviewView: UIView { + override class var layerClass: AnyClass { AVCaptureVideoPreviewLayer.self } + var videoPreviewLayer: AVCaptureVideoPreviewLayer { layer as! AVCaptureVideoPreviewLayer } +} + +// MARK: - UIImage orientation fix +private extension UIImage { + func fixedOrientation() -> UIImage { + guard imageOrientation != .up else { return self } + UIGraphicsBeginImageContextWithOptions(size, false, scale) + draw(in: CGRect(origin: .zero, size: size)) + let normalized = UIGraphicsGetImageFromCurrentImageContext() + UIGraphicsEndImageContext() + return normalized ?? self + } +} diff --git a/CodiCue/BodyScan/BodyScanFlowView.swift b/CodiCue/BodyScan/BodyScanFlowView.swift new file mode 100644 index 0000000..ae7deea --- /dev/null +++ b/CodiCue/BodyScan/BodyScanFlowView.swift @@ -0,0 +1,147 @@ +// +// BodyScanFlowView.swift +// CodiCue +// +// Created by Yeeun on 9/23/25. +// + +import SwiftUI + +enum BodyScanPose: Int, CaseIterable { + case front = 0, side, back + + var title: String { + switch self { + case .front: return "Step 1 / 3 - 정면 촬영" + case .side: return "Step 2 / 3 - 측면 촬영" + case .back: return "Step 3 / 3 - 후면 촬영" + } + } + + var guide: String { + "아래와 같이 카메라와 약 2m 떨어져 해당 방향을 바라보고 전신 사진을 촬영해주세요!" + } + + var sampleName: String { + switch self { + case .front: return "sample_front" + case .side: return "sample_side" + case .back: return "sample_back" + } + } +} + +struct BodyScanFlowView: View { + @State private var step: Int = 0 + @State private var showCamera: Bool = false + @State private var photos: [BodyScanPose: UIImage] = [:] + + private var isPreview: Bool { + ProcessInfo.processInfo.environment["XCODE_RUNNING_FOR_PREVIEWS"] == "1" + } + private var pose: BodyScanPose { BodyScanPose(rawValue: step)! } + private var isLast: Bool { step == BodyScanPose.allCases.count - 1 } + + var body: some View { + VStack(alignment: .leading, spacing: 16) { + Text(pose.title) + .font(.title3.bold()) + + Text(pose.guide) + .font(.subheadline) + .foregroundStyle(.secondary) + + VStack(spacing: 8) { + Text("예시 \(pose == .front ? "전신" : pose == .side ? "측면" : "후면") 사진") + .font(.caption) + .foregroundStyle(.secondary) + + ZStack { + RoundedRectangle(cornerRadius: 16) + .fill(Color(.systemGray6)) + sampleImage + .resizable() + .scaledToFit() + .padding(24) + } + .frame(maxWidth: .infinity) + .frame(height: 360) + } + + Spacer(minLength: 0) + + Button { + showCamera = true + } label: { + Text("촬영하기") + .font(.headline.weight(.semibold)) + .foregroundStyle(.white) + .frame(maxWidth: .infinity, minHeight: 48) + .background(RoundedRectangle(cornerRadius: 12).fill(Color("primaryColor"))) + } + .buttonStyle(.plain) + } + .padding(.horizontal, 16) + .padding(.top, 10) + .padding(.bottom, 16) + .navigationTitle("") + .navigationBarTitleDisplayMode(.inline) + .toolbar(.visible, for: .tabBar) + .sheet(isPresented: $showCamera) { cameraSheet } + .overlay(alignment: .topTrailing) { + HStack(spacing: 6) { + ForEach(BodyScanPose.allCases, id: \.rawValue) { p in + Circle() + .fill(photos[p] == nil ? Color.gray.opacity(0.25) : Color("primaryColor")) + .frame(width: 8, height: 8) + } + } + .padding(12) + } + } + + @ViewBuilder + private var cameraSheet: some View { + #if targetEnvironment(simulator) + PhotoLibraryPicker { image in + if let image { photos[pose] = image; advance() } + } + #else + if UIImagePickerController.isSourceTypeAvailable(.camera) { + BodyScanCameraView { image in + photos[pose] = image + advance() + } + } else { + VStack(spacing: 12) { + Text("이 기기에서는 카메라를 사용할 수 없어요.") + .font(.headline) + Button("닫기") { showCamera = false } + .buttonStyle(.borderedProminent) + } + .padding() + } + #endif + } + + private var sampleImage: Image { + if let ui = UIImage(named: pose.sampleName) { + return Image(uiImage: ui) + } else { + switch pose { + case .front: return Image(systemName: "person.fill") + case .side: return Image(systemName: "person.fill.turn.right") + case .back: return Image(systemName: "person.fill.turn.down") + } + } + } + + private func advance() { + if isLast { + // 결과 화면으로 이동 연결 예정 + } else { + step += 1 + } + showCamera = false + } +} diff --git a/CodiCue/BodyScan/BodyScanHomeView.swift b/CodiCue/BodyScan/BodyScanHomeView.swift new file mode 100644 index 0000000..88b32b0 --- /dev/null +++ b/CodiCue/BodyScan/BodyScanHomeView.swift @@ -0,0 +1,158 @@ +// +// BodyScanHomeView.swift +// CodiCue +// +// Created by Yeeun on 9/23/25. +// + +import SwiftUI + +struct BodyScanHomeView: View { + @State private var reports: [BodyScanReport] = BodyScanReport.mock + + var body: some View { + ScrollView { + VStack(alignment: .leading, spacing: 16) { + + Text("AI 체형 분석") + .font(.title3.weight(.bold)) + + Text("AI가 전신 사진을 분석하여 체형을 진단하고 스타일을 추천해드립니다!") + .font(.subheadline) + .foregroundStyle(.primary) + .fixedSize(horizontal: false, vertical: true) + + NavigationLink { + BodyScanStep1View() + } label: { + Text("AI 체형 분석 시작하기") + .font(.subheadline.weight(.semibold)) + .foregroundStyle(.white) + .frame(maxWidth: .infinity, minHeight: 44) + .background(RoundedRectangle(cornerRadius: 12).fill(Color("primaryColor"))) + } + .buttonStyle(.plain) + + Text("기존 분석 결과") + .font(.headline) + .padding(.top, 8) + + VStack(spacing: 12) { + ForEach(reports) { report in + BodyScanReportCard(report: report) + } + } + .padding(.bottom, 8) + } + .padding(.horizontal, 16) + .padding(.top, 10) + .padding(.bottom, 24) + } + .navigationTitle("") + .navigationBarTitleDisplayMode(.inline) + .toolbar(.visible, for: .tabBar) + } +} + +private struct BodyScanReportCard: View { + let report: BodyScanReport + + var body: some View { + HStack(alignment: .top, spacing: 14) { + ZStack { + RoundedRectangle(cornerRadius: 12) + .fill(Color(.systemGray6)) + Image(systemName: figureSymbol) + .font(.system(size: 34, weight: .regular)) + .foregroundStyle(.secondary) + } + .frame(width: 64, height: 88) + + VStack(alignment: .leading, spacing: 6) { + Text(report.dateString + " · 분석 결과") + .font(.caption) + .foregroundStyle(.secondary) + + Text(report.title) + .font(.body.weight(.semibold)) + + VStack(alignment: .leading, spacing: 4) { + ForEach(report.highlights, id: \.self) { line in + HStack(alignment: .top, spacing: 8) { + Circle().frame(width: 4, height: 4) + .foregroundStyle(.secondary) + .padding(.top, 6) + Text(line) + .font(.subheadline) + .foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) + } + } + } + .padding(.top, 2) + } + + Spacer(minLength: 0) + } + .padding(14) + .background( + RoundedRectangle(cornerRadius: 16) + .fill(Color.white) + .shadow(color: .black.opacity(0.06), radius: 12, x: 0, y: 4) + ) + } + + private var figureSymbol: String { + if UIImage(systemName: "figure.stand") != nil { + return "figure.stand" + } else { + return "person" + } + } +} + + +struct BodyScanReport: Identifiable { + let id = UUID() + let date: Date + let title: String + let highlights: [String] + + var dateString: String { + let f = DateFormatter() + f.locale = Locale(identifier: "ko_KR") + f.dateFormat = "yyyy. MM. dd." + return f.string(from: date) + } + + static let mock: [BodyScanReport] = [ + .init( + date: Calendar.current.date(byAdding: .day, value: -1, to: Date())!, + title: "역삼각형 체형", + highlights: [ + "어깨가 넓고, 허리가 얇은 체형", + "세미 오버 재킷 스타일 추천" + ] + ), + .init( + date: Calendar.current.date(byAdding: .day, value: -7, to: Date())!, + title: "역삼각형 체형", + highlights: [ + "어깨가 넓고, 허리가 얇은 체형", + "세미 오버 재킷 스타일 추천" + ] + ), + .init( + date: Calendar.current.date(byAdding: .day, value: -30, to: Date())!, + title: "역삼각형 체형", + highlights: [ + "어깨가 넓고, 허리가 얇은 체형", + "세미 오버 재킷 스타일 추천" + ] + ) + ] +} + +#Preview { + NavigationView { BodyScanHomeView() } +} diff --git a/CodiCue/BodyScan/BodyScanStep1View.swift b/CodiCue/BodyScan/BodyScanStep1View.swift new file mode 100644 index 0000000..edb33d3 --- /dev/null +++ b/CodiCue/BodyScan/BodyScanStep1View.swift @@ -0,0 +1,69 @@ +// +// BodyScanStep1View.swift +// CodiCue +// +// Created by Yeeun on 9/23/25. +// + +import SwiftUI + +struct BodyScanStep1View: View { + @State private var showingCamera = false + @State private var capturedImage: UIImage? = nil + + var body: some View { + VStack(spacing: 16) { + HStack { + Text("Step 1 / 3 - 정면 촬영") + .font(.title3.bold()) + Spacer() + } + + Text("아래와 같이 카메라와 약 2m 떨어져 정면을 바라보고\n전신 사진을 촬영해주세요!") + .font(.subheadline) + .foregroundStyle(.secondary) + .frame(maxWidth: .infinity, alignment: .leading) + + ZStack { + RoundedRectangle(cornerRadius: 20) + .fill(Color(.systemGray6)) + if let img = capturedImage { + Image(uiImage: img) + .resizable() + .scaledToFit() + .padding(12) + } else { + // 예시 일러스트/이모지 + Text("🧍‍♂️") + .font(.system(size: 120)) + } + } + .frame(height: 360) + + Button { + showingCamera = true + } label: { + Text("촬영하기") + .font(.headline.weight(.semibold)) + .foregroundStyle(.white) + .frame(maxWidth: .infinity, minHeight: 48) + .background(RoundedRectangle(cornerRadius: 12).fill(Color("primaryColor"))) + } + .buttonStyle(.plain) + + Spacer() + } + .padding(16) + .sheet(isPresented: $showingCamera) { + BodyScanCameraView { image in + self.capturedImage = image + } + } + .navigationTitle("") + .navigationBarTitleDisplayMode(.inline) + } +} + +#Preview { + NavigationView { BodyScanStep1View() } +} diff --git a/CodiCue/BodyScan/PhotoLibraryPicker.swift b/CodiCue/BodyScan/PhotoLibraryPicker.swift new file mode 100644 index 0000000..62281bf --- /dev/null +++ b/CodiCue/BodyScan/PhotoLibraryPicker.swift @@ -0,0 +1,41 @@ +// +// PhotoLibraryPicker.swift +// CodiCue +// +// Created by Yeeun on 9/23/25. +// + +import SwiftUI +import PhotosUI + +struct PhotoLibraryPicker: UIViewControllerRepresentable { + var onPick: (UIImage?) -> Void + + func makeUIViewController(context: Context) -> PHPickerViewController { + var config = PHPickerConfiguration() + config.filter = .images + config.selectionLimit = 1 + let vc = PHPickerViewController(configuration: config) + vc.delegate = context.coordinator + return vc + } + + func updateUIViewController(_ uiViewController: PHPickerViewController, context: Context) {} + + func makeCoordinator() -> Coordinator { Coordinator(onPick: onPick) } + + final class Coordinator: NSObject, PHPickerViewControllerDelegate { + let onPick: (UIImage?) -> Void + init(onPick: @escaping (UIImage?) -> Void) { self.onPick = onPick } + + func picker(_ picker: PHPickerViewController, didFinishPicking results: [PHPickerResult]) { + picker.dismiss(animated: true) + guard let provider = results.first?.itemProvider, provider.canLoadObject(ofClass: UIImage.self) else { + onPick(nil); return + } + provider.loadObject(ofClass: UIImage.self) { obj, _ in + DispatchQueue.main.async { self.onPick(obj as? UIImage) } + } + } + } +} diff --git a/CodiCue/Mypage/MyPageMainView.swift b/CodiCue/Mypage/MyPageMainView.swift index 80eb9c2..279e66a 100644 --- a/CodiCue/Mypage/MyPageMainView.swift +++ b/CodiCue/Mypage/MyPageMainView.swift @@ -18,6 +18,7 @@ struct MyPageMainView: View { Spacer() } .padding(.bottom, 12) + HStack { Text("체형 정보 수정") .font(.title3) @@ -25,8 +26,10 @@ struct MyPageMainView: View { Spacer() } .padding(.bottom, 4) - MyPageButtonView(label: "AI 체형 분석 바로가기") + + MyPageNavLink(label: "AI 체형 분석 바로가기", destination: BodyScanHomeView()) .padding(.bottom, 8) + HStack { Text("개인정보 관리") .font(.title3) @@ -56,10 +59,13 @@ struct MyPageMainView: View { Spacer() } .padding(.bottom, 4) + MyPageButtonView(label: "이용 약관") .padding(.bottom, 4) + MyPageButtonView(label: "개인정보 처리방침") .padding(.bottom, 4) + MyPageButtonView(label: "유료 결제 약관") .padding(.bottom, 64) @@ -71,9 +77,8 @@ struct MyPageMainView: View { var version: String? { guard let dictionary = Bundle.main.infoDictionary, - let version = dictionary["CFBundleShortVersionString"] as? String, - let build = dictionary["CFBundleVersion"] as? String - else { return nil } + let version = dictionary["CFBundleShortVersionString"] as? String, + let build = dictionary["CFBundleVersion"] as? String else { return nil } let versionAndBuild: String = "v\(version) (\(build))" return versionAndBuild @@ -102,6 +107,30 @@ private struct MyPageButtonView: View { } } +private struct MyPageNavLink: View { + let label: String + let destination: Destination + + var body: some View { + NavigationLink(destination: destination) { + HStack { + Text(label) + Spacer() + Image(systemName: "chevron.right") + } + .fontWeight(.semibold) + .padding(.horizontal) + .padding(.vertical, 12) + .foregroundStyle(Color("primaryColor")) + .background(Color("primaryColor").opacity(0.2)) + .clipShape(Capsule()) + } + .buttonStyle(.plain) + } +} + #Preview { - MyPageMainView() + NavigationView { // 미리보기에서 푸시 확인용 + MyPageMainView() + } }