Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -21,3 +21,4 @@ xcuserdata/
*.xcodeproj/project.xcworkspace/xcuserdata/*

# End of https://www.toptal.com/developers/gitignore/api/xcode
.DS_Store
2 changes: 2 additions & 0 deletions CodiCue.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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;
Expand Down
188 changes: 188 additions & 0 deletions CodiCue/BodyScan/BodyScanCameraView.swift
Original file line number Diff line number Diff line change
@@ -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
}
}
147 changes: 147 additions & 0 deletions CodiCue/BodyScan/BodyScanFlowView.swift
Original file line number Diff line number Diff line change
@@ -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
}
}
Loading