- RecordEditView 신규: 저장된 기록의 유통기한을 그래픽 달력 + '시간 추가'로 수정. 날짜 상세 그리드의 길게 누르기 메뉴와 전체화면 뷰어 상단 버튼 양쪽에서 진입 - 설정 '데이터 관리'를 매장별로 분리: 지난 기록이 있는 매장만 행으로 나열해 개별 삭제, 2개 매장 이상일 때만 '모든 매장에서 삭제' 행 노출(각각 확인 대화상자) - 주 시작 요일 설정(일요일/월요일): 월 캘린더와 촬영 플로우 미니 달력의 그리드·요일 기호·주말 색이 모두 따라감 (기본은 기존과 같은 일요일 시작) - CLAUDE.md를 현재 동작(설정 화면, hasTime, 동적 색)에 맞게 갱신 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01J2HQuA6RPtkh45QTMN83i2
199 lines
7.4 KiB
Swift
199 lines
7.4 KiB
Swift
import SwiftUI
|
|
import UIKit
|
|
|
|
// MARK: - 색상 팔레트 (라이트/다크 자동 전환)
|
|
|
|
extension Color {
|
|
init(hex: UInt32) {
|
|
self.init(
|
|
.sRGB,
|
|
red: Double((hex >> 16) & 0xFF) / 255,
|
|
green: Double((hex >> 8) & 0xFF) / 255,
|
|
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.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.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.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.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,
|
|
endPoint: .bottomTrailing
|
|
)
|
|
}
|
|
|
|
// 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"
|
|
|
|
/// 주 시작 요일 저장 키. false = 일요일 시작(기본), true = 월요일 시작.
|
|
let weekStartStorageKey = "weekStartsMonday"
|
|
|
|
// MARK: - 공용 스타일
|
|
|
|
struct CardBackground: ViewModifier {
|
|
var cornerRadius: CGFloat = 18
|
|
|
|
func body(content: Content) -> some View {
|
|
content
|
|
.background(
|
|
RoundedRectangle(cornerRadius: cornerRadius, style: .continuous)
|
|
.fill(Theme.surface)
|
|
.overlay(
|
|
RoundedRectangle(cornerRadius: cornerRadius, style: .continuous)
|
|
.strokeBorder(Theme.stroke, lineWidth: 1)
|
|
)
|
|
)
|
|
}
|
|
}
|
|
|
|
extension View {
|
|
func cardStyle(cornerRadius: CGFloat = 18) -> some View {
|
|
modifier(CardBackground(cornerRadius: cornerRadius))
|
|
}
|
|
}
|
|
|
|
// MARK: - 햅틱
|
|
|
|
enum Haptics {
|
|
static func success() { UINotificationFeedbackGenerator().notificationOccurred(.success) }
|
|
static func warning() { UINotificationFeedbackGenerator().notificationOccurred(.warning) }
|
|
static func tap() { UIImpactFeedbackGenerator(style: .light).impactOccurred() }
|
|
static func shutter() { UIImpactFeedbackGenerator(style: .medium).impactOccurred() }
|
|
}
|
|
|
|
// MARK: - 달력 유틸
|
|
|
|
enum KoreanCalendar {
|
|
static let calendar: Calendar = {
|
|
var c = Calendar(identifier: .gregorian)
|
|
c.locale = Locale(identifier: "ko_KR")
|
|
return c
|
|
}()
|
|
|
|
private static let baseSymbols = ["일", "월", "화", "수", "목", "금", "토"]
|
|
|
|
/// 주 시작 설정에 맞춰 정렬된 요일 기호. (일요일 시작 / 월요일 시작)
|
|
static func weekdaySymbols(startsOnMonday: Bool) -> [String] {
|
|
startsOnMonday ? Array(baseSymbols[1...]) + [baseSymbols[0]] : baseSymbols
|
|
}
|
|
|
|
/// 해당 월의 그리드 셀 배열. 앞쪽 빈칸은 nil, 이후 1...말일.
|
|
static func gridDays(year: Int, month: Int, startsOnMonday: Bool) -> [Int?] {
|
|
let comps = DateComponents(year: year, month: month, day: 1)
|
|
guard let first = calendar.date(from: comps),
|
|
let range = calendar.range(of: .day, in: .month, for: first) else { return [] }
|
|
let firstWeekday = calendar.component(.weekday, from: first) // 1=일 ... 7=토
|
|
let gridFirstWeekday = startsOnMonday ? 2 : 1
|
|
let leading = (firstWeekday - gridFirstWeekday + 7) % 7
|
|
return Array(repeating: nil, count: leading) + range.map { $0 }
|
|
}
|
|
|
|
static func date(year: Int, month: Int, day: Int) -> Date? {
|
|
calendar.date(from: DateComponents(year: year, month: month, day: day))
|
|
}
|
|
|
|
static func isToday(year: Int, month: Int, day: Int) -> Bool {
|
|
let t = calendar.dateComponents([.year, .month, .day], from: .now)
|
|
return t.year == year && t.month == month && t.day == day
|
|
}
|
|
|
|
/// 오늘부터 해당 날짜까지 남은 일수 (음수면 지난 날짜)
|
|
static func daysFromToday(to date: Date) -> Int {
|
|
let today = calendar.startOfDay(for: .now)
|
|
let target = calendar.startOfDay(for: date)
|
|
return calendar.dateComponents([.day], from: today, to: target).day ?? 0
|
|
}
|
|
|
|
static func weekdayColor(columnIndex: Int, startsOnMonday: Bool) -> Color {
|
|
// 열 위치를 실제 요일(0=일 ... 6=토)로 환산해 색을 정한다.
|
|
let weekday = (columnIndex + (startsOnMonday ? 1 : 0)) % 7
|
|
switch weekday {
|
|
case 0: return Theme.sunday
|
|
case 6: return Theme.saturday
|
|
default: return Theme.textSecondary
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - 이미지 유틸
|
|
|
|
extension UIImage {
|
|
func downscaled(maxDimension: CGFloat) -> UIImage {
|
|
let longest = max(size.width, size.height)
|
|
guard longest > maxDimension, longest > 0 else { return self }
|
|
let ratio = maxDimension / longest
|
|
let newSize = CGSize(width: size.width * ratio, height: size.height * ratio)
|
|
let format = UIGraphicsImageRendererFormat.default()
|
|
format.scale = 1
|
|
return UIGraphicsImageRenderer(size: newSize, format: format).image { _ in
|
|
draw(in: CGRect(origin: .zero, size: newSize))
|
|
}
|
|
}
|
|
}
|