feat(expiranner): 라이트/다크 모드 · 시간 포함 유통기한 · 지난 데이터 정리
- 설정 화면 신규(첫 화면 헤더 톱니 진입): 화면 모드(시스템/라이트/다크) 선택, '기간 지난 데이터 삭제'로 만료된 기록 일괄 정리(매장 무관, 확인 후 실행) - Theme 색상을 라이트/다크 자동 전환 동적 색으로 전환, 기본값은 기존과 동일한 다크 - 유통기한에 시각(시:분) 선택 입력: 날짜 선택 후 '시간 추가' 버튼으로만 노출돼 기본 사용성(사진→날짜→저장)은 그대로 유지, 유제품 등 필요 시에만 사용 - ProductRecord.hasTime 플래그 추가(기본 false, 라이트웨이트 마이그레이션), 시각 포함 기록은 날짜 상세 그리드에 시각 배지 표시 - .gitignore에 Xcode 빌드 산출물/파생 데이터/SwiftPM 상태 추가해 상태 오염 방지 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01J2HQuA6RPtkh45QTMN83i2
This commit is contained in:
parent
7251e0c1cc
commit
6c4fe817bd
12
.gitignore
vendored
12
.gitignore
vendored
@ -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/
|
||||
|
||||
@ -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<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()
|
||||
@ -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) {
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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)) {
|
||||
|
||||
@ -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
|
||||
|
||||
194
myApp/Expiranner/IOS/SettingsView.swift
Normal file
194
myApp/Expiranner/IOS/SettingsView.swift
Normal file
@ -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<Void, Never>?
|
||||
|
||||
/// 유통기한이 이미 지난 기록(매장 무관 전체).
|
||||
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<Content: View>(
|
||||
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))
|
||||
}
|
||||
}
|
||||
@ -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)
|
||||
|
||||
@ -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 {
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user