- 매장 선택 → 연/월 → 월 캘린더(날짜별 제품 사진 썸네일) → 날짜 상세 플로우로 전면 재작성 - 스캔 플로우: 촬영 → 재촬영/사용 → 그 달 날짜만 선택 → 저장 후 카메라 자동 복귀 (손전등 지원) - 날짜 상세 다중 선택 삭제 (전체 선택/해제 + 일괄 삭제 확인) - 다크 전용 민트 테마, SwiftData 모델(Store ↔ ProductRecord, 원본+썸네일 분리 저장) - 밝은 민트 앱 아이콘(SVG 원본 포함) + 런치 스크린/인앱 스플래시 - CLAUDE.md 새 구조 기준으로 재작성 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
383 lines
13 KiB
Swift
383 lines
13 KiB
Swift
import SwiftUI
|
|
import SwiftData
|
|
|
|
/// 스캔 플로우: 촬영 → 확인(재촬영/사용) → 날짜 선택 → 저장 → 다시 촬영.
|
|
struct CaptureFlowView: View {
|
|
let store: Store
|
|
let year: Int
|
|
let month: Int
|
|
|
|
@Environment(\.dismiss) private var dismiss
|
|
@Environment(\.modelContext) private var context
|
|
@StateObject private var camera = CameraService()
|
|
|
|
private enum Phase {
|
|
case camera
|
|
case review(UIImage)
|
|
case pickDate(UIImage)
|
|
}
|
|
|
|
@State private var phase: Phase = .camera
|
|
@State private var selectedDay: Int?
|
|
@State private var isCapturing = false
|
|
@State private var toastText: String?
|
|
@State private var toastTask: Task<Void, Never>?
|
|
|
|
var body: some View {
|
|
ZStack {
|
|
Color.black.ignoresSafeArea()
|
|
switch phase {
|
|
case .camera:
|
|
cameraPhase
|
|
case .review(let image):
|
|
reviewPhase(image)
|
|
case .pickDate(let image):
|
|
pickDatePhase(image)
|
|
}
|
|
}
|
|
.statusBarHidden()
|
|
.task { await camera.start() }
|
|
.onDisappear {
|
|
camera.stop()
|
|
toastTask?.cancel()
|
|
}
|
|
}
|
|
|
|
// MARK: - 1. 카메라
|
|
|
|
private var cameraPhase: some View {
|
|
ZStack {
|
|
switch camera.state {
|
|
case .running, .idle:
|
|
CameraPreview(session: camera.session)
|
|
.ignoresSafeArea()
|
|
case .denied:
|
|
permissionDeniedView
|
|
case .failed:
|
|
cameraFailedView
|
|
}
|
|
|
|
VStack {
|
|
topBar
|
|
if let toastText {
|
|
toast(toastText)
|
|
.transition(.move(edge: .top).combined(with: .opacity))
|
|
}
|
|
Spacer()
|
|
if camera.state == .running || camera.state == .idle {
|
|
shutterButton
|
|
}
|
|
}
|
|
.padding(.horizontal, 20)
|
|
.padding(.bottom, 30)
|
|
}
|
|
.animation(.snappy, value: toastText)
|
|
}
|
|
|
|
private var topBar: some View {
|
|
HStack {
|
|
overlayCircleButton(systemName: "xmark") { dismiss() }
|
|
Spacer()
|
|
Text(verbatim: "\(year)년 \(month)월")
|
|
.font(.system(.subheadline, design: .rounded).weight(.bold))
|
|
.foregroundStyle(.white)
|
|
.padding(.horizontal, 14)
|
|
.padding(.vertical, 7)
|
|
.background(Capsule().fill(.white.opacity(0.12)))
|
|
Spacer()
|
|
overlayCircleButton(
|
|
systemName: camera.isTorchOn ? "bolt.fill" : "bolt.slash",
|
|
tint: camera.isTorchOn ? Theme.warn : .white
|
|
) {
|
|
camera.toggleTorch()
|
|
Haptics.tap()
|
|
}
|
|
}
|
|
.padding(.top, 14)
|
|
}
|
|
|
|
private var shutterButton: some View {
|
|
Button {
|
|
capture()
|
|
} label: {
|
|
ZStack {
|
|
Circle()
|
|
.strokeBorder(.white.opacity(0.9), lineWidth: 4)
|
|
.frame(width: 76, height: 76)
|
|
Circle()
|
|
.fill(.white)
|
|
.frame(width: 60, height: 60)
|
|
.scaleEffect(isCapturing ? 0.8 : 1)
|
|
}
|
|
}
|
|
.disabled(isCapturing || camera.state != .running)
|
|
.animation(.spring(duration: 0.2), value: isCapturing)
|
|
}
|
|
|
|
private func capture() {
|
|
guard !isCapturing else { return }
|
|
isCapturing = true
|
|
Haptics.shutter()
|
|
camera.capturePhoto { data in
|
|
isCapturing = false
|
|
guard let data, let image = UIImage(data: data) else {
|
|
showToast("촬영에 실패했어요. 다시 시도해 주세요.")
|
|
return
|
|
}
|
|
withAnimation(.snappy) {
|
|
phase = .review(image)
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - 2. 확인 (재촬영 / 사용)
|
|
|
|
private func reviewPhase(_ image: UIImage) -> some View {
|
|
VStack(spacing: 0) {
|
|
Image(uiImage: image)
|
|
.resizable()
|
|
.scaledToFit()
|
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
|
.clipShape(RoundedRectangle(cornerRadius: 20, style: .continuous))
|
|
.padding(.horizontal, 16)
|
|
.padding(.top, 24)
|
|
|
|
HStack(spacing: 12) {
|
|
secondaryButton(title: "재촬영", systemName: "arrow.counterclockwise") {
|
|
withAnimation(.snappy) { phase = .camera }
|
|
}
|
|
primaryButton(title: "사용하기", systemName: "checkmark") {
|
|
withAnimation(.snappy) { phase = .pickDate(image) }
|
|
}
|
|
}
|
|
.padding(20)
|
|
}
|
|
}
|
|
|
|
// MARK: - 3. 날짜 선택
|
|
|
|
private func pickDatePhase(_ image: UIImage) -> some View {
|
|
VStack(spacing: 20) {
|
|
VStack(spacing: 4) {
|
|
Text("유통기한 선택")
|
|
.font(.system(.title3, design: .rounded).weight(.bold))
|
|
.foregroundStyle(Theme.textPrimary)
|
|
Text(verbatim: "\(year)년 \(month)월 중에서 선택하세요")
|
|
.font(.footnote)
|
|
.foregroundStyle(Theme.textSecondary)
|
|
}
|
|
.padding(.top, 24)
|
|
|
|
Image(uiImage: image)
|
|
.resizable()
|
|
.scaledToFill()
|
|
.frame(width: 130, height: 130)
|
|
.clipShape(RoundedRectangle(cornerRadius: 18, style: .continuous))
|
|
.overlay(
|
|
RoundedRectangle(cornerRadius: 18, style: .continuous)
|
|
.strokeBorder(Theme.stroke, lineWidth: 1)
|
|
)
|
|
|
|
dayPickerGrid
|
|
.padding(.horizontal, 20)
|
|
|
|
Spacer(minLength: 0)
|
|
|
|
HStack(spacing: 12) {
|
|
secondaryButton(title: "재촬영", systemName: "arrow.counterclockwise") {
|
|
selectedDay = nil
|
|
withAnimation(.snappy) { phase = .camera }
|
|
}
|
|
primaryButton(title: "저장하기", systemName: "checkmark.circle.fill") {
|
|
save(image: image)
|
|
}
|
|
.disabled(selectedDay == nil)
|
|
.opacity(selectedDay == nil ? 0.4 : 1)
|
|
}
|
|
.padding(20)
|
|
}
|
|
.background(Theme.bg.ignoresSafeArea())
|
|
}
|
|
|
|
private var dayPickerGrid: some View {
|
|
let columns = Array(repeating: GridItem(.flexible(), spacing: 4), count: 7)
|
|
let days = KoreanCalendar.gridDays(year: year, month: month)
|
|
return VStack(spacing: 8) {
|
|
HStack(spacing: 4) {
|
|
ForEach(0..<7, id: \.self) { index in
|
|
Text(KoreanCalendar.weekdaySymbols[index])
|
|
.font(.caption2.weight(.semibold))
|
|
.foregroundStyle(KoreanCalendar.weekdayColor(columnIndex: index))
|
|
.frame(maxWidth: .infinity)
|
|
}
|
|
}
|
|
LazyVGrid(columns: columns, spacing: 4) {
|
|
ForEach(Array(days.enumerated()), id: \.offset) { _, day in
|
|
if let day {
|
|
dayButton(day)
|
|
} else {
|
|
Color.clear.frame(height: 42)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
.padding(14)
|
|
.cardStyle()
|
|
}
|
|
|
|
private func dayButton(_ day: Int) -> some View {
|
|
let isSelected = selectedDay == day
|
|
let isToday = KoreanCalendar.isToday(year: year, month: month, day: day)
|
|
return Button {
|
|
selectedDay = day
|
|
Haptics.tap()
|
|
} label: {
|
|
Text(verbatim: "\(day)")
|
|
.font(.system(size: 15, weight: isSelected ? .bold : .medium, design: .rounded))
|
|
.foregroundStyle(isSelected ? Theme.onAccent : Theme.textPrimary)
|
|
.frame(maxWidth: .infinity)
|
|
.frame(height: 42)
|
|
.background(
|
|
Circle()
|
|
.fill(isSelected ? AnyShapeStyle(Theme.accentGradient) : AnyShapeStyle(.clear))
|
|
.aspectRatio(1, contentMode: .fit)
|
|
)
|
|
.overlay(
|
|
Circle()
|
|
.strokeBorder(isToday && !isSelected ? Theme.accent.opacity(0.7) : .clear, lineWidth: 1.2)
|
|
.aspectRatio(1, contentMode: .fit)
|
|
)
|
|
}
|
|
.buttonStyle(.plain)
|
|
}
|
|
|
|
// MARK: - 저장
|
|
|
|
private func save(image: UIImage) {
|
|
guard let day = selectedDay,
|
|
let date = KoreanCalendar.date(year: year, month: month, day: day) else { return }
|
|
|
|
let fullImage = image.downscaled(maxDimension: 1600)
|
|
let thumbnail = image.downscaled(maxDimension: 360)
|
|
guard let imageData = fullImage.jpegData(compressionQuality: 0.8),
|
|
let thumbnailData = thumbnail.jpegData(compressionQuality: 0.7) else {
|
|
showToast("사진 저장에 실패했어요.")
|
|
return
|
|
}
|
|
|
|
let record = ProductRecord(
|
|
expiryDate: KoreanCalendar.calendar.startOfDay(for: date),
|
|
imageData: imageData,
|
|
thumbnailData: thumbnailData,
|
|
store: store
|
|
)
|
|
context.insert(record)
|
|
try? context.save()
|
|
|
|
Haptics.success()
|
|
showToast("\(month)월 \(day)일에 저장했어요")
|
|
selectedDay = nil
|
|
withAnimation(.snappy) { phase = .camera }
|
|
}
|
|
|
|
// MARK: - 공용 컴포넌트
|
|
|
|
private func showToast(_ text: String) {
|
|
toastTask?.cancel()
|
|
toastText = text
|
|
toastTask = Task {
|
|
try? await Task.sleep(for: .seconds(1.8))
|
|
guard !Task.isCancelled else { return }
|
|
toastText = nil
|
|
}
|
|
}
|
|
|
|
private func toast(_ text: String) -> some View {
|
|
Label(text, systemImage: "checkmark.circle.fill")
|
|
.font(.system(.subheadline, design: .rounded).weight(.semibold))
|
|
.foregroundStyle(Theme.onAccent)
|
|
.padding(.horizontal, 16)
|
|
.padding(.vertical, 10)
|
|
.background(Capsule().fill(Theme.accent))
|
|
.padding(.top, 10)
|
|
}
|
|
|
|
private func overlayCircleButton(
|
|
systemName: String,
|
|
tint: Color = .white,
|
|
action: @escaping () -> Void
|
|
) -> some View {
|
|
Button(action: action) {
|
|
Image(systemName: systemName)
|
|
.font(.body.weight(.semibold))
|
|
.foregroundStyle(tint)
|
|
.frame(width: 42, height: 42)
|
|
.background(Circle().fill(.white.opacity(0.12)))
|
|
}
|
|
}
|
|
|
|
private func primaryButton(title: String, systemName: String, action: @escaping () -> Void) -> some View {
|
|
Button(action: action) {
|
|
Label(title, systemImage: systemName)
|
|
.font(.system(.body, design: .rounded).weight(.bold))
|
|
.foregroundStyle(Theme.onAccent)
|
|
.frame(maxWidth: .infinity)
|
|
.frame(height: 54)
|
|
.background(Capsule().fill(Theme.accentGradient))
|
|
}
|
|
}
|
|
|
|
private func secondaryButton(title: String, systemName: String, action: @escaping () -> Void) -> some View {
|
|
Button(action: action) {
|
|
Label(title, systemImage: systemName)
|
|
.font(.system(.body, design: .rounded).weight(.semibold))
|
|
.foregroundStyle(Theme.textPrimary)
|
|
.frame(maxWidth: .infinity)
|
|
.frame(height: 54)
|
|
.background(Capsule().fill(Theme.surfaceHi))
|
|
}
|
|
}
|
|
|
|
// MARK: - 권한/오류 안내
|
|
|
|
private var permissionDeniedView: some View {
|
|
VStack(spacing: 16) {
|
|
Image(systemName: "camera.badge.ellipsis")
|
|
.font(.system(size: 44))
|
|
.foregroundStyle(Theme.textSecondary)
|
|
Text("카메라 권한이 필요해요")
|
|
.font(.system(.headline, design: .rounded))
|
|
.foregroundStyle(Theme.textPrimary)
|
|
Text("설정에서 Expiranner의 카메라 접근을 허용해 주세요.")
|
|
.font(.footnote)
|
|
.foregroundStyle(Theme.textSecondary)
|
|
.multilineTextAlignment(.center)
|
|
Button {
|
|
if let url = URL(string: UIApplication.openSettingsURLString) {
|
|
UIApplication.shared.open(url)
|
|
}
|
|
} label: {
|
|
Text("설정 열기")
|
|
.font(.system(.subheadline, design: .rounded).weight(.bold))
|
|
.foregroundStyle(Theme.onAccent)
|
|
.padding(.horizontal, 24)
|
|
.padding(.vertical, 12)
|
|
.background(Capsule().fill(Theme.accentGradient))
|
|
}
|
|
}
|
|
.padding(40)
|
|
}
|
|
|
|
private var cameraFailedView: some View {
|
|
VStack(spacing: 12) {
|
|
Image(systemName: "exclamationmark.triangle")
|
|
.font(.system(size: 40))
|
|
.foregroundStyle(Theme.warn)
|
|
Text("카메라를 사용할 수 없어요")
|
|
.font(.system(.headline, design: .rounded))
|
|
.foregroundStyle(Theme.textPrimary)
|
|
}
|
|
.padding(40)
|
|
}
|
|
}
|