feat(compat): iOS 하한 18.0 인하 — 글라스 버블 머티리얼 폴백·사용자 심볼 안전 렌더·Release 인라이너 크래시 우회

- 배포 타깃 26.0→18.0 (앱·위젯. 워치 10.0 불변)
- RadialNavigationView: glassEffect 계열을 @available(iOS 26) 선언 격리 + 18 머티리얼 원 폴백 (배치·연출 동일)
- DiaryZoomContainer.Coordinator → 비제네릭 톱레벨 DiaryZoomCoordinator + AnyView 소거 (하한 18 Release wholemodule에서 swift-frontend SILPerformanceInliner 무한 재귀 크래시 실측·우회)
- Image(safeSymbol:)/SymbolCompat: 카탈로그의 상위 OS 전용 심볼(18 기준 3개)이 교차 기기에서 빈 아이콘이 되지 않게 사용자 심볼 렌더 48곳+워치 7곳 폴백
- -symbolAuditDump 검증 인자 추가, iOS 18.5 시뮬 QA(빌드·radial 폴백·일기 줌·progressSelfTest 49 ALL PASS)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NNxA172hNDRpDsJ2qztKPf
This commit is contained in:
songyc macbook 2026-08-11 16:31:40 +09:00
parent f81fdc82f9
commit 5b526fa650
28 changed files with 258 additions and 95 deletions

View File

@ -527,7 +527,7 @@
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 26.0;
IPHONEOS_DEPLOYMENT_TARGET = 18.0;
LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE;
MTL_FAST_MATH = YES;
@ -584,7 +584,7 @@
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 26.0;
IPHONEOS_DEPLOYMENT_TARGET = 18.0;
LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
MTL_ENABLE_DEBUG_INFO = NO;
MTL_FAST_MATH = YES;
@ -832,7 +832,7 @@
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_FILE = Widgets/Info.plist;
INFOPLIST_KEY_CFBundleDisplayName = "하루 다님 위젯";
IPHONEOS_DEPLOYMENT_TARGET = 26.0;
IPHONEOS_DEPLOYMENT_TARGET = 18.0;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
@ -858,7 +858,7 @@
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_FILE = Widgets/Info.plist;
INFOPLIST_KEY_CFBundleDisplayName = "하루 다님 위젯";
IPHONEOS_DEPLOYMENT_TARGET = 26.0;
IPHONEOS_DEPLOYMENT_TARGET = 18.0;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",

View File

