- placeholder()의 생 한국어 리터럴 6곳을 String(localized:)로: ③다짐 링·④다짐 현황·잠금화면 위젯의 spanLabel "오늘"(기존 키 재사용), 워치 목표/다짐 달성률 "목표"/"하루"(기존 키 재사용), 워치 현재 현황 샘플 "독서"(워치위젯 카탈로그 신규 1키 — en Reading/ja 読書, iOS 위젯 카탈로그와 동일 값) - placeholder는 갤러리/로딩 스켈레톤 전용 경로라 타임라인 예산·버튼 반응성·갱신 경로 무영향 — ①⑤ 위젯 placeholder가 이미 쓰던 패턴과 동일 - 검증: Debug·Store·워치 3스킴 빌드 성공, 전 카탈로그 missing/stale 0 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013yKDMhuF39GVYy3FMcGHh7
621 lines
24 KiB
Swift
621 lines
24 KiB
Swift
//
|
||
// GoalWidgets.swift
|
||
// Haru_DanimWidgets
|
||
//
|
||
// ② 목표 진행률 위젯 + ④ 다짐 현황 위젯 (CLAUDE.md §8.2)
|
||
//
|
||
|
||
import SwiftUI
|
||
import WidgetKit
|
||
import AppIntents
|
||
|
||
// MARK: - ② 목표 진행률 위젯 (Goal Progress)
|
||
// 목표의 하루/주간/월간 진행률을 가로 진행바로 표시.
|
||
// - 소형: 목표 1개
|
||
// - 중형: [목표 2개] 또는 [목표 1개 + 상위 다짐 2~3개] — 설정에서 선택
|
||
// - 대형: [목표 4개 (2×2)] / [목표+다짐 위아래 2개] / [목표 1개 + 모든 다짐] — 설정에서 선택
|
||
|
||
/// 중형 위젯 구성
|
||
enum GoalMediumStyle: String, AppEnum {
|
||
/// 목표 2개 나란히
|
||
case twoGoals
|
||
/// 목표 1개 + 상위 다짐들의 진행률
|
||
case goalWithQuests
|
||
|
||
static let typeDisplayRepresentation = TypeDisplayRepresentation(name: "중형 위젯 구성")
|
||
static let caseDisplayRepresentations: [GoalMediumStyle: DisplayRepresentation] = [
|
||
.twoGoals: "목표 2개",
|
||
.goalWithQuests: "목표 1개 + 다짐 진행률",
|
||
]
|
||
}
|
||
|
||
/// 대형 위젯 구성
|
||
enum GoalLargeStyle: String, AppEnum {
|
||
/// 목표 4개 (2×2)
|
||
case fourGoals
|
||
/// [목표 1개 + 다짐]을 위아래로 2개
|
||
case twoGoalsWithQuests
|
||
/// 목표 1개 + 소속된 모든 다짐
|
||
case oneGoalAllQuests
|
||
|
||
static let typeDisplayRepresentation = TypeDisplayRepresentation(name: "대형 위젯 구성")
|
||
static let caseDisplayRepresentations: [GoalLargeStyle: DisplayRepresentation] = [
|
||
.fourGoals: "목표 4개 (2×2)",
|
||
.twoGoalsWithQuests: "목표+다짐 2개 (위아래)",
|
||
.oneGoalAllQuests: "목표 1개 + 모든 다짐",
|
||
]
|
||
}
|
||
|
||
struct GoalProgressConfigIntent: WidgetConfigurationIntent {
|
||
static let title: LocalizedStringResource = "목표 진행률 위젯"
|
||
static let description = IntentDescription("진행률을 표시할 목표들과 위젯 구성, 테마를 선택하세요.")
|
||
|
||
@Parameter(title: "목표")
|
||
var goals: [GoalEntity]?
|
||
|
||
@Parameter(title: "중형 위젯 구성", default: .twoGoals)
|
||
var mediumStyle: GoalMediumStyle
|
||
|
||
@Parameter(title: "대형 위젯 구성", default: .fourGoals)
|
||
var largeStyle: GoalLargeStyle
|
||
|
||
@Parameter(title: "테마", default: .matchApp)
|
||
var theme: WidgetThemeOption
|
||
}
|
||
|
||
struct GoalProgressEntry: TimelineEntry {
|
||
let date: Date
|
||
let locked: Bool
|
||
let theme: WidgetThemeOption
|
||
let mediumStyle: GoalMediumStyle
|
||
let largeStyle: GoalLargeStyle
|
||
/// 패밀리·구성별 슬롯 수만큼 채워짐 (nil = 빈 칸)
|
||
let goals: [GoalSnapshot?]
|
||
|
||
/// 소속 다짐 중 측정 중인 것이 있으면 진행바가 계속 채워지므로 미래 엔트리를 미리 계산해 둔다
|
||
var isLive: Bool { goals.contains { $0?.quests.contains { $0.isRunning } == true } }
|
||
}
|
||
|
||
struct GoalProgressProvider: AppIntentTimelineProvider {
|
||
static func capacity(_ family: WidgetFamily, configuration: GoalProgressConfigIntent) -> Int {
|
||
switch family {
|
||
case .systemMedium: return configuration.mediumStyle == .twoGoals ? 2 : 1
|
||
case .systemLarge:
|
||
switch configuration.largeStyle {
|
||
case .fourGoals: return 4
|
||
case .twoGoalsWithQuests: return 2
|
||
case .oneGoalAllQuests: return 1
|
||
}
|
||
default: return 1
|
||
}
|
||
}
|
||
|
||
@MainActor
|
||
static func makeEntry(_ configuration: GoalProgressConfigIntent, family: WidgetFamily,
|
||
now: Date = .now) -> GoalProgressEntry {
|
||
IntentStore.refresh()
|
||
guard WidgetStore.isUnlocked else {
|
||
return GoalProgressEntry(date: now, locked: true, theme: configuration.theme,
|
||
mediumStyle: configuration.mediumStyle,
|
||
largeStyle: configuration.largeStyle, goals: [])
|
||
}
|
||
let capacity = Self.capacity(family, configuration: configuration)
|
||
let goals = WidgetStore.selectedGoals(configuration.goals, defaultCount: capacity)
|
||
return GoalProgressEntry(
|
||
date: now,
|
||
locked: false,
|
||
theme: configuration.theme,
|
||
mediumStyle: configuration.mediumStyle,
|
||
largeStyle: configuration.largeStyle,
|
||
goals: WidgetStore.slots(goals.map { GoalSnapshot.make(goal: $0, now: now) }, capacity: capacity)
|
||
)
|
||
}
|
||
|
||
func placeholder(in context: Context) -> GoalProgressEntry {
|
||
GoalProgressEntry(date: .now, locked: false, theme: .matchApp,
|
||
mediumStyle: .twoGoals, largeStyle: .fourGoals, goals: [.sample])
|
||
}
|
||
|
||
@MainActor
|
||
func snapshot(for configuration: GoalProgressConfigIntent, in context: Context) async -> GoalProgressEntry {
|
||
Self.makeEntry(configuration, family: context.family)
|
||
}
|
||
|
||
@MainActor
|
||
func timeline(for configuration: GoalProgressConfigIntent, in context: Context) async -> Timeline<GoalProgressEntry> {
|
||
let first = Self.makeEntry(configuration, family: context.family)
|
||
return WidgetRefresh.timeline(first: first, live: first.isLive) { date in
|
||
Self.makeEntry(configuration, family: context.family, now: date)
|
||
}
|
||
}
|
||
}
|
||
|
||
/// 목표 1개: 아이콘+이름 + 하루/주간/월간 진행바 3줄
|
||
struct GoalBarsCellView: View {
|
||
let goal: GoalSnapshot
|
||
/// 셀 하나가 위젯 전체를 차지할 때 (소형) — 위젯 모서리에 맞춰 여백 없이 채움
|
||
var fullBleed = false
|
||
|
||
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: String(localized: "하루"), ratio: goal.dayRatio, color: goal.color)
|
||
SpanBarRow(label: String(localized: "주간"), ratio: goal.weekRatio, color: goal.color)
|
||
SpanBarRow(label: String(localized: "월간"), ratio: goal.monthRatio, color: goal.color)
|
||
}
|
||
.padding(fullBleed ? 14 : 10)
|
||
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top)
|
||
.background(WidgetCellBackground(fullBleed: fullBleed))
|
||
}
|
||
}
|
||
|
||
/// 다짐 1개의 하루 진행률 한 줄 (아이콘 + 이름 + 진행바 + %).
|
||
/// 연속 달성이 있으면 % 아래에 아주 작게 덧붙인다 (기존 열 폭·구성은 유지).
|
||
struct QuestBarRow: View {
|
||
let quest: QuestCellSnapshot
|
||
|
||
var body: some View {
|
||
HStack(spacing: 6) {
|
||
Image(systemName: quest.symbolName)
|
||
.font(.system(size: 10, weight: .semibold))
|
||
.foregroundStyle(quest.color)
|
||
.frame(width: 14)
|
||
Text(quest.targetName)
|
||
.font(.caption2)
|
||
.lineLimit(1)
|
||
.frame(width: 52, alignment: .leading)
|
||
ProgressView(value: min(max(quest.ratio, 0), 1))
|
||
.progressViewStyle(.linear)
|
||
.tint(quest.isAtMost && !quest.isAchieved ? Color.red : quest.color)
|
||
if let streak = quest.streakLabel {
|
||
VStack(alignment: .trailing, spacing: 0) {
|
||
Text(Format.percent(quest.displayRatio))
|
||
.font(.system(size: 9, weight: .semibold).monospacedDigit())
|
||
.foregroundStyle(.secondary)
|
||
Text(streak)
|
||
.font(.system(size: 6.5, weight: .semibold))
|
||
.foregroundStyle(AppTheme.yellow)
|
||
.lineLimit(1)
|
||
.minimumScaleFactor(0.8)
|
||
}
|
||
.frame(width: 34, alignment: .trailing)
|
||
} else {
|
||
Text(Format.percent(quest.displayRatio))
|
||
.font(.system(size: 9, weight: .semibold).monospacedDigit())
|
||
.foregroundStyle(.secondary)
|
||
.frame(width: 34, alignment: .trailing)
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
/// 다짐 하루 진행률 목록 카드 — 고정 개수 대신 주어진 높이에 들어가는 만큼 최대한 채운다.
|
||
/// 다 못 담으면 헤더 오른쪽에 "+N" 표기. (중형/대형 구성들이 공용)
|
||
struct QuestBarsColumn: View {
|
||
let quests: [QuestCellSnapshot]
|
||
|
||
/// 연속 달성 줄이 하나라도 있으면 행 높이를 살짝 키운다 (없으면 기존 14 그대로 —
|
||
/// 표시 개수 계산(fitCount)이 자동으로 따라오므로 넘치지 않는다)
|
||
private var rowHeight: CGFloat {
|
||
quests.contains { $0.streakLabel != nil } ? 19 : 14
|
||
}
|
||
private let rowSpacing: CGFloat = 6
|
||
private let headerHeight: CGFloat = 17
|
||
|
||
var body: some View {
|
||
GeometryReader { geo in
|
||
let fitCount = max(Int((geo.size.height - headerHeight + rowSpacing) / (rowHeight + rowSpacing)), 1)
|
||
let shown = Array(quests.prefix(fitCount))
|
||
VStack(alignment: .leading, spacing: rowSpacing) {
|
||
HStack {
|
||
Text("다짐 (하루)")
|
||
.font(.system(size: 9, weight: .semibold))
|
||
.foregroundStyle(.secondary)
|
||
Spacer()
|
||
if quests.count > shown.count {
|
||
Text(verbatim: "+\(quests.count - shown.count)")
|
||
.font(.system(size: 9, weight: .semibold).monospacedDigit())
|
||
.foregroundStyle(.tertiary)
|
||
}
|
||
}
|
||
.frame(height: headerHeight - rowSpacing, alignment: .top)
|
||
if quests.isEmpty {
|
||
Spacer()
|
||
Text("다짐이 없어요")
|
||
.font(.caption2)
|
||
.foregroundStyle(.secondary)
|
||
.frame(maxWidth: .infinity)
|
||
Spacer()
|
||
} else {
|
||
ForEach(shown) { quest in
|
||
QuestBarRow(quest: quest)
|
||
.frame(height: rowHeight)
|
||
}
|
||
Spacer(minLength: 0)
|
||
}
|
||
}
|
||
}
|
||
.padding(10)
|
||
.background(WidgetCellBackground())
|
||
}
|
||
}
|
||
|
||
/// 목표 1개 + 상위 다짐들의 하루 진행률 (중형 구성 2 / 대형 구성 2)
|
||
struct GoalWithQuestsCellView: View {
|
||
let goal: GoalSnapshot
|
||
|
||
var body: some View {
|
||
HStack(alignment: .top, spacing: 10) {
|
||
GoalBarsCellView(goal: goal)
|
||
.frame(maxWidth: .infinity)
|
||
QuestBarsColumn(quests: goal.quests)
|
||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||
}
|
||
}
|
||
}
|
||
|
||
struct GoalProgressWidgetView: View {
|
||
@Environment(\.widgetFamily) private var envFamily
|
||
/// 앱 내 DEBUG 미리보기에서 패밀리를 강제할 때 사용
|
||
var previewFamily: WidgetFamily? = nil
|
||
private var family: WidgetFamily { previewFamily ?? envFamily }
|
||
let entry: GoalProgressEntry
|
||
|
||
var body: some View {
|
||
Group {
|
||
if entry.locked {
|
||
WidgetLockedView()
|
||
.padding(12)
|
||
} else if !entry.goals.contains(where: { $0 != nil }) {
|
||
WidgetEmptyView(symbolName: "flag.fill", message: "목표 탭에서 목표를 만들면\n진행률이 표시돼요")
|
||
} else {
|
||
switch family {
|
||
case .systemMedium:
|
||
mediumLayout
|
||
.padding(10)
|
||
case .systemLarge:
|
||
largeLayout
|
||
.padding(10)
|
||
default:
|
||
// 소형: 셀 하나가 위젯 전체를 여백 없이 채움 (풀블리드)
|
||
if let goal = entry.goals.first ?? nil {
|
||
GoalBarsCellView(goal: goal, fullBleed: true)
|
||
}
|
||
}
|
||
}
|
||
}
|
||
.widgetAppTheme(entry.theme)
|
||
}
|
||
|
||
@ViewBuilder
|
||
private var mediumLayout: some View {
|
||
switch entry.mediumStyle {
|
||
case .twoGoals:
|
||
HStack(spacing: 8) {
|
||
ForEach(0..<2, id: \.self) { index in
|
||
slotCell(index)
|
||
}
|
||
}
|
||
case .goalWithQuests:
|
||
if let goal = entry.goals.first ?? nil {
|
||
GoalWithQuestsCellView(goal: goal)
|
||
}
|
||
}
|
||
}
|
||
|
||
@ViewBuilder
|
||
private var largeLayout: some View {
|
||
switch entry.largeStyle {
|
||
case .fourGoals:
|
||
VStack(spacing: 8) {
|
||
HStack(spacing: 8) {
|
||
slotCell(0)
|
||
slotCell(1)
|
||
}
|
||
HStack(spacing: 8) {
|
||
slotCell(2)
|
||
slotCell(3)
|
||
}
|
||
}
|
||
case .twoGoalsWithQuests:
|
||
VStack(spacing: 8) {
|
||
ForEach(0..<2, id: \.self) { index in
|
||
if index < entry.goals.count, let goal = entry.goals[index] {
|
||
GoalWithQuestsCellView(goal: goal)
|
||
} else {
|
||
WidgetBlankCellView()
|
||
}
|
||
}
|
||
}
|
||
case .oneGoalAllQuests:
|
||
if let goal = entry.goals.first ?? nil {
|
||
VStack(spacing: 8) {
|
||
GoalBarsCellView(goal: goal)
|
||
.frame(maxHeight: 130)
|
||
QuestBarsColumn(quests: goal.quests)
|
||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
@ViewBuilder
|
||
private func slotCell(_ index: Int) -> some View {
|
||
if index < entry.goals.count, let goal = entry.goals[index] {
|
||
GoalBarsCellView(goal: goal)
|
||
} else {
|
||
WidgetBlankCellView()
|
||
}
|
||
}
|
||
}
|
||
|
||
struct GoalProgressWidget: Widget {
|
||
var body: some WidgetConfiguration {
|
||
AppIntentConfiguration(
|
||
kind: "HaruGoalBarsWidget",
|
||
intent: GoalProgressConfigIntent.self,
|
||
provider: GoalProgressProvider()
|
||
) { entry in
|
||
GoalProgressWidgetView(entry: entry)
|
||
}
|
||
.configurationDisplayName("목표 진행률")
|
||
.description("목표의 하루·주간·월간 진행률을 진행바로 확인해요. (프리미엄)")
|
||
.supportedFamilies([.systemSmall, .systemMedium, .systemLarge])
|
||
.contentMarginsDisabled()
|
||
}
|
||
}
|
||
|
||
// MARK: - ④ 다짐 현황 위젯 (Quest Status, 인터랙티브 없음)
|
||
// 선택한 목표에 속한 다짐들의 진행률 링을 한눈에. 링 아래엔 이름만(% 없음),
|
||
// 좌측 상단에 목표 아이콘+이름.
|
||
// - 소형: 다짐 4개 (2×2)
|
||
// - 중형: 다짐 8개 (4×2)
|
||
// - 대형: [다짐 16개 (4×4)] / [목표 4개 (2×2)] / [목표 2개 (위아래)] — 설정에서 선택
|
||
|
||
/// 대형 위젯 구성
|
||
enum QuestStatusLargeStyle: String, AppEnum {
|
||
/// 목표 1개의 다짐을 최대 16개 (4×4)
|
||
case manyQuests
|
||
/// 목표 4개를 2×2로 (목표당 다짐 4개)
|
||
case fourGoals
|
||
/// 목표 2개를 위아래로 (목표당 다짐 8개)
|
||
case twoGoals
|
||
|
||
static let typeDisplayRepresentation = TypeDisplayRepresentation(name: "대형 위젯 구성")
|
||
static let caseDisplayRepresentations: [QuestStatusLargeStyle: DisplayRepresentation] = [
|
||
.manyQuests: "목표 1개 · 다짐 16개",
|
||
.fourGoals: "목표 4개 (2×2)",
|
||
.twoGoals: "목표 2개 (위아래)",
|
||
]
|
||
}
|
||
|
||
struct QuestStatusConfigIntent: WidgetConfigurationIntent {
|
||
static let title: LocalizedStringResource = "다짐 현황 위젯"
|
||
static let description = IntentDescription("다짐들의 진행률 링을 보여줄 목표와 기간, 테마를 선택하세요.")
|
||
|
||
@Parameter(title: "목표")
|
||
var goals: [GoalEntity]?
|
||
|
||
@Parameter(title: "진행률 기간", default: .day)
|
||
var span: SpanOption
|
||
|
||
@Parameter(title: "대형 위젯 구성", default: .manyQuests)
|
||
var largeStyle: QuestStatusLargeStyle
|
||
|
||
@Parameter(title: "테마", default: .matchApp)
|
||
var theme: WidgetThemeOption
|
||
}
|
||
|
||
struct QuestStatusEntry: TimelineEntry {
|
||
let date: Date
|
||
let locked: Bool
|
||
let theme: WidgetThemeOption
|
||
let spanLabel: String
|
||
let largeStyle: QuestStatusLargeStyle
|
||
let goals: [GoalSnapshot?]
|
||
|
||
var isLive: Bool { goals.contains { $0?.quests.contains { $0.isRunning } == true } }
|
||
}
|
||
|
||
struct QuestStatusProvider: AppIntentTimelineProvider {
|
||
static func capacity(_ family: WidgetFamily, configuration: QuestStatusConfigIntent) -> Int {
|
||
guard family == .systemLarge else { return 1 }
|
||
switch configuration.largeStyle {
|
||
case .manyQuests: return 1
|
||
case .fourGoals: return 4
|
||
case .twoGoals: return 2
|
||
}
|
||
}
|
||
|
||
@MainActor
|
||
static func makeEntry(_ configuration: QuestStatusConfigIntent, family: WidgetFamily,
|
||
now: Date = .now) -> QuestStatusEntry {
|
||
IntentStore.refresh()
|
||
guard WidgetStore.isUnlocked else {
|
||
return QuestStatusEntry(date: now, locked: true, theme: configuration.theme,
|
||
spanLabel: "", largeStyle: configuration.largeStyle, goals: [])
|
||
}
|
||
let capacity = Self.capacity(family, configuration: configuration)
|
||
let goals = WidgetStore.selectedGoals(configuration.goals, defaultCount: capacity)
|
||
let span = configuration.span.statSpan
|
||
return QuestStatusEntry(
|
||
date: now,
|
||
locked: false,
|
||
theme: configuration.theme,
|
||
spanLabel: configuration.span.label,
|
||
largeStyle: configuration.largeStyle,
|
||
goals: WidgetStore.slots(goals.map { GoalSnapshot.make(goal: $0, span: span, now: now) },
|
||
capacity: capacity)
|
||
)
|
||
}
|
||
|
||
func placeholder(in context: Context) -> QuestStatusEntry {
|
||
QuestStatusEntry(date: .now, locked: false, theme: .matchApp, spanLabel: String(localized: "오늘"),
|
||
largeStyle: .manyQuests, goals: [.sample])
|
||
}
|
||
|
||
@MainActor
|
||
func snapshot(for configuration: QuestStatusConfigIntent, in context: Context) async -> QuestStatusEntry {
|
||
Self.makeEntry(configuration, family: context.family)
|
||
}
|
||
|
||
@MainActor
|
||
func timeline(for configuration: QuestStatusConfigIntent, in context: Context) async -> Timeline<QuestStatusEntry> {
|
||
let first = Self.makeEntry(configuration, family: context.family)
|
||
return WidgetRefresh.timeline(first: first, live: first.isLive) { date in
|
||
Self.makeEntry(configuration, family: context.family, now: date)
|
||
}
|
||
}
|
||
}
|
||
|
||
/// 링 + 이름만 (진행률 % 텍스트 없음). 연속 달성이 있으면 이름 아래 아주 작게 덧붙인다.
|
||
struct QuestRingCellView: View {
|
||
let quest: QuestCellSnapshot
|
||
var ringSize: CGFloat = 34
|
||
|
||
var body: some View {
|
||
VStack(spacing: 5) {
|
||
QuestRingView(snapshot: quest, lineWidth: 4, iconSize: ringSize * 0.36)
|
||
.frame(width: ringSize, height: ringSize)
|
||
VStack(spacing: 1) {
|
||
Text(quest.targetName)
|
||
.font(.system(size: 8, weight: .medium))
|
||
.lineLimit(1)
|
||
.foregroundStyle(.secondary)
|
||
if let streak = quest.streakLabel {
|
||
Text(streak)
|
||
.font(.system(size: 7, weight: .semibold))
|
||
.foregroundStyle(AppTheme.yellow)
|
||
.lineLimit(1)
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
struct QuestStatusWidgetView: View {
|
||
@Environment(\.widgetFamily) private var envFamily
|
||
/// 앱 내 DEBUG 미리보기에서 패밀리를 강제할 때 사용
|
||
var previewFamily: WidgetFamily? = nil
|
||
private var family: WidgetFamily { previewFamily ?? envFamily }
|
||
let entry: QuestStatusEntry
|
||
|
||
var body: some View {
|
||
Group {
|
||
if entry.locked {
|
||
WidgetLockedView()
|
||
} else if !entry.goals.contains(where: { $0 != nil }) {
|
||
WidgetEmptyView(symbolName: "square.grid.2x2", message: "목표 탭에서 목표와\n다짐을 만들어 보세요")
|
||
} else {
|
||
switch family {
|
||
case .systemMedium:
|
||
// 중형: 다짐 상위 8개 (4×2)
|
||
if let goal = entry.goals.first ?? nil {
|
||
goalSection(goal, maxCount: 8, columns: 4, ringSize: goal.quests.count > 4 ? 30 : 38)
|
||
}
|
||
case .systemLarge:
|
||
largeLayout
|
||
default:
|
||
// 소형: 다짐 상위 4개 (2×2)
|
||
if let goal = entry.goals.first ?? nil {
|
||
goalSection(goal, maxCount: 4, columns: 2, ringSize: 32)
|
||
}
|
||
}
|
||
}
|
||
}
|
||
.padding(13)
|
||
.widgetAppTheme(entry.theme)
|
||
}
|
||
|
||
@ViewBuilder
|
||
private var largeLayout: some View {
|
||
switch entry.largeStyle {
|
||
case .manyQuests:
|
||
if let goal = entry.goals.first ?? nil {
|
||
goalSection(goal, maxCount: 16, columns: 4, ringSize: 44)
|
||
}
|
||
case .fourGoals:
|
||
VStack(spacing: 10) {
|
||
ForEach(0..<2, id: \.self) { row in
|
||
HStack(spacing: 10) {
|
||
ForEach(0..<2, id: \.self) { col in
|
||
goalSlot(row * 2 + col, maxCount: 4, columns: 2, ringSize: 30)
|
||
}
|
||
}
|
||
}
|
||
}
|
||
case .twoGoals:
|
||
VStack(spacing: 10) {
|
||
ForEach(0..<2, id: \.self) { index in
|
||
goalSlot(index, maxCount: 8, columns: 4, ringSize: 34)
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
@ViewBuilder
|
||
private func goalSlot(_ index: Int, maxCount: Int, columns: Int, ringSize: CGFloat) -> some View {
|
||
if index < entry.goals.count, let goal = entry.goals[index] {
|
||
goalSection(goal, maxCount: maxCount, columns: columns, ringSize: ringSize)
|
||
} else {
|
||
WidgetBlankCellView()
|
||
}
|
||
}
|
||
|
||
private func goalSection(_ goal: GoalSnapshot, maxCount: Int, columns: Int, ringSize: CGFloat) -> some View {
|
||
VStack(spacing: 10) {
|
||
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: 8), count: columns)
|
||
LazyVGrid(columns: grid, spacing: 12) {
|
||
ForEach(quests) { quest in
|
||
QuestRingCellView(quest: quest, ringSize: ringSize)
|
||
}
|
||
}
|
||
Spacer(minLength: 0)
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
struct QuestStatusWidget: Widget {
|
||
var body: some WidgetConfiguration {
|
||
AppIntentConfiguration(
|
||
kind: "HaruGoalQuestGridWidget",
|
||
intent: QuestStatusConfigIntent.self,
|
||
provider: QuestStatusProvider()
|
||
) { entry in
|
||
QuestStatusWidgetView(entry: entry)
|
||
}
|
||
.configurationDisplayName("다짐 현황")
|
||
.description("목표에 속한 다짐들의 진행률을 원형 링으로 한눈에 봐요. (프리미엄)")
|
||
.supportedFamilies([.systemSmall, .systemMedium, .systemLarge])
|
||
.contentMarginsDisabled()
|
||
}
|
||
}
|