mycode/myApp/HaruDanim/Widgets/ActionRunWidget.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

343 lines
13 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

//
// ActionRunWidget.swift
// Haru_DanimWidgets
//
// (CLAUDE.md §8.2)
// ( = / + , = +1).
// . .
// - : 1 ()
// - : 4 (2×2)
// - : [ 4 (2×2)] [ 8 (2×4)]
//
import SwiftUI
import WidgetKit
import AppIntents
// MARK: -
/// ( )
enum ActionValueDisplayOption: String, AppEnum {
case day, week, month, hidden
static let typeDisplayRepresentation = TypeDisplayRepresentation(name: "누적값 표시")
static let caseDisplayRepresentations: [ActionValueDisplayOption: DisplayRepresentation] = [
.day: "오늘 누적",
.week: "이번 주 누적",
.month: "이번 달 누적",
.hidden: "표시 안 함",
]
/// ( .day )
var statSpan: StatSpan {
switch self {
case .day, .hidden: return .day
case .week: return .week
case .month: return .month
}
}
/// . nil =
var label: String? {
switch self {
case .day: return String(localized: "오늘")
case .week: return String(localized: "이번 주")
case .month: return String(localized: "이번 달")
case .hidden: return nil
}
}
}
///
enum ActionLargeStyle: String, AppEnum {
/// 4 ( 4 2×2)
case fourCells
/// 8 ( 2 2×4)
case eightCells
static let typeDisplayRepresentation = TypeDisplayRepresentation(name: "대형 위젯 구성")
static let caseDisplayRepresentations: [ActionLargeStyle: DisplayRepresentation] = [
.fourCells: "큰 셀 4개 (2×2)",
.eightCells: "작은 셀 8개 (2×4)",
]
}
struct ActionRunConfigIntent: WidgetConfigurationIntent {
static let title: LocalizedStringResource = "행동 실행 위젯"
static let description = IntentDescription("실행할 행동들과 누적값 표시 방식, 테마를 선택하세요. 선택하지 않은 칸은 비워 둬요.")
@Parameter(title: "행동")
var actions: [ActionEntity]?
@Parameter(title: "누적값 표시", default: .day)
var valueDisplay: ActionValueDisplayOption
@Parameter(title: "대형 위젯 구성", default: .fourCells)
var largeStyle: ActionLargeStyle
@Parameter(title: "테마", default: .matchApp)
var theme: WidgetThemeOption
}
// MARK: -
struct ActionRunEntry: TimelineEntry {
let date: Date
let locked: Bool
let theme: WidgetThemeOption
/// . nil =
let periodLabel: String?
let largeStyle: ActionLargeStyle
/// (nil = )
let cells: [ActionCellSnapshot?]
}
struct ActionRunProvider: AppIntentTimelineProvider {
/// · ( 1 / 4 / 4 8)
static func capacity(_ family: WidgetFamily, largeStyle: ActionLargeStyle) -> Int {
switch family {
case .systemMedium: return 4
case .systemLarge: return largeStyle == .eightCells ? 8 : 4
default: return 1
}
}
@MainActor
static func makeEntry(_ configuration: ActionRunConfigIntent, family: WidgetFamily) -> ActionRunEntry {
IntentStore.refresh()
guard WidgetStore.isUnlocked else {
return ActionRunEntry(date: .now, locked: true, theme: configuration.theme,
periodLabel: nil, largeStyle: configuration.largeStyle, cells: [])
}
let capacity = Self.capacity(family, largeStyle: configuration.largeStyle)
let actions = WidgetStore.selectedActions(configuration.actions, defaultCount: capacity)
let span = configuration.valueDisplay.statSpan
return ActionRunEntry(
date: .now,
locked: false,
theme: configuration.theme,
periodLabel: configuration.valueDisplay.label,
largeStyle: configuration.largeStyle,
cells: WidgetStore.slots(actions.map { ActionCellSnapshot.make(action: $0, span: span) },
capacity: capacity)
)
}
func placeholder(in context: Context) -> ActionRunEntry {
ActionRunEntry(date: .now, locked: false, theme: .matchApp, periodLabel: String(localized: "오늘"),
largeStyle: .fourCells,
cells: Array(ActionCellSnapshot.samples.prefix(
Self.capacity(context.family, largeStyle: .fourCells))))
}
@MainActor
func snapshot(for configuration: ActionRunConfigIntent, in context: Context) async -> ActionRunEntry {
Self.makeEntry(configuration, family: context.family)
}
@MainActor
func timeline(for configuration: ActionRunConfigIntent, in context: Context) async -> Timeline<ActionRunEntry> {
// / reloadAllTimelines ,
// Text(_:style:.timer)
//
let first = Self.makeEntry(configuration, family: context.family)
return WidgetRefresh.timeline(first: first, live: false) { _ in first }
}
}
// MARK: -
struct ActionRunCellView: View {
/// . .fullColor (// , iOS 18 accented·vibrant)
/// white-on-white .
@Environment(\.widgetRenderingMode) private var renderingMode
let cell: ActionCellSnapshot
/// nil =
let periodLabel: String?
/// ( 2×2 / 2×4)
var rowLayout = false
/// ()
var fullBleed = false
private var isFullColor: Bool { renderingMode == .fullColor }
var body: some View {
Button(intent: RunActionIntent(actionID: cell.id.uuidString)) {
Group {
if rowLayout {
rowContent
} else {
tileContent
}
}
// : . / : .
.foregroundStyle(isFullColor ? AnyShapeStyle(.white) : AnyShapeStyle(.primary))
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .leading)
.background(cellBackground)
.overlay {
if cell.isRunning {
if fullBleed {
ContainerRelativeShape()
.strokeBorder(AppTheme.yellow, lineWidth: 2)
} else {
RoundedRectangle(cornerRadius: 14, style: .continuous)
.strokeBorder(AppTheme.yellow, lineWidth: 2)
}
}
}
}
.buttonStyle(.plain)
}
/// ( / 4)
private var tileContent: some View {
VStack(alignment: .leading, spacing: 4) {
HStack {
Image(systemName: cell.symbolName)
.font(.system(size: 18, weight: .semibold))
Spacer()
runningDot
}
Spacer(minLength: 0)
Text(cell.name)
.font(.caption.weight(.semibold))
.lineLimit(1)
valueText
.font(.caption.monospacedDigit())
.opacity(0.85)
.lineLimit(1)
}
.padding(fullBleed ? 14 : 12)
}
/// ( 2×2 / 8)
private var rowContent: some View {
HStack(spacing: 8) {
Image(systemName: cell.symbolName)
.font(.system(size: 15, weight: .semibold))
VStack(alignment: .leading, spacing: 1) {
Text(cell.name)
.font(.caption2.weight(.semibold))
.lineLimit(1)
valueText
.font(.caption2.monospacedDigit())
.opacity(0.85)
.lineLimit(1)
}
Spacer(minLength: 0)
runningDot
}
.padding(.horizontal, 10)
.padding(.vertical, 6)
}
@ViewBuilder
private var runningDot: some View {
if cell.isRunning {
Image(systemName: "record.circle")
.font(.system(size: 11, weight: .bold))
.foregroundStyle(isFullColor ? AnyShapeStyle(AppTheme.yellow) : AnyShapeStyle(.primary))
}
}
@ViewBuilder
private var valueText: some View {
if let base = cell.tickingBase {
// : ( )
Text(base, style: .timer)
} else if let periodLabel {
if cell.isCount {
Text("\(periodLabel) \(Int(cell.value))")
} else {
Text("\(periodLabel) \(Format.durationShort(cell.value))")
}
}
}
/// : , /
/// ( ) .
@ViewBuilder private var cellBackground: some View {
WidgetTintedCellBackground(
color: isFullColor ? cell.color : cell.color.opacity(0.2),
fullBleed: fullBleed
)
}
}
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()
.padding(12)
} else if !entry.cells.contains(where: { $0 != nil }) {
WidgetEmptyView(symbolName: "bolt.fill", message: "앱에서 행동을 만들면\n여기서 실행할 수 있어요")
} else {
switch family {
case .systemMedium:
slotGrid(rows: 2, rowLayout: true, spacing: 8)
.padding(10)
case .systemLarge:
if entry.largeStyle == .eightCells {
slotGrid(rows: 4, rowLayout: true, spacing: 8)
.padding(10)
} else {
slotGrid(rows: 2, rowLayout: false, spacing: 8)
.padding(10)
}
default:
// : ()
if let cell = entry.cells.first ?? nil {
ActionRunCellView(cell: cell, periodLabel: entry.periodLabel, fullBleed: true)
} else {
WidgetBlankCellView(fullBleed: true)
}
}
}
}
.widgetAppTheme(entry.theme)
}
/// 2 × rows . .
private func slotGrid(rows: Int, rowLayout: Bool, spacing: CGFloat) -> some View {
VStack(spacing: spacing) {
ForEach(0..<rows, id: \.self) { row in
HStack(spacing: spacing) {
ForEach(0..<2, id: \.self) { col in
let index = row * 2 + col
if index < entry.cells.count, let cell = entry.cells[index] {
ActionRunCellView(cell: cell, periodLabel: entry.periodLabel, rowLayout: rowLayout)
} else {
WidgetBlankCellView()
}
}
}
}
}
}
}
// 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])
.contentMarginsDisabled()
}
}