mycode/myApp/HaruDanim/IOS/Views/RadialNavigationView.swift
songyc macbook 16e8737976 feat(1.4-b3): '일기만' 정리 옵션·라디얼 FAB 가림 수정·1.4 전용 프로모션 — 빌드 3
- 데이터 정리·초기화에 '정리할 데이터' 축(기록과 일기/일기만) 추가 — 일기만은 기록·구조·양식 보존, 백업은 일기 PDF만, DataReset.deleteDiaries(전체/날짜까지)
- 자가 검증 18→27건(일기만 9건: 기준일 포함 삭제·기록/행동/양식 보존·cascade) — 26·18.5 심 ALL PASS
- 라디얼 모드 FAB가 목록 마지막 행을 가리던 실측 문제 수정 — 탭 루트(NavigationStack 안쪽) 하단 safeAreaInset 78pt, 26·18.5 시각 확인
- 도움말 '데이터 정리·초기화'에 일기만 문구, 신규 12키 en/ja 완역(카탈로그 missing/stale 0)
- whats-new-1.4에 일기만 반영 + promotional-text-1.4.txt 신설(ko 154/en 169/ja 139자 — 이용자 3명 감사 멘트)
- iOS 18.5 심층 QA: 자가 검증 106건 ALL PASS + 라디얼/위젯/초기화 화면 확인
- CURRENT_PROJECT_VERSION 2→3, Debug/Store/워치 3빌드 성공

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WtZwRRjbtM9pZJDM4FkTq6
2026-08-12 02:19:34 +09:00

414 lines
19 KiB
Swift
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

