Polish app theming and toolbar badge behavior

This commit is contained in:
2026-04-18 10:03:18 +02:00
parent b5cf3d9e01
commit 243029c798
3 changed files with 187 additions and 26 deletions
+88 -7
View File
@@ -1,12 +1,93 @@
import SwiftUI 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 { enum AppTheme {
static let accent = Color(red: 0.12, green: 0.40, blue: 0.31) 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 warmAccent = Color(red: 0.84, green: 0.71, blue: 0.48)
static let border = Color.black.opacity(0.08) static let border = Color.adaptive(
static let shadow = Color.black.opacity(0.05) light: (0.00, 0.00, 0.00, 0.08),
static let cardFill = Color.white.opacity(0.96) dark: (1.00, 1.00, 1.00, 0.12)
static let mutedFill = Color(red: 0.972, green: 0.976, blue: 0.970) )
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 { enum AppLayout {
@@ -64,15 +145,15 @@ struct AppBackground: View {
var body: some View { var body: some View {
LinearGradient( LinearGradient(
colors: [ colors: [
Color(red: 0.975, green: 0.978, blue: 0.972), AppTheme.backgroundTop,
Color.white AppTheme.backgroundBottom
], ],
startPoint: .top, startPoint: .top,
endPoint: .bottom endPoint: .bottom
) )
.overlay(alignment: .top) { .overlay(alignment: .top) {
Rectangle() Rectangle()
.fill(Color.black.opacity(0.02)) .fill(AppTheme.backgroundGlow)
.frame(height: 160) .frame(height: 160)
.blur(radius: 60) .blur(radius: 60)
.offset(y: -90) .offset(y: -90)
+30 -3
View File
@@ -282,9 +282,23 @@ private final class QRScannerViewModel: NSObject, ObservableObject, AVCaptureMet
continuation.resume() continuation.resume()
} }
guard let device = AVCaptureDevice.default(for: .video), guard let device = AVCaptureDevice.default(for: .video) else {
let input = try? AVCaptureDeviceInput(device: device), DispatchQueue.main.async {
self.captureSession.canAddInput(input) else { 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 { DispatchQueue.main.async {
self.isPreviewAvailable = false self.isPreviewAvailable = false
self.statusMessage = "No compatible camera was found. Use the fallback payload below." 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() let output = AVCaptureMetadataOutput()
guard self.captureSession.canAddOutput(output) else { guard self.captureSession.canAddOutput(output) else {
self.captureSession.removeInput(input)
DispatchQueue.main.async { DispatchQueue.main.async {
self.isPreviewAvailable = false self.isPreviewAvailable = false
self.statusMessage = "Unable to configure QR metadata scanning on this device." 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) self.captureSession.addOutput(output)
output.setMetadataObjectsDelegate(self, queue: DispatchQueue.main) 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] output.metadataObjectTypes = [.qr]
self.isConfigured = true self.isConfigured = true
+69 -16
View File
@@ -19,7 +19,7 @@ private extension View {
func cleanTabBarOnIOS() -> some View { func cleanTabBarOnIOS() -> some View {
#if os(iOS) #if os(iOS)
toolbarBackground(.visible, for: .tabBar) toolbarBackground(.visible, for: .tabBar)
.toolbarBackground(Color.white.opacity(0.98), for: .tabBar) .toolbarBackground(AppTheme.chromeFill, for: .tabBar)
#else #else
self self
#endif #endif
@@ -28,6 +28,7 @@ private extension View {
struct HomeRootView: View { struct HomeRootView: View {
@ObservedObject var model: AppViewModel @ObservedObject var model: AppViewModel
@State private var notificationBellFrame: CGRect?
var body: some View { var body: some View {
Group { Group {
@@ -37,6 +38,16 @@ struct HomeRootView: View {
RegularHomeContainer(model: model) RegularHomeContainer(model: model)
} }
} }
.onPreferenceChange(NotificationBellFrameKey.self) { notificationBellFrame = $0 }
.overlay(alignment: .topLeading) {
if usesCompactNavigation {
NotificationBellBadgeOverlay(
unreadCount: model.unreadNotificationCount,
bellFrame: notificationBellFrame
)
.ignoresSafeArea()
}
}
.sheet(isPresented: $model.isNotificationCenterPresented) { .sheet(isPresented: $model.isNotificationCenterPresented) {
NotificationCenterSheet(model: model) NotificationCenterSheet(model: model)
} }
@@ -90,6 +101,7 @@ private struct RegularHomeContainer: View {
var body: some View { var body: some View {
NavigationSplitView { NavigationSplitView {
Sidebar(model: model) Sidebar(model: model)
.navigationSplitViewColumnWidth(min: 240, ideal: 280, max: 320)
} detail: { } detail: {
HomeSectionScreen(model: model, section: model.selectedSection, compactLayout: false) HomeSectionScreen(model: model, section: model.selectedSection, compactLayout: false)
.navigationTitle(model.selectedSection.title) .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 { private struct HomeSectionScreen: View {
@ObservedObject var model: AppViewModel @ObservedObject var model: AppViewModel
let section: AppSection let section: AppSection
@@ -876,25 +921,33 @@ private struct NotificationBellButton: View {
Button { Button {
model.isNotificationCenterPresented = true model.isNotificationCenterPresented = true
} label: { } label: {
ZStack(alignment: .topTrailing) { Image(systemName: imageName)
Image(systemName: model.unreadNotificationCount == 0 ? "bell" : "bell.badge.fill") .font(.headline)
.font(.headline) .foregroundStyle(iconTone)
.foregroundStyle(model.unreadNotificationCount == 0 ? .primary : dashboardAccent) .frame(width: 28, height: 28, alignment: .center)
.background(alignment: .center) {
if model.unreadNotificationCount > 0 { #if os(iOS)
Text("\(min(model.unreadNotificationCount, 9))") GeometryReader { proxy in
.font(.caption2.weight(.bold)) Color.clear
.padding(.horizontal, 5) .preference(key: NotificationBellFrameKey.self, value: proxy.frame(in: .global))
.padding(.vertical, 2) }
.background(Color.orange, in: Capsule()) #endif
.foregroundStyle(.white)
.offset(x: 10, y: -10)
} }
}
.frame(width: 28, height: 28)
} }
.accessibilityLabel("Notifications") .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 { private struct NotificationCenterSheet: View {