From dc9434a241c297c343e9b6673f59c284d0f3ed19 Mon Sep 17 00:00:00 2001 From: songyc macbook Date: Sun, 12 Jul 2026 23:26:59 +0900 Subject: [PATCH] refactor(ui): aggressively prune widget config parameters and optimize floating menu performance/glass styling MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Widget configuration — delete the multi-slot pickers entirely -------------------------------------------------------------- Every home/lock widget exposed 3–4 separate entity slots (action1–4, goal1–4, quest1–4, secondGoal) regardless of family, cluttering the long-press config screen with slots a given size never renders. Replaced that with a single anchor selection per widget that self-configures by size family (the view already prefixes by family; providers now build the candidate list from one selection): - ActionRun (A): one anchor Action → fills following actions (main-tab order) up to the family's count. - GoalBars (B): one anchor Goal → fills following goals up to the count. - QuestRing (C): one Goal → renders that goal's quests, sized by family. - GoalQuestGrid (D): one Goal (dropped secondGoal) → its quests; large now shows up to 16 rings of the single goal. - Stats: one anchor Action → plus following actions on the same chart. New WidgetStore helpers actionsFrom/goalsFrom/questsOfGoal centralize the "anchor + following / goal's quests" logic; the None sentinel is ignored gracefully so clearing falls back to sensible defaults. No @Model, timeline, or reload/sync logic touched. Floating radial menu (iPhone) — performance + glassmorphism ----------------------------------------------------------- Each of the ~7 icons and ~7 labels had its own .ultraThinMaterial, so the expanded menu ran ~15 separate blur passes that re-rendered every frame as content scrolled behind them — the source of the progressive frame drops. Now a single unified glass dome (one DomeShape filled with .ultraThinMaterial) sits behind the whole fan and is the only blur; icons are lightweight symbols (only the current tab gets a solid green chip) and labels are plain text with a legibility shadow. The dome is styled per glassmorphism: a soft white top-to-bottom gradient overlay, a thin white.opacity(0.2) border, and a soft shadow; the FAB gets the same gradient/border treatment. Animation is snappier and lighter: the per-item implicit spring animations with staggered delays are gone — one withAnimation(.easeOut ~0.24 open / .easeIn ~0.18 close) drives the whole fan, and the press style uses a short easeOut instead of a spring. Verified in the simulator: build succeeds; the expanded menu renders the single glass dome with all tabs and no crash (light/dark, expanded/collapsed); the refactored C/D widgets render from a single selected goal, self-sized by family. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_013yKDMhuF39GVYy3FMcGHh7 --- .../IOS/Views/RadialNavigationView.swift | 117 ++++++++++++------ .../IOS/Views/WidgetPreviewScreen.swift | 2 +- myApp/HaruDanim/Widgets/ActionRunWidget.swift | 26 +--- myApp/HaruDanim/Widgets/GoalWidgets.swift | 54 ++------ myApp/HaruDanim/Widgets/QuestRingWidget.swift | 30 +---- .../HaruDanim/Widgets/StatsChartWidget.swift | 22 +--- myApp/HaruDanim/Widgets/WidgetSupport.swift | 26 ++++ 7 files changed, 136 insertions(+), 141 deletions(-) diff --git a/myApp/HaruDanim/IOS/Views/RadialNavigationView.swift b/myApp/HaruDanim/IOS/Views/RadialNavigationView.swift index 19a9fba..ee37967 100644 --- a/myApp/HaruDanim/IOS/Views/RadialNavigationView.swift +++ b/myApp/HaruDanim/IOS/Views/RadialNavigationView.swift @@ -84,6 +84,10 @@ struct RadialMenu: View { AppTab(rawValue: selection) ?? .main } + /// 열고/닫는 전환. 무거운 스프링 대신 짧고 빠른 easeOut으로 프레임 드랍을 줄인다. + private var openCurve: Animation { .easeOut(duration: 0.24) } + private var closeCurve: Animation { .easeIn(duration: 0.18) } + var body: some View { GeometryReader { geo in // 양 끝 아이콘이 화면 밖으로 잘리지 않도록 반경을 화면 폭에 맞춰 제한 @@ -99,6 +103,13 @@ struct RadialMenu: View { // FAB(하단 중앙)를 기준점으로 아이콘들이 위쪽 반원으로 펼쳐진다. ZStack { + // 부채꼴 전체를 덮는 '단 하나의' 유리 배경 — 블러 연산을 한 번만 수행한다. + // (예전엔 아이콘·라벨마다 ultraThinMaterial을 따로 씌워 15개 가까운 블러가 + // 스크롤 중 매 프레임 재계산돼 버벅임의 주원인이었다) + if expanded { + glassFan(radius: radius) + .transition(.scale(scale: 0.4, anchor: .bottom).combined(with: .opacity)) + } ForEach(Array(tabs.enumerated()), id: \.element.id) { index, tab in arcItem(tab, index: index, count: tabs.count, radius: radius) } @@ -112,6 +123,31 @@ struct RadialMenu: View { .ignoresSafeArea(.keyboard) } + // MARK: 통합 유리 배경 (부채꼴 돔) + + /// 아이콘들이 얹히는 반원 돔. 유리(ultraThinMaterial) 위에 은은한 화이트 그라데이션 + + /// 얇은 반투명 흰 테두리 + 약한 그림자를 덧대 고급스러운 글라스 질감을 만든다. 블러는 여기 한 번만. + private func glassFan(radius: CGFloat) -> some View { + let r = radius + itemSize / 2 + 12 // 아이콘 원까지 넉넉히 감싸는 반경 + let skirt = itemSize / 2 + 8 // 수평(양 끝) 아이콘까지 덮도록 아래로 살짝 연장 + return DomeShape(skirt: skirt) + .fill(.ultraThinMaterial) + .overlay( + DomeShape(skirt: skirt).fill( + LinearGradient( + colors: [.white.opacity(0.30), .white.opacity(0.05)], + startPoint: .top, endPoint: .bottom + ) + ) + ) + .overlay(DomeShape(skirt: skirt).stroke(.white.opacity(0.2), lineWidth: 1)) + .frame(width: 2 * r, height: r + skirt) + // 돔의 지름선을 FAB 중심 높이에 맞춰 위쪽 반원이 아이콘들을 감싸게 한다. + .offset(y: -(r - skirt) / 2) + .shadow(color: .black.opacity(0.18), radius: 14, y: 6) + .allowsHitTesting(false) + } + // MARK: 중앙 플로팅 버튼 private var fab: some View { @@ -121,14 +157,19 @@ struct RadialMenu: View { ZStack { Circle() .fill(.ultraThinMaterial) - .overlay(Circle().strokeBorder(.white.opacity(0.18), lineWidth: 1)) .overlay( - Circle().fill(AppTheme.green.opacity(expanded ? 0.0 : 0.14)) + Circle().fill( + LinearGradient( + colors: [.white.opacity(0.28), .white.opacity(0.02)], + startPoint: .top, endPoint: .bottom + ) + ) ) + .overlay(Circle().fill(AppTheme.green.opacity(expanded ? 0.0 : 0.14))) + .overlay(Circle().strokeBorder(.white.opacity(0.22), lineWidth: 1)) Image(systemName: expanded ? "xmark" : currentTab.symbol) .font(.system(size: expanded ? 22 : 24, weight: .semibold)) .foregroundStyle(AppTheme.green) - .rotationEffect(.degrees(expanded ? 90 : 0)) .contentTransition(.symbolEffect(.replace)) } .frame(width: fabSize, height: fabSize) @@ -138,7 +179,7 @@ struct RadialMenu: View { .accessibilityLabel(expanded ? Text("메뉴 닫기") : Text("탭 메뉴 열기")) } - // MARK: 반원 위 탭 아이콘 + // MARK: 반원 위 탭 아이콘 (개별 블러 없이 유리 돔 위에 얹히는 가벼운 심볼) private func arcItem(_ tab: AppTab, index: Int, count: Int, radius: CGFloat) -> some View { // 왼쪽(180°) → 위(90°) → 오른쪽(0°)으로 이어지는 위쪽 반원에 균등 배치 @@ -151,54 +192,37 @@ struct RadialMenu: View { return Button { select(tab) } label: { - // 바깥 ZStack의 레이아웃 프레임 = 아이콘 원(itemSize)뿐이므로, - // 라벨을 offset으로 아래에 띄워도 아이콘 중심은 반원 위 위치를 그대로 유지한다. ZStack { - // 아이콘 원 + // 아이콘 — 재질 블러 없음. 현재 탭만 초록 틴트 원으로 강조(가벼운 단색 채움). ZStack { - Circle() - .fill(.ultraThinMaterial) - .overlay( - Circle().fill(AppTheme.green.opacity(isCurrent ? 0.22 : 0)) - ) - .overlay( - Circle().strokeBorder( - isCurrent ? AppTheme.green.opacity(0.9) : .white.opacity(0.16), - lineWidth: isCurrent ? 2 : 1 - ) - ) + if isCurrent { + Circle() + .fill(AppTheme.green.opacity(0.9)) + .overlay(Circle().strokeBorder(.white.opacity(0.3), lineWidth: 1)) + } Image(systemName: tab.symbol) .font(.system(size: 20, weight: .semibold)) - .foregroundStyle(isCurrent ? AppTheme.green : .primary) + .foregroundStyle(isCurrent ? .white : .primary) } .frame(width: itemSize, height: itemSize) - .shadow(color: .black.opacity(0.18), radius: 6, y: 3) - // 탭 이름 라벨 (글라스 캡슐, 원 아래로) + // 탭 이름 라벨 — 캡슐 블러 없이 텍스트 + 가독성용 그림자만 Text(tab.label) .font(.system(size: 11, weight: .semibold)) .lineLimit(1) .fixedSize() .foregroundStyle(isCurrent ? AppTheme.green : .primary) - .padding(.horizontal, 7) - .padding(.vertical, 2.5) - .background(Capsule().fill(.ultraThinMaterial)) - .overlay(Capsule().strokeBorder(.white.opacity(0.14), lineWidth: 0.5)) - .shadow(color: .black.opacity(0.12), radius: 3, y: 1) + .shadow(color: .black.opacity(0.15), radius: 2, y: 1) .offset(y: labelOffset) } } .buttonStyle(RadialPressStyle()) .accessibilityLabel(Text(tab.label)) - // 펼쳐질 때 중앙(FAB)에서 각 위치로 피어나고, 접힐 땐 중앙으로 모인다. + // 펼쳐질 때 중앙(FAB)에서 각 위치로 퍼지고, 접힐 땐 중앙으로 모인다. + // (per-item 암시적 애니메이션·지연 제거 → 바깥 withAnimation 하나로 함께 움직여 가볍다) .offset(x: expanded ? dx : 0, y: expanded ? dy : 0) .scaleEffect(expanded ? 1 : 0.3) .opacity(expanded ? 1 : 0) - .animation( - .spring(response: 0.42, dampingFraction: 0.72) - .delay(expanded ? Double(index) * 0.035 : 0), - value: expanded - ) // 접혀 있을 땐 FAB 아래 숨어 있으므로 터치를 받지 않게 한다. .allowsHitTesting(expanded) } @@ -206,13 +230,13 @@ struct RadialMenu: View { // MARK: 동작 private func toggle() { - withAnimation(.spring(response: 0.42, dampingFraction: 0.72)) { + withAnimation(expanded ? closeCurve : openCurve) { expanded.toggle() } } private func close() { - withAnimation(.spring(response: 0.42, dampingFraction: 0.72)) { + withAnimation(closeCurve) { expanded = false } } @@ -224,11 +248,32 @@ struct RadialMenu: View { } } -/// FAB·아이콘 공통 눌림 효과 (살짝 줄어들며 반응) +/// 위쪽 반원(돔) + 아래 짧은 스커트. 부채꼴 아이콘들을 하나의 유리 면으로 감싸는 배경 모양. +private struct DomeShape: Shape { + /// 지름선 아래로 연장하는 높이 (수평 위치 아이콘까지 덮기 위함) + var skirt: CGFloat = 0 + + func path(in rect: CGRect) -> Path { + var p = Path() + let r = rect.width / 2 + let cx = rect.midX + let baseY = rect.maxY - skirt + p.addArc( + center: CGPoint(x: cx, y: baseY), radius: r, + startAngle: .degrees(180), endAngle: .degrees(360), clockwise: false + ) + p.addLine(to: CGPoint(x: cx + r, y: rect.maxY)) + p.addLine(to: CGPoint(x: cx - r, y: rect.maxY)) + p.closeSubpath() + return p + } +} + +/// FAB·아이콘 공통 눌림 효과 (살짝 줄어들며 반응). 스프링 대신 짧은 easeOut으로 가볍게. private struct RadialPressStyle: ButtonStyle { func makeBody(configuration: Configuration) -> some View { configuration.label .scaleEffect(configuration.isPressed ? 0.9 : 1) - .animation(.spring(response: 0.25, dampingFraction: 0.6), value: configuration.isPressed) + .animation(.easeOut(duration: 0.14), value: configuration.isPressed) } } diff --git a/myApp/HaruDanim/IOS/Views/WidgetPreviewScreen.swift b/myApp/HaruDanim/IOS/Views/WidgetPreviewScreen.swift index 16feb56..fc082d0 100644 --- a/myApp/HaruDanim/IOS/Views/WidgetPreviewScreen.swift +++ b/myApp/HaruDanim/IOS/Views/WidgetPreviewScreen.swift @@ -108,7 +108,7 @@ struct WidgetPreviewScreen: View { widgetBox(.systemMedium) { GoalQuestGridWidgetView(previewFamily: .systemMedium, entry: entry) } } .id("D") - row("D 다짐 현황 · 대형(목표 2개)") { + row("D 다짐 현황 · 대형") { widgetBox(.systemLarge) { GoalQuestGridWidgetView(previewFamily: .systemLarge, entry: entry) } } } diff --git a/myApp/HaruDanim/Widgets/ActionRunWidget.swift b/myApp/HaruDanim/Widgets/ActionRunWidget.swift index 5f768f3..2fe37cf 100644 --- a/myApp/HaruDanim/Widgets/ActionRunWidget.swift +++ b/myApp/HaruDanim/Widgets/ActionRunWidget.swift @@ -14,30 +14,16 @@ import AppIntents struct ActionRunConfigIntent: WidgetConfigurationIntent { static let title: LocalizedStringResource = "행동 실행 위젯" - static let description = IntentDescription("표시할 행동과 누적 기간을 선택하세요. 비워 두면 최근 행동이 채워져요.") + static let description = IntentDescription("표시할 행동 하나와 누적 기간을 선택하세요. 중형·대형 위젯은 이어지는 행동이 자동으로 채워져요.") @Parameter(title: "표시 기간", default: .day) var period: SpanOption @Parameter(title: "행동") - var action1: ActionEntity? - - @Parameter(title: "행동 2 (중형·대형)") - var action2: ActionEntity? - - @Parameter(title: "행동 3 (중형·대형)") - var action3: ActionEntity? - - @Parameter(title: "행동 4 (대형)") - var action4: ActionEntity? + var action: ActionEntity? static var parameterSummary: some ParameterSummary { - Summary("행동을 \(\.$period) 기준으로 표시") { - \.$action1 - \.$action2 - \.$action3 - \.$action4 - } + Summary("\(\.$action)을(를) \(\.$period) 기준으로 표시") } } @@ -58,10 +44,8 @@ struct ActionRunProvider: AppIntentTimelineProvider { 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 + // 단일 앵커 행동에서 시작해 크기에 맞춰 최대 4개까지 스스로 채운다 (뷰가 패밀리별로 자른다) + let actions = WidgetStore.actionsFrom(anchor: configuration.action?.id, count: 4) return ActionRunEntry( date: .now, locked: false, diff --git a/myApp/HaruDanim/Widgets/GoalWidgets.swift b/myApp/HaruDanim/Widgets/GoalWidgets.swift index a4ab6c5..9b04891 100644 --- a/myApp/HaruDanim/Widgets/GoalWidgets.swift +++ b/myApp/HaruDanim/Widgets/GoalWidgets.swift @@ -13,27 +13,13 @@ import AppIntents struct GoalBarsConfigIntent: WidgetConfigurationIntent { static let title: LocalizedStringResource = "목표 진행률 위젯" - static let description = IntentDescription("하루·주간·월간 진행률을 표시할 목표를 선택하세요. 비워 두면 진행 중인 목표가 채워져요.") + static let description = IntentDescription("진행률을 표시할 목표 하나를 선택하세요. 중형·대형 위젯은 이어지는 목표가 자동으로 채워져요.") @Parameter(title: "목표") - var goal1: GoalEntity? - - @Parameter(title: "목표 2 (중형·대형)") - var goal2: GoalEntity? - - @Parameter(title: "목표 3 (대형)") - var goal3: GoalEntity? - - @Parameter(title: "목표 4 (대형)") - var goal4: GoalEntity? + var goal: GoalEntity? static var parameterSummary: some ParameterSummary { - Summary("목표의 하루·주간·월간 진행률을 표시") { - \.$goal1 - \.$goal2 - \.$goal3 - \.$goal4 - } + Summary("\(\.$goal)의 하루·주간·월간 진행률을 표시") } } @@ -53,10 +39,8 @@ struct GoalBarsProvider: AppIntentTimelineProvider { 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 + // 단일 앵커 목표에서 시작해 크기에 맞춰 최대 4개까지 스스로 채운다 (뷰가 패밀리별로 자른다) + let goals = WidgetStore.goalsFrom(anchor: configuration.goal?.id, count: 4) return GoalBarsEntry(date: now, locked: false, goals: goals.map { GoalSnapshot.make(goal: $0, now: now) }) } @@ -177,22 +161,16 @@ struct GoalBarsWidget: Widget { struct GoalQuestGridConfigIntent: WidgetConfigurationIntent { static let title: LocalizedStringResource = "다짐 현황 위젯" - static let description = IntentDescription("다짐들의 진행률 링을 표시할 목표와 기간을 선택하세요.") + static let description = IntentDescription("다짐들의 진행률 링을 표시할 목표 하나와 기간을 선택하세요.") @Parameter(title: "목표") var goal: GoalEntity? - @Parameter(title: "목표 2 (대형 전용)") - var secondGoal: GoalEntity? - @Parameter(title: "진행률 기간", default: .day) var span: SpanOption static var parameterSummary: some ParameterSummary { - Summary("목표의 다짐들을 \(\.$span) 기준으로 표시") { - \.$goal - \.$secondGoal - } + Summary("\(\.$goal)의 다짐들을 \(\.$span) 기준으로 표시") } } @@ -213,10 +191,8 @@ struct GoalQuestGridProvider: AppIntentTimelineProvider { 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) } + // 단일 선택 목표 하나 (없으면 진행 중 기본 목표). 다짐 개수는 뷰가 패밀리별로 자른다. + let goals = [WidgetStore.goal(configuration.goal?.id) ?? WidgetStore.defaultGoals(1).first].compactMap { $0 } return GoalQuestGridEntry( date: now, locked: false, @@ -280,15 +256,9 @@ struct GoalQuestGridWidgetView: View { 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: 16) { - 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) + // 대형 D: 선택한 목표 1개의 다짐을 최대 16개까지 크게 + if let goal = entry.goals.first { + goalSection(goal, maxCount: 16, columns: 4, ringSize: 44) } default: // 소형 D: 2 x 2 링 + 최상단 목표 이름 diff --git a/myApp/HaruDanim/Widgets/QuestRingWidget.swift b/myApp/HaruDanim/Widgets/QuestRingWidget.swift index f376305..30d72d3 100644 --- a/myApp/HaruDanim/Widgets/QuestRingWidget.swift +++ b/myApp/HaruDanim/Widgets/QuestRingWidget.swift @@ -14,30 +14,16 @@ import AppIntents struct QuestRingConfigIntent: WidgetConfigurationIntent { static let title: LocalizedStringResource = "다짐 진행률 위젯" - static let description = IntentDescription("원형 진행률로 표시할 다짐과 기간을 선택하세요. 비워 두면 최근 목표의 다짐이 채워져요.") + static let description = IntentDescription("원형 진행률로 표시할 목표 하나와 기간을 선택하세요. 위젯 크기에 맞춰 목표의 다짐이 자동으로 채워져요.") @Parameter(title: "진행률 기간", default: .day) var span: SpanOption - @Parameter(title: "다짐") - var quest1: QuestEntity? - - @Parameter(title: "다짐 2 (중형·대형)") - var quest2: QuestEntity? - - @Parameter(title: "다짐 3 (대형)") - var quest3: QuestEntity? - - @Parameter(title: "다짐 4 (대형)") - var quest4: QuestEntity? + @Parameter(title: "목표") + var goal: GoalEntity? static var parameterSummary: some ParameterSummary { - Summary("다짐의 진행률을 \(\.$span) 기준으로 표시") { - \.$quest1 - \.$quest2 - \.$quest3 - \.$quest4 - } + Summary("\(\.$goal)의 다짐 진행률을 \(\.$span) 기준으로 표시") } } @@ -61,12 +47,8 @@ struct QuestRingProvider: AppIntentTimelineProvider { 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)) - } + // 단일 선택 목표의 다짐을 최대 4개까지 (뷰가 패밀리별로 자른다) + let quests = WidgetStore.questsOfGoal(configuration.goal?.id, count: 4) return QuestRingEntry( date: now, locked: false, diff --git a/myApp/HaruDanim/Widgets/StatsChartWidget.swift b/myApp/HaruDanim/Widgets/StatsChartWidget.swift index 919f0f0..d93a9ba 100644 --- a/myApp/HaruDanim/Widgets/StatsChartWidget.swift +++ b/myApp/HaruDanim/Widgets/StatsChartWidget.swift @@ -36,26 +36,16 @@ enum StatsChartSpan: String, AppEnum { struct StatsChartConfigIntent: WidgetConfigurationIntent { static let title: LocalizedStringResource = "통계 위젯" - static let description = IntentDescription("그래프로 보여줄 행동과 기간을 선택하세요. 비워 두면 최근 행동이 채워져요.") + static let description = IntentDescription("그래프로 보여줄 행동 하나와 기간을 선택하세요. 큰 위젯은 이어지는 행동이 함께 그려져요.") @Parameter(title: "통계 기간", default: .week) var span: StatsChartSpan @Parameter(title: "행동") - var action1: ActionEntity? - - @Parameter(title: "행동 2") - var action2: ActionEntity? - - @Parameter(title: "행동 3") - var action3: ActionEntity? + var action: ActionEntity? static var parameterSummary: some ParameterSummary { - Summary("행동 통계를 \(\.$span)(으)로 표시") { - \.$action1 - \.$action2 - \.$action3 - } + Summary("\(\.$action) 통계를 \(\.$span)(으)로 표시") } } @@ -94,10 +84,8 @@ struct StatsChartProvider: AppIntentTimelineProvider { 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 + // 단일 앵커 행동에서 시작해 최대 3개까지 스스로 채운다 (같은 그래프에 겹쳐 그림) + let actions = WidgetStore.actionsFrom(anchor: configuration.action?.id, count: 3) let isTimeType = !actions.contains { $0.trackingType == .count } let math = DayMath() diff --git a/myApp/HaruDanim/Widgets/WidgetSupport.swift b/myApp/HaruDanim/Widgets/WidgetSupport.swift index 40353a7..7a54bb3 100644 --- a/myApp/HaruDanim/Widgets/WidgetSupport.swift +++ b/myApp/HaruDanim/Widgets/WidgetSupport.swift @@ -313,4 +313,30 @@ enum WidgetStore { let active = goals.filter { $0.status == .inProgress } return Array((active.isEmpty ? goals : active).prefix(count)) } + + /// 단일 선택(앵커) 행동에서 시작해 모음 탭 순서로 이어지는 행동을 count개까지. + /// 위젯이 크기(패밀리)에 맞춰 스스로 1·3·4개를 보여줄 수 있도록 넉넉히 반환하고, + /// 실제 표시 개수는 각 위젯 뷰가 패밀리별로 잘라 쓴다. 선택이 없으면 앞에서부터 채운다. + static func actionsFrom(anchor id: UUID?, count: Int) -> [Action] { + let all = IntentStore.actions() + guard let id, let idx = all.firstIndex(where: { $0.uuid == id }) else { + return defaultActions(count) + } + return Array(all[idx...].prefix(count)) + } + + /// 단일 선택(앵커) 목표에서 시작해 목록 순서로 이어지는 목표를 count개까지. + static func goalsFrom(anchor id: UUID?, count: Int) -> [Goal] { + let all = IntentStore.goals() + guard let id, let idx = all.firstIndex(where: { $0.uuid == id }) else { + return defaultGoals(count) + } + return Array(all[idx...].prefix(count)) + } + + /// 단일 선택(앵커) 목표에 속한 다짐을 count개까지 (없으면 진행 중 기본 목표). + static func questsOfGoal(_ id: UUID?, count: Int) -> [Quest] { + let goal = goal(id) ?? defaultGoals(1).first + return Array((goal?.sortedQuests ?? []).prefix(count)) + } }