refactor(ui): aggressively prune widget config parameters and optimize floating menu performance/glass styling

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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013yKDMhuF39GVYy3FMcGHh7
This commit is contained in:
songyc macbook 2026-07-12 23:26:59 +09:00
parent 4a03868163
commit dc9434a241
7 changed files with 136 additions and 141 deletions

View File

@ -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 {
if isCurrent {
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
)
)
.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)
}
}

View File

@ -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) }
}
}

View File

@ -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,

View File

@ -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 +

View File

@ -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,

View File

@ -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()

View File

@ -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))
}
}