@ -203,7 +203,7 @@ struct WatchActionListView: View {
store.run(action)
} label: {
HStack(spacing: 8) {
Image(systemName: action.symbolName)
Image(safeSymbol: action.symbolName)
.font(.system(size: 14, weight: .semibold))
.foregroundStyle(.white)
.frame(width: 28, height: 28)

View File

@ -116,7 +116,7 @@ struct GoalRateComplicationView: View {
case .accessoryRectangular:
VStack(alignment: .leading, spacing: 2) {
HStack(spacing: 4) {
Image(systemName: entry.symbolName)
Image(safeSymbol: entry.symbolName)
.font(.system(size: 11))
Text(title)
.font(.headline)
@ -130,7 +130,7 @@ struct GoalRateComplicationView: View {
}
default:
Gauge(value: min(max(entry.ratio, 0), 1)) {
Image(systemName: entry.symbolName)
Image(safeSymbol: entry.symbolName)
} currentValueLabel: {
Text("\(percent)")
.font(.system(size: 13, weight: .bold).monospacedDigit())
@ -261,7 +261,7 @@ struct QuestRateComplicationView: View {
// // ( §9.2-3)
VStack(alignment: .leading, spacing: 2) {
HStack(spacing: 4) {
Image(systemName: quest.symbolName)
Image(safeSymbol: quest.symbolName)
.font(.system(size: 11))
Text(quest.name)
.font(.headline)
@ -306,7 +306,7 @@ struct QuestRateComplicationView: View {
}
} else {
Gauge(value: min(max(quest.ratio(forSpanRaw: entry.spanRaw), 0), 1)) {
Image(systemName: quest.symbolName)
Image(safeSymbol: quest.symbolName)
} currentValueLabel: {
Text("\(Int((quest.ratio(forSpanRaw: entry.spanRaw) * 100).rounded()))")
.font(.system(size: 13, weight: .bold).monospacedDigit())

View File

@ -114,7 +114,7 @@ struct StatusComplicationView: View {
case .accessoryRectangular:
VStack(alignment: .leading, spacing: 1) {
HStack(spacing: 4) {
Image(systemName: entry.runningSymbol ?? "record.circle")
Image(safeSymbol: entry.runningSymbol ?? "record.circle")
.font(.system(size: 11))
Text(name)
.font(.headline)
@ -136,7 +136,7 @@ struct StatusComplicationView: View {
}
default:
VStack(spacing: 1) {
Image(systemName: entry.runningSymbol ?? "record.circle")
Image(safeSymbol: entry.runningSymbol ?? "record.circle")
.font(.system(size: 14, weight: .semibold))
if let start = entry.runningStartAt {
Text(start, style: .timer)

View File

@ -0,0 +1,25 @@
//
// SymbolCompat.swift
// Haru_DanimWatchWidgets
//
// SF (watchOS 10)
// (iOS 26) .
// Shared/SymbolCompat.swift(·)
// .
//
import SwiftUI
import UIKit
enum WatchSymbolCompat {
static func safe(_ name: String) -> String {
UIImage(systemName: name) != nil ? name : "questionmark.circle.fill"
}
}
extension Image {
/// OS
init(safeSymbol name: String) {
self.init(systemName: WatchSymbolCompat.safe(name))
}
}

View File

@ -68,6 +68,7 @@ struct ContentView: View {
DebugSeed.watchGoalsIfRequested(context: context)
DebugSeed.endGoalYesterdayIfRequested(context: context)
DebugSeed.dumpStreaksIfRequested(context: context)
DebugSeed.dumpMissingSymbolsIfRequested()
DebugSeed.runProgressSelfTestIfRequested()
CSVImport.selfTestIfRequested()
await DebugSeed.runIntentSmokeTestIfRequested()

View File

@ -485,6 +485,18 @@ enum DebugSeed {
try? lines.joined(separator: "\n").write(to: url, atomically: true, encoding: .utf8)
}
/// `-symbolAuditDump` : OS
/// Documents/symbol-audit.txt OS(iOS 18) QA.
/// , "26 18
/// " ( ).
static func dumpMissingSymbolsIfRequested() {
guard UserDefaults.standard.bool(forKey: "symbolAuditDump") else { return }
let missing = SymbolCatalog.missingSymbolsOnThisOS
let lines = ["missing=\(missing.count)"] + missing
let url = URL.documentsDirectory.appending(path: "symbol-audit.txt")
try? lines.joined(separator: "\n").write(to: url, atomically: true, encoding: .utf8)
}
/// `-watchGoals <N>` : N ' '
/// (0 . )
static func watchGoalsIfRequested(context: ModelContext) {

View File

@ -25,6 +25,15 @@ enum SymbolCatalog {
return categories.flatMap(\.symbols).filter { seen.insert($0).inserted }
}
#if DEBUG
/// (-symbolAuditDump): OS OS QA (§14)
static var missingSymbolsOnThisOS: [String] {
var seen = Set<String>()
return rawCategories.flatMap(\.1)
.filter { seen.insert($0).inserted && UIImage(systemName: $0) == nil }
}
#endif
private static let rawCategories: [(String, [String])] = [
(String(localized: "인기"), [
"star.fill", "heart.fill", "flame.fill", "bolt.fill", "leaf.fill",

View File

@ -170,7 +170,7 @@ struct ActionRow: View {
var body: some View {
HStack(spacing: 12) {
Image(systemName: action.symbolName)
Image(safeSymbol: action.symbolName)
.font(.system(size: 16, weight: .semibold))
.foregroundStyle(.white)
.frame(width: 34, height: 34)
@ -263,7 +263,7 @@ struct FavoriteOrderSheet: View {
Section {
ForEach(favorites) { action in
HStack(spacing: 10) {
Image(systemName: action.symbolName)
Image(safeSymbol: action.symbolName)
.font(.system(size: 13, weight: .semibold))
.foregroundStyle(.white)
.frame(width: 28, height: 28)
@ -347,7 +347,7 @@ struct ActionDetailView: View {
HStack {
Text("아이콘")
Spacer()
Image(systemName: action.symbolName)
Image(safeSymbol: action.symbolName)
.foregroundStyle(.white)
.frame(width: 30, height: 30)
.background(action.color, in: RoundedRectangle(cornerRadius: 8))
@ -461,7 +461,7 @@ struct ActionEditorView: View {
showingSymbolPicker = true
} label: {
HStack {
Image(systemName: symbolName)
Image(safeSymbol: symbolName)
.font(.system(size: 20))
.foregroundStyle(AppTheme.green)
.frame(width: 36, height: 36)

View File

@ -281,7 +281,7 @@ struct DiaryDefaultsSheet: View {
}
)) {
HStack(spacing: 10) {
Image(systemName: goal.symbolName)
Image(safeSymbol: goal.symbolName)
.font(.system(size: 12, weight: .semibold))
.foregroundStyle(.white)
.frame(width: 26, height: 26)
@ -319,7 +319,7 @@ struct DiaryDefaultsSheet: View {
}
)) {
HStack(spacing: 10) {
Image(systemName: action.symbolName)
Image(safeSymbol: action.symbolName)
.font(.system(size: 12, weight: .semibold))
.foregroundStyle(.white)
.frame(width: 26, height: 26)

View File

@ -465,8 +465,8 @@ private struct DiaryZoomContainer<Content: View>: UIViewRepresentable {
let zoomEnabled: Bool
@ViewBuilder let content: () -> Content
func makeCoordinator() -> Coordinator {
Coordinator()
func makeCoordinator() -> DiaryZoomCoordinator {
DiaryZoomCoordinator()
}
func makeUIView(context: Context) -> DiaryZoomScrollView {
@ -490,7 +490,7 @@ private struct DiaryZoomContainer<Content: View>: UIViewRepresentable {
NSNumber(value: UITouch.TouchType.direct.rawValue)
]
let hosting = UIHostingController(rootView: content())
let hosting = UIHostingController(rootView: AnyView(content()))
hosting.view.backgroundColor = .clear
hosting.safeAreaRegions = []
hosting.view.frame = CGRect(origin: .zero, size: pageSize)
@ -516,27 +516,33 @@ private struct DiaryZoomContainer<Content: View>: UIViewRepresentable {
}
func updateUIView(_ scroll: DiaryZoomScrollView, context: Context) {
context.coordinator.hosting?.rootView = content()
context.coordinator.hosting?.rootView = AnyView(content())
scroll.pinchGestureRecognizer?.isEnabled = zoomEnabled
// ( )
scroll.panGestureRecognizer.minimumNumberOfTouches = zoomEnabled ? 1 : 2
}
}
final class Coordinator: NSObject, UIScrollViewDelegate {
var hosting: UIHostingController<Content>?
/// · + AnyView (1.4 18 ):
/// DiaryZoomContainer<Content> UIHostingController<Content> ,
/// 18 Release(-O wholemodule) swift-frontend deinit
/// (SILPerformanceInliner
/// isCallerAndCalleeLayoutConstraintsCompatible 26 , ).
/// rootView AnyView .
private final class DiaryZoomCoordinator: NSObject, UIScrollViewDelegate {
var hosting: UIHostingController<AnyView>?
func viewForZooming(in scrollView: UIScrollView) -> UIView? {
(scrollView as? DiaryZoomScrollView)?.zoomedView
}
func viewForZooming(in scrollView: UIScrollView) -> UIView? {
(scrollView as? DiaryZoomScrollView)?.zoomedView
}
func scrollViewDidZoom(_ scrollView: UIScrollView) {
(scrollView as? DiaryZoomScrollView)?.centerZoomedView()
}
func scrollViewDidZoom(_ scrollView: UIScrollView) {
(scrollView as? DiaryZoomScrollView)?.centerZoomedView()
}
func scrollViewDidEndZooming(_ scrollView: UIScrollView, with view: UIView?, atScale scale: CGFloat) {
// ( )
(scrollView as? DiaryZoomScrollView)?.updateCanvasSharpness()
}
func scrollViewDidEndZooming(_ scrollView: UIScrollView, with view: UIView?, atScale scale: CGFloat) {
// ( )
(scrollView as? DiaryZoomScrollView)?.updateCanvasSharpness()
}
}

View File

@ -1565,7 +1565,7 @@ private struct DiaryGoalsCard: View {
let dayRatio = goal.combinedSpanRatio(.day, math: math, now: referenceNow)
VStack(alignment: .leading, spacing: 10) {
HStack(spacing: 10) {
Image(systemName: goal.symbolName)
Image(safeSymbol: goal.symbolName)
.font(.system(size: 13, weight: .semibold))
.foregroundStyle(.white)
.frame(width: 28, height: 28)
@ -1627,7 +1627,7 @@ private struct DiaryGoalsCard: View {
let target = quest.targetValue
let limit = quest.direction == .atMost
HStack(alignment: .firstTextBaseline, spacing: 8) {
Image(systemName: quest.targetSymbol)
Image(safeSymbol: quest.targetSymbol)
.font(.system(size: 10, weight: .semibold))
.foregroundStyle(.white)
.frame(width: 20, height: 20)
@ -1798,7 +1798,7 @@ private struct DiaryGoalFilterSheet: View {
toggle(id)
} label: {
HStack(spacing: 10) {
Image(systemName: goal.symbolName)
Image(safeSymbol: goal.symbolName)
.font(.system(size: 13, weight: .semibold))
.foregroundStyle(.white)
.frame(width: 28, height: 28)

View File

@ -978,7 +978,7 @@ struct ExportSectionView: View {
private func recordRow(_ row: ExportRecordRow) -> some View {
HStack(spacing: 10) {
Image(systemName: row.symbol)
Image(safeSymbol: row.symbol)
.font(.system(size: 13, weight: .semibold))
.foregroundStyle(.white)
.frame(width: 28, height: 28)
@ -1082,7 +1082,7 @@ struct ExportSectionView: View {
ForEach(group.rows) { row in
GridRow {
HStack(spacing: 8) {
Image(systemName: row.symbol)
Image(safeSymbol: row.symbol)
.font(.system(size: 11, weight: .semibold))
.foregroundStyle(.white)
.frame(width: 24, height: 24)
@ -1195,7 +1195,7 @@ private struct ExportTimetableView: View {
.lineLimit(1)
.padding(4)
} else if height >= 12 {
Image(systemName: block.symbol)
Image(safeSymbol: block.symbol)
.font(.system(size: 7, weight: .bold))
.foregroundStyle(.white)
.padding(2)

View File

@ -197,7 +197,7 @@ struct GoalListView: View {
///
private func reorderRow(_ goal: Goal) -> some View {
HStack(spacing: 10) {
Image(systemName: goal.symbolName)
Image(safeSymbol: goal.symbolName)
.font(.system(size: 13, weight: .semibold))
.foregroundStyle(.white)
.frame(width: 28, height: 28)
@ -249,7 +249,7 @@ struct GoalRow: View {
var body: some View {
VStack(alignment: .leading, spacing: 8) {
HStack(spacing: 10) {
Image(systemName: goal.symbolName)
Image(safeSymbol: goal.symbolName)
.font(.system(size: 16, weight: .semibold))
.foregroundStyle(.white)
.frame(width: 34, height: 34)
@ -339,7 +339,7 @@ struct QuestRow: View {
var body: some View {
VStack(alignment: .leading, spacing: 6) {
HStack(spacing: 8) {
Image(systemName: quest.targetSymbol)
Image(safeSymbol: quest.targetSymbol)
.font(.system(size: 12, weight: .semibold))
.foregroundStyle(.white)
.frame(width: 24, height: 24)
@ -641,7 +641,7 @@ struct GoalEditorView: View {
showingSymbolPicker = true
} label: {
HStack {
Image(systemName: symbolName)
Image(safeSymbol: symbolName)
.font(.system(size: 18))
.foregroundStyle(.white)
.frame(width: 34, height: 34)

View File

@ -473,7 +473,7 @@ private struct HistoryRecordsView: View {
onTapSession(item.session)
} label: {
HStack(spacing: 10) {
Image(systemName: item.action.symbolName)
Image(safeSymbol: item.action.symbolName)
.font(.system(size: 14, weight: .semibold))
.foregroundStyle(.white)
.frame(width: 30, height: 30)
@ -512,7 +512,7 @@ private struct HistoryRecordsView: View {
onTapEntry(item.entry)
} label: {
HStack(spacing: 10) {
Image(systemName: item.action.symbolName)
Image(safeSymbol: item.action.symbolName)
.font(.system(size: 14, weight: .semibold))
.foregroundStyle(.white)
.frame(width: 30, height: 30)
@ -667,13 +667,13 @@ struct TimetableView: View {
.fill(item.action.color.opacity(0.85))
.overlay(alignment: .topLeading) {
if !weekly && height >= 18 {
Label(item.action.name, systemImage: item.action.symbolName)
Label(item.action.name, systemImage: SymbolCompat.safe(item.action.symbolName))
.font(.caption2.weight(.semibold))
.foregroundStyle(.white)
.lineLimit(1)
.padding(4)
} else if height >= 14 {
Image(systemName: item.action.symbolName)
Image(safeSymbol: item.action.symbolName)
.font(.system(size: 8, weight: .bold))
.foregroundStyle(.white)
.padding(2)
@ -701,7 +701,7 @@ struct TimetableView: View {
Circle()
.strokeBorder(AppTheme.surface, lineWidth: 2)
if !weekly {
Image(systemName: item.action.symbolName)
Image(safeSymbol: item.action.symbolName)
.font(.system(size: 8, weight: .bold))
.foregroundStyle(.white)
}

View File

@ -721,7 +721,7 @@ struct GoalSummaryCard: View {
} label: {
VStack(alignment: .leading, spacing: 10) {
HStack(spacing: 10) {
Image(systemName: goal.symbolName)
Image(safeSymbol: goal.symbolName)
.font(.system(size: 15, weight: .semibold))
.foregroundStyle(.white)
.frame(width: 32, height: 32)
@ -807,7 +807,7 @@ struct GoalSummaryCard: View {
let progress = QuestProgress(quest: quest)
return VStack(alignment: .leading, spacing: 4) {
HStack(spacing: 6) {
Image(systemName: quest.targetSymbol)
Image(safeSymbol: quest.targetSymbol)
.font(.system(size: 9, weight: .semibold))
.foregroundStyle(.white)
.frame(width: 18, height: 18)
@ -905,7 +905,7 @@ struct SessionMemoSheet: View {
VStack(alignment: .leading, spacing: 12) {
if let action = session.action {
HStack(spacing: 10) {
Image(systemName: action.symbolName)
Image(safeSymbol: action.symbolName)
.font(.system(size: 15, weight: .semibold))
.foregroundStyle(.white)
.frame(width: 32, height: 32)
@ -964,7 +964,7 @@ struct CountMemoSheet: View {
VStack(alignment: .leading, spacing: 12) {
if let action = entry.action {
HStack(spacing: 10) {
Image(systemName: action.symbolName)
Image(safeSymbol: action.symbolName)
.font(.system(size: 15, weight: .semibold))
.foregroundStyle(.white)
.frame(width: 32, height: 32)
@ -1093,7 +1093,7 @@ struct RunningSessionRow: View {
var body: some View {
HStack(spacing: 10) {
Image(systemName: action.symbolName)
Image(safeSymbol: action.symbolName)
.font(.system(size: 16, weight: .semibold))
.foregroundStyle(.white)
.frame(width: 34, height: 34)
@ -1212,7 +1212,7 @@ struct ActionButtonCell: View {
} label: {
VStack(alignment: .leading, spacing: compact ? 3 : 6) {
HStack {
Image(systemName: action.symbolName)
Image(safeSymbol: action.symbolName)
.font(.system(size: compact ? 17 : 26, weight: .medium))
.foregroundStyle(.white)
.symbolEffect(.bounce, value: bounce)

View File

@ -98,7 +98,7 @@ struct QuestEditorView: View {
Picker("행동", selection: $selectedAction) {
Text("선택").tag(Action?.none)
ForEach(allActions) { action in
Label(action.name, systemImage: action.symbolName)
Label(action.name, systemImage: SymbolCompat.safe(action.symbolName))
.tag(Optional(action))
}
}

View File

@ -21,6 +21,9 @@
// - : () + . : FAB .
// ( ) .
// - (~150pt) .
// - iOS 18 (1.4 OS ): glassEffect 26 , 18
// (+ +) ·
// · · (LiquidBallBackground ).
//
// iPhone(Compact) iPad·Mac SidebarRootView, watch .
//
@ -129,16 +132,15 @@ struct GlassBubbleMenu: View {
// FAB( ) .
ZStack {
glow(radius: radius)
// +
GlassEffectContainer(spacing: glassBlendSpacing) {
ZStack {
if expanded {
ForEach(Array(tabs.enumerated()), id: \.element.id) { index, tab in
bubble(tab, index: index, count: tabs.count, radius: radius)
}
}
fab
// + .
// 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)
@ -196,6 +198,18 @@ struct GlassBubbleMenu: View {
.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:
///
@ -230,15 +244,8 @@ struct GlassBubbleMenu: View {
.contentShape(Circle())
}
.buttonStyle(.plain)
// : · .interactive · .
// .
.glassEffect(
isCurrent
? .regular.tint(AppTheme.green.opacity(0.85)).interactive()
: .regular.interactive(),
in: .circle
)
.glassEffectID(tab.rawValue, in: glassSpace)
// (26) (18): .
.modifier(LiquidBallBackground(tinted: isCurrent, morphID: tab.rawValue, namespace: glassSpace))
.offset(x: bloomed ? dx : 0, y: bloomed ? dy : 0)
// + ( ).
// FAB .
@ -266,8 +273,7 @@ struct GlassBubbleMenu: View {
.contentShape(Circle())
}
.buttonStyle(.plain)
.glassEffect(.regular.interactive(), in: .circle)
.glassEffectID("fab", in: glassSpace)
.modifier(LiquidBallBackground(tinted: false, morphID: "fab", namespace: glassSpace))
// ' '
.scaleEffect(expanded ? 1.07 : 1)
.animation(.spring(response: 0.3, dampingFraction: 0.7), value: expanded)
@ -322,3 +328,78 @@ struct GlassBubbleMenu: View {
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)))
}
}
}

View File

@ -189,7 +189,7 @@ struct RecordFilterSheet: View {
}
} label: {
HStack(spacing: 10) {
Image(systemName: action.symbolName)
Image(safeSymbol: action.symbolName)
.font(.system(size: 12, weight: .semibold))
.foregroundStyle(.white)
.frame(width: 24, height: 24)

View File

@ -352,7 +352,7 @@ struct SettingsView: View {
.foregroundStyle(.primary)
.lineLimit(1)
} icon: {
Image(systemName: goal.symbolName)
Image(safeSymbol: goal.symbolName)
.foregroundStyle(goal.color)
}
Spacer()

View File

@ -0,0 +1,29 @@
//
// SymbolCompat.swift
// Haru_Danim
//
// SF (1.4 iOS 18 ).
// OS (: SF 7 broom.fill) ,
// 26 iCloud iOS 18 ( watchOS)
// Image(systemName:) . (·)
// ( ) .
// · (WatchSymbolCompat
// ).
//
import SwiftUI
import UIKit
enum SymbolCompat {
/// OS
static func safe(_ name: String) -> String {
UIImage(systemName: name) != nil ? name : "questionmark.circle.fill"
}
}
extension Image {
/// OS
init(safeSymbol name: String) {
self.init(systemName: SymbolCompat.safe(name))
}
}

View File

@ -90,7 +90,7 @@ struct ActionRunControl: ControlWidget {
: value.isCount
? String(localized: "탭해서 +1")
: isOn ? String(localized: "측정 중") : String(localized: "시작"),
systemImage: value.symbolName
systemImage: SymbolCompat.safe(value.symbolName)
)
}
}

View File

@ -196,7 +196,7 @@ struct ActionRunCellView: View {
private var tileContent: some View {
VStack(alignment: .leading, spacing: 4) {
HStack {
Image(systemName: cell.symbolName)
Image(safeSymbol: cell.symbolName)
.font(.system(size: 18, weight: .semibold))
Spacer()
runningDot
@ -219,7 +219,7 @@ struct ActionRunCellView: View {
/// ( 2×2 / 8)
private var rowContent: some View {
HStack(spacing: 8) {
Image(systemName: cell.symbolName)
Image(safeSymbol: cell.symbolName)
.font(.system(size: 15, weight: .semibold))
VStack(alignment: .leading, spacing: 1) {
Text(cell.name)

View File

@ -139,7 +139,7 @@ struct GoalBarsCellView: View {
var body: some View {
VStack(alignment: .leading, spacing: 5) {
HStack(spacing: 5) {
Image(systemName: goal.symbolName)
Image(safeSymbol: goal.symbolName)
.font(.system(size: 11, weight: .semibold))
.foregroundStyle(goal.color)
Text(goal.title)
@ -173,7 +173,7 @@ struct QuestBarRow: View {
var body: some View {
HStack(spacing: 6) {
Image(systemName: quest.symbolName)
Image(safeSymbol: quest.symbolName)
.font(.system(size: 10, weight: .semibold))
.foregroundStyle(quest.color)
.frame(width: 14)
@ -596,7 +596,7 @@ struct QuestStatusWidgetView: View {
private func goalSection(_ goal: GoalSnapshot, maxCount: Int, columns: Int, ringSize: CGFloat) -> some View {
VStack(spacing: 10) {
HStack(spacing: 4) {
Image(systemName: goal.symbolName)
Image(safeSymbol: goal.symbolName)
.font(.system(size: 10, weight: .semibold))
.foregroundStyle(goal.color)
Text(goal.title)

View File

@ -33,7 +33,7 @@ struct TrackingLiveActivity: Widget {
DynamicIsland {
DynamicIslandExpandedRegion(.leading) {
HStack(spacing: 8) {
Image(systemName: context.state.symbolName)
Image(safeSymbol: context.state.symbolName)
.font(.system(size: 16, weight: .semibold))
.foregroundStyle(.white)
.frame(width: 32, height: 32)
@ -65,7 +65,7 @@ struct TrackingLiveActivity: Widget {
}
}
} compactLeading: {
Image(systemName: context.state.symbolName)
Image(safeSymbol: context.state.symbolName)
.foregroundStyle(context.state.tintColor)
} compactTrailing: {
HStack(spacing: 2) {
@ -80,7 +80,7 @@ struct TrackingLiveActivity: Widget {
}
}
} minimal: {
Image(systemName: context.state.symbolName)
Image(safeSymbol: context.state.symbolName)
.foregroundStyle(context.state.tintColor)
}
}

View File

@ -154,7 +154,7 @@ struct LockGoalWidgetView: View {
switch entry.style {
case .gauge:
Gauge(value: min(max(entry.ratio, 0), 1)) {
Image(systemName: goal.symbolName)
Image(safeSymbol: goal.symbolName)
.widgetAccentable()
} currentValueLabel: {
Text("\(Int((entry.ratio * 100).rounded()))")
@ -180,7 +180,7 @@ struct LockGoalWidgetView: View {
ZStack {
AccessoryWidgetBackground()
VStack(spacing: 3) {
Image(systemName: goal.symbolName)
Image(safeSymbol: goal.symbolName)
.font(.system(size: 12, weight: .semibold))
.widgetAccentable()
questDots(goal, size: 6)
@ -192,7 +192,7 @@ struct LockGoalWidgetView: View {
private func rectangularView(_ goal: GoalSnapshot) -> some View {
VStack(alignment: .leading, spacing: 2) {
HStack(spacing: 4) {
Image(systemName: goal.symbolName)
Image(safeSymbol: goal.symbolName)
.font(.system(size: 11, weight: .semibold))
Text(goal.title)
.font(.caption.weight(.semibold))

View File

@ -234,7 +234,7 @@ struct NowRunningSmallView: View {
var body: some View {
VStack(alignment: .leading, spacing: 3) {
HStack {
Image(systemName: primary.symbolName)
Image(safeSymbol: primary.symbolName)
.font(.system(size: 18, weight: .semibold))
Spacer()
Image(systemName: "record.circle")
@ -285,7 +285,7 @@ struct NowRunningHeroView: View {
var body: some View {
HStack(spacing: 12) {
Image(systemName: primary.symbolName)
Image(safeSymbol: primary.symbolName)
.font(.system(size: 22, weight: .semibold))
.frame(width: 46, height: 46)
.background(
@ -404,7 +404,7 @@ struct NowRunningRowView: View {
var body: some View {
HStack(spacing: 10) {
Image(systemName: item.symbolName)
Image(safeSymbol: item.symbolName)
.font(.system(size: 14, weight: .semibold))
.foregroundStyle(isFullColor ? AnyShapeStyle(.white) : AnyShapeStyle(.primary))
.frame(width: 30, height: 30)
@ -458,7 +458,7 @@ struct LockScreenActivityView: View {
var body: some View {
HStack(spacing: 12) {
Image(systemName: state.symbolName)
Image(safeSymbol: state.symbolName)
.font(.system(size: 18, weight: .semibold))
.foregroundStyle(.white)
.frame(width: 40, height: 40)

View File

@ -248,7 +248,7 @@ struct WidgetEmptyView: View {
var body: some View {
VStack(spacing: 6) {
Image(systemName: symbolName)
Image(safeSymbol: symbolName)
.font(.title3)
.foregroundStyle(AppTheme.green)
Text(message)
@ -297,7 +297,7 @@ struct QuestRingView: View {
style: StrokeStyle(lineWidth: lineWidth, lineCap: .round)
)
.rotationEffect(.degrees(-90))
Image(systemName: snapshot.symbolName)
Image(safeSymbol: snapshot.symbolName)
.font(.system(size: iconSize, weight: .semibold))
.foregroundStyle(snapshot.color)
}