mycode/myApp/HaruDanim/Widgets/WidgetSupport.swift
songyc macbook 49dd1d0ff2 feat(widgets): complete redesign of 5 widget types with modular Intents, fixed floating menu UI/Strings
Widget system rebuilt as 5 independent widgets, each with its own
WidgetConfigurationIntent registered in the WidgetBundle:

- ① ActionRun: multi-action selection, value display option (day/week/
  month/hidden), full tag-color cells; small 1 / medium 4 (2x2) / large
  4 big cells or 8 compact cells (2x4) via largeStyle. Unfilled slots
  render as dashed blank cells.
- ② GoalProgress: multi-goal selection; medium [2 goals | goal+top
  quests], large [4 goals | 2x goal+quests | 1 goal + all quests] via
  medium/largeStyle. New QuestBarRow / GoalWithQuestsCellView.
- ③ QuestRing: multi-quest selection + span; surface-card ring (tag
  color) with centered icon, name and "오늘 N%" text; interactive run
  via RunActionIntent; small 1 / medium 2 / large 4 (2x2).
- ④ QuestStatus (display-only): goal selection + span; rings with name
  only (no %), goal icon+name top-left; small 4 (2x2) / medium 8 (4x2) /
  large [16 (4x4) | 4 goals | 2 goals] via largeStyle.
- ⑤ StatsChart: multi-action selection capped per family (2/4/6) +
  3 span modes; same design scaled by family.

Common foundation:
- Theme option revived on every intent: match app / light fixed / dark
  fixed (WidgetThemeOption AppEnum) applied through widgetAppTheme(_:)
  with proper iOS 17 containerBackground; non-fullColor rendering modes
  (tinted/clear/accented) keep the Color.clear + semantic-color defense
  so content never goes white-on-white.
- Providers are family-aware (capacity by size) and slot-pad selections
  (WidgetStore.slots); defaults fill from existing data so fresh widgets
  never look empty.
- Interactive path unchanged: RunActionIntent(actionID:) +
  IntentStore.performWrite -> reloadAllTimelines.

Floating menu polish:
- All glass capsules now share a uniform width (longest label wins,
  locale-proof) for a tidy popped-up list.
- Settings strings updated: nav style renamed to '플로팅 글래스 캡슐'
  with description matching the capsule popup, order editor retitled
  '캡슐 메뉴 순서' with bottom-to-top wording.

Verified: BUILD SUCCEEDED (iPhone 17 Pro sim); widget preview harness
extended with per-variant scroll anchors and all 15 layout variants
screenshot-checked, including forced accented mode (legible translucent
cells) and blank-slot rendering; capsule menu and settings text
screenshot-checked.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013yKDMhuF39GVYy3FMcGHh7
2026-07-13 01:17:39 +09:00

354 lines
14 KiB
Swift

