mycode/myApp/HaruDanim/IOS/Views/RadialNavigationView.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

242 lines
10 KiB
Swift

//
// RadialNavigationView.swift
// Haru_Danim
//
// [] iPhone ( ).
//
// :
// - TabView 7 ·
// ( FAB ),
// - (ZStack ) .
// FAB/ · .
//
// UI: ' (Floating Glass Capsules)'.
// - , FAB (Capsule)
// . ultraThinMaterial + +
// , + .
// - offset·scale·opacity + .
//
// iPhone(Compact) iPad·Mac SidebarRootView, watch .
//
import SwiftUI
// MARK: -
struct RadialTabView: View {
@State private var router: AppRouter
@State private var menuExpanded = false
init() {
let router = AppRouter()
// '' .
router.allTabsDirect = true
_router = State(initialValue: router)
#if DEBUG
// : -radialExpanded YES ( )
if UserDefaults.standard.bool(forKey: "radialExpanded") {
_menuExpanded = State(initialValue: true)
}
#endif
}
var body: some View {
ZStack {
// 1) TabView .
TabView(selection: $router.tabSelection) {
ForEach(AppTab.allCases) { tab in
Tab(tab.label, systemImage: tab.symbol, value: tab.rawValue) {
NavigationStack {
tab.rootView
}
.toolbar(.hidden, for: .tabBar)
}
}
}
// 2) . collapsed FAB .
FloatingCapsuleMenu(
expanded: $menuExpanded,
selection: $router.tabSelection
)
}
.environment(router)
}
}
// MARK: -
struct FloatingCapsuleMenu: View {
@Binding var expanded: Bool
/// AppTab.rawValue ( tabSelection )
@Binding var selection: String
/// ( , )
@AppStorage(SettingsKeys.radialTabOrder) private var radialOrderRaw = ""
//
private let fabSize: CGFloat = 62
private let bottomInset: CGFloat = 42
/// FAB ( )
private let firstItemGap: CGFloat = 78
///
private let itemSpacing: CGFloat = 56
/// ( )
private var tabs: [AppTab] { AppTab.radialOrder(from: radialOrderRaw) }
private var currentTab: AppTab {
AppTab(rawValue: selection) ?? .main
}
/// : (snappy) .
private var openCurve: Animation { .spring(response: 0.3, dampingFraction: 0.7, blendDuration: 0) }
/// : .
private var closeCurve: Animation { .spring(response: 0.25, dampingFraction: 0.9, blendDuration: 0) }
var body: some View {
ZStack {
// . .
Color.black
.opacity(expanded ? 0.22 : 0)
.ignoresSafeArea()
.allowsHitTesting(expanded)
.onTapGesture { close() }
// FAB( ) .
ZStack {
ForEach(Array(tabs.enumerated()), id: \.element.id) { index, tab in
capsuleItem(tab, index: index)
}
fab
}
.frame(width: fabSize, height: fabSize)
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .bottom)
.padding(.bottom, bottomInset)
}
.ignoresSafeArea(.keyboard)
}
// MARK: ( + )
/// . ultraThinMaterial
/// .
/// padding / .
private func capsuleItem(_ tab: AppTab, index: Int) -> some View {
// index 0( ) FAB , .
let restY = -(firstItemGap + CGFloat(index) * itemSpacing)
let isCurrent = tab == currentTab
return Button {
select(tab)
} label: {
// ' '
// ( ) .
ZStack(alignment: .leading) {
ForEach(tabs) { t in
capsuleContent(t)
.opacity(t == tab ? 1 : 0)
.accessibilityHidden(t != tab)
}
}
.foregroundStyle(isCurrent ? AppTheme.green : .primary)
.padding(.horizontal, 18)
.padding(.vertical, 12)
.background {
ZStack {
Capsule().fill(.ultraThinMaterial)
// ( )
Capsule().fill(AppTheme.green.opacity(isCurrent ? 0.16 : 0))
}
}
.overlay(
//
Capsule().stroke(Color.white.opacity(0.4), lineWidth: 1)
)
.shadow(color: .black.opacity(0.15), radius: 8, x: 0, y: 4)
}
.buttonStyle(RadialPressStyle())
.accessibilityLabel(Text(tab.label))
// : FAB (offset 0, ) .
// : FAB .
.offset(y: expanded ? restY : 0)
.scaleEffect(expanded ? 1 : 0.3, anchor: .bottom)
.opacity(expanded ? 1 : 0)
// FAB .
.allowsHitTesting(expanded)
}
/// ( + ). ZStack .
private func capsuleContent(_ tab: AppTab) -> some View {
HStack(spacing: 8) {
Image(systemName: tab.symbol)
.font(.system(size: 15, weight: .semibold))
.frame(width: 20)
Text(tab.label)
.font(.system(size: 15, weight: .semibold))
.lineLimit(1)
.fixedSize()
}
}
// MARK:
private var fab: some View {
Button {
toggle()
} label: {
ZStack {
Circle()
.fill(.ultraThinMaterial)
.overlay(
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.4), lineWidth: 1))
Image(systemName: expanded ? "xmark" : currentTab.symbol)
.font(.system(size: expanded ? 22 : 24, weight: .semibold))
.foregroundStyle(AppTheme.green)
.contentTransition(.symbolEffect(.replace))
}
.frame(width: fabSize, height: fabSize)
.shadow(color: .black.opacity(0.22), radius: 10, y: 4)
}
.buttonStyle(RadialPressStyle())
.accessibilityLabel(expanded ? Text("메뉴 닫기") : Text("탭 메뉴 열기"))
}
// MARK:
private func toggle() {
withAnimation(expanded ? closeCurve : openCurve) {
expanded.toggle()
}
}
private func close() {
withAnimation(closeCurve) {
expanded = false
}
}
private func select(_ tab: AppTab) {
// : .
selection = tab.rawValue
close()
}
}
/// FAB· ( ). easeOut .
private struct RadialPressStyle: ButtonStyle {
func makeBody(configuration: Configuration) -> some View {
configuration.label
.scaleEffect(configuration.isPressed ? 0.9 : 1)
.animation(.easeOut(duration: 0.14), value: configuration.isPressed)
}
}