Polish app theming and toolbar badge behavior
This commit is contained in:
@@ -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)
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
Reference in New Issue
Block a user