//
// WidgetSupport.swift
// Haru_DanimWidgets
//
// / (CLAUDE.md §8)
// - SwiftData(App Group DB)
// - : ( ·· )
// reloadAllTimelines .
// (WidgetKit ).
// - , / , /
//
import SwiftUI
import SwiftData
import WidgetKit
// MARK: -
enum WidgetRefresh {
/// "15 .after" 6
/// . :
/// - (live): 10 1 .atEnd
/// ( 1 )
/// - : ( 4 )
static func timeline<E: TimelineEntry>(first: E, live: Bool, make: (Date) -> E) -> Timeline<E> {
let now = first.date
if live {
let future = stride(from: 600, through: 3600, by: 600)
.map { make(now.addingTimeInterval(Double($0))) }
return Timeline(entries: [first] + future, policy: .atEnd)
}
let boundary = DayMath().dayRange(containing: now).upperBound
return Timeline(entries: [first], policy: .after(min(boundary, now.addingTimeInterval(4 * 3600))))
}
}
// 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) }
@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: -
/// (placeholder/preview)
///
extension ActionCellSnapshot {
static let samples: [ActionCellSnapshot] = [
ActionCellSnapshot(id: UUID(), name: String(localized: "독서"), symbolName: "book.fill",
colorHex: "#2F6B4F", isCount: false, isRunning: true, value: 45 * 60, tickingBase: nil),
ActionCellSnapshot(id: UUID(), name: String(localized: "달리기"), symbolName: "figure.run",
colorHex: "#9D5B4A", isCount: false, isRunning: false, value: 30 * 60, tickingBase: nil),
ActionCellSnapshot(id: UUID(), name: String(localized: "물 마시기"), symbolName: "drop.fill",
colorHex: "#4A7A9D", isCount: true, isRunning: false, value: 5, tickingBase: nil),
ActionCellSnapshot(id: UUID(), name: String(localized: "팔굽혀펴기"), symbolName: "dumbbell.fill",
colorHex: "#7A5C9D", isCount: true, isRunning: false, value: 20, tickingBase: nil),
]
}
extension QuestCellSnapshot {
static let samples: [QuestCellSnapshot] = [
QuestCellSnapshot(id: UUID(), targetName: String(localized: "독서"), symbolName: "book.fill",
colorHex: "#2F6B4F", ratio: 0.75, displayRatio: 0.75,
isAtMost: false, isAchieved: false, runTarget: nil, isRunning: true),
QuestCellSnapshot(id: UUID(), targetName: String(localized: "달리기"), symbolName: "figure.run",
colorHex: "#9D5B4A", ratio: 0.4, displayRatio: 0.4,
isAtMost: false, isAchieved: false, runTarget: nil, isRunning: false),
QuestCellSnapshot(id: UUID(), targetName: String(localized: "물 마시기"), symbolName: "drop.fill",
colorHex: "#4A7A9D", ratio: 1, displayRatio: 1.1,
isAtMost: false, isAchieved: true, runTarget: nil, isRunning: false),
QuestCellSnapshot(id: UUID(), targetName: String(localized: "영상 시청"), symbolName: "play.rectangle.fill",
colorHex: "#7A5C9D", ratio: 1, displayRatio: 1,
isAtMost: true, isAchieved: true, runTarget: nil, isRunning: false),
]
}
extension GoalSnapshot {
static let sample = GoalSnapshot(
id: UUID(), title: String(localized: "건강한 생활 습관"), symbolName: "heart.fill",
colorHex: "#2F6B4F", dayRatio: 0.62, weekRatio: 0.48, monthRatio: 0.7,
quests: QuestCellSnapshot.samples
)
}
// MARK: -
struct WidgetEmptyView: View {
let symbolName: String
let message: String
var body: some View {
VStack(spacing: 6) {
Image(systemName: symbolName)
.font(.title3)
.foregroundStyle(AppTheme.green)
Text(message)
.font(.caption2)
.foregroundStyle(.secondary)
.multilineTextAlignment(.center)
}
.padding(12)
}
}
// 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))
}
// MARK:
// (nil/ ) ,
// ( ).
// ' ' (NoneEntityID) .
static func selectedActions(_ entities: [ActionEntity]?, defaultCount: Int) -> [Action] {
guard let entities, !entities.isEmpty else { return defaultActions(defaultCount) }
return entities.compactMap { action($0.id) }
}
static func selectedGoals(_ entities: [GoalEntity]?, defaultCount: Int) -> [Goal] {
guard let entities, !entities.isEmpty else { return defaultGoals(defaultCount) }
return entities.compactMap { goal($0.id) }
}
static func selectedQuests(_ entities: [QuestEntity]?, defaultCount: Int) -> [Quest] {
guard let entities, !entities.isEmpty else {
// : ,
// (
// )
let fromGoal = defaultGoals(1).first?.sortedQuests ?? []
let rest = IntentStore.quests().filter { quest in
!fromGoal.contains { $0.uuid == quest.uuid }
}
return Array((fromGoal + rest).prefix(defaultCount))
}
return entities.compactMap { quest($0.id) }
}
/// nil( ) .
/// " " .
static func slots<T>(_ items: [T], capacity: Int) -> [T?] {
var result: [T?] = items.prefix(capacity).map { $0 }
while result.count < capacity { result.append(nil) }
return result
}
}