- 행동 실행(A): 소형1/중형3/대형4칸, Button(intent:)로 앱 없이 즉시 실행, 측정 중 실시간 타이머 + 노란 테두리, 표시 기간(오늘/이번 주/이번 달) 옵션 - 목표 진행률(B): 하루/주간/월간 가로 진행바, 소형1/중형2/대형4 - 다짐 진행률(C): 원형 링 + 기간 옵션, 단일 행동 다짐은 눌러서 실행, '이하 유지'는 한도 지킴/초과로 표현 - 다짐 현황(D): 목표명 + 원형 링 그리드 (소형 2x2 / 중형 최대 8 / 대형 목표 2개) - 통계: 행동 최대 3개 꺾은선 그래프, 앱 통계 탭과 동일 산식·색, 소형은 '한 달 일별' 제외 (스펙 §8.3) - 잠금화면: circular/rectangular/inline, 표시 방식 3종(게이지/숫자/다짐 점) - 전 위젯 프리미엄 게이트 (미결제 시 잠금 안내) - DEBUG 미리보기 화면: -widgetPreview YES|lock, -widgetPreviewScroll B|C|D|S Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013yKDMhuF39GVYy3FMcGHh7
239 lines
7.8 KiB
Swift
239 lines
7.8 KiB
Swift
//
|
|
// WidgetSupport.swift
|
|
// Haru_DanimWidgets
|
|
//
|
|
// 홈/잠금화면 위젯 공용 인프라 (CLAUDE.md §8)
|
|
// - 타임라인 시점에 SwiftData(App Group DB)에서 읽어 만든 스냅숏 구조체
|
|
// - 프리미엄 잠금 표시, 원형/막대 게이지 등 공용 뷰
|
|
//
|
|
|
|
import SwiftUI
|
|
import SwiftData
|
|
import WidgetKit
|
|
|
|
// MARK: - 스냅숏 모델
|
|
|
|
/// 행동 실행 셀 스냅숏 (소형/중형/대형 A)
|
|
struct ActionCellSnapshot: Identifiable {
|
|
let id: UUID
|
|
let name: String
|
|
let symbolName: String
|
|
let colorHex: String
|
|
let isCount: Bool
|
|
let isRunning: Bool
|
|
/// 표시 기간 내 누적값 (시간=초, 횟수=회). 진행 중 세션 포함(스냅숏 시점 기준).
|
|
let value: Double
|
|
/// 진행 중일 때 누적 시간이 실시간으로 흐르도록 하는 기준 시각 (now - 누적초)
|
|
let tickingBase: Date?
|
|
|
|
var color: Color { Color(hex: colorHex) }
|
|
|
|
var entity: ActionEntity {
|
|
ActionEntity(
|
|
id: id, name: name, symbolName: symbolName,
|
|
trackingTypeRaw: isCount ? TrackingType.count.rawValue : TrackingType.time.rawValue
|
|
)
|
|
}
|
|
|
|
@MainActor
|
|
static func make(action: Action, span: StatSpan, now: Date = .now) -> ActionCellSnapshot {
|
|
let math = DayMath()
|
|
let range: Range<Date> = switch span {
|
|
case .day: math.dayRange(containing: now)
|
|
case .week: math.weekRange(containing: now)
|
|
case .month: math.monthRange(containing: now)
|
|
}
|
|
let agg = Aggregator(math: math)
|
|
let isCount = action.trackingType == .count
|
|
let value: Double = isCount
|
|
? Double(agg.count(for: action, in: range))
|
|
: agg.seconds(for: action, in: range, now: now)
|
|
let running = action.isRunning
|
|
return ActionCellSnapshot(
|
|
id: action.uuid,
|
|
name: action.name,
|
|
symbolName: action.symbolName,
|
|
colorHex: action.tags.sorted { $0.createdAt < $1.createdAt }.first?.colorHex ?? "#2F6B4F",
|
|
isCount: isCount,
|
|
isRunning: running,
|
|
value: value,
|
|
tickingBase: running && !isCount ? now.addingTimeInterval(-value) : nil
|
|
)
|
|
}
|
|
}
|
|
|
|
/// 다짐 진행률 셀 스냅숏 (소형/중형/대형 C·D, 컴플리케이션)
|
|
struct QuestCellSnapshot: Identifiable {
|
|
let id: UUID
|
|
let targetName: String
|
|
let symbolName: String
|
|
let colorHex: String
|
|
/// 게이지 비율 0...1 ('이하 유지'는 100% 또는 0%)
|
|
let ratio: Double
|
|
/// 퍼센트 문구용 (이상 달성은 100% 초과 가능)
|
|
let displayRatio: Double
|
|
let isAtMost: Bool
|
|
let isAchieved: Bool
|
|
/// 실행 버튼 대상 (단일 행동 대상 다짐만)
|
|
let runTarget: ActionEntity?
|
|
let isRunning: Bool
|
|
|
|
var color: Color { Color(hex: colorHex) }
|
|
|
|
@MainActor
|
|
static func make(quest: Quest, span: StatSpan, now: Date = .now) -> QuestCellSnapshot {
|
|
let result = QuestProgress(quest: quest).spanProgress(span, now: now)
|
|
let action = quest.targetAction
|
|
return QuestCellSnapshot(
|
|
id: quest.uuid,
|
|
targetName: quest.targetName,
|
|
symbolName: quest.targetSymbol,
|
|
colorHex: action?.tags.sorted { $0.createdAt < $1.createdAt }.first?.colorHex
|
|
?? quest.targetTag?.colorHex ?? "#2F6B4F",
|
|
ratio: result.ratio,
|
|
displayRatio: result.displayRatio,
|
|
isAtMost: quest.direction == .atMost,
|
|
isAchieved: result.isAchieved,
|
|
runTarget: action.map(ActionEntity.init),
|
|
isRunning: action?.isRunning ?? false
|
|
)
|
|
}
|
|
}
|
|
|
|
/// 목표 진행률 스냅숏 (소형/중형/대형 B, 잠금화면, 컴플리케이션)
|
|
struct GoalSnapshot: Identifiable {
|
|
let id: UUID
|
|
let title: String
|
|
let symbolName: String
|
|
let colorHex: String
|
|
/// 하루/주간/월간 진행률 (다짐 평균, 0...1)
|
|
let dayRatio: Double
|
|
let weekRatio: Double
|
|
let monthRatio: Double
|
|
let quests: [QuestCellSnapshot]
|
|
|
|
var color: Color { Color(hex: colorHex) }
|
|
|
|
func ratio(for span: StatSpan) -> Double {
|
|
switch span {
|
|
case .day: return dayRatio
|
|
case .week: return weekRatio
|
|
case .month: return monthRatio
|
|
}
|
|
}
|
|
|
|
@MainActor
|
|
static func make(goal: Goal, span: StatSpan = .day, now: Date = .now) -> GoalSnapshot {
|
|
GoalSnapshot(
|
|
id: goal.uuid,
|
|
title: goal.title,
|
|
symbolName: goal.symbolName,
|
|
colorHex: goal.colorHex,
|
|
dayRatio: goal.combinedSpanRatio(.day, now: now),
|
|
weekRatio: goal.combinedSpanRatio(.week, now: now),
|
|
monthRatio: goal.combinedSpanRatio(.month, now: now),
|
|
quests: goal.sortedQuests.map { QuestCellSnapshot.make(quest: $0, span: span, now: now) }
|
|
)
|
|
}
|
|
}
|
|
|
|
// MARK: - 프리미엄 잠금
|
|
|
|
struct WidgetLockedView: View {
|
|
var body: some View {
|
|
VStack(spacing: 6) {
|
|
Image(systemName: "crown.fill")
|
|
.font(.title3)
|
|
.foregroundStyle(AppTheme.yellow)
|
|
Text("프리미엄 기능")
|
|
.font(.caption.weight(.semibold))
|
|
Text("앱의 설정 → 프리미엄에서\n잠금 해제해 주세요")
|
|
.font(.caption2)
|
|
.foregroundStyle(.secondary)
|
|
.multilineTextAlignment(.center)
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - 공용 게이지
|
|
|
|
/// 원형 진행바 + 가운데 아이콘 (소형 C·D)
|
|
struct QuestRingView: View {
|
|
let snapshot: QuestCellSnapshot
|
|
var lineWidth: CGFloat = 5
|
|
var iconSize: CGFloat = 15
|
|
|
|
var body: some View {
|
|
ZStack {
|
|
Circle()
|
|
.stroke(snapshot.color.opacity(0.2), lineWidth: lineWidth)
|
|
Circle()
|
|
.trim(from: 0, to: min(max(snapshot.ratio, 0), 1))
|
|
.stroke(
|
|
snapshot.isAtMost && !snapshot.isAchieved ? Color.red : snapshot.color,
|
|
style: StrokeStyle(lineWidth: lineWidth, lineCap: .round)
|
|
)
|
|
.rotationEffect(.degrees(-90))
|
|
Image(systemName: snapshot.symbolName)
|
|
.font(.system(size: iconSize, weight: .semibold))
|
|
.foregroundStyle(snapshot.color)
|
|
}
|
|
}
|
|
}
|
|
|
|
/// 가로 진행바 한 줄 (소형 B)
|
|
struct SpanBarRow: View {
|
|
let label: String
|
|
let ratio: Double
|
|
let color: Color
|
|
|
|
var body: some View {
|
|
VStack(alignment: .leading, spacing: 2) {
|
|
HStack {
|
|
Text(label)
|
|
.font(.caption2)
|
|
.foregroundStyle(.secondary)
|
|
Spacer()
|
|
Text(Format.percent(ratio))
|
|
.font(.caption2.weight(.semibold).monospacedDigit())
|
|
}
|
|
ProgressView(value: min(max(ratio, 0), 1))
|
|
.progressViewStyle(.linear)
|
|
.tint(color)
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - 데이터 로드 헬퍼
|
|
|
|
@MainActor
|
|
enum WidgetStore {
|
|
static var isUnlocked: Bool { PremiumGate.isUnlocked(.homeWidgets) }
|
|
|
|
static func action(_ id: UUID?) -> Action? {
|
|
guard let id else { return nil }
|
|
return IntentStore.actions().first { $0.uuid == id }
|
|
}
|
|
|
|
static func goal(_ id: UUID?) -> Goal? {
|
|
guard let id else { return nil }
|
|
return IntentStore.goals().first { $0.uuid == id }
|
|
}
|
|
|
|
static func quest(_ id: UUID?) -> Quest? {
|
|
guard let id else { return nil }
|
|
return IntentStore.quests().first { $0.uuid == id }
|
|
}
|
|
|
|
/// 설정에서 아직 선택하지 않았을 때의 기본 대상
|
|
static func defaultActions(_ count: Int) -> [Action] {
|
|
Array(IntentStore.actions().prefix(count))
|
|
}
|
|
|
|
static func defaultGoals(_ count: Int) -> [Goal] {
|
|
let goals = IntentStore.goals()
|
|
let active = goals.filter { $0.status == .inProgress }
|
|
return Array((active.isEmpty ? goals : active).prefix(count))
|
|
}
|
|
}
|