feat(widgets): 홈/잠금화면 위젯 6종 + Interactive Widgets (CLAUDE.md §8)
- 행동 실행(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
This commit is contained in:
parent
18f49bc0db
commit
ed8b52792a
10
myApp/HaruDanim/Haru_DanimWidgets.entitlements
Normal file
10
myApp/HaruDanim/Haru_DanimWidgets.entitlements
Normal file
@ -0,0 +1,10 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>com.apple.security.application-groups</key>
|
||||
<array>
|
||||
<string>group.com.yechan.HaruDanim</string>
|
||||
</array>
|
||||
</dict>
|
||||
</plist>
|
||||
@ -15,7 +15,16 @@ struct ContentView: View {
|
||||
|
||||
var body: some View {
|
||||
ZStack {
|
||||
#if DEBUG
|
||||
// 검증용: -widgetPreview YES(홈 위젯) / lock(잠금화면 위젯) → 위젯 미리보기 화면
|
||||
if let mode = UserDefaults.standard.string(forKey: "widgetPreview") {
|
||||
WidgetPreviewScreen(showLock: mode == "lock")
|
||||
} else {
|
||||
MainTabView()
|
||||
}
|
||||
#else
|
||||
MainTabView()
|
||||
#endif
|
||||
if showSplash {
|
||||
SplashView()
|
||||
.transition(.opacity)
|
||||
|
||||
182
myApp/HaruDanim/IOS/Views/WidgetPreviewScreen.swift
Normal file
182
myApp/HaruDanim/IOS/Views/WidgetPreviewScreen.swift
Normal file
@ -0,0 +1,182 @@
|
||||
//
|
||||
// WidgetPreviewScreen.swift
|
||||
// Haru_Danim
|
||||
//
|
||||
// DEBUG 전용: 위젯 뷰들을 앱 안에서 실제 크기로 렌더링해 검증하는 화면.
|
||||
// 런치 인자 `-widgetPreview YES`(홈 위젯) / `-widgetPreview lock`(잠금화면)으로 진입.
|
||||
// (시뮬레이터 홈 화면에 위젯을 자동으로 추가할 방법이 없어 만든 검증 도구)
|
||||
//
|
||||
|
||||
#if DEBUG
|
||||
import SwiftUI
|
||||
import WidgetKit
|
||||
|
||||
struct WidgetPreviewScreen: View {
|
||||
let showLock: Bool
|
||||
|
||||
@State private var actionEntry: ActionRunEntry?
|
||||
@State private var goalBarsEntry: GoalBarsEntry?
|
||||
@State private var questRingEntry: QuestRingEntry?
|
||||
@State private var gridEntry: GoalQuestGridEntry?
|
||||
@State private var statsEntrySmall: StatsChartEntry?
|
||||
@State private var statsEntryMedium: StatsChartEntry?
|
||||
@State private var lockEntries: [(String, LockGoalEntry)] = []
|
||||
|
||||
var body: some View {
|
||||
ScrollViewReader { proxy in
|
||||
ScrollView {
|
||||
VStack(alignment: .leading, spacing: 16) {
|
||||
if showLock {
|
||||
lockSection
|
||||
} else {
|
||||
homeSection
|
||||
}
|
||||
}
|
||||
.padding()
|
||||
}
|
||||
.background(Color(white: 0.9))
|
||||
.onAppear {
|
||||
load()
|
||||
// -widgetPreviewScroll B|C|D|S → 해당 섹션으로 자동 스크롤
|
||||
if let anchor = UserDefaults.standard.string(forKey: "widgetPreviewScroll") {
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 1.5) {
|
||||
withAnimation { proxy.scrollTo(anchor, anchor: .top) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func load() {
|
||||
// -premium 런치 인자가 PremiumGate 캐시에 반영되도록 매니저를 먼저 초기화
|
||||
_ = PremiumManager.shared
|
||||
if showLock {
|
||||
var gauge = LockGoalConfigIntent()
|
||||
gauge.style = .gauge
|
||||
var number = LockGoalConfigIntent()
|
||||
number.style = .number
|
||||
var dots = LockGoalConfigIntent()
|
||||
dots.style = .dots
|
||||
lockEntries = [
|
||||
("gauge", LockGoalProvider.makeEntry(gauge)),
|
||||
("number", LockGoalProvider.makeEntry(number)),
|
||||
("dots", LockGoalProvider.makeEntry(dots)),
|
||||
]
|
||||
} else {
|
||||
actionEntry = ActionRunProvider.makeEntry(ActionRunConfigIntent())
|
||||
goalBarsEntry = GoalBarsProvider.makeEntry(GoalBarsConfigIntent())
|
||||
questRingEntry = QuestRingProvider.makeEntry(QuestRingConfigIntent())
|
||||
gridEntry = GoalQuestGridProvider.makeEntry(GoalQuestGridConfigIntent())
|
||||
statsEntrySmall = StatsChartProvider.makeEntry(StatsChartConfigIntent(), family: .systemSmall)
|
||||
statsEntryMedium = StatsChartProvider.makeEntry(StatsChartConfigIntent(), family: .systemMedium)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: 홈 위젯
|
||||
|
||||
@ViewBuilder
|
||||
private var homeSection: some View {
|
||||
if let entry = actionEntry {
|
||||
row("A 행동 실행 · 소형/중형") {
|
||||
widgetBox(.systemSmall) { ActionRunWidgetView(previewFamily: .systemSmall, entry: entry) }
|
||||
widgetBox(.systemMedium) { ActionRunWidgetView(previewFamily: .systemMedium, entry: entry) }
|
||||
}
|
||||
row("A 행동 실행 · 대형") {
|
||||
widgetBox(.systemLarge) { ActionRunWidgetView(previewFamily: .systemLarge, entry: entry) }
|
||||
}
|
||||
}
|
||||
if let entry = goalBarsEntry {
|
||||
row("B 목표 진행률 · 소형/중형") {
|
||||
widgetBox(.systemSmall) { GoalBarsWidgetView(previewFamily: .systemSmall, entry: entry) }
|
||||
widgetBox(.systemMedium) { GoalBarsWidgetView(previewFamily: .systemMedium, entry: entry) }
|
||||
}
|
||||
.id("B")
|
||||
}
|
||||
if let entry = questRingEntry {
|
||||
row("C 다짐 진행률 · 소형/중형") {
|
||||
widgetBox(.systemSmall) { QuestRingWidgetView(previewFamily: .systemSmall, entry: entry) }
|
||||
widgetBox(.systemMedium) { QuestRingWidgetView(previewFamily: .systemMedium, entry: entry) }
|
||||
}
|
||||
.id("C")
|
||||
}
|
||||
if let entry = gridEntry {
|
||||
row("D 다짐 현황 · 소형/중형") {
|
||||
widgetBox(.systemSmall) { GoalQuestGridWidgetView(previewFamily: .systemSmall, entry: entry) }
|
||||
widgetBox(.systemMedium) { GoalQuestGridWidgetView(previewFamily: .systemMedium, entry: entry) }
|
||||
}
|
||||
.id("D")
|
||||
row("D 다짐 현황 · 대형(목표 2개)") {
|
||||
widgetBox(.systemLarge) { GoalQuestGridWidgetView(previewFamily: .systemLarge, entry: entry) }
|
||||
}
|
||||
}
|
||||
if let small = statsEntrySmall, let medium = statsEntryMedium {
|
||||
row("통계 · 소형/중형") {
|
||||
widgetBox(.systemSmall) { StatsChartWidgetView(previewFamily: .systemSmall, entry: small) }
|
||||
widgetBox(.systemMedium) { StatsChartWidgetView(previewFamily: .systemMedium, entry: medium) }
|
||||
}
|
||||
.id("S")
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: 잠금화면 위젯
|
||||
|
||||
@ViewBuilder
|
||||
private var lockSection: some View {
|
||||
ForEach(lockEntries, id: \.0) { name, entry in
|
||||
row("잠금화면 · \(name)") {
|
||||
ZStack {
|
||||
Color.black
|
||||
LockGoalWidgetView(previewFamily: .accessoryCircular, entry: entry)
|
||||
.foregroundStyle(.white)
|
||||
}
|
||||
.frame(width: 72, height: 72)
|
||||
.clipShape(Circle())
|
||||
ZStack {
|
||||
Color.black
|
||||
LockGoalWidgetView(previewFamily: .accessoryRectangular, entry: entry)
|
||||
.foregroundStyle(.white)
|
||||
.padding(6)
|
||||
}
|
||||
.frame(width: 172, height: 76)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 16))
|
||||
}
|
||||
}
|
||||
if let entry = lockEntries.first?.1 {
|
||||
row("잠금화면 · inline") {
|
||||
ZStack {
|
||||
Color.black
|
||||
LockGoalWidgetView(previewFamily: .accessoryInline, entry: entry)
|
||||
.foregroundStyle(.white)
|
||||
}
|
||||
.frame(width: 230, height: 32)
|
||||
.clipShape(Capsule())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: 레이아웃 헬퍼
|
||||
|
||||
private func row(_ title: String, @ViewBuilder content: () -> some View) -> some View {
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
Text(title)
|
||||
.font(.caption.weight(.semibold))
|
||||
.foregroundStyle(.secondary)
|
||||
VStack(alignment: .leading, spacing: 12, content: content)
|
||||
}
|
||||
}
|
||||
|
||||
private func widgetBox(_ family: WidgetFamily, @ViewBuilder content: () -> some View) -> some View {
|
||||
let size: CGSize = switch family {
|
||||
case .systemMedium: CGSize(width: 338, height: 158)
|
||||
case .systemLarge: CGSize(width: 338, height: 354)
|
||||
default: CGSize(width: 158, height: 158)
|
||||
}
|
||||
return content()
|
||||
.padding(12)
|
||||
.frame(width: size.width, height: size.height)
|
||||
.background(AppTheme.background)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 22, style: .continuous))
|
||||
.shadow(color: .black.opacity(0.08), radius: 6, y: 2)
|
||||
}
|
||||
}
|
||||
#endif
|
||||
202
myApp/HaruDanim/Widgets/ActionRunWidget.swift
Normal file
202
myApp/HaruDanim/Widgets/ActionRunWidget.swift
Normal file
@ -0,0 +1,202 @@
|
||||
//
|
||||
// ActionRunWidget.swift
|
||||
// Haru_DanimWidgets
|
||||
//
|
||||
// 행동 실행 및 표기 위젯 (CLAUDE.md §8.2 소형 A / 중형 A / 대형 A)
|
||||
// Interactive Widgets: 셀을 누르면 앱을 켜지 않고 즉시 실행 (시간형 토글 / 횟수형 +1)
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
import WidgetKit
|
||||
import AppIntents
|
||||
|
||||
// MARK: - 설정
|
||||
|
||||
struct ActionRunConfigIntent: WidgetConfigurationIntent {
|
||||
static let title: LocalizedStringResource = "행동 실행 위젯"
|
||||
static let description = IntentDescription("표시할 행동과 누적 기간을 선택하세요.")
|
||||
|
||||
@Parameter(title: "표시 기간", default: .day)
|
||||
var period: SpanOption
|
||||
|
||||
@Parameter(title: "행동 1")
|
||||
var action1: ActionEntity?
|
||||
|
||||
@Parameter(title: "행동 2")
|
||||
var action2: ActionEntity?
|
||||
|
||||
@Parameter(title: "행동 3")
|
||||
var action3: ActionEntity?
|
||||
|
||||
@Parameter(title: "행동 4")
|
||||
var action4: ActionEntity?
|
||||
}
|
||||
|
||||
// MARK: - 타임라인
|
||||
|
||||
struct ActionRunEntry: TimelineEntry {
|
||||
let date: Date
|
||||
let locked: Bool
|
||||
let periodLabel: String
|
||||
let cells: [ActionCellSnapshot]
|
||||
}
|
||||
|
||||
struct ActionRunProvider: AppIntentTimelineProvider {
|
||||
@MainActor
|
||||
static func makeEntry(_ configuration: ActionRunConfigIntent) -> ActionRunEntry {
|
||||
guard WidgetStore.isUnlocked else {
|
||||
return ActionRunEntry(date: .now, locked: true, periodLabel: "", cells: [])
|
||||
}
|
||||
let span = configuration.period.statSpan
|
||||
let chosen = [configuration.action1, configuration.action2, configuration.action3, configuration.action4]
|
||||
.compactMap { $0 }
|
||||
.compactMap { WidgetStore.action($0.id) }
|
||||
let actions = chosen.isEmpty ? WidgetStore.defaultActions(4) : chosen
|
||||
return ActionRunEntry(
|
||||
date: .now,
|
||||
locked: false,
|
||||
periodLabel: configuration.period.label,
|
||||
cells: actions.map { ActionCellSnapshot.make(action: $0, span: span) }
|
||||
)
|
||||
}
|
||||
|
||||
func placeholder(in context: Context) -> ActionRunEntry {
|
||||
ActionRunEntry(date: .now, locked: false, periodLabel: "오늘", cells: [])
|
||||
}
|
||||
|
||||
@MainActor
|
||||
func snapshot(for configuration: ActionRunConfigIntent, in context: Context) async -> ActionRunEntry {
|
||||
Self.makeEntry(configuration)
|
||||
}
|
||||
|
||||
@MainActor
|
||||
func timeline(for configuration: ActionRunConfigIntent, in context: Context) async -> Timeline<ActionRunEntry> {
|
||||
// 값 자체는 인텐트 실행/앱 사용 시 reloadAllTimelines로 갱신되고,
|
||||
// 날짜 경계를 넘길 때를 대비해 15분 주기로도 새로 고침
|
||||
Timeline(entries: [Self.makeEntry(configuration)], policy: .after(.now.addingTimeInterval(15 * 60)))
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - 뷰
|
||||
|
||||
struct ActionRunCellView: View {
|
||||
let cell: ActionCellSnapshot
|
||||
let periodLabel: String
|
||||
var compact = false
|
||||
|
||||
var body: some View {
|
||||
Button(intent: RunActionIntent(action: cell.entity)) {
|
||||
VStack(alignment: .leading, spacing: compact ? 2 : 4) {
|
||||
HStack {
|
||||
Image(systemName: cell.symbolName)
|
||||
.font(.system(size: compact ? 14 : 18, weight: .semibold))
|
||||
Spacer()
|
||||
if cell.isRunning {
|
||||
Image(systemName: "record.circle")
|
||||
.font(.system(size: compact ? 10 : 12, weight: .bold))
|
||||
.foregroundStyle(AppTheme.yellow)
|
||||
}
|
||||
}
|
||||
Spacer(minLength: 0)
|
||||
Text(cell.name)
|
||||
.font(compact ? .caption2.weight(.semibold) : .caption.weight(.semibold))
|
||||
.lineLimit(1)
|
||||
Group {
|
||||
if cell.isCount {
|
||||
Text("\(periodLabel) \(Int(cell.value))회")
|
||||
} else if let base = cell.tickingBase {
|
||||
// 측정 중: 누적 시간이 실시간으로 흐름
|
||||
Text(base, style: .timer)
|
||||
} else {
|
||||
Text("\(periodLabel) \(Format.durationShort(cell.value))")
|
||||
}
|
||||
}
|
||||
.font(compact ? .caption2.monospacedDigit() : .caption.monospacedDigit())
|
||||
.opacity(0.85)
|
||||
.lineLimit(1)
|
||||
}
|
||||
.foregroundStyle(.white)
|
||||
.padding(compact ? 8 : 12)
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .leading)
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: 14, style: .continuous)
|
||||
.fill(cell.color)
|
||||
)
|
||||
.overlay {
|
||||
if cell.isRunning {
|
||||
RoundedRectangle(cornerRadius: 14, style: .continuous)
|
||||
.strokeBorder(AppTheme.yellow, lineWidth: 2)
|
||||
}
|
||||
}
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
}
|
||||
|
||||
struct ActionRunWidgetView: View {
|
||||
@Environment(\.widgetFamily) private var envFamily
|
||||
/// 앱 내 DEBUG 미리보기에서 패밀리를 강제할 때 사용
|
||||
var previewFamily: WidgetFamily? = nil
|
||||
private var family: WidgetFamily { previewFamily ?? envFamily }
|
||||
let entry: ActionRunEntry
|
||||
|
||||
var body: some View {
|
||||
Group {
|
||||
if entry.locked {
|
||||
WidgetLockedView()
|
||||
} else if entry.cells.isEmpty {
|
||||
Text("앱에서 행동을 만들면 여기서 실행할 수 있어요.")
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.secondary)
|
||||
.multilineTextAlignment(.center)
|
||||
} else {
|
||||
switch family {
|
||||
case .systemMedium:
|
||||
HStack(spacing: 8) {
|
||||
ForEach(entry.cells.prefix(3)) { cell in
|
||||
ActionRunCellView(cell: cell, periodLabel: entry.periodLabel, compact: true)
|
||||
}
|
||||
}
|
||||
case .systemLarge:
|
||||
let cells = Array(entry.cells.prefix(4))
|
||||
VStack(spacing: 8) {
|
||||
HStack(spacing: 8) {
|
||||
ForEach(cells.prefix(2)) { cell in
|
||||
ActionRunCellView(cell: cell, periodLabel: entry.periodLabel)
|
||||
}
|
||||
}
|
||||
if cells.count > 2 {
|
||||
HStack(spacing: 8) {
|
||||
ForEach(cells.dropFirst(2)) { cell in
|
||||
ActionRunCellView(cell: cell, periodLabel: entry.periodLabel)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
default:
|
||||
if let cell = entry.cells.first {
|
||||
ActionRunCellView(cell: cell, periodLabel: entry.periodLabel)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.containerBackground(AppTheme.background, for: .widget)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - 위젯 정의
|
||||
|
||||
struct ActionRunWidget: Widget {
|
||||
var body: some WidgetConfiguration {
|
||||
AppIntentConfiguration(
|
||||
kind: "HaruActionRunWidget",
|
||||
intent: ActionRunConfigIntent.self,
|
||||
provider: ActionRunProvider()
|
||||
) { entry in
|
||||
ActionRunWidgetView(entry: entry)
|
||||
}
|
||||
.configurationDisplayName("행동 실행")
|
||||
.description("행동을 위젯에서 바로 실행하고 누적값을 확인해요. (프리미엄)")
|
||||
.supportedFamilies([.systemSmall, .systemMedium, .systemLarge])
|
||||
}
|
||||
}
|
||||
324
myApp/HaruDanim/Widgets/GoalWidgets.swift
Normal file
324
myApp/HaruDanim/Widgets/GoalWidgets.swift
Normal file
@ -0,0 +1,324 @@
|
||||
//
|
||||
// GoalWidgets.swift
|
||||
// Haru_DanimWidgets
|
||||
//
|
||||
// 목표 진행률 위젯 (CLAUDE.md §8.2 소형/중형/대형 B, 소형/중형/대형 D)
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
import WidgetKit
|
||||
import AppIntents
|
||||
|
||||
// MARK: - B: 목표 진행바 위젯
|
||||
|
||||
struct GoalBarsConfigIntent: WidgetConfigurationIntent {
|
||||
static let title: LocalizedStringResource = "목표 진행률 위젯"
|
||||
static let description = IntentDescription("하루·주간·월간 진행률을 표시할 목표를 선택하세요.")
|
||||
|
||||
@Parameter(title: "목표 1")
|
||||
var goal1: GoalEntity?
|
||||
|
||||
@Parameter(title: "목표 2")
|
||||
var goal2: GoalEntity?
|
||||
|
||||
@Parameter(title: "목표 3")
|
||||
var goal3: GoalEntity?
|
||||
|
||||
@Parameter(title: "목표 4")
|
||||
var goal4: GoalEntity?
|
||||
}
|
||||
|
||||
struct GoalBarsEntry: TimelineEntry {
|
||||
let date: Date
|
||||
let locked: Bool
|
||||
let goals: [GoalSnapshot]
|
||||
}
|
||||
|
||||
struct GoalBarsProvider: AppIntentTimelineProvider {
|
||||
@MainActor
|
||||
static func makeEntry(_ configuration: GoalBarsConfigIntent) -> GoalBarsEntry {
|
||||
guard WidgetStore.isUnlocked else {
|
||||
return GoalBarsEntry(date: .now, locked: true, goals: [])
|
||||
}
|
||||
let chosen = [configuration.goal1, configuration.goal2, configuration.goal3, configuration.goal4]
|
||||
.compactMap { $0 }
|
||||
.compactMap { WidgetStore.goal($0.id) }
|
||||
let goals = chosen.isEmpty ? WidgetStore.defaultGoals(4) : chosen
|
||||
return GoalBarsEntry(date: .now, locked: false, goals: goals.map { GoalSnapshot.make(goal: $0) })
|
||||
}
|
||||
|
||||
func placeholder(in context: Context) -> GoalBarsEntry {
|
||||
GoalBarsEntry(date: .now, locked: false, goals: [])
|
||||
}
|
||||
|
||||
@MainActor
|
||||
func snapshot(for configuration: GoalBarsConfigIntent, in context: Context) async -> GoalBarsEntry {
|
||||
Self.makeEntry(configuration)
|
||||
}
|
||||
|
||||
@MainActor
|
||||
func timeline(for configuration: GoalBarsConfigIntent, in context: Context) async -> Timeline<GoalBarsEntry> {
|
||||
Timeline(entries: [Self.makeEntry(configuration)], policy: .after(.now.addingTimeInterval(15 * 60)))
|
||||
}
|
||||
}
|
||||
|
||||
struct GoalBarsCellView: View {
|
||||
let goal: GoalSnapshot
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 5) {
|
||||
HStack(spacing: 5) {
|
||||
Image(systemName: goal.symbolName)
|
||||
.font(.system(size: 11, weight: .semibold))
|
||||
.foregroundStyle(goal.color)
|
||||
Text(goal.title)
|
||||
.font(.caption.weight(.semibold))
|
||||
.lineLimit(1)
|
||||
}
|
||||
SpanBarRow(label: "하루", ratio: goal.dayRatio, color: goal.color)
|
||||
SpanBarRow(label: "주간", ratio: goal.weekRatio, color: goal.color)
|
||||
SpanBarRow(label: "월간", ratio: goal.monthRatio, color: goal.color)
|
||||
}
|
||||
.padding(10)
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top)
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: 14, style: .continuous)
|
||||
.fill(AppTheme.surface)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
struct GoalBarsWidgetView: View {
|
||||
@Environment(\.widgetFamily) private var envFamily
|
||||
/// 앱 내 DEBUG 미리보기에서 패밀리를 강제할 때 사용
|
||||
var previewFamily: WidgetFamily? = nil
|
||||
private var family: WidgetFamily { previewFamily ?? envFamily }
|
||||
let entry: GoalBarsEntry
|
||||
|
||||
private var visibleCount: Int {
|
||||
switch family {
|
||||
case .systemMedium: return 2
|
||||
case .systemLarge: return 4
|
||||
default: return 1
|
||||
}
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
Group {
|
||||
if entry.locked {
|
||||
WidgetLockedView()
|
||||
} else if entry.goals.isEmpty {
|
||||
Text("앱의 목표 탭에서 목표를 만들면 진행률이 표시돼요.")
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.secondary)
|
||||
.multilineTextAlignment(.center)
|
||||
} else {
|
||||
let goals = Array(entry.goals.prefix(visibleCount))
|
||||
switch family {
|
||||
case .systemMedium:
|
||||
HStack(spacing: 8) {
|
||||
ForEach(goals) { GoalBarsCellView(goal: $0) }
|
||||
}
|
||||
case .systemLarge:
|
||||
VStack(spacing: 8) {
|
||||
HStack(spacing: 8) {
|
||||
ForEach(goals.prefix(2)) { GoalBarsCellView(goal: $0) }
|
||||
}
|
||||
if goals.count > 2 {
|
||||
HStack(spacing: 8) {
|
||||
ForEach(goals.dropFirst(2)) { GoalBarsCellView(goal: $0) }
|
||||
}
|
||||
}
|
||||
}
|
||||
default:
|
||||
if let goal = goals.first {
|
||||
GoalBarsCellView(goal: goal)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.containerBackground(AppTheme.background, for: .widget)
|
||||
}
|
||||
}
|
||||
|
||||
struct GoalBarsWidget: Widget {
|
||||
var body: some WidgetConfiguration {
|
||||
AppIntentConfiguration(
|
||||
kind: "HaruGoalBarsWidget",
|
||||
intent: GoalBarsConfigIntent.self,
|
||||
provider: GoalBarsProvider()
|
||||
) { entry in
|
||||
GoalBarsWidgetView(entry: entry)
|
||||
}
|
||||
.configurationDisplayName("목표 진행률")
|
||||
.description("목표의 하루·주간·월간 진행률을 진행바로 확인해요. (프리미엄)")
|
||||
.supportedFamilies([.systemSmall, .systemMedium, .systemLarge])
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - D: 목표의 다짐 링 그리드 위젯
|
||||
|
||||
struct GoalQuestGridConfigIntent: WidgetConfigurationIntent {
|
||||
static let title: LocalizedStringResource = "다짐 현황 위젯"
|
||||
static let description = IntentDescription("다짐들의 진행률 링을 표시할 목표와 기간을 선택하세요.")
|
||||
|
||||
@Parameter(title: "목표")
|
||||
var goal: GoalEntity?
|
||||
|
||||
@Parameter(title: "목표 2 (대형 전용)")
|
||||
var secondGoal: GoalEntity?
|
||||
|
||||
@Parameter(title: "진행률 기간", default: .day)
|
||||
var span: SpanOption
|
||||
}
|
||||
|
||||
struct GoalQuestGridEntry: TimelineEntry {
|
||||
let date: Date
|
||||
let locked: Bool
|
||||
let spanLabel: String
|
||||
let goals: [GoalSnapshot]
|
||||
}
|
||||
|
||||
struct GoalQuestGridProvider: AppIntentTimelineProvider {
|
||||
@MainActor
|
||||
static func makeEntry(_ configuration: GoalQuestGridConfigIntent) -> GoalQuestGridEntry {
|
||||
guard WidgetStore.isUnlocked else {
|
||||
return GoalQuestGridEntry(date: .now, locked: true, spanLabel: "", goals: [])
|
||||
}
|
||||
let span = configuration.span.statSpan
|
||||
var goals: [Goal] = []
|
||||
if let first = WidgetStore.goal(configuration.goal?.id) { goals.append(first) }
|
||||
if let second = WidgetStore.goal(configuration.secondGoal?.id) { goals.append(second) }
|
||||
if goals.isEmpty { goals = WidgetStore.defaultGoals(2) }
|
||||
return GoalQuestGridEntry(
|
||||
date: .now,
|
||||
locked: false,
|
||||
spanLabel: configuration.span.label,
|
||||
goals: goals.map { GoalSnapshot.make(goal: $0, span: span) }
|
||||
)
|
||||
}
|
||||
|
||||
func placeholder(in context: Context) -> GoalQuestGridEntry {
|
||||
GoalQuestGridEntry(date: .now, locked: false, spanLabel: "오늘", goals: [])
|
||||
}
|
||||
|
||||
@MainActor
|
||||
func snapshot(for configuration: GoalQuestGridConfigIntent, in context: Context) async -> GoalQuestGridEntry {
|
||||
Self.makeEntry(configuration)
|
||||
}
|
||||
|
||||
@MainActor
|
||||
func timeline(for configuration: GoalQuestGridConfigIntent, in context: Context) async -> Timeline<GoalQuestGridEntry> {
|
||||
Timeline(entries: [Self.makeEntry(configuration)], policy: .after(.now.addingTimeInterval(15 * 60)))
|
||||
}
|
||||
}
|
||||
|
||||
struct QuestRingCellView: View {
|
||||
let quest: QuestCellSnapshot
|
||||
var ringSize: CGFloat = 34
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 3) {
|
||||
QuestRingView(snapshot: quest, lineWidth: 4, iconSize: ringSize * 0.36)
|
||||
.frame(width: ringSize, height: ringSize)
|
||||
Text(quest.targetName)
|
||||
.font(.system(size: 8, weight: .medium))
|
||||
.lineLimit(1)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct GoalQuestGridWidgetView: View {
|
||||
@Environment(\.widgetFamily) private var envFamily
|
||||
/// 앱 내 DEBUG 미리보기에서 패밀리를 강제할 때 사용
|
||||
var previewFamily: WidgetFamily? = nil
|
||||
private var family: WidgetFamily { previewFamily ?? envFamily }
|
||||
let entry: GoalQuestGridEntry
|
||||
|
||||
var body: some View {
|
||||
Group {
|
||||
if entry.locked {
|
||||
WidgetLockedView()
|
||||
} else if entry.goals.isEmpty {
|
||||
Text("앱의 목표 탭에서 목표와 다짐을 만들어 보세요.")
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.secondary)
|
||||
.multilineTextAlignment(.center)
|
||||
} else {
|
||||
switch family {
|
||||
case .systemMedium:
|
||||
// 중형 D: 1 x 4 나열, 4개 초과면 2줄로 최대 8개
|
||||
if let goal = entry.goals.first {
|
||||
goalSection(goal, maxCount: 8, columns: 4, ringSize: goal.quests.count > 4 ? 30 : 38)
|
||||
}
|
||||
case .systemLarge:
|
||||
// 대형 D: 목표 2개를 2줄로, 혹은 1개 목표의 다짐 8개 크게
|
||||
if entry.goals.count > 1 {
|
||||
VStack(spacing: 10) {
|
||||
ForEach(entry.goals.prefix(2)) { goal in
|
||||
goalSection(goal, maxCount: 4, columns: 4, ringSize: 40)
|
||||
}
|
||||
}
|
||||
} else if let goal = entry.goals.first {
|
||||
goalSection(goal, maxCount: 8, columns: 4, ringSize: 44)
|
||||
}
|
||||
default:
|
||||
// 소형 D: 2 x 2 링 + 최상단 목표 이름
|
||||
if let goal = entry.goals.first {
|
||||
goalSection(goal, maxCount: 4, columns: 2, ringSize: 32)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.containerBackground(AppTheme.background, for: .widget)
|
||||
}
|
||||
|
||||
private func goalSection(_ goal: GoalSnapshot, maxCount: Int, columns: Int, ringSize: CGFloat) -> some View {
|
||||
VStack(spacing: 6) {
|
||||
HStack(spacing: 4) {
|
||||
Image(systemName: goal.symbolName)
|
||||
.font(.system(size: 10, weight: .semibold))
|
||||
.foregroundStyle(goal.color)
|
||||
Text(goal.title)
|
||||
.font(.caption2.weight(.semibold))
|
||||
.lineLimit(1)
|
||||
Spacer()
|
||||
Text(entry.spanLabel)
|
||||
.font(.system(size: 8))
|
||||
.foregroundStyle(.tertiary)
|
||||
}
|
||||
if goal.quests.isEmpty {
|
||||
Spacer()
|
||||
Text("다짐이 없어요")
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.secondary)
|
||||
Spacer()
|
||||
} else {
|
||||
let quests = Array(goal.quests.prefix(maxCount))
|
||||
let grid = [GridItem](repeating: GridItem(.flexible(), spacing: 4), count: columns)
|
||||
LazyVGrid(columns: grid, spacing: 6) {
|
||||
ForEach(quests) { quest in
|
||||
QuestRingCellView(quest: quest, ringSize: ringSize)
|
||||
}
|
||||
}
|
||||
Spacer(minLength: 0)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct GoalQuestGridWidget: Widget {
|
||||
var body: some WidgetConfiguration {
|
||||
AppIntentConfiguration(
|
||||
kind: "HaruGoalQuestGridWidget",
|
||||
intent: GoalQuestGridConfigIntent.self,
|
||||
provider: GoalQuestGridProvider()
|
||||
) { entry in
|
||||
GoalQuestGridWidgetView(entry: entry)
|
||||
}
|
||||
.configurationDisplayName("다짐 현황")
|
||||
.description("목표에 속한 다짐들의 진행률을 원형 링으로 한눈에 봐요. (프리미엄)")
|
||||
.supportedFamilies([.systemSmall, .systemMedium, .systemLarge])
|
||||
}
|
||||
}
|
||||
@ -13,6 +13,12 @@ import ActivityKit
|
||||
struct HaruDanimWidgetsBundle: WidgetBundle {
|
||||
var body: some Widget {
|
||||
TrackingLiveActivity()
|
||||
ActionRunWidget()
|
||||
GoalBarsWidget()
|
||||
QuestRingWidget()
|
||||
GoalQuestGridWidget()
|
||||
StatsChartWidget()
|
||||
LockGoalWidget()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
215
myApp/HaruDanim/Widgets/LockScreenWidgets.swift
Normal file
215
myApp/HaruDanim/Widgets/LockScreenWidgets.swift
Normal file
@ -0,0 +1,215 @@
|
||||
//
|
||||
// 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
|
||||
}
|
||||
|
||||
// MARK: - 타임라인
|
||||
|
||||
struct LockGoalEntry: TimelineEntry {
|
||||
let date: Date
|
||||
let locked: Bool
|
||||
let spanLabel: String
|
||||
let style: LockStyleOption
|
||||
let goal: GoalSnapshot?
|
||||
let ratio: Double
|
||||
}
|
||||
|
||||
struct LockGoalProvider: AppIntentTimelineProvider {
|
||||
@MainActor
|
||||
static func makeEntry(_ configuration: LockGoalConfigIntent) -> LockGoalEntry {
|
||||
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) }
|
||||
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 {
|
||||
LockGoalEntry(date: .now, locked: false, spanLabel: "오늘", style: .gauge, goal: nil, ratio: 0)
|
||||
}
|
||||
|
||||
@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> {
|
||||
Timeline(entries: [Self.makeEntry(configuration)], policy: .after(.now.addingTimeInterval(15 * 60)))
|
||||
}
|
||||
}
|
||||
|
||||
// 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")
|
||||
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)
|
||||
}
|
||||
|
||||
@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)
|
||||
} currentValueLabel: {
|
||||
Text("\(Int((entry.ratio * 100).rounded()))")
|
||||
.font(.system(size: 14, weight: .bold).monospacedDigit())
|
||||
}
|
||||
.gaugeStyle(.accessoryCircular)
|
||||
case .number:
|
||||
VStack(spacing: 0) {
|
||||
Text("\(Int((entry.ratio * 100).rounded()))%")
|
||||
.font(.system(size: 16, weight: .bold).monospacedDigit())
|
||||
Text(entry.spanLabel)
|
||||
.font(.system(size: 9))
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
case .dots:
|
||||
// 다짐 달성 여부를 점으로 (최대 6개)
|
||||
VStack(spacing: 3) {
|
||||
Image(systemName: goal.symbolName)
|
||||
.font(.system(size: 12, weight: .semibold))
|
||||
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)
|
||||
}
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 다짐 달성 여부 점 표시 (달성 = 채움)
|
||||
private func questDots(_ goal: GoalSnapshot, size: CGFloat) -> some View {
|
||||
HStack(spacing: 3) {
|
||||
ForEach(goal.quests.prefix(6)) { quest in
|
||||
Circle()
|
||||
.strokeBorder(.primary, lineWidth: 1)
|
||||
.background(Circle().fill(quest.isAchieved ? AnyShapeStyle(.primary) : AnyShapeStyle(.clear)))
|
||||
.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])
|
||||
}
|
||||
}
|
||||
205
myApp/HaruDanim/Widgets/QuestRingWidget.swift
Normal file
205
myApp/HaruDanim/Widgets/QuestRingWidget.swift
Normal file
@ -0,0 +1,205 @@
|
||||
//
|
||||
// QuestRingWidget.swift
|
||||
// Haru_DanimWidgets
|
||||
//
|
||||
// 다짐 원형 진행률 위젯 (CLAUDE.md §8.2 소형 C / 중형 C / 대형 C)
|
||||
// 다짐 대상이 단일 행동이면 셀을 눌러 바로 실행 (Interactive)
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
import WidgetKit
|
||||
import AppIntents
|
||||
|
||||
// MARK: - 설정
|
||||
|
||||
struct QuestRingConfigIntent: WidgetConfigurationIntent {
|
||||
static let title: LocalizedStringResource = "다짐 진행률 위젯"
|
||||
static let description = IntentDescription("원형 진행률로 표시할 다짐과 기간을 선택하세요.")
|
||||
|
||||
@Parameter(title: "진행률 기간", default: .day)
|
||||
var span: SpanOption
|
||||
|
||||
@Parameter(title: "다짐 1")
|
||||
var quest1: QuestEntity?
|
||||
|
||||
@Parameter(title: "다짐 2")
|
||||
var quest2: QuestEntity?
|
||||
|
||||
@Parameter(title: "다짐 3")
|
||||
var quest3: QuestEntity?
|
||||
|
||||
@Parameter(title: "다짐 4")
|
||||
var quest4: QuestEntity?
|
||||
}
|
||||
|
||||
// MARK: - 타임라인
|
||||
|
||||
struct QuestRingEntry: TimelineEntry {
|
||||
let date: Date
|
||||
let locked: Bool
|
||||
let spanLabel: String
|
||||
let cells: [QuestCellSnapshot]
|
||||
}
|
||||
|
||||
struct QuestRingProvider: AppIntentTimelineProvider {
|
||||
@MainActor
|
||||
static func makeEntry(_ configuration: QuestRingConfigIntent) -> QuestRingEntry {
|
||||
guard WidgetStore.isUnlocked else {
|
||||
return QuestRingEntry(date: .now, locked: true, spanLabel: "", cells: [])
|
||||
}
|
||||
let span = configuration.span.statSpan
|
||||
var quests = [configuration.quest1, configuration.quest2, configuration.quest3, configuration.quest4]
|
||||
.compactMap { $0 }
|
||||
.compactMap { WidgetStore.quest($0.id) }
|
||||
if quests.isEmpty {
|
||||
quests = Array(WidgetStore.defaultGoals(1).flatMap(\.sortedQuests).prefix(4))
|
||||
}
|
||||
return QuestRingEntry(
|
||||
date: .now,
|
||||
locked: false,
|
||||
spanLabel: configuration.span.label,
|
||||
cells: quests.map { QuestCellSnapshot.make(quest: $0, span: span) }
|
||||
)
|
||||
}
|
||||
|
||||
func placeholder(in context: Context) -> QuestRingEntry {
|
||||
QuestRingEntry(date: .now, locked: false, spanLabel: "오늘", cells: [])
|
||||
}
|
||||
|
||||
@MainActor
|
||||
func snapshot(for configuration: QuestRingConfigIntent, in context: Context) async -> QuestRingEntry {
|
||||
Self.makeEntry(configuration)
|
||||
}
|
||||
|
||||
@MainActor
|
||||
func timeline(for configuration: QuestRingConfigIntent, in context: Context) async -> Timeline<QuestRingEntry> {
|
||||
Timeline(entries: [Self.makeEntry(configuration)], policy: .after(.now.addingTimeInterval(15 * 60)))
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - 뷰
|
||||
|
||||
struct QuestRingActionCellView: View {
|
||||
let cell: QuestCellSnapshot
|
||||
let spanLabel: String
|
||||
var ringSize: CGFloat = 52
|
||||
|
||||
var body: some View {
|
||||
if let target = cell.runTarget {
|
||||
Button(intent: RunActionIntent(action: target)) {
|
||||
content
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
} else {
|
||||
content
|
||||
}
|
||||
}
|
||||
|
||||
private var content: some View {
|
||||
VStack(spacing: 4) {
|
||||
ZStack {
|
||||
QuestRingView(snapshot: cell, lineWidth: 5, iconSize: ringSize * 0.34)
|
||||
.frame(width: ringSize, height: ringSize)
|
||||
if cell.isRunning {
|
||||
Circle()
|
||||
.fill(AppTheme.yellow)
|
||||
.frame(width: 9, height: 9)
|
||||
.offset(x: ringSize * 0.38, y: -ringSize * 0.38)
|
||||
}
|
||||
}
|
||||
Text(cell.targetName)
|
||||
.font(.caption2.weight(.semibold))
|
||||
.lineLimit(1)
|
||||
Text(percentText)
|
||||
.font(.caption2.monospacedDigit())
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
.padding(6)
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: 14, style: .continuous)
|
||||
.fill(AppTheme.surface)
|
||||
)
|
||||
}
|
||||
|
||||
private var percentText: String {
|
||||
if cell.isAtMost {
|
||||
return cell.isAchieved ? "\(spanLabel) 한도 지킴" : "\(spanLabel) 한도 초과"
|
||||
}
|
||||
return "\(spanLabel) \(Format.percent(cell.displayRatio))"
|
||||
}
|
||||
}
|
||||
|
||||
struct QuestRingWidgetView: View {
|
||||
@Environment(\.widgetFamily) private var envFamily
|
||||
/// 앱 내 DEBUG 미리보기에서 패밀리를 강제할 때 사용
|
||||
var previewFamily: WidgetFamily? = nil
|
||||
private var family: WidgetFamily { previewFamily ?? envFamily }
|
||||
let entry: QuestRingEntry
|
||||
|
||||
private var visibleCount: Int {
|
||||
switch family {
|
||||
case .systemMedium: return 2
|
||||
case .systemLarge: return 4
|
||||
default: return 1
|
||||
}
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
Group {
|
||||
if entry.locked {
|
||||
WidgetLockedView()
|
||||
} else if entry.cells.isEmpty {
|
||||
Text("앱의 목표 탭에서 다짐을 만들면 진행률이 표시돼요.")
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.secondary)
|
||||
.multilineTextAlignment(.center)
|
||||
} else {
|
||||
let cells = Array(entry.cells.prefix(visibleCount))
|
||||
switch family {
|
||||
case .systemMedium:
|
||||
HStack(spacing: 8) {
|
||||
ForEach(cells) { QuestRingActionCellView(cell: $0, spanLabel: entry.spanLabel) }
|
||||
}
|
||||
case .systemLarge:
|
||||
VStack(spacing: 8) {
|
||||
HStack(spacing: 8) {
|
||||
ForEach(cells.prefix(2)) {
|
||||
QuestRingActionCellView(cell: $0, spanLabel: entry.spanLabel, ringSize: 60)
|
||||
}
|
||||
}
|
||||
if cells.count > 2 {
|
||||
HStack(spacing: 8) {
|
||||
ForEach(cells.dropFirst(2)) {
|
||||
QuestRingActionCellView(cell: $0, spanLabel: entry.spanLabel, ringSize: 60)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
default:
|
||||
if let cell = cells.first {
|
||||
QuestRingActionCellView(cell: cell, spanLabel: entry.spanLabel, ringSize: 58)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.containerBackground(AppTheme.background, for: .widget)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - 위젯 정의
|
||||
|
||||
struct QuestRingWidget: Widget {
|
||||
var body: some WidgetConfiguration {
|
||||
AppIntentConfiguration(
|
||||
kind: "HaruQuestRingWidget",
|
||||
intent: QuestRingConfigIntent.self,
|
||||
provider: QuestRingProvider()
|
||||
) { entry in
|
||||
QuestRingWidgetView(entry: entry)
|
||||
}
|
||||
.configurationDisplayName("다짐 진행률")
|
||||
.description("다짐의 진행률을 원형 링으로 보고, 눌러서 바로 실행해요. (프리미엄)")
|
||||
.supportedFamilies([.systemSmall, .systemMedium, .systemLarge])
|
||||
}
|
||||
}
|
||||
248
myApp/HaruDanim/Widgets/StatsChartWidget.swift
Normal file
248
myApp/HaruDanim/Widgets/StatsChartWidget.swift
Normal file
@ -0,0 +1,248 @@
|
||||
//
|
||||
// StatsChartWidget.swift
|
||||
// Haru_DanimWidgets
|
||||
//
|
||||
// 통계 위젯 (CLAUDE.md §8.3) — 선택한 행동들의 꺾은선 그래프
|
||||
// 앱 통계 탭과 동일: 선 색 = 행동 색, 시간형은 h 단위, 횟수형은 회
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
import WidgetKit
|
||||
import AppIntents
|
||||
import Charts
|
||||
|
||||
// MARK: - 설정
|
||||
|
||||
enum StatsChartSpan: String, AppEnum {
|
||||
case week
|
||||
case monthByDay
|
||||
case monthByWeek
|
||||
|
||||
static let typeDisplayRepresentation = TypeDisplayRepresentation(name: "통계 기간")
|
||||
static let caseDisplayRepresentations: [StatsChartSpan: DisplayRepresentation] = [
|
||||
.week: "일주일 (하루 단위)",
|
||||
.monthByDay: "한 달 (하루 단위, 중형 이상)",
|
||||
.monthByWeek: "한 달 (주 단위)",
|
||||
]
|
||||
|
||||
var title: String {
|
||||
switch self {
|
||||
case .week: return "일주일 통계"
|
||||
case .monthByDay: return "한 달 통계 (일별)"
|
||||
case .monthByWeek: return "한 달 통계 (주별)"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct StatsChartConfigIntent: WidgetConfigurationIntent {
|
||||
static let title: LocalizedStringResource = "통계 위젯"
|
||||
static let description = IntentDescription("그래프로 보여줄 행동과 기간을 선택하세요.")
|
||||
|
||||
@Parameter(title: "통계 기간", default: .week)
|
||||
var span: StatsChartSpan
|
||||
|
||||
@Parameter(title: "행동 1")
|
||||
var action1: ActionEntity?
|
||||
|
||||
@Parameter(title: "행동 2")
|
||||
var action2: ActionEntity?
|
||||
|
||||
@Parameter(title: "행동 3")
|
||||
var action3: ActionEntity?
|
||||
}
|
||||
|
||||
// MARK: - 타임라인
|
||||
|
||||
struct StatsPoint: Identifiable {
|
||||
let id = UUID()
|
||||
/// 하루 단위 그래프의 x축 (일별)
|
||||
let day: Date?
|
||||
/// 주 단위 그래프의 x축 ("1주차" 등)
|
||||
let weekLabel: String?
|
||||
let actionName: String
|
||||
let value: Double
|
||||
}
|
||||
|
||||
struct StatsChartEntry: TimelineEntry {
|
||||
let date: Date
|
||||
let locked: Bool
|
||||
let title: String
|
||||
let isWeekAxis: Bool
|
||||
let isTimeType: Bool
|
||||
let names: [String]
|
||||
let colorHexes: [String]
|
||||
let points: [StatsPoint]
|
||||
}
|
||||
|
||||
struct StatsChartProvider: AppIntentTimelineProvider {
|
||||
@MainActor
|
||||
static func makeEntry(_ configuration: StatsChartConfigIntent, family: WidgetFamily) -> StatsChartEntry {
|
||||
guard WidgetStore.isUnlocked else {
|
||||
return StatsChartEntry(date: .now, locked: true, title: "", isWeekAxis: false,
|
||||
isTimeType: true, names: [], colorHexes: [], points: [])
|
||||
}
|
||||
// 소형은 "한 달 (하루 단위)" 제외 (CLAUDE.md §8.3) → 주 단위로 대체
|
||||
var span = configuration.span
|
||||
if family == .systemSmall && span == .monthByDay { span = .monthByWeek }
|
||||
|
||||
let chosen = [configuration.action1, configuration.action2, configuration.action3]
|
||||
.compactMap { $0 }
|
||||
.compactMap { WidgetStore.action($0.id) }
|
||||
let actions = chosen.isEmpty ? WidgetStore.defaultActions(3) : chosen
|
||||
let isTimeType = !actions.contains { $0.trackingType == .count }
|
||||
|
||||
let math = DayMath()
|
||||
let agg = Aggregator(math: math)
|
||||
let now = Date.now
|
||||
var points: [StatsPoint] = []
|
||||
|
||||
func value(_ action: Action, in range: Range<Date>) -> Double {
|
||||
switch action.trackingType {
|
||||
case .time: return agg.seconds(for: action, in: range, now: now) / 3600
|
||||
case .count: return Double(agg.count(for: action, in: range))
|
||||
}
|
||||
}
|
||||
|
||||
switch span {
|
||||
case .week, .monthByDay:
|
||||
let range = span == .week ? math.weekRange(containing: now) : math.monthRange(containing: now)
|
||||
for key in math.dayKeys(in: range) {
|
||||
let dayRange = math.dayRange(forKey: key)
|
||||
for action in actions {
|
||||
points.append(StatsPoint(day: key, weekLabel: nil, actionName: action.name,
|
||||
value: value(action, in: dayRange)))
|
||||
}
|
||||
}
|
||||
case .monthByWeek:
|
||||
let month = math.monthRange(containing: now)
|
||||
var cursor = month.lowerBound
|
||||
var index = 1
|
||||
while cursor < month.upperBound {
|
||||
let week = math.weekRange(containing: cursor)
|
||||
let clipped = max(week.lowerBound, month.lowerBound)..<min(week.upperBound, month.upperBound)
|
||||
for action in actions {
|
||||
points.append(StatsPoint(day: nil, weekLabel: "\(index)주", actionName: action.name,
|
||||
value: value(action, in: clipped)))
|
||||
}
|
||||
cursor = week.upperBound
|
||||
index += 1
|
||||
}
|
||||
}
|
||||
|
||||
return StatsChartEntry(
|
||||
date: now,
|
||||
locked: false,
|
||||
title: span.title,
|
||||
isWeekAxis: span == .monthByWeek,
|
||||
isTimeType: isTimeType,
|
||||
names: actions.map(\.name),
|
||||
colorHexes: actions.map { $0.tags.sorted { $0.createdAt < $1.createdAt }.first?.colorHex ?? "#2F6B4F" },
|
||||
points: points
|
||||
)
|
||||
}
|
||||
|
||||
func placeholder(in context: Context) -> StatsChartEntry {
|
||||
StatsChartEntry(date: .now, locked: false, title: "일주일 통계", isWeekAxis: false,
|
||||
isTimeType: true, names: [], colorHexes: [], points: [])
|
||||
}
|
||||
|
||||
@MainActor
|
||||
func snapshot(for configuration: StatsChartConfigIntent, in context: Context) async -> StatsChartEntry {
|
||||
Self.makeEntry(configuration, family: context.family)
|
||||
}
|
||||
|
||||
@MainActor
|
||||
func timeline(for configuration: StatsChartConfigIntent, in context: Context) async -> Timeline<StatsChartEntry> {
|
||||
Timeline(entries: [Self.makeEntry(configuration, family: context.family)],
|
||||
policy: .after(.now.addingTimeInterval(30 * 60)))
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - 뷰
|
||||
|
||||
struct StatsChartWidgetView: View {
|
||||
@Environment(\.widgetFamily) private var envFamily
|
||||
/// 앱 내 DEBUG 미리보기에서 패밀리를 강제할 때 사용
|
||||
var previewFamily: WidgetFamily? = nil
|
||||
private var family: WidgetFamily { previewFamily ?? envFamily }
|
||||
let entry: StatsChartEntry
|
||||
|
||||
var body: some View {
|
||||
Group {
|
||||
if entry.locked {
|
||||
WidgetLockedView()
|
||||
} else if entry.points.isEmpty {
|
||||
Text("앱에서 행동을 만들고 기록하면 그래프가 표시돼요.")
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.secondary)
|
||||
.multilineTextAlignment(.center)
|
||||
} else {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text(entry.title)
|
||||
.font(.caption2.weight(.semibold))
|
||||
.foregroundStyle(.secondary)
|
||||
chart
|
||||
}
|
||||
}
|
||||
}
|
||||
.containerBackground(AppTheme.background, for: .widget)
|
||||
}
|
||||
|
||||
private var colors: [Color] {
|
||||
entry.colorHexes.map { Color(hex: $0) }
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var chart: some View {
|
||||
Chart(entry.points) { point in
|
||||
if let day = point.day {
|
||||
LineMark(
|
||||
x: .value("날짜", day, unit: .day),
|
||||
y: .value(entry.isTimeType ? "시간" : "값", point.value)
|
||||
)
|
||||
.foregroundStyle(by: .value("행동", point.actionName))
|
||||
.interpolationMethod(.monotone)
|
||||
} else if let weekLabel = point.weekLabel {
|
||||
LineMark(
|
||||
x: .value("주차", weekLabel),
|
||||
y: .value(entry.isTimeType ? "시간" : "값", point.value)
|
||||
)
|
||||
.foregroundStyle(by: .value("행동", point.actionName))
|
||||
.interpolationMethod(.monotone)
|
||||
}
|
||||
}
|
||||
.chartForegroundStyleScale(domain: entry.names, range: colors)
|
||||
.chartLegend(family == .systemSmall ? .hidden : .visible)
|
||||
.chartYAxisLabel(family == .systemSmall ? "" : (entry.isTimeType ? "시간(h)" : "횟수"))
|
||||
.chartXAxis {
|
||||
if entry.isWeekAxis {
|
||||
AxisMarks { _ in
|
||||
AxisGridLine()
|
||||
AxisValueLabel()
|
||||
}
|
||||
} else {
|
||||
AxisMarks(values: .stride(by: .day, count: family == .systemSmall ? 2 : 1)) { value in
|
||||
AxisGridLine()
|
||||
AxisValueLabel(format: .dateTime.day(), centered: false)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - 위젯 정의
|
||||
|
||||
struct StatsChartWidget: Widget {
|
||||
var body: some WidgetConfiguration {
|
||||
AppIntentConfiguration(
|
||||
kind: "HaruStatsChartWidget",
|
||||
intent: StatsChartConfigIntent.self,
|
||||
provider: StatsChartProvider()
|
||||
) { entry in
|
||||
StatsChartWidgetView(entry: entry)
|
||||
}
|
||||
.configurationDisplayName("행동 통계")
|
||||
.description("선택한 행동들의 통계 꺾은선 그래프를 확인해요. (프리미엄)")
|
||||
.supportedFamilies([.systemSmall, .systemMedium, .systemLarge])
|
||||
}
|
||||
}
|
||||
238
myApp/HaruDanim/Widgets/WidgetSupport.swift
Normal file
238
myApp/HaruDanim/Widgets/WidgetSupport.swift
Normal file
@ -0,0 +1,238 @@
|
||||
//
|
||||
// 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))
|
||||
}
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user