mycode/myApp/HaruDanim/Widgets/LockScreenWidgets.swift
songyc macbook ddd46a4084 feat(widget): lock-screen quest dots fill by progress ratio
- 잠금화면 위젯 '다짐 점'이 달성/미달성 이진 채움이라 진행 정도를 알 수 없다는 보고 → 각 점이 진행률(ratio)만큼 아래에서부터 차오르게 변경. 표시 전용 소형 뷰 수정이라 위험 낮음 판단 후 진행
- ratio를 그대로 쓰므로 달성=완전 채움(기존과 동일), '이하 유지'는 ratio가 1/0이라 한도 안=채움/초과=빈 점 동작 불변. 작은 크기(5~6pt)에선 부채꼴보다 물높이 방식이 가독성 좋아 채택
- 위젯 미리보기 잠금 섹션에 '다짐 여러 개+주간' 항목 추가 — 부분 채움(반쯤 찬 점/빈 점/가득 점) 시뮬 스크린샷으로 circular/rectangular 모두 확인
- 새 문자열 없음(카탈로그 클린), 도움말 무변경(스타일 세부 서술 없음). CLAUDE.md §10 갱신. Debug/Store 빌드 성공

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014CkLDi8Lr21vGqo4SjZaTp
2026-07-19 05:36:46 +09:00

259 lines
9.4 KiB
Swift

//
// 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<LockGoalEntry> {
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)
}
}
}
}
/// ( = ).
/// ratio ' ' =/= .
/// .
private func questDots(_ goal: GoalSnapshot, size: CGFloat) -> some View {
HStack(spacing: 3) {
ForEach(goal.quests.prefix(6)) { quest in
let fill = min(max(quest.ratio, 0), 1)
Circle()
.strokeBorder(.primary, lineWidth: 1)
.background(
GeometryReader { geo in
VStack(spacing: 0) {
Spacer(minLength: 0)
Rectangle()
.fill(.primary)
.frame(height: geo.size.height * fill)
}
}
.clipShape(Circle())
)
.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])
}
}