// // LockScreenWidgets.swift // Haru_DanimWidgets // // 잠금화면 위젯 (CLAUDE.md §8.4) — 목표 1개의 달성률/다짐 현황을 간단하게 // 표시 방식 옵션: 원형 게이지 / 숫자만 / 다짐 점 표시 // import SwiftUI import WidgetKit import AppIntents // MARK: - 설정 enum LockStyleOption: String, AppEnum { case gauge case number case dots static let typeDisplayRepresentation = TypeDisplayRepresentation(name: "표시 방식") static let caseDisplayRepresentations: [LockStyleOption: DisplayRepresentation] = [ .gauge: "원형 게이지", .number: "숫자만", .dots: "다짐 점 표시", ] } struct LockGoalConfigIntent: WidgetConfigurationIntent { static let title: LocalizedStringResource = "잠금화면 목표 위젯" static let description = IntentDescription("잠금화면에 표시할 목표와 기간, 표시 방식을 선택하세요.") @Parameter(title: "목표") var goal: GoalEntity? @Parameter(title: "달성률 기간", default: .day) var span: SpanOption @Parameter(title: "표시 방식", default: .gauge) var style: LockStyleOption static var parameterSummary: some ParameterSummary { Summary("목표 달성률을 \(\.$span) 기준 \(\.$style)(으)로 표시") { \.$goal } } } // MARK: - 타임라인 struct LockGoalEntry: TimelineEntry { let date: Date let locked: Bool let spanLabel: String let style: LockStyleOption let goal: GoalSnapshot? let ratio: Double var isLive: Bool { goal?.quests.contains { $0.isRunning } ?? false } } struct LockGoalProvider: AppIntentTimelineProvider { @MainActor static func makeEntry(_ configuration: LockGoalConfigIntent, now: Date = .now) -> LockGoalEntry { IntentStore.refresh() guard PremiumGate.isUnlocked(.lockScreenWidgets) else { return LockGoalEntry(date: now, locked: true, spanLabel: "", style: .gauge, goal: nil, ratio: 0) } let span = configuration.span.statSpan let model = WidgetStore.goal(configuration.goal?.id) ?? WidgetStore.defaultGoals(1).first let snapshot = model.map { GoalSnapshot.make(goal: $0, span: span, now: now) } return LockGoalEntry( date: now, locked: false, spanLabel: configuration.span.label, style: configuration.style, goal: snapshot, ratio: snapshot?.ratio(for: span) ?? 0 ) } func placeholder(in context: Context) -> LockGoalEntry { let goal = GoalSnapshot.sample return LockGoalEntry(date: .now, locked: false, spanLabel: String(localized: "오늘"), style: .gauge, goal: goal, ratio: goal.dayRatio) } @MainActor func snapshot(for configuration: LockGoalConfigIntent, in context: Context) async -> LockGoalEntry { Self.makeEntry(configuration) } @MainActor func timeline(for configuration: LockGoalConfigIntent, in context: Context) async -> Timeline { let first = Self.makeEntry(configuration) return WidgetRefresh.timeline(first: first, live: first.isLive) { date in Self.makeEntry(configuration, now: date) } } } // MARK: - 뷰 struct LockGoalWidgetView: View { @Environment(\.widgetFamily) private var envFamily /// 앱 내 DEBUG 미리보기에서 패밀리를 강제할 때 사용 var previewFamily: WidgetFamily? = nil private var family: WidgetFamily { previewFamily ?? envFamily } let entry: LockGoalEntry var body: some View { Group { if entry.locked { switch family { case .accessoryInline: Text("하루 다님 · 프리미엄 기능") default: VStack(spacing: 2) { Image(systemName: "crown.fill") .widgetAccentable() Text("프리미엄") .font(.caption2) } } } else if let goal = entry.goal { switch family { case .accessoryInline: // 한 줄: 아이콘 + 목표명 + % Text("\(goal.title) \(Format.percent(entry.ratio))") case .accessoryRectangular: rectangularView(goal) default: circularView(goal) } } else { switch family { case .accessoryInline: Text("하루 다님 · 목표 없음") default: VStack(spacing: 2) { Image(systemName: "flag.checkered") Text("목표 없음") .font(.caption2) } } } } .containerBackground(.clear, for: .widget) } /// 잠금화면(비브런트/악센트 렌더링)에서는 시스템이 색을 덮어쓰므로 /// 배경은 시스템 표준(AccessoryWidgetBackground)만 쓰고, /// 텍스트는 .primary + 강조 요소는 .widgetAccentable()로 렌더링을 맡긴다 @ViewBuilder private func circularView(_ goal: GoalSnapshot) -> some View { switch entry.style { case .gauge: Gauge(value: min(max(entry.ratio, 0), 1)) { Image(systemName: goal.symbolName) .widgetAccentable() } currentValueLabel: { Text("\(Int((entry.ratio * 100).rounded()))") .font(.system(size: 14, weight: .bold).monospacedDigit()) .foregroundStyle(.primary) } .gaugeStyle(.accessoryCircular) case .number: ZStack { AccessoryWidgetBackground() VStack(spacing: 0) { Text("\(Int((entry.ratio * 100).rounded()))%") .font(.system(size: 16, weight: .bold).monospacedDigit()) .foregroundStyle(.primary) .widgetAccentable() Text(entry.spanLabel) .font(.system(size: 9)) .foregroundStyle(.secondary) } } case .dots: // 다짐 달성 여부를 점으로 (최대 6개) ZStack { AccessoryWidgetBackground() VStack(spacing: 3) { Image(systemName: goal.symbolName) .font(.system(size: 12, weight: .semibold)) .widgetAccentable() questDots(goal, size: 6) } } } } private func rectangularView(_ goal: GoalSnapshot) -> some View { VStack(alignment: .leading, spacing: 2) { HStack(spacing: 4) { Image(systemName: goal.symbolName) .font(.system(size: 11, weight: .semibold)) Text(goal.title) .font(.caption.weight(.semibold)) .lineLimit(1) } .foregroundStyle(.primary) .widgetAccentable() Gauge(value: min(max(entry.ratio, 0), 1)) { EmptyView() } .gaugeStyle(.accessoryLinearCapacity) HStack { Text("\(entry.spanLabel) \(Format.percent(entry.ratio))") .font(.caption2) .foregroundStyle(.secondary) Spacer() if !goal.quests.isEmpty { questDots(goal, size: 5) } } } } /// 다짐 진행률 점 표시 — 미완료는 링(호)이 진행률만큼 감기고, 완료는 꽉 찬 원반. /// 예전 '아래에서 차오르는 물높이' 방식은 5~6pt 크기에서 90%와 100%의 차이(위쪽 1px 남짓)가 /// 사실상 안 보였다 — 링은 끝의 '틈'이 둘레에 생겨 눈에 띄고(완료 직전에도 틈이 남게 /// 미완료는 최대 88%까지만 감는다), 완료는 면적 자체(빈 링 vs 원반)가 달라 어떤 배경에서도 /// 구분된다. ratio를 그대로 쓰므로 '이하 유지'는 한도 안=원반 / 초과=빈 트랙으로 의미 유지. private func questDots(_ goal: GoalSnapshot, size: CGFloat) -> some View { HStack(spacing: 3) { ForEach(goal.quests.prefix(6)) { quest in let ratio = min(max(quest.ratio, 0), 1) let lineWidth = max(size * 0.3, 1.4) ZStack { if ratio >= 0.999 { Circle() .fill(.primary) } else { Circle() .inset(by: lineWidth / 2) .stroke(.primary.opacity(0.35), lineWidth: lineWidth) Circle() .inset(by: lineWidth / 2) // 시작도 보이게 최소 10%, 완료 직전에도 틈이 남게 최대 88% .trim(from: 0, to: ratio > 0 ? min(max(ratio, 0.1), 0.88) : 0) .stroke(.primary, style: StrokeStyle(lineWidth: lineWidth, lineCap: .round)) .rotationEffect(.degrees(-90)) } } .frame(width: size, height: size) } } } } // MARK: - 위젯 정의 struct LockGoalWidget: Widget { var body: some WidgetConfiguration { AppIntentConfiguration( kind: "HaruLockGoalWidget", intent: LockGoalConfigIntent.self, provider: LockGoalProvider() ) { entry in LockGoalWidgetView(entry: entry) } .configurationDisplayName("목표 달성률 (잠금화면)") .description("잠금화면에서 목표 달성률과 다짐 현황을 확인해요. (프리미엄)") .supportedFamilies([.accessoryCircular, .accessoryRectangular, .accessoryInline]) } }