//
// RadialNavigationView.swift
// Haru_Danim
//
// [] iPhone ( ).
//
// :
// - TabView 7 ·
// ( FAB ),
// - (ZStack ) .
// FAB/ · .
//
// UI: ' (Glass Bubbles)' (glassEffect) .
// - FAB (.regular.interactive · ·
// ) FAB .
// - GlassEffectContainer :
// material 14( 7 + 7) + 14
// . 8( 7 + FAB) ,
// . FAB
// · .
// - : () + . : FAB .
// ( ) .
// - (~150pt) .
// - iOS 18 (1.4 OS ): glassEffect 26 , 18
// (+ +) ·
// · · (LiquidBallBackground ).
//
// 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.phoneCases) { tab in
Tab(tab.label, systemImage: tab.symbol, value: tab.rawValue) {
NavigationStack {
// FAB( 42pt + 64pt)
//
// FAB (1.4(2) ).
// NavigationStack '' Tab ( )
// TabView safe area ()
tab.rootView
.safeAreaInset(edge: .bottom) {
Color.clear.frame(height: 78)
}
}
.toolbar(.hidden, for: .tabBar)
}
}
}
// 2) . collapsed FAB .
GlassBubbleMenu(
expanded: $menuExpanded,
selection: $router.tabSelection
)
}
.environment(router)
}
}
// MARK: -
struct GlassBubbleMenu: View {
@Binding var expanded: Bool
/// AppTab.rawValue ( tabSelection )
@Binding var selection: String
/// ( , )
@AppStorage(SettingsKeys.radialTabOrder) private var radialOrderRaw = ""
/// ' ' (rawValue, = , )
@AppStorage(SettingsKeys.radialDoubleTapTab) private var doubleTapTabRaw = ""
/// FAB . (doubleTapWindow)
/// FAB .
/// ,
/// ( FAB ).
@State private var openTapAt: Date?
private let doubleTapWindow: TimeInterval = 0.35
/// FAB
@Namespace private var glassSpace
/// 2 : FAB .
/// expanded(·) GlassEffectContainer
/// glassEffect opacity/scale (
/// ), (if expanded),
/// offset (bloomed) .
@State private var bloomed = false
//
private let fabSize: CGFloat = 64
private let bubbleSize: CGFloat = 58
/// FAB . .
private let desiredRadius: CGFloat = 152
private let bottomInset: CGFloat = 42
private let edgePadding: CGFloat = 14
/// . (~20pt)
/// , FAB .
private let glassBlendSpacing: CGFloat = 18
/// ( )
private var tabs: [AppTab] { AppTab.radialOrder(from: radialOrderRaw) }
private var currentTab: AppTab {
AppTab(rawValue: selection) ?? .main
}
var body: some View {
GeometryReader { geo in
//
let radius = min(desiredRadius, geo.size.width / 2 - bubbleSize / 2 - edgePadding)
ZStack {
dimLayer
// FAB( ) .
ZStack {
glow(radius: radius)
// + .
// iOS 18 ( ).
// 26 API LiquidGlassBall
if #available(iOS 26.0, *) {
GlassClusterContainer(spacing: glassBlendSpacing) {
bubbleCluster(radius: radius)
}
} else {
bubbleCluster(radius: radius)
}
}
.frame(width: fabSize, height: fabSize)
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .bottom)
.padding(.bottom, bottomInset)
}
}
.ignoresSafeArea(.keyboard)
.sensoryFeedback(.impact(weight: .light), trigger: expanded)
.onAppear {
// -radialExpanded
if expanded { bloomed = true }
#if DEBUG
// : -radialDoubleTapTest YES 2 FAB
// ( -settings.radialDoubleTapTab <> )
if UserDefaults.standard.bool(forKey: "radialDoubleTapTest") {
Task { @MainActor in
try? await Task.sleep(for: .seconds(2))
toggle()
try? await Task.sleep(for: .milliseconds(180))
toggle()
}
}
#endif
}
}
// MARK:
/// . ( )
/// . .
private var dimLayer: some View {
LinearGradient(
colors: [.black.opacity(0.06), .black.opacity(0.32)],
startPoint: .top, endPoint: .bottom
)
.ignoresSafeArea()
.opacity(expanded ? 1 : 0)
.animation(.easeOut(duration: 0.22), value: expanded)
.allowsHitTesting(expanded)
.onTapGesture { close() }
}
/// .
/// .
private func glow(radius: CGFloat) -> some View {
RadialGradient(
colors: [AppTheme.green.opacity(0.22), AppTheme.green.opacity(0.05), .clear],
center: .center, startRadius: 0, endRadius: radius * 1.3
)
.frame(width: radius * 2.6, height: radius * 2.6)
.scaleEffect(expanded ? 1 : 0.15)
.opacity(expanded ? 1 : 0)
.animation(.spring(response: 0.5, dampingFraction: 0.8), value: expanded)
.allowsHitTesting(false)
}
/// FAB + 26 GlassEffectContainer
private func bubbleCluster(radius: CGFloat) -> some View {
ZStack {
if expanded {
ForEach(Array(tabs.enumerated()), id: \.element.id) { index, tab in
bubble(tab, index: index, count: tabs.count, radius: radius)
}
}
fab
}
}
// MARK:
///
/// .
/// : FAB (offset 0 = FAB ),
/// bloomed .
/// .
private func bubble(_ tab: AppTab, index: Int, count: Int, radius: CGFloat) -> some View {
// (180°) (90°) (0°)
let t = count > 1 ? Double(index) / Double(count - 1) : 0.5
let angle = Double.pi - t * Double.pi // π 0
let dx = CGFloat(cos(angle)) * radius
let dy = -CGFloat(sin(angle)) * radius // +Y
let isCurrent = tab == currentTab
return Button {
select(tab)
} label: {
VStack(spacing: 2) {
Image(systemName: tab.symbol)
.font(.system(size: 19, weight: .semibold))
Text(tab.label)
.font(.system(size: 9.5, weight: .semibold))
.lineLimit(1)
.minimumScaleFactor(0.75)
}
.foregroundStyle(isCurrent ? AnyShapeStyle(.white) : AnyShapeStyle(.primary))
// FAB
// (glassEffect '' )
.opacity(bloomed ? 1 : 0)
.frame(width: bubbleSize, height: bubbleSize)
.contentShape(Circle())
}
.buttonStyle(.plain)
// (26) (18): .
.modifier(LiquidBallBackground(tinted: isCurrent, morphID: tab.rawValue, namespace: glassSpace))
.offset(x: bloomed ? dx : 0, y: bloomed ? dy : 0)
// + ( ).
// FAB .
.animation(
bloomed
? .spring(response: 0.45, dampingFraction: 0.6).delay(Double(index) * 0.04)
: .spring(response: 0.26, dampingFraction: 0.9),
value: bloomed
)
.allowsHitTesting(expanded && bloomed)
.accessibilityLabel(Text(tab.label))
}
// MARK:
private var fab: some View {
Button {
toggle()
} label: {
Image(systemName: expanded ? "xmark" : currentTab.symbol)
.font(.system(size: expanded ? 21 : 23, weight: .semibold))
.foregroundStyle(AppTheme.green)
.contentTransition(.symbolEffect(.replace))
.frame(width: fabSize, height: fabSize)
.contentShape(Circle())
}
.buttonStyle(.plain)
.modifier(LiquidBallBackground(tinted: false, morphID: "fab", namespace: glassSpace))
// ' '
.scaleEffect(expanded ? 1.07 : 1)
.animation(.spring(response: 0.3, dampingFraction: 0.7), value: expanded)
.accessibilityLabel(expanded ? Text("메뉴 닫기") : Text("탭 메뉴 열기"))
}
// MARK:
private func toggle() {
if expanded {
// ( )
if let tappedAt = openTapAt,
Date.now.timeIntervalSince(tappedAt) < doubleTapWindow,
let tab = AppTab(rawValue: doubleTapTabRaw) {
openTapAt = nil
selection = tab.rawValue
close()
return
}
openTapAt = nil
close()
} else {
openTapAt = doubleTapTabRaw.isEmpty ? nil : .now
open()
}
}
/// 1: FAB (offset 0, FAB ).
/// 2: bloomed .
/// ( )
private func open() {
withAnimation(.spring(response: 0.3, dampingFraction: 0.75)) {
expanded = true
}
Task { @MainActor in
guard expanded else { return }
bloomed = true
}
}
/// : FAB .
private func close() {
withAnimation(.spring(response: 0.26, dampingFraction: 0.9)) {
bloomed = false
expanded = false
}
}
private func select(_ tab: AppTab) {
// : .
selection = tab.rawValue
close()
}
}
// MARK: - (26 / 18 )
/// 26 LiquidGlassBall @available
@available(iOS 26.0, *)
private struct GlassClusterContainer<Content: View>: View {
let spacing: CGFloat
@ViewBuilder let content: Content
var body: some View {
GlassEffectContainer(spacing: spacing) { content }
}
}
/// iOS 26: (.regular.interactive · ·
/// ) + glassEffectID FAB . .
/// iOS 18 : + + ·
/// ·· 26 . .
/// 26 @available ( 18 )
/// glassEffect ( 18 Release
/// DiaryZoomCoordinator , §6.7)
@available(iOS 26.0, *)
private struct LiquidGlassBall: ViewModifier {
let tinted: Bool
let morphID: String
let namespace: Namespace.ID
func body(content: Content) -> some View {
content
.glassEffect(
tinted
? .regular.tint(AppTheme.green.opacity(0.85)).interactive()
: .regular.interactive(),
in: .circle
)
.glassEffectID(morphID, in: namespace)
}
}
/// iOS 18 + +
private struct MaterialBall: ViewModifier {
let tinted: Bool
func body(content: Content) -> some View {
content
.background {
ZStack {
Circle().fill(.ultraThinMaterial)
if tinted {
Circle().fill(AppTheme.green.opacity(0.85))
}
Circle().strokeBorder(.white.opacity(0.28), lineWidth: 0.6)
}
.shadow(color: .black.opacity(0.2), radius: 9, y: 4)
}
}
}
private struct LiquidBallBackground: ViewModifier {
let tinted: Bool
let morphID: String
let namespace: Namespace.ID
// OS AnyView
// ·
func body(content: Content) -> AnyView {
if #available(iOS 26.0, *) {
return AnyView(content.modifier(
LiquidGlassBall(tinted: tinted, morphID: morphID, namespace: namespace)
))
} else {
return AnyView(content.modifier(MaterialBall(tinted: tinted)))
}
}
}