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)) } } }