From 243029c7989a759f309cb37a56af6ca4912a8943 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=BCrgen=20Kunz?= Date: Sat, 18 Apr 2026 10:03:18 +0200 Subject: [PATCH] Polish app theming and toolbar badge behavior --- Sources/App/AppComponents.swift | 95 +++++++++++++++++++++-- Sources/Features/Auth/QRScannerView.swift | 33 +++++++- Sources/Features/Home/HomeRootView.swift | 85 ++++++++++++++++---- 3 files changed, 187 insertions(+), 26 deletions(-) diff --git a/Sources/App/AppComponents.swift b/Sources/App/AppComponents.swift index 9f2f432..87d7cf6 100644 --- a/Sources/App/AppComponents.swift +++ b/Sources/App/AppComponents.swift @@ -1,12 +1,93 @@ import SwiftUI +#if os(macOS) +import AppKit +#elseif canImport(UIKit) +import UIKit +#endif + +private extension Color { + static func adaptive( + light: (red: Double, green: Double, blue: Double, opacity: Double), + dark: (red: Double, green: Double, blue: Double, opacity: Double) + ) -> Color { + #if os(macOS) + Color( + nsColor: NSColor(name: nil) { appearance in + let matchedAppearance = appearance.bestMatch(from: [.darkAqua, .vibrantDark, .aqua, .vibrantLight]) + let components = matchedAppearance == .darkAqua || matchedAppearance == .vibrantDark ? dark : light + return NSColor( + red: components.red, + green: components.green, + blue: components.blue, + alpha: components.opacity + ) + } + ) + #elseif canImport(UIKit) && !os(watchOS) + Color( + uiColor: UIColor { traits in + let components = traits.userInterfaceStyle == .dark ? dark : light + return UIColor( + red: components.red, + green: components.green, + blue: components.blue, + alpha: components.opacity + ) + } + ) + #elseif os(watchOS) + Color( + red: dark.red, + green: dark.green, + blue: dark.blue, + opacity: dark.opacity + ) + #else + Color( + red: light.red, + green: light.green, + blue: light.blue, + opacity: light.opacity + ) + #endif + } +} enum AppTheme { static let accent = Color(red: 0.12, green: 0.40, blue: 0.31) static let warmAccent = Color(red: 0.84, green: 0.71, blue: 0.48) - static let border = Color.black.opacity(0.08) - static let shadow = Color.black.opacity(0.05) - static let cardFill = Color.white.opacity(0.96) - static let mutedFill = Color(red: 0.972, green: 0.976, blue: 0.970) + static let border = Color.adaptive( + light: (0.00, 0.00, 0.00, 0.08), + dark: (1.00, 1.00, 1.00, 0.12) + ) + static let shadow = Color.adaptive( + light: (0.00, 0.00, 0.00, 0.05), + dark: (0.00, 0.00, 0.00, 0.32) + ) + static let cardFill = Color.adaptive( + light: (1.00, 1.00, 1.00, 0.96), + dark: (0.11, 0.12, 0.14, 0.96) + ) + static let mutedFill = Color.adaptive( + light: (0.972, 0.976, 0.970, 1.00), + dark: (0.16, 0.17, 0.19, 1.00) + ) + static let backgroundTop = Color.adaptive( + light: (0.975, 0.978, 0.972, 1.00), + dark: (0.08, 0.09, 0.10, 1.00) + ) + static let backgroundBottom = Color.adaptive( + light: (1.00, 1.00, 1.00, 1.00), + dark: (0.05, 0.06, 0.07, 1.00) + ) + static let backgroundGlow = Color.adaptive( + light: (0.00, 0.00, 0.00, 0.02), + dark: (1.00, 1.00, 1.00, 0.06) + ) + static let chromeFill = Color.adaptive( + light: (1.00, 1.00, 1.00, 0.98), + dark: (0.10, 0.11, 0.13, 0.98) + ) } enum AppLayout { @@ -64,15 +145,15 @@ struct AppBackground: View { var body: some View { LinearGradient( colors: [ - Color(red: 0.975, green: 0.978, blue: 0.972), - Color.white + AppTheme.backgroundTop, + AppTheme.backgroundBottom ], startPoint: .top, endPoint: .bottom ) .overlay(alignment: .top) { Rectangle() - .fill(Color.black.opacity(0.02)) + .fill(AppTheme.backgroundGlow) .frame(height: 160) .blur(radius: 60) .offset(y: -90) diff --git a/Sources/Features/Auth/QRScannerView.swift b/Sources/Features/Auth/QRScannerView.swift index b13d7b9..ba3c6a0 100644 --- a/Sources/Features/Auth/QRScannerView.swift +++ b/Sources/Features/Auth/QRScannerView.swift @@ -282,9 +282,23 @@ private final class QRScannerViewModel: NSObject, ObservableObject, AVCaptureMet continuation.resume() } - guard let device = AVCaptureDevice.default(for: .video), - let input = try? AVCaptureDeviceInput(device: device), - self.captureSession.canAddInput(input) else { + guard let device = AVCaptureDevice.default(for: .video) else { + DispatchQueue.main.async { + self.isPreviewAvailable = false + self.statusMessage = "No compatible camera was found. Use the fallback payload below." + } + return + } + + guard let input = try? AVCaptureDeviceInput(device: device) else { + DispatchQueue.main.async { + self.isPreviewAvailable = false + self.statusMessage = "No compatible camera was found. Use the fallback payload below." + } + return + } + + guard self.captureSession.canAddInput(input) else { DispatchQueue.main.async { self.isPreviewAvailable = false self.statusMessage = "No compatible camera was found. Use the fallback payload below." @@ -296,6 +310,7 @@ private final class QRScannerViewModel: NSObject, ObservableObject, AVCaptureMet let output = AVCaptureMetadataOutput() guard self.captureSession.canAddOutput(output) else { + self.captureSession.removeInput(input) DispatchQueue.main.async { self.isPreviewAvailable = false self.statusMessage = "Unable to configure QR metadata scanning on this device." @@ -305,6 +320,18 @@ private final class QRScannerViewModel: NSObject, ObservableObject, AVCaptureMet self.captureSession.addOutput(output) output.setMetadataObjectsDelegate(self, queue: DispatchQueue.main) + + let supportedTypes = output.availableMetadataObjectTypes + guard supportedTypes.contains(.qr) else { + self.captureSession.removeOutput(output) + self.captureSession.removeInput(input) + DispatchQueue.main.async { + self.isPreviewAvailable = false + self.statusMessage = "This camera does not support QR metadata scanning. Use the fallback payload below." + } + return + } + output.metadataObjectTypes = [.qr] self.isConfigured = true diff --git a/Sources/Features/Home/HomeRootView.swift b/Sources/Features/Home/HomeRootView.swift index 519dbfb..4f102e4 100644 --- a/Sources/Features/Home/HomeRootView.swift +++ b/Sources/Features/Home/HomeRootView.swift @@ -19,7 +19,7 @@ private extension View { func cleanTabBarOnIOS() -> some View { #if os(iOS) toolbarBackground(.visible, for: .tabBar) - .toolbarBackground(Color.white.opacity(0.98), for: .tabBar) + .toolbarBackground(AppTheme.chromeFill, for: .tabBar) #else self #endif @@ -28,6 +28,7 @@ private extension View { struct HomeRootView: View { @ObservedObject var model: AppViewModel + @State private var notificationBellFrame: CGRect? var body: some View { Group { @@ -37,6 +38,16 @@ struct HomeRootView: View { RegularHomeContainer(model: model) } } + .onPreferenceChange(NotificationBellFrameKey.self) { notificationBellFrame = $0 } + .overlay(alignment: .topLeading) { + if usesCompactNavigation { + NotificationBellBadgeOverlay( + unreadCount: model.unreadNotificationCount, + bellFrame: notificationBellFrame + ) + .ignoresSafeArea() + } + } .sheet(isPresented: $model.isNotificationCenterPresented) { NotificationCenterSheet(model: model) } @@ -90,6 +101,7 @@ private struct RegularHomeContainer: View { var body: some View { NavigationSplitView { Sidebar(model: model) + .navigationSplitViewColumnWidth(min: 240, ideal: 280, max: 320) } detail: { HomeSectionScreen(model: model, section: model.selectedSection, compactLayout: false) .navigationTitle(model.selectedSection.title) @@ -111,6 +123,39 @@ private struct DashboardToolbar: ToolbarContent { } } +private struct NotificationBellFrameKey: PreferenceKey { + static var defaultValue: CGRect? = nil + + static func reduce(value: inout CGRect?, nextValue: () -> CGRect?) { + value = nextValue() ?? value + } +} + +private struct NotificationBellBadgeOverlay: View { + let unreadCount: Int + let bellFrame: CGRect? + + var body: some View { + GeometryReader { proxy in + if unreadCount > 0, let bellFrame { + let rootFrame = proxy.frame(in: .global) + + Text("\(min(unreadCount, 9))") + .font(.caption2.weight(.bold)) + .foregroundStyle(.white) + .frame(minWidth: 18, minHeight: 18) + .padding(.horizontal, 3) + .background(Color.orange, in: Capsule()) + .position( + x: bellFrame.maxX - rootFrame.minX - 2, + y: bellFrame.minY - rootFrame.minY + 2 + ) + } + } + .allowsHitTesting(false) + } +} + private struct HomeSectionScreen: View { @ObservedObject var model: AppViewModel let section: AppSection @@ -876,25 +921,33 @@ private struct NotificationBellButton: View { Button { model.isNotificationCenterPresented = true } label: { - ZStack(alignment: .topTrailing) { - Image(systemName: model.unreadNotificationCount == 0 ? "bell" : "bell.badge.fill") - .font(.headline) - .foregroundStyle(model.unreadNotificationCount == 0 ? .primary : dashboardAccent) - - if model.unreadNotificationCount > 0 { - Text("\(min(model.unreadNotificationCount, 9))") - .font(.caption2.weight(.bold)) - .padding(.horizontal, 5) - .padding(.vertical, 2) - .background(Color.orange, in: Capsule()) - .foregroundStyle(.white) - .offset(x: 10, y: -10) + Image(systemName: imageName) + .font(.headline) + .foregroundStyle(iconTone) + .frame(width: 28, height: 28, alignment: .center) + .background(alignment: .center) { + #if os(iOS) + GeometryReader { proxy in + Color.clear + .preference(key: NotificationBellFrameKey.self, value: proxy.frame(in: .global)) + } + #endif } - } - .frame(width: 28, height: 28) } .accessibilityLabel("Notifications") } + + private var imageName: String { + #if os(iOS) + model.unreadNotificationCount == 0 ? "bell" : "bell.fill" + #else + model.unreadNotificationCount == 0 ? "bell" : "bell.badge.fill" + #endif + } + + private var iconTone: some ShapeStyle { + model.unreadNotificationCount == 0 ? Color.primary : dashboardAccent + } } private struct NotificationCenterSheet: View {