From 6c4fe817bd943b396e760b73c38bc2371609029d Mon Sep 17 00:00:00 2001 From: songyc macbook Date: Thu, 23 Jul 2026 17:46:12 +0900 Subject: [PATCH] =?UTF-8?q?feat(expiranner):=20=EB=9D=BC=EC=9D=B4=ED=8A=B8?= =?UTF-8?q?/=EB=8B=A4=ED=81=AC=20=EB=AA=A8=EB=93=9C=20=C2=B7=20=EC=8B=9C?= =?UTF-8?q?=EA=B0=84=20=ED=8F=AC=ED=95=A8=20=EC=9C=A0=ED=86=B5=EA=B8=B0?= =?UTF-8?q?=ED=95=9C=20=C2=B7=20=EC=A7=80=EB=82=9C=20=EB=8D=B0=EC=9D=B4?= =?UTF-8?q?=ED=84=B0=20=EC=A0=95=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 설정 화면 신규(첫 화면 헤더 톱니 진입): 화면 모드(시스템/라이트/다크) 선택, '기간 지난 데이터 삭제'로 만료된 기록 일괄 정리(매장 무관, 확인 후 실행) - Theme 색상을 라이트/다크 자동 전환 동적 색으로 전환, 기본값은 기존과 동일한 다크 - 유통기한에 시각(시:분) 선택 입력: 날짜 선택 후 '시간 추가' 버튼으로만 노출돼 기본 사용성(사진→날짜→저장)은 그대로 유지, 유제품 등 필요 시에만 사용 - ProductRecord.hasTime 플래그 추가(기본 false, 라이트웨이트 마이그레이션), 시각 포함 기록은 날짜 상세 그리드에 시각 배지 표시 - .gitignore에 Xcode 빌드 산출물/파생 데이터/SwiftPM 상태 추가해 상태 오염 방지 Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01J2HQuA6RPtkh45QTMN83i2 --- .gitignore | 12 ++ myApp/Expiranner/IOS/CaptureFlowView.swift | 104 ++++++++++- myApp/Expiranner/IOS/DayDetailView.swift | 18 ++ myApp/Expiranner/IOS/ExpirannerApp.swift | 3 +- myApp/Expiranner/IOS/Models.swift | 7 +- myApp/Expiranner/IOS/SettingsView.swift | 194 +++++++++++++++++++++ myApp/Expiranner/IOS/StoreListView.swift | 37 ++-- myApp/Expiranner/IOS/Theme.swift | 78 +++++++-- 8 files changed, 422 insertions(+), 31 deletions(-) create mode 100644 myApp/Expiranner/IOS/SettingsView.swift diff --git a/.gitignore b/.gitignore index ad2257d..e4f9fd8 100644 --- a/.gitignore +++ b/.gitignore @@ -14,3 +14,15 @@ java/Main.java node_modules/ # Xcode 사용자별 IDE 상태 (커밋 불필요) **/xcuserdata/ +*.xcuserstate +**/*.xcuserdatad/ +# Xcode 빌드 산출물 / 파생 데이터 (매 빌드마다 새로 생겨 상태를 오염시킴) +**/build/ +**/DerivedData/ +DerivedData/ +*.moved-aside +*.hmap +*.ipa +# Swift Package Manager 로컬 상태 +**/.swiftpm/ +.build/ diff --git a/myApp/Expiranner/IOS/CaptureFlowView.swift b/myApp/Expiranner/IOS/CaptureFlowView.swift index b39b002..b3ad018 100644 --- a/myApp/Expiranner/IOS/CaptureFlowView.swift +++ b/myApp/Expiranner/IOS/CaptureFlowView.swift @@ -19,10 +19,17 @@ struct CaptureFlowView: View { @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? + /// 시간 추가 시 기본으로 보여줄 시각 (오후 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() @@ -181,11 +188,16 @@ struct CaptureFlowView: View { dayPickerGrid .padding(.horizontal, 20) + if selectedDay != nil { + timeRow + .padding(.horizontal, 20) + } + Spacer(minLength: 0) HStack(spacing: 12) { secondaryButton(title: "재촬영", systemName: "arrow.counterclockwise") { - selectedDay = nil + resetPickState() withAnimation(.snappy) { phase = .camera } } primaryButton(title: "저장하기", systemName: "checkmark.circle.fill") { @@ -251,11 +263,82 @@ struct CaptureFlowView: View { .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 date = KoreanCalendar.date(year: year, month: month, day: day) else { return } + 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) @@ -266,7 +349,8 @@ struct CaptureFlowView: View { } let record = ProductRecord( - expiryDate: KoreanCalendar.calendar.startOfDay(for: date), + expiryDate: expiryDate, + hasTime: includeTime, imageData: imageData, thumbnailData: thumbnailData, store: store @@ -275,11 +359,21 @@ struct CaptureFlowView: View { try? context.save() Haptics.success() - showToast("\(month)월 \(day)일에 저장했어요") - selectedDay = nil + 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) { diff --git a/myApp/Expiranner/IOS/DayDetailView.swift b/myApp/Expiranner/IOS/DayDetailView.swift index cb6a2c7..aa8cbdf 100644 --- a/myApp/Expiranner/IOS/DayDetailView.swift +++ b/myApp/Expiranner/IOS/DayDetailView.swift @@ -123,6 +123,13 @@ struct DayDetailView: View { return formatter.string(from: date) } + private func timeText(_ date: Date) -> String { + let formatter = DateFormatter() + formatter.locale = Locale(identifier: "ko_KR") + formatter.dateFormat = "a h:mm" + return formatter.string(from: date) + } + private var ddayChip: some View { let diff = KoreanCalendar.daysFromToday(to: date) let (text, color): (String, Color) = @@ -189,6 +196,17 @@ struct DayDetailView: View { .padding(6) } } + .overlay(alignment: .bottomLeading) { + if record.hasTime { + Label(timeText(record.expiryDate), systemImage: "clock.fill") + .font(.system(size: 10, weight: .bold, design: .rounded)) + .foregroundStyle(.white) + .padding(.horizontal, 6) + .padding(.vertical, 3) + .background(Capsule().fill(.black.opacity(0.55))) + .padding(5) + } + } .overlay( RoundedRectangle(cornerRadius: 14, style: .continuous) .strokeBorder( diff --git a/myApp/Expiranner/IOS/ExpirannerApp.swift b/myApp/Expiranner/IOS/ExpirannerApp.swift index 43ae77f..fd8327c 100644 --- a/myApp/Expiranner/IOS/ExpirannerApp.swift +++ b/myApp/Expiranner/IOS/ExpirannerApp.swift @@ -31,7 +31,6 @@ struct ExpirannerApp: App { var body: some Scene { WindowGroup { RootView() - .preferredColorScheme(.dark) .tint(Theme.accent) } .modelContainer(container) @@ -40,6 +39,7 @@ struct ExpirannerApp: App { /// 시스템 런치 스크린에서 자연스럽게 이어지는 인앱 스플래시. private struct RootView: View { + @AppStorage(appearanceStorageKey) private var appearanceRaw = AppearanceMode.dark.rawValue @State private var showSplash = true var body: some View { @@ -51,6 +51,7 @@ private struct RootView: View { .zIndex(1) } } + .preferredColorScheme(AppearanceMode(rawValue: appearanceRaw)?.colorScheme) .task { try? await Task.sleep(for: .seconds(1.0)) withAnimation(.easeOut(duration: 0.4)) { diff --git a/myApp/Expiranner/IOS/Models.swift b/myApp/Expiranner/IOS/Models.swift index 491b272..fbb439e 100644 --- a/myApp/Expiranner/IOS/Models.swift +++ b/myApp/Expiranner/IOS/Models.swift @@ -17,8 +17,10 @@ final class Store { @Model final class ProductRecord { - /// 유통기한 날짜 (자정으로 정규화) + /// 유통기한. `hasTime`이 false면 자정으로 정규화된 날짜, true면 시각까지 포함. var expiryDate: Date + /// 유통기한에 시각(시:분)이 포함되는지 여부. 기본값 false(날짜만). + var hasTime: Bool = false var createdAt: Date @Attribute(.externalStorage) @@ -27,8 +29,9 @@ final class ProductRecord { var store: Store? - init(expiryDate: Date, imageData: Data, thumbnailData: Data, store: Store?) { + init(expiryDate: Date, hasTime: Bool = false, imageData: Data, thumbnailData: Data, store: Store?) { self.expiryDate = expiryDate + self.hasTime = hasTime self.createdAt = .now self.imageData = imageData self.thumbnailData = thumbnailData diff --git a/myApp/Expiranner/IOS/SettingsView.swift b/myApp/Expiranner/IOS/SettingsView.swift new file mode 100644 index 0000000..ad1bba5 --- /dev/null +++ b/myApp/Expiranner/IOS/SettingsView.swift @@ -0,0 +1,194 @@ +import SwiftUI +import SwiftData + +/// 설정 화면: 화면 모드 선택 + 기간 지난 데이터 정리. +struct SettingsView: View { + @Environment(\.dismiss) private var dismiss + @Environment(\.modelContext) private var context + @Query private var allRecords: [ProductRecord] + + @AppStorage(appearanceStorageKey) private var appearanceRaw = AppearanceMode.dark.rawValue + + @State private var confirmDeleteExpired = false + @State private var toastText: String? + @State private var toastTask: Task? + + /// 유통기한이 이미 지난 기록(매장 무관 전체). + private var expiredRecords: [ProductRecord] { + allRecords.filter { KoreanCalendar.daysFromToday(to: $0.expiryDate) < 0 } + } + + var body: some View { + NavigationStack { + ZStack { + Theme.bg.ignoresSafeArea() + ScrollView { + VStack(alignment: .leading, spacing: 26) { + appearanceSection + dataSection + } + .padding(20) + } + + if let toastText { + VStack { + Spacer() + toast(toastText) + .padding(.bottom, 30) + } + .transition(.move(edge: .bottom).combined(with: .opacity)) + } + } + .navigationTitle("설정") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .confirmationAction) { + Button("완료") { dismiss() } + .font(.body.weight(.semibold)) + } + } + .animation(.snappy, value: toastText) + } + .confirmationDialog( + deleteDialogTitle, + isPresented: $confirmDeleteExpired, + titleVisibility: .visible + ) { + Button("삭제", role: .destructive) { deleteExpired() } + Button("취소", role: .cancel) {} + } message: { + Text("매장과 상관없이 유통기한이 지난 기록이 모두 삭제됩니다. 되돌릴 수 없어요.") + } + .onDisappear { toastTask?.cancel() } + } + + // MARK: - 화면 모드 + + private var appearanceSection: some View { + section(title: "화면 모드", subtitle: "앱 전체의 밝기 테마를 선택하세요.") { + HStack(spacing: 10) { + ForEach(AppearanceMode.allCases) { mode in + appearanceChip(mode) + } + } + } + } + + private func appearanceChip(_ mode: AppearanceMode) -> some View { + let isSelected = appearanceRaw == mode.rawValue + return Button { + guard !isSelected else { return } + appearanceRaw = mode.rawValue + Haptics.tap() + } label: { + VStack(spacing: 8) { + Image(systemName: mode.icon) + .font(.title3) + .foregroundStyle(isSelected ? Theme.onAccent : Theme.textSecondary) + Text(mode.title) + .font(.system(.subheadline, design: .rounded).weight(.semibold)) + .foregroundStyle(isSelected ? Theme.onAccent : Theme.textPrimary) + } + .frame(maxWidth: .infinity) + .frame(height: 74) + .background( + RoundedRectangle(cornerRadius: 16, style: .continuous) + .fill(isSelected ? AnyShapeStyle(Theme.accentGradient) : AnyShapeStyle(Theme.surfaceHi)) + ) + .overlay( + RoundedRectangle(cornerRadius: 16, style: .continuous) + .strokeBorder(isSelected ? .clear : Theme.stroke, lineWidth: 1) + ) + } + .buttonStyle(.plain) + } + + // MARK: - 데이터 관리 + + private var dataSection: some View { + let count = expiredRecords.count + return section(title: "데이터 관리", subtitle: "유통기한이 지난 기록을 한 번에 정리합니다.") { + Button { + confirmDeleteExpired = true + } label: { + HStack(spacing: 12) { + Image(systemName: "trash") + .font(.body.weight(.semibold)) + VStack(alignment: .leading, spacing: 2) { + Text("기간 지난 데이터 삭제") + .font(.system(.body, design: .rounded).weight(.semibold)) + Text(count > 0 ? "지난 기록 \(count)개" : "지난 기록이 없어요") + .font(.caption) + .foregroundStyle(count > 0 ? Theme.danger.opacity(0.8) : Theme.textSecondary) + } + Spacer() + if count > 0 { + Image(systemName: "chevron.right") + .font(.footnote.weight(.semibold)) + .foregroundStyle(Theme.textSecondary) + } + } + .foregroundStyle(count > 0 ? Theme.danger : Theme.textSecondary) + .padding(16) + .frame(maxWidth: .infinity, alignment: .leading) + .cardStyle() + } + .buttonStyle(.plain) + .disabled(count == 0) + } + } + + private var deleteDialogTitle: String { + "지난 기록 \(expiredRecords.count)개를 삭제할까요?" + } + + private func deleteExpired() { + let targets = expiredRecords + guard !targets.isEmpty else { return } + for record in targets { + context.delete(record) + } + try? context.save() + Haptics.warning() + showToast("지난 기록 \(targets.count)개를 삭제했어요") + } + + // MARK: - 공용 + + private func section( + title: String, + subtitle: String, + @ViewBuilder content: () -> Content + ) -> some View { + VStack(alignment: .leading, spacing: 12) { + VStack(alignment: .leading, spacing: 3) { + Text(title) + .font(.system(.headline, design: .rounded)) + .foregroundStyle(Theme.textPrimary) + Text(subtitle) + .font(.caption) + .foregroundStyle(Theme.textSecondary) + } + content() + } + } + + 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.accentGradient)) + } +} diff --git a/myApp/Expiranner/IOS/StoreListView.swift b/myApp/Expiranner/IOS/StoreListView.swift index cc5008c..20a8655 100644 --- a/myApp/Expiranner/IOS/StoreListView.swift +++ b/myApp/Expiranner/IOS/StoreListView.swift @@ -12,6 +12,7 @@ struct StoreListView: View { @State private var renameTarget: Store? @State private var renameText = "" @State private var deleteTarget: Store? + @State private var showingSettings = false var body: some View { NavigationStack { @@ -54,6 +55,9 @@ struct StoreListView: View { Button("삭제", role: .destructive) { deleteStore() } Button("취소", role: .cancel) { deleteTarget = nil } } + .sheet(isPresented: $showingSettings) { + SettingsView() + } } // MARK: - 헤더 @@ -71,16 +75,29 @@ struct StoreListView: View { .lineSpacing(3) } Spacer() - Button { - newName = "" - showingAdd = true - } label: { - Image(systemName: "plus") - .font(.title3.weight(.semibold)) - .foregroundStyle(Theme.onAccent) - .frame(width: 44, height: 44) - .background(Circle().fill(Theme.accentGradient)) - .shadow(color: Theme.accent.opacity(0.35), radius: 12, y: 4) + HStack(spacing: 10) { + Button { + showingSettings = true + Haptics.tap() + } label: { + Image(systemName: "gearshape") + .font(.title3.weight(.semibold)) + .foregroundStyle(Theme.textSecondary) + .frame(width: 44, height: 44) + .background(Circle().fill(Theme.surface)) + .overlay(Circle().strokeBorder(Theme.stroke, lineWidth: 1)) + } + Button { + newName = "" + showingAdd = true + } label: { + Image(systemName: "plus") + .font(.title3.weight(.semibold)) + .foregroundStyle(Theme.onAccent) + .frame(width: 44, height: 44) + .background(Circle().fill(Theme.accentGradient)) + .shadow(color: Theme.accent.opacity(0.35), radius: 12, y: 4) + } } } .padding(.top, 12) diff --git a/myApp/Expiranner/IOS/Theme.swift b/myApp/Expiranner/IOS/Theme.swift index 748e541..dc15d3e 100644 --- a/myApp/Expiranner/IOS/Theme.swift +++ b/myApp/Expiranner/IOS/Theme.swift @@ -1,7 +1,7 @@ import SwiftUI import UIKit -// MARK: - 색상 팔레트 (다크 전용) +// MARK: - 색상 팔레트 (라이트/다크 자동 전환) extension Color { init(hex: UInt32) { @@ -12,26 +12,40 @@ extension Color { blue: Double(hex & 0xFF) / 255 ) } + + /// 라이트/다크 모드에 따라 자동으로 전환되는 동적 색. + /// `.preferredColorScheme`으로 지정한 모드를 그대로 따른다. + static func dynamic(light: Color, dark: Color) -> Color { + Color(UIColor { trait in + trait.userInterfaceStyle == .dark ? UIColor(dark) : UIColor(light) + }) + } } enum Theme { - static let bg = Color(hex: 0x0B0E13) - static let surface = Color(hex: 0x161B25) - static let surfaceHi = Color(hex: 0x202836) - static let stroke = Color.white.opacity(0.07) + static let bg = Color.dynamic(light: Color(hex: 0xF2F4F7), dark: Color(hex: 0x0B0E13)) + static let surface = Color.dynamic(light: Color(hex: 0xFFFFFF), dark: Color(hex: 0x161B25)) + static let surfaceHi = Color.dynamic(light: Color(hex: 0xE9EDF3), dark: Color(hex: 0x202836)) + static let stroke = Color.dynamic(light: .black.opacity(0.08), dark: .white.opacity(0.07)) - static let accent = Color(hex: 0x3BE39F) - static let accentSoft = Color(hex: 0x3BE39F).opacity(0.14) + // 텍스트/테두리로 쓰이므로 라이트 모드에서는 대비를 위해 더 진한 민트. + static let accent = Color.dynamic(light: Color(hex: 0x0FA06E), dark: Color(hex: 0x3BE39F)) + static let accentSoft = Color.dynamic( + light: Color(hex: 0x0FA06E).opacity(0.12), + dark: Color(hex: 0x3BE39F).opacity(0.14) + ) + // 밝은 accentGradient 위에 얹는 어두운 글자색 (두 모드 공통). static let onAccent = Color(hex: 0x07281B) - static let danger = Color(hex: 0xFF7A7A) - static let warn = Color(hex: 0xFFC94D) - static let sunday = Color(hex: 0xFF8A80) - static let saturday = Color(hex: 0x7EB6FF) + static let danger = Color.dynamic(light: Color(hex: 0xD64545), dark: Color(hex: 0xFF7A7A)) + static let warn = Color.dynamic(light: Color(hex: 0xB77E00), dark: Color(hex: 0xFFC94D)) + static let sunday = Color.dynamic(light: Color(hex: 0xD64C42), dark: Color(hex: 0xFF8A80)) + static let saturday = Color.dynamic(light: Color(hex: 0x3568C4), dark: Color(hex: 0x7EB6FF)) - static let textPrimary = Color(hex: 0xF3F6FA) - static let textSecondary = Color(hex: 0x8D99AD) + static let textPrimary = Color.dynamic(light: Color(hex: 0x1A1E26), dark: Color(hex: 0xF3F6FA)) + static let textSecondary = Color.dynamic(light: Color(hex: 0x687285), dark: Color(hex: 0x8D99AD)) + // 밝은 민트 그라디언트 — 두 모드 공통(위에 onAccent 어두운 글자). static let accentGradient = LinearGradient( colors: [Color(hex: 0x5CF0B6), Color(hex: 0x1FC98B)], startPoint: .topLeading, @@ -39,6 +53,44 @@ enum Theme { ) } +// MARK: - 화면 모드 설정 + +/// 사용자가 고를 수 있는 외관 모드. `@AppStorage(appearanceStorageKey)`에 raw 값으로 저장. +enum AppearanceMode: Int, CaseIterable, Identifiable { + case system = 0 + case light = 1 + case dark = 2 + + var id: Int { rawValue } + + var title: String { + switch self { + case .system: return "시스템" + case .light: return "라이트" + case .dark: return "다크" + } + } + + var icon: String { + switch self { + case .system: return "iphone" + case .light: return "sun.max.fill" + case .dark: return "moon.fill" + } + } + + var colorScheme: ColorScheme? { + switch self { + case .system: return nil + case .light: return .light + case .dark: return .dark + } + } +} + +/// 외관 모드 저장 키. 기본값은 기존 동작 유지를 위해 `.dark`. +let appearanceStorageKey = "appearanceMode" + // MARK: - 공용 스타일 struct CardBackground: ViewModifier {