mycode/myApp/Expiranner/IOS/CaptureFlowView.swift
songyc macbook ceb0ab8e16 feat(expiranner): 기록 날짜·시간 수정 + 매장별 지난 데이터 정리 + 주 시작 요일 설정
- RecordEditView 신규: 저장된 기록의 유통기한을 그래픽 달력 + '시간 추가'로 수정.
  날짜 상세 그리드의 길게 누르기 메뉴와 전체화면 뷰어 상단 버튼 양쪽에서 진입
- 설정 '데이터 관리'를 매장별로 분리: 지난 기록이 있는 매장만 행으로 나열해
  개별 삭제, 2개 매장 이상일 때만 '모든 매장에서 삭제' 행 노출(각각 확인 대화상자)
- 주 시작 요일 설정(일요일/월요일): 월 캘린더와 촬영 플로우 미니 달력의
  그리드·요일 기호·주말 색이 모두 따라감 (기본은 기존과 같은 일요일 시작)
- CLAUDE.md를 현재 동작(설정 화면, hasTime, 동적 색)에 맞게 갱신

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01J2HQuA6RPtkh45QTMN83i2
2026-07-28 02:29:36 +09:00

479 lines
17 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()
@AppStorage(weekStartStorageKey) private var weekStartsMonday = false
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 includeTime = false
@State private var timeSelection = Self.defaultTime
@State private var isCapturing = false
@State private var toastText: String?
@State private var toastTask: Task<Void, Never>?
/// ( 6). .
private static var defaultTime: Date {
KoreanCalendar.calendar.date(bySettingHour: 18, minute: 0, second: 0, of: .now) ?? .now
}
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)
if selectedDay != nil {
timeRow
.padding(.horizontal, 20)
}
Spacer(minLength: 0)
HStack(spacing: 12) {
secondaryButton(title: "재촬영", systemName: "arrow.counterclockwise") {
resetPickState()
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, startsOnMonday: weekStartsMonday)
let symbols = KoreanCalendar.weekdaySymbols(startsOnMonday: weekStartsMonday)
return VStack(spacing: 8) {
HStack(spacing: 4) {
ForEach(0..<7, id: \.self) { index in
Text(symbols[index])
.font(.caption2.weight(.semibold))
.foregroundStyle(KoreanCalendar.weekdayColor(columnIndex: index, startsOnMonday: weekStartsMonday))
.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: - ()
/// . .
@ViewBuilder
private var timeRow: some View {
if includeTime {
HStack(spacing: 10) {
Image(systemName: "clock.fill")
.font(.footnote)
.foregroundStyle(Theme.accent)
Text("유통 시각")
.font(.system(.subheadline, design: .rounded).weight(.semibold))
.foregroundStyle(Theme.textPrimary)
Spacer()
DatePicker(
"",
selection: $timeSelection,
displayedComponents: .hourAndMinute
)
.labelsHidden()
.tint(Theme.accent)
Button {
withAnimation(.snappy) { includeTime = false }
Haptics.tap()
} label: {
Image(systemName: "xmark.circle.fill")
.font(.title3)
.foregroundStyle(Theme.textSecondary)
}
.buttonStyle(.plain)
}
.padding(.horizontal, 14)
.padding(.vertical, 10)
.cardStyle()
} else {
Button {
withAnimation(.snappy) { includeTime = true }
Haptics.tap()
} label: {
Label("시간 추가", systemImage: "clock")
.font(.system(.footnote, design: .rounded).weight(.semibold))
.foregroundStyle(Theme.textSecondary)
.padding(.horizontal, 14)
.padding(.vertical, 8)
.background(Capsule().fill(Theme.surfaceHi))
}
.buttonStyle(.plain)
.frame(maxWidth: .infinity, alignment: .center)
}
}
private func resetPickState() {
selectedDay = nil
includeTime = false
timeSelection = Self.defaultTime
}
// MARK: -
private func save(image: UIImage) {
guard let day = selectedDay,
let baseDate = KoreanCalendar.date(year: year, month: month, day: day) else { return }
let calendar = KoreanCalendar.calendar
let expiryDate: Date
if includeTime {
let t = calendar.dateComponents([.hour, .minute], from: timeSelection)
expiryDate = calendar.date(
bySettingHour: t.hour ?? 0,
minute: t.minute ?? 0,
second: 0,
of: baseDate
) ?? calendar.startOfDay(for: baseDate)
} else {
expiryDate = calendar.startOfDay(for: baseDate)
}
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: expiryDate,
hasTime: includeTime,
imageData: imageData,
thumbnailData: thumbnailData,
store: store
)
context.insert(record)
try? context.save()
Haptics.success()
showToast(savedToastText(day: day, date: expiryDate))
resetPickState()
withAnimation(.snappy) { phase = .camera }
}
private func savedToastText(day: Int, date: Date) -> String {
if includeTime {
let formatter = DateFormatter()
formatter.locale = Locale(identifier: "ko_KR")
formatter.dateFormat = "a h:mm"
return "\(month)\(day)\(formatter.string(from: date))에 저장했어요"
}
return "\(month)\(day)일에 저장했어요"
}
// 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)
}
}