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

View File

@@ -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)

View File

@@ -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

View File

@@ -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 {