mirror of
https://github.com/FBukovina/Atoll.git
synced 2026-10-27 15:14:33 +00:00
3830 lines
165 KiB
Swift
3830 lines
165 KiB
Swift
//
|
||
// SettingsView.swift
|
||
// DynamicIsland
|
||
//
|
||
// Created by Richard Kunkli on 07/08/2024.
|
||
//
|
||
import Defaults
|
||
import EventKit
|
||
import KeyboardShortcuts
|
||
import LaunchAtLogin
|
||
import LottieUI
|
||
import AVFoundation
|
||
import Sparkle
|
||
import SwiftUI
|
||
import SwiftUIIntrospect
|
||
import UniformTypeIdentifiers
|
||
|
||
private enum SettingsTab: String, CaseIterable, Identifiable {
|
||
case general
|
||
case appearance
|
||
case lockScreen
|
||
case media
|
||
case timer
|
||
case calendar
|
||
case hud
|
||
case battery
|
||
case stats
|
||
case clipboard
|
||
case screenAssistant
|
||
case colorPicker
|
||
case downloads
|
||
case shelf
|
||
case shortcuts
|
||
case extensions
|
||
case about
|
||
|
||
var id: String { rawValue }
|
||
|
||
var title: String {
|
||
switch self {
|
||
case .general: return "General"
|
||
case .appearance: return "Appearance"
|
||
case .lockScreen: return "Lock Screen"
|
||
case .media: return "Media"
|
||
case .timer: return "Timer"
|
||
case .calendar: return "Calendar"
|
||
case .hud: return "HUDs"
|
||
case .battery: return "Battery"
|
||
case .stats: return "Stats"
|
||
case .clipboard: return "Clipboard"
|
||
case .screenAssistant: return "Screen Assistant"
|
||
case .colorPicker: return "Color Picker"
|
||
case .downloads: return "Downloads"
|
||
case .shelf: return "Shelf"
|
||
case .shortcuts: return "Shortcuts"
|
||
case .extensions: return "Extensions"
|
||
case .about: return "About"
|
||
}
|
||
}
|
||
|
||
var systemImage: String {
|
||
switch self {
|
||
case .general: return "gear"
|
||
case .appearance: return "paintpalette"
|
||
case .lockScreen: return "lock.laptopcomputer"
|
||
case .media: return "play.laptopcomputer"
|
||
case .timer: return "timer"
|
||
case .calendar: return "calendar"
|
||
case .hud: return "dial.medium.fill"
|
||
case .battery: return "battery.100.bolt"
|
||
case .stats: return "chart.xyaxis.line"
|
||
case .clipboard: return "clipboard"
|
||
case .screenAssistant: return "brain.head.profile"
|
||
case .colorPicker: return "eyedropper"
|
||
case .downloads: return "square.and.arrow.down"
|
||
case .shelf: return "books.vertical"
|
||
case .shortcuts: return "keyboard"
|
||
case .extensions: return "puzzlepiece.extension"
|
||
case .about: return "info.circle"
|
||
}
|
||
}
|
||
|
||
var tint: Color {
|
||
switch self {
|
||
case .general: return Color.blue
|
||
case .appearance: return Color.purple
|
||
case .lockScreen: return Color.orange
|
||
case .media: return Color.green
|
||
case .timer: return Color.red
|
||
case .calendar: return Color.cyan
|
||
case .hud: return Color.indigo
|
||
case .battery: return Color.yellow
|
||
case .stats: return Color.teal
|
||
case .clipboard: return Color.mint
|
||
case .screenAssistant: return Color.pink
|
||
case .colorPicker: return Color.accentColor
|
||
case .downloads: return Color.gray
|
||
case .shelf: return Color.brown
|
||
case .shortcuts: return Color.orange
|
||
case .extensions: return Color.blue
|
||
case .about: return Color.secondary
|
||
}
|
||
}
|
||
}
|
||
|
||
private struct SettingsSearchEntry: Identifiable {
|
||
let tab: SettingsTab
|
||
let title: String
|
||
let keywords: [String]
|
||
|
||
var id: String { "\(tab.rawValue)-\(title)" }
|
||
}
|
||
|
||
struct SettingsView: View {
|
||
@StateObject var extensionManager = DynamicIslandExtensionManager()
|
||
@State private var selectedTab: SettingsTab = .general
|
||
@State private var searchText: String = ""
|
||
@Default(.enableMinimalisticUI) var enableMinimalisticUI
|
||
|
||
let updaterController: SPUStandardUpdaterController?
|
||
|
||
init(updaterController: SPUStandardUpdaterController? = nil) {
|
||
self.updaterController = updaterController
|
||
}
|
||
|
||
var body: some View {
|
||
NavigationSplitView {
|
||
List(filteredTabs, selection: selectionBinding) { tab in
|
||
NavigationLink(value: tab) {
|
||
HStack(spacing: 10) {
|
||
sidebarIcon(for: tab)
|
||
Text(tab.title)
|
||
.foregroundStyle(Color.primary)
|
||
}
|
||
.padding(.vertical, 4)
|
||
}
|
||
.tag(tab)
|
||
.listRowInsets(EdgeInsets(top: 4, leading: 12, bottom: 4, trailing: 10))
|
||
}
|
||
.listStyle(SidebarListStyle())
|
||
.frame(minWidth: 205)
|
||
.toolbar(removing: .sidebarToggle)
|
||
.navigationSplitViewColumnWidth(min: 220, ideal: 240, max: 360)
|
||
.searchable(text: $searchText, placement: .sidebar, prompt: Text("Search Settings"))
|
||
.searchSuggestions {
|
||
ForEach(searchSuggestions) { suggestion in
|
||
Button {
|
||
selectedTab = suggestion.tab
|
||
} label: {
|
||
Label {
|
||
Text("\(suggestion.title) — \(suggestion.tab.title)")
|
||
} icon: {
|
||
Image(systemName: suggestion.tab.systemImage)
|
||
}
|
||
}
|
||
}
|
||
}
|
||
.environment(\.defaultMinListRowHeight, 44)
|
||
} detail: {
|
||
detailView(for: resolvedSelection)
|
||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||
}
|
||
.navigationSplitViewStyle(.balanced)
|
||
.toolbar(removing: .sidebarToggle)
|
||
.toolbar {
|
||
Button("") {} // Empty label, does nothing
|
||
.controlSize(.extraLarge)
|
||
.opacity(0) // Invisible, but reserves space for a consistent look between tabs
|
||
.disabled(true)
|
||
}
|
||
.environmentObject(extensionManager)
|
||
.formStyle(.grouped)
|
||
.frame(width: 700)
|
||
.onChange(of: searchText) { _, newValue in
|
||
let matches = tabsMatchingSearch(newValue)
|
||
guard let firstMatch = matches.first else { return }
|
||
if !matches.contains(resolvedSelection) {
|
||
selectedTab = firstMatch
|
||
}
|
||
}
|
||
.background {
|
||
Group {
|
||
if #available(macOS 26.0, *) {
|
||
RoundedRectangle(cornerRadius: 18, style: .continuous)
|
||
.glassEffect(
|
||
.clear
|
||
.tint(Color.white.opacity(0.1))
|
||
.interactive(),
|
||
in: .rect(cornerRadius: 18)
|
||
)
|
||
} else {
|
||
ZStack {
|
||
Color(NSColor.windowBackgroundColor)
|
||
RoundedRectangle(cornerRadius: 18, style: .continuous)
|
||
.fill(.ultraThinMaterial)
|
||
}
|
||
}
|
||
}
|
||
.ignoresSafeArea()
|
||
}
|
||
}
|
||
|
||
private var resolvedSelection: SettingsTab {
|
||
availableTabs.contains(selectedTab) ? selectedTab : (availableTabs.first ?? .general)
|
||
}
|
||
|
||
private var filteredTabs: [SettingsTab] {
|
||
tabsMatchingSearch(searchText)
|
||
}
|
||
|
||
private var selectionBinding: Binding<SettingsTab> {
|
||
Binding(
|
||
get: { resolvedSelection },
|
||
set: { selectedTab = $0 }
|
||
)
|
||
}
|
||
|
||
@ViewBuilder
|
||
private func sidebarIcon(for tab: SettingsTab) -> some View {
|
||
RoundedRectangle(cornerRadius: 8, style: .continuous)
|
||
.fill(tab.tint)
|
||
.frame(width: 26, height: 26)
|
||
.overlay {
|
||
Image(systemName: tab.systemImage)
|
||
.font(.system(size: 13, weight: .semibold))
|
||
.foregroundStyle(Color.white)
|
||
}
|
||
}
|
||
|
||
private var availableTabs: [SettingsTab] {
|
||
let ordered: [SettingsTab] = [
|
||
.general,
|
||
.appearance,
|
||
.lockScreen,
|
||
.media,
|
||
.timer,
|
||
.calendar,
|
||
.hud,
|
||
.battery,
|
||
.stats,
|
||
.clipboard,
|
||
.screenAssistant,
|
||
.colorPicker,
|
||
.downloads,
|
||
.shelf,
|
||
.shortcuts,
|
||
.extensions,
|
||
.about
|
||
]
|
||
|
||
return ordered.filter { isTabVisible($0) }
|
||
}
|
||
|
||
private func tabsMatchingSearch(_ query: String) -> [SettingsTab] {
|
||
let trimmed = query.trimmingCharacters(in: .whitespacesAndNewlines)
|
||
guard !trimmed.isEmpty else { return availableTabs }
|
||
|
||
let entryMatches = searchEntries(matching: trimmed)
|
||
let matchingTabs = Set(entryMatches.map(\.tab))
|
||
|
||
return availableTabs.filter { tab in
|
||
tab.title.localizedCaseInsensitiveContains(trimmed) || matchingTabs.contains(tab)
|
||
}
|
||
}
|
||
|
||
private var searchSuggestions: [SettingsSearchEntry] {
|
||
Array(searchEntries(matching: searchText).prefix(8))
|
||
}
|
||
|
||
private func searchEntries(matching query: String) -> [SettingsSearchEntry] {
|
||
let trimmed = query.trimmingCharacters(in: .whitespacesAndNewlines)
|
||
guard !trimmed.isEmpty else { return [] }
|
||
|
||
return settingsSearchIndex
|
||
.filter { availableTabs.contains($0.tab) }
|
||
.filter { entry in
|
||
entry.title.localizedCaseInsensitiveContains(trimmed) ||
|
||
entry.keywords.contains { $0.localizedCaseInsensitiveContains(trimmed) }
|
||
}
|
||
}
|
||
|
||
private var settingsSearchIndex: [SettingsSearchEntry] {
|
||
[
|
||
// General
|
||
SettingsSearchEntry(tab: .general, title: "Enable Minimalistic UI", keywords: ["minimalistic", "ui mode", "general"]),
|
||
SettingsSearchEntry(tab: .general, title: "Menubar icon", keywords: ["menu bar", "status bar", "icon"]),
|
||
SettingsSearchEntry(tab: .general, title: "Launch at login", keywords: ["autostart", "startup"]),
|
||
SettingsSearchEntry(tab: .general, title: "Show on all displays", keywords: ["multi-display", "external monitor"]),
|
||
SettingsSearchEntry(tab: .general, title: "Show on a specific display", keywords: ["preferred screen", "display picker"]),
|
||
SettingsSearchEntry(tab: .general, title: "Automatically switch displays", keywords: ["auto switch", "displays"]),
|
||
SettingsSearchEntry(tab: .general, title: "Hide Dynamic Island during screenshots & recordings", keywords: ["privacy", "screenshot", "recording"]),
|
||
SettingsSearchEntry(tab: .general, title: "Enable Screen Recording Detection", keywords: ["screen recording", "indicator"]),
|
||
SettingsSearchEntry(tab: .general, title: "Show Recording Indicator", keywords: ["recording indicator", "red dot"]),
|
||
SettingsSearchEntry(tab: .general, title: "Enable Focus Detection", keywords: ["focus", "do not disturb", "dnd"]),
|
||
SettingsSearchEntry(tab: .general, title: "Show Focus Indicator", keywords: ["focus icon", "moon"]),
|
||
SettingsSearchEntry(tab: .general, title: "Show Focus Label", keywords: ["focus label", "text"]),
|
||
SettingsSearchEntry(tab: .general, title: "Enable Camera Detection", keywords: ["camera", "privacy indicator"]),
|
||
SettingsSearchEntry(tab: .general, title: "Enable Microphone Detection", keywords: ["microphone", "privacy"]),
|
||
SettingsSearchEntry(tab: .general, title: "Enable gestures", keywords: ["gestures", "trackpad"]),
|
||
SettingsSearchEntry(tab: .general, title: "Close gesture", keywords: ["pinch", "swipe"]),
|
||
SettingsSearchEntry(tab: .general, title: "Extend hover area", keywords: ["hover", "cursor"]),
|
||
SettingsSearchEntry(tab: .general, title: "Enable haptics", keywords: ["haptic", "feedback"]),
|
||
SettingsSearchEntry(tab: .general, title: "Open notch on hover", keywords: ["hover to open", "auto open"]),
|
||
SettingsSearchEntry(tab: .general, title: "Non-notch display height", keywords: ["display height", "menu bar size"]),
|
||
|
||
// Battery (Charge)
|
||
SettingsSearchEntry(tab: .battery, title: "Show battery indicator", keywords: ["battery hud", "charge"]),
|
||
SettingsSearchEntry(tab: .battery, title: "Show battery percentage", keywords: ["battery percent"]),
|
||
SettingsSearchEntry(tab: .battery, title: "Show power status notifications", keywords: ["notifications", "power"]),
|
||
SettingsSearchEntry(tab: .battery, title: "Show power status icons", keywords: ["power icons", "charging icon"]),
|
||
|
||
// Downloads
|
||
SettingsSearchEntry(tab: .downloads, title: "Show download progress", keywords: ["downloads", "progress"]),
|
||
SettingsSearchEntry(tab: .downloads, title: "Enable Safari Downloads", keywords: ["safari", "browser"]),
|
||
SettingsSearchEntry(tab: .downloads, title: "Download indicator style", keywords: ["indicator", "style"]),
|
||
SettingsSearchEntry(tab: .downloads, title: "Download icon style", keywords: ["icon", "style"]),
|
||
|
||
// HUDs
|
||
SettingsSearchEntry(tab: .hud, title: "Show Bluetooth device connections", keywords: ["bluetooth", "hud"]),
|
||
SettingsSearchEntry(tab: .hud, title: "Use circular battery indicator", keywords: ["battery", "circular"]),
|
||
SettingsSearchEntry(tab: .hud, title: "Show battery percentage text in HUD", keywords: ["battery text"]),
|
||
SettingsSearchEntry(tab: .hud, title: "Scroll device name in HUD", keywords: ["marquee", "device name"]),
|
||
SettingsSearchEntry(tab: .hud, title: "Color-coded battery display", keywords: ["color", "battery"]),
|
||
SettingsSearchEntry(tab: .hud, title: "Color-coded volume display", keywords: ["volume", "color"]),
|
||
SettingsSearchEntry(tab: .hud, title: "Smooth color transitions", keywords: ["gradient", "smooth"]),
|
||
SettingsSearchEntry(tab: .hud, title: "Show percentages beside progress bars", keywords: ["percentages", "progress"]),
|
||
SettingsSearchEntry(tab: .hud, title: "HUD style", keywords: ["inline", "compact"]),
|
||
SettingsSearchEntry(tab: .hud, title: "Progressbar style", keywords: ["progress", "style"]),
|
||
SettingsSearchEntry(tab: .hud, title: "Enable glowing effect", keywords: ["glow", "indicator"]),
|
||
SettingsSearchEntry(tab: .hud, title: "Use accent color", keywords: ["accent", "color"]),
|
||
|
||
// Media
|
||
SettingsSearchEntry(tab: .media, title: "Music Source", keywords: ["media source", "controller"]),
|
||
SettingsSearchEntry(tab: .media, title: "Skip buttons", keywords: ["skip", "controls", "±10"]),
|
||
SettingsSearchEntry(tab: .media, title: "Sneak Peek Style", keywords: ["sneak peek", "preview"]),
|
||
SettingsSearchEntry(tab: .media, title: "Enable lyrics", keywords: ["lyrics", "song text"]),
|
||
|
||
// Calendar
|
||
SettingsSearchEntry(tab: .calendar, title: "Show calendar", keywords: ["calendar", "events"]),
|
||
SettingsSearchEntry(tab: .calendar, title: "Enable reminder live activity", keywords: ["reminder", "live activity"]),
|
||
SettingsSearchEntry(tab: .calendar, title: "Countdown style", keywords: ["reminder countdown"]),
|
||
SettingsSearchEntry(tab: .calendar, title: "Show lock screen reminder", keywords: ["lock screen", "reminder widget"]),
|
||
SettingsSearchEntry(tab: .calendar, title: "Chip color", keywords: ["reminder chip", "color"]),
|
||
|
||
// Shelf
|
||
SettingsSearchEntry(tab: .shelf, title: "Enable shelf", keywords: ["shelf", "dock"]),
|
||
SettingsSearchEntry(tab: .shelf, title: "Open shelf tab by default if items added", keywords: ["auto open", "shelf tab"]),
|
||
|
||
// Appearance
|
||
SettingsSearchEntry(tab: .appearance, title: "Settings icon in notch", keywords: ["settings button", "toolbar"]),
|
||
SettingsSearchEntry(tab: .appearance, title: "Enable window shadow", keywords: ["shadow", "appearance"]),
|
||
SettingsSearchEntry(tab: .appearance, title: "Corner radius scaling", keywords: ["corner radius", "shape"]),
|
||
SettingsSearchEntry(tab: .appearance, title: "Use simpler close animation", keywords: ["close animation", "notch"]),
|
||
SettingsSearchEntry(tab: .appearance, title: "Enable colored spectrograms", keywords: ["spectrogram", "audio"]),
|
||
SettingsSearchEntry(tab: .appearance, title: "Enable blur effect behind album art", keywords: ["blur", "album art"]),
|
||
SettingsSearchEntry(tab: .appearance, title: "Slider color", keywords: ["slider", "accent"]),
|
||
SettingsSearchEntry(tab: .appearance, title: "Enable Dynamic mirror", keywords: ["mirror", "reflection"]),
|
||
SettingsSearchEntry(tab: .appearance, title: "Mirror shape", keywords: ["mirror shape", "circle", "rectangle"]),
|
||
SettingsSearchEntry(tab: .appearance, title: "Show cool face animation while inactivity", keywords: ["face animation", "idle"]),
|
||
|
||
// Lock Screen
|
||
SettingsSearchEntry(tab: .lockScreen, title: "Enable lock screen live activity", keywords: ["lock screen", "live activity"]),
|
||
SettingsSearchEntry(tab: .lockScreen, title: "Play lock/unlock sounds", keywords: ["chime", "sound"]),
|
||
SettingsSearchEntry(tab: .lockScreen, title: "Material", keywords: ["glass", "frosted", "liquid"]),
|
||
SettingsSearchEntry(tab: .lockScreen, title: "Show lock screen media panel", keywords: ["media panel", "lock screen media"]),
|
||
SettingsSearchEntry(tab: .lockScreen, title: "Show media app icon", keywords: ["app icon", "media"]),
|
||
SettingsSearchEntry(tab: .lockScreen, title: "Show panel border", keywords: ["panel border"]),
|
||
SettingsSearchEntry(tab: .lockScreen, title: "Enable media panel blur", keywords: ["blur", "media panel"]),
|
||
SettingsSearchEntry(tab: .lockScreen, title: "Show lock screen timer", keywords: ["timer widget", "lock screen timer"]),
|
||
SettingsSearchEntry(tab: .lockScreen, title: "Enable timer blur", keywords: ["timer blur"]),
|
||
SettingsSearchEntry(tab: .lockScreen, title: "Show lock screen weather", keywords: ["weather widget"]),
|
||
SettingsSearchEntry(tab: .lockScreen, title: "Layout", keywords: ["inline", "circular", "weather layout"]),
|
||
SettingsSearchEntry(tab: .lockScreen, title: "Weather data provider", keywords: ["wttr", "open meteo"]),
|
||
SettingsSearchEntry(tab: .lockScreen, title: "Temperature unit", keywords: ["celsius", "fahrenheit"]),
|
||
SettingsSearchEntry(tab: .lockScreen, title: "Show location label", keywords: ["location", "weather"]),
|
||
SettingsSearchEntry(tab: .lockScreen, title: "Show charging status", keywords: ["charging", "weather"]),
|
||
SettingsSearchEntry(tab: .lockScreen, title: "Show charging percentage", keywords: ["charging percentage"]),
|
||
SettingsSearchEntry(tab: .lockScreen, title: "Show battery indicator", keywords: ["battery gauge", "weather"]),
|
||
SettingsSearchEntry(tab: .lockScreen, title: "Use MacBook icon when on battery", keywords: ["laptop icon", "battery"]),
|
||
SettingsSearchEntry(tab: .lockScreen, title: "Show Bluetooth battery", keywords: ["bluetooth", "gauge"]),
|
||
SettingsSearchEntry(tab: .lockScreen, title: "Show AQI widget", keywords: ["air quality", "aqi"]),
|
||
SettingsSearchEntry(tab: .lockScreen, title: "Air quality scale", keywords: ["aqi", "scale"]),
|
||
SettingsSearchEntry(tab: .lockScreen, title: "Use colored gauges", keywords: ["gauge tint", "monochrome"]),
|
||
|
||
// Shortcuts
|
||
SettingsSearchEntry(tab: .shortcuts, title: "Enable global keyboard shortcuts", keywords: ["keyboard", "shortcut"]),
|
||
|
||
// Timer
|
||
SettingsSearchEntry(tab: .timer, title: "Enable timer feature", keywords: ["timer", "enable"]),
|
||
SettingsSearchEntry(tab: .timer, title: "Mirror macOS Clock timers", keywords: ["system timer", "clock app"]),
|
||
SettingsSearchEntry(tab: .timer, title: "Show lock screen timer widget", keywords: ["lock screen", "timer widget"]),
|
||
SettingsSearchEntry(tab: .timer, title: "Enable timer blur", keywords: ["timer blur", "lock screen"]),
|
||
SettingsSearchEntry(tab: .timer, title: "Timer tint", keywords: ["timer colour", "preset"]),
|
||
SettingsSearchEntry(tab: .timer, title: "Solid colour", keywords: ["timer colour", "custom"]),
|
||
SettingsSearchEntry(tab: .timer, title: "Progress style", keywords: ["progress", "bar", "ring"]),
|
||
SettingsSearchEntry(tab: .timer, title: "Accent colour", keywords: ["accent", "timer"]),
|
||
|
||
// Stats
|
||
SettingsSearchEntry(tab: .stats, title: "Enable system stats monitoring", keywords: ["stats", "monitoring"]),
|
||
SettingsSearchEntry(tab: .stats, title: "Stop monitoring after closing the notch", keywords: ["stats", "auto stop"]),
|
||
SettingsSearchEntry(tab: .stats, title: "CPU Usage", keywords: ["cpu", "graph"]),
|
||
SettingsSearchEntry(tab: .stats, title: "Memory Usage", keywords: ["memory", "ram"]),
|
||
SettingsSearchEntry(tab: .stats, title: "GPU Usage", keywords: ["gpu", "graphics"]),
|
||
SettingsSearchEntry(tab: .stats, title: "Network Activity", keywords: ["network", "graph"]),
|
||
SettingsSearchEntry(tab: .stats, title: "Disk I/O", keywords: ["disk", "io"]),
|
||
|
||
// Clipboard
|
||
SettingsSearchEntry(tab: .clipboard, title: "Enable Clipboard Manager", keywords: ["clipboard", "manager"]),
|
||
SettingsSearchEntry(tab: .clipboard, title: "Show Clipboard Icon", keywords: ["icon", "clipboard"]),
|
||
SettingsSearchEntry(tab: .clipboard, title: "Display Mode", keywords: ["list", "grid", "clipboard"]),
|
||
SettingsSearchEntry(tab: .clipboard, title: "History Size", keywords: ["history", "clipboard"]),
|
||
|
||
// Screen Assistant
|
||
SettingsSearchEntry(tab: .screenAssistant, title: "Enable Screen Assistant", keywords: ["screen assistant", "ai"]),
|
||
SettingsSearchEntry(tab: .screenAssistant, title: "Display Mode", keywords: ["screen assistant", "mode"]),
|
||
|
||
// Color Picker
|
||
SettingsSearchEntry(tab: .colorPicker, title: "Enable Color Picker", keywords: ["color picker", "eyedropper"]),
|
||
SettingsSearchEntry(tab: .colorPicker, title: "Show Color Picker Icon", keywords: ["color icon", "toolbar"]),
|
||
SettingsSearchEntry(tab: .colorPicker, title: "Display Mode", keywords: ["color", "list"]),
|
||
SettingsSearchEntry(tab: .colorPicker, title: "History Size", keywords: ["color history"]),
|
||
SettingsSearchEntry(tab: .colorPicker, title: "Show All Color Formats", keywords: ["hex", "hsl", "color formats"])
|
||
]
|
||
}
|
||
|
||
private func isTabVisible(_ tab: SettingsTab) -> Bool {
|
||
switch tab {
|
||
case .timer, .stats, .clipboard, .screenAssistant, .colorPicker, .shelf:
|
||
return !enableMinimalisticUI
|
||
case .downloads:
|
||
return extensionManager.installedExtensions.contains { $0.bundleIdentifier == downloadManagerExtension }
|
||
default:
|
||
return true
|
||
}
|
||
}
|
||
|
||
@ViewBuilder
|
||
private func detailView(for tab: SettingsTab) -> some View {
|
||
switch tab {
|
||
case .general:
|
||
GeneralSettings()
|
||
case .appearance:
|
||
Appearance()
|
||
case .lockScreen:
|
||
LockScreenSettings()
|
||
case .media:
|
||
Media()
|
||
case .timer:
|
||
TimerSettings()
|
||
case .calendar:
|
||
CalendarSettings()
|
||
case .hud:
|
||
HUD()
|
||
case .battery:
|
||
Charge()
|
||
case .stats:
|
||
StatsSettings()
|
||
case .clipboard:
|
||
ClipboardSettings()
|
||
case .screenAssistant:
|
||
ScreenAssistantSettings()
|
||
case .colorPicker:
|
||
ColorPickerSettings()
|
||
case .downloads:
|
||
Downloads()
|
||
case .shelf:
|
||
Shelf()
|
||
case .shortcuts:
|
||
Shortcuts()
|
||
case .extensions:
|
||
Extensions()
|
||
case .about:
|
||
if let controller = updaterController {
|
||
About(updaterController: controller)
|
||
} else {
|
||
About(updaterController: SPUStandardUpdaterController(startingUpdater: false, updaterDelegate: nil, userDriverDelegate: nil))
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
struct GeneralSettings: View {
|
||
@State private var screens: [String] = NSScreen.screens.compactMap { $0.localizedName }
|
||
@EnvironmentObject var vm: DynamicIslandViewModel
|
||
@ObservedObject var coordinator = DynamicIslandViewCoordinator.shared
|
||
@ObservedObject var recordingManager = ScreenRecordingManager.shared
|
||
@ObservedObject var privacyManager = PrivacyIndicatorManager.shared
|
||
@ObservedObject var doNotDisturbManager = DoNotDisturbManager.shared
|
||
|
||
@Default(.mirrorShape) var mirrorShape
|
||
@Default(.showEmojis) var showEmojis
|
||
@Default(.gestureSensitivity) var gestureSensitivity
|
||
@Default(.minimumHoverDuration) var minimumHoverDuration
|
||
@Default(.nonNotchHeight) var nonNotchHeight
|
||
@Default(.nonNotchHeightMode) var nonNotchHeightMode
|
||
@Default(.notchHeight) var notchHeight
|
||
@Default(.notchHeightMode) var notchHeightMode
|
||
@Default(.showOnAllDisplays) var showOnAllDisplays
|
||
@Default(.automaticallySwitchDisplay) var automaticallySwitchDisplay
|
||
@Default(.enableGestures) var enableGestures
|
||
@Default(.openNotchOnHover) var openNotchOnHover
|
||
@Default(.enableScreenRecordingDetection) var enableScreenRecordingDetection
|
||
@Default(.showRecordingIndicator) var showRecordingIndicator
|
||
@Default(.enableDoNotDisturbDetection) var enableDoNotDisturbDetection
|
||
@Default(.showDoNotDisturbIndicator) var showDoNotDisturbIndicator
|
||
@Default(.showDoNotDisturbLabel) var showDoNotDisturbLabel
|
||
@Default(.enableMinimalisticUI) var enableMinimalisticUI
|
||
|
||
var body: some View {
|
||
Form {
|
||
Section {
|
||
Defaults.Toggle("Enable Minimalistic UI", key: .enableMinimalisticUI)
|
||
.onChange(of: enableMinimalisticUI) { _, newValue in
|
||
if newValue {
|
||
// Auto-enable simpler animation mode
|
||
Defaults[.useModernCloseAnimation] = true
|
||
}
|
||
}
|
||
} header: {
|
||
Text("UI Mode")
|
||
} footer: {
|
||
Text("Minimalistic mode focuses on media controls and system HUDs, hiding all extra features for a clean, focused experience. Automatically enables simpler animations.")
|
||
}
|
||
|
||
Section {
|
||
Defaults.Toggle("Menubar icon", key: .menubarIcon)
|
||
LaunchAtLogin.Toggle("Launch at login")
|
||
Defaults.Toggle(key: .showOnAllDisplays) {
|
||
Text("Show on all displays")
|
||
}
|
||
.onChange(of: showOnAllDisplays) {
|
||
NotificationCenter.default.post(name: Notification.Name.showOnAllDisplaysChanged, object: nil)
|
||
}
|
||
Picker("Show on a specific display", selection: $coordinator.preferredScreen) {
|
||
ForEach(screens, id: \.self) { screen in
|
||
Text(screen)
|
||
}
|
||
}
|
||
.onChange(of: NSScreen.screens) {
|
||
screens = NSScreen.screens.compactMap({$0.localizedName})
|
||
}
|
||
.disabled(showOnAllDisplays)
|
||
Defaults.Toggle("Automatically switch displays", key: .automaticallySwitchDisplay)
|
||
.onChange(of: automaticallySwitchDisplay) {
|
||
NotificationCenter.default.post(name: Notification.Name.automaticallySwitchDisplayChanged, object: nil)
|
||
}
|
||
.disabled(showOnAllDisplays)
|
||
Defaults.Toggle("Hide Dynamic Island during screenshots & recordings", key: .hideDynamicIslandFromScreenCapture)
|
||
} header: {
|
||
Text("System features")
|
||
}
|
||
|
||
Section {
|
||
Defaults.Toggle("Enable Screen Recording Detection", key: .enableScreenRecordingDetection)
|
||
|
||
Defaults.Toggle("Show Recording Indicator", key: .showRecordingIndicator)
|
||
.disabled(!enableScreenRecordingDetection)
|
||
|
||
// Note: Polling removed - now uses event-driven private API detection
|
||
|
||
if recordingManager.isMonitoring {
|
||
HStack {
|
||
Text("Detection Status")
|
||
Spacer()
|
||
if recordingManager.isRecording {
|
||
HStack(spacing: 4) {
|
||
Circle()
|
||
.fill(Color.red)
|
||
.frame(width: 8, height: 8)
|
||
Text("Recording Detected")
|
||
.foregroundColor(.red)
|
||
}
|
||
} else {
|
||
Text("Active - No Recording")
|
||
.foregroundColor(.green)
|
||
}
|
||
}
|
||
}
|
||
} header: {
|
||
Text("Screen Recording")
|
||
} footer: {
|
||
Text("Uses event-driven private API for real-time screen recording detection")
|
||
}
|
||
|
||
Section {
|
||
Defaults.Toggle("Enable Focus Detection", key: .enableDoNotDisturbDetection)
|
||
|
||
Defaults.Toggle("Show Focus Indicator", key: .showDoNotDisturbIndicator)
|
||
.disabled(!enableDoNotDisturbDetection)
|
||
|
||
Defaults.Toggle("Show Focus Label", key: .showDoNotDisturbLabel)
|
||
.disabled(!enableDoNotDisturbDetection)
|
||
|
||
if doNotDisturbManager.isMonitoring {
|
||
HStack {
|
||
Text("Focus Status")
|
||
Spacer()
|
||
if doNotDisturbManager.isDoNotDisturbActive {
|
||
HStack(spacing: 4) {
|
||
Circle()
|
||
.fill(Color.purple)
|
||
.frame(width: 8, height: 8)
|
||
Text(doNotDisturbManager.currentFocusModeName.isEmpty ? "Focus Enabled" : doNotDisturbManager.currentFocusModeName)
|
||
.foregroundColor(.purple)
|
||
}
|
||
} else {
|
||
Text("Active - No Focus")
|
||
.foregroundColor(.green)
|
||
}
|
||
}
|
||
} else {
|
||
HStack {
|
||
Text("Focus Status")
|
||
Spacer()
|
||
Text("Disabled")
|
||
.foregroundColor(.secondary)
|
||
}
|
||
}
|
||
} header: {
|
||
Text("Do Not Disturb")
|
||
} footer: {
|
||
Text("Listens for Focus session changes via distributed notifications")
|
||
}
|
||
|
||
Section {
|
||
Defaults.Toggle("Enable Camera Detection", key: .enableCameraDetection)
|
||
Defaults.Toggle("Enable Microphone Detection", key: .enableMicrophoneDetection)
|
||
|
||
if privacyManager.isMonitoring {
|
||
HStack {
|
||
Text("Camera Status")
|
||
Spacer()
|
||
if privacyManager.cameraActive {
|
||
HStack(spacing: 4) {
|
||
Circle()
|
||
.fill(Color.green)
|
||
.frame(width: 8, height: 8)
|
||
Text("Camera Active")
|
||
.foregroundColor(.green)
|
||
}
|
||
} else {
|
||
Text("Inactive")
|
||
.foregroundColor(.secondary)
|
||
}
|
||
}
|
||
|
||
HStack {
|
||
Text("Microphone Status")
|
||
Spacer()
|
||
if privacyManager.microphoneActive {
|
||
HStack(spacing: 4) {
|
||
Circle()
|
||
.fill(Color.yellow)
|
||
.frame(width: 8, height: 8)
|
||
Text("Microphone Active")
|
||
.foregroundColor(.yellow)
|
||
}
|
||
} else {
|
||
Text("Inactive")
|
||
.foregroundColor(.secondary)
|
||
}
|
||
}
|
||
}
|
||
} header: {
|
||
Text("Privacy Indicators")
|
||
} footer: {
|
||
Text("Shows green camera icon and yellow microphone icon when in use. Uses event-driven CoreAudio and CoreMediaIO APIs.")
|
||
}
|
||
|
||
Section {
|
||
Picker(selection: $notchHeightMode, label:
|
||
Text("Notch display height")) {
|
||
Text("Match real notch size")
|
||
.tag(WindowHeightMode.matchRealNotchSize)
|
||
Text("Match menubar height")
|
||
.tag(WindowHeightMode.matchMenuBar)
|
||
Text("Custom height")
|
||
.tag(WindowHeightMode.custom)
|
||
}
|
||
.onChange(of: notchHeightMode) {
|
||
switch notchHeightMode {
|
||
case .matchRealNotchSize:
|
||
notchHeight = 38
|
||
case .matchMenuBar:
|
||
notchHeight = 44
|
||
case .custom:
|
||
notchHeight = 38
|
||
}
|
||
NotificationCenter.default.post(name: Notification.Name.notchHeightChanged, object: nil)
|
||
}
|
||
if notchHeightMode == .custom {
|
||
Slider(value: $notchHeight, in: 15...45, step: 1) {
|
||
Text("Custom notch size - \(notchHeight, specifier: "%.0f")")
|
||
}
|
||
.onChange(of: notchHeight) {
|
||
NotificationCenter.default.post(name: Notification.Name.notchHeightChanged, object: nil)
|
||
}
|
||
}
|
||
Picker("Non-notch display height", selection: $nonNotchHeightMode) {
|
||
Text("Match menubar height")
|
||
.tag(WindowHeightMode.matchMenuBar)
|
||
Text("Match real notch size")
|
||
.tag(WindowHeightMode.matchRealNotchSize)
|
||
Text("Custom height")
|
||
.tag(WindowHeightMode.custom)
|
||
}
|
||
.onChange(of: nonNotchHeightMode) {
|
||
switch nonNotchHeightMode {
|
||
case .matchMenuBar:
|
||
nonNotchHeight = 24
|
||
case .matchRealNotchSize:
|
||
nonNotchHeight = 32
|
||
case .custom:
|
||
nonNotchHeight = 32
|
||
}
|
||
NotificationCenter.default.post(name: Notification.Name.notchHeightChanged, object: nil)
|
||
}
|
||
if nonNotchHeightMode == .custom {
|
||
Slider(value: $nonNotchHeight, in: 0...40, step: 1) {
|
||
Text("Custom notch size - \(nonNotchHeight, specifier: "%.0f")")
|
||
}
|
||
.onChange(of: nonNotchHeight) {
|
||
NotificationCenter.default.post(name: Notification.Name.notchHeightChanged, object: nil)
|
||
}
|
||
}
|
||
} header: {
|
||
Text("Notch Height")
|
||
}
|
||
|
||
NotchBehaviour()
|
||
|
||
gestureControls()
|
||
}
|
||
.toolbar {
|
||
Button("Quit app") {
|
||
NSApp.terminate(self)
|
||
}
|
||
.controlSize(.extraLarge)
|
||
}
|
||
.navigationTitle("General")
|
||
.onChange(of: openNotchOnHover) {
|
||
if !openNotchOnHover {
|
||
enableGestures = true
|
||
}
|
||
}
|
||
}
|
||
|
||
@ViewBuilder
|
||
func gestureControls() -> some View {
|
||
Section {
|
||
Defaults.Toggle("Enable gestures", key: .enableGestures)
|
||
.disabled(!openNotchOnHover)
|
||
if enableGestures {
|
||
Toggle("Media change with horizontal gestures", isOn: .constant(false))
|
||
.disabled(true)
|
||
Defaults.Toggle("Close gesture", key: .closeGestureEnabled)
|
||
Slider(value: $gestureSensitivity, in: 100...300, step: 100) {
|
||
HStack {
|
||
Text("Gesture sensitivity")
|
||
Spacer()
|
||
Text(Defaults[.gestureSensitivity] == 100 ? "High" : Defaults[.gestureSensitivity] == 200 ? "Medium" : "Low")
|
||
.foregroundStyle(.secondary)
|
||
}
|
||
}
|
||
}
|
||
} header: {
|
||
HStack {
|
||
Text("Gesture control")
|
||
customBadge(text: "Beta")
|
||
}
|
||
} footer: {
|
||
Text("Two-finger swipe up on notch to close, two-finger swipe down on notch to open when **Open notch on hover** option is disabled")
|
||
.multilineTextAlignment(.trailing)
|
||
.foregroundStyle(.secondary)
|
||
.font(.caption)
|
||
}
|
||
}
|
||
|
||
@ViewBuilder
|
||
func NotchBehaviour() -> some View {
|
||
Section {
|
||
Defaults.Toggle("Extend hover area", key: .extendHoverArea)
|
||
Defaults.Toggle("Enable haptics", key: .enableHaptics)
|
||
Defaults.Toggle("Open notch on hover", key: .openNotchOnHover)
|
||
Toggle("Remember last tab", isOn: $coordinator.openLastTabByDefault)
|
||
if openNotchOnHover {
|
||
Slider(value: $minimumHoverDuration, in: 0...1, step: 0.1) {
|
||
HStack {
|
||
Text("Minimum hover duration")
|
||
Spacer()
|
||
Text("\(minimumHoverDuration, specifier: "%.1f")s")
|
||
.foregroundStyle(.secondary)
|
||
}
|
||
}
|
||
.onChange(of: minimumHoverDuration) {
|
||
NotificationCenter.default.post(name: Notification.Name.notchHeightChanged, object: nil)
|
||
}
|
||
}
|
||
} header: {
|
||
Text("Notch behavior")
|
||
}
|
||
}
|
||
}
|
||
|
||
struct Charge: View {
|
||
var body: some View {
|
||
Form {
|
||
Section {
|
||
Defaults.Toggle("Show battery indicator", key: .showBatteryIndicator)
|
||
Defaults.Toggle("Show power status notifications", key: .showPowerStatusNotifications)
|
||
} header: {
|
||
Text("General")
|
||
}
|
||
Section {
|
||
Defaults.Toggle("Show battery percentage", key: .showBatteryPercentage)
|
||
Defaults.Toggle("Show power status icons", key: .showPowerStatusIcons)
|
||
} header: {
|
||
Text("Battery Information")
|
||
}
|
||
}
|
||
.navigationTitle("Battery")
|
||
}
|
||
}
|
||
|
||
struct Downloads: View {
|
||
@Default(.selectedDownloadIndicatorStyle) var selectedDownloadIndicatorStyle
|
||
@Default(.selectedDownloadIconStyle) var selectedDownloadIconStyle
|
||
var body: some View {
|
||
Form {
|
||
warningBadge("We don't support downloads yet", "It will be supported later on.")
|
||
Section {
|
||
Defaults.Toggle("Show download progress", key: .enableDownloadListener)
|
||
.disabled(true)
|
||
Defaults.Toggle("Enable Safari Downloads", key: .enableSafariDownloads)
|
||
.disabled(!Defaults[.enableDownloadListener])
|
||
Picker("Download indicator style", selection: $selectedDownloadIndicatorStyle) {
|
||
Text("Progress bar")
|
||
.tag(DownloadIndicatorStyle.progress)
|
||
Text("Percentage")
|
||
.tag(DownloadIndicatorStyle.percentage)
|
||
}
|
||
Picker("Download icon style", selection: $selectedDownloadIconStyle) {
|
||
Text("Only app icon")
|
||
.tag(DownloadIconStyle.onlyAppIcon)
|
||
Text("Only download icon")
|
||
.tag(DownloadIconStyle.onlyIcon)
|
||
Text("Both")
|
||
.tag(DownloadIconStyle.iconAndAppIcon)
|
||
}
|
||
|
||
} header: {
|
||
HStack {
|
||
Text("Download indicators")
|
||
comingSoonTag()
|
||
}
|
||
}
|
||
Section {
|
||
List {
|
||
ForEach([].indices, id: \.self) { index in
|
||
Text("\(index)")
|
||
}
|
||
}
|
||
.frame(minHeight: 96)
|
||
.overlay {
|
||
if true {
|
||
Text("No excluded apps")
|
||
.foregroundStyle(Color(.secondaryLabelColor))
|
||
}
|
||
}
|
||
.actionBar(padding: 0) {
|
||
Group {
|
||
Button {} label: {
|
||
Image(systemName: "plus")
|
||
.frame(width: 25, height: 16, alignment: .center)
|
||
.contentShape(Rectangle())
|
||
.foregroundStyle(.secondary)
|
||
}
|
||
|
||
Divider()
|
||
Button {} label: {
|
||
Image(systemName: "minus")
|
||
.frame(width: 20, height: 16, alignment: .center)
|
||
.contentShape(Rectangle())
|
||
.foregroundStyle(.secondary)
|
||
}
|
||
}
|
||
}
|
||
} header: {
|
||
HStack(spacing: 4) {
|
||
Text("Exclude apps")
|
||
comingSoonTag()
|
||
}
|
||
}
|
||
}
|
||
.navigationTitle("Downloads")
|
||
}
|
||
}
|
||
|
||
struct HUD: View {
|
||
@EnvironmentObject var vm: DynamicIslandViewModel
|
||
@Default(.inlineHUD) var inlineHUD
|
||
@Default(.progressBarStyle) var progressBarStyle
|
||
@Default(.enableSystemHUD) var enableSystemHUD
|
||
@Default(.enableVolumeHUD) var enableVolumeHUD
|
||
@Default(.enableBrightnessHUD) var enableBrightnessHUD
|
||
@Default(.enableKeyboardBacklightHUD) var enableKeyboardBacklightHUD
|
||
@Default(.systemHUDSensitivity) var systemHUDSensitivity
|
||
@ObservedObject var coordinator = DynamicIslandViewCoordinator.shared
|
||
|
||
var body: some View {
|
||
Form {
|
||
Section {
|
||
Toggle("Enable HUD replacement", isOn: $coordinator.hudReplacement)
|
||
} header: {
|
||
Text("General")
|
||
} footer: {
|
||
Text("Replaces system HUD notifications with Dynamic Island displays.")
|
||
.foregroundStyle(.secondary)
|
||
.font(.caption)
|
||
}
|
||
|
||
Section {
|
||
Toggle("Enable Built-in System HUD", isOn: $enableSystemHUD)
|
||
|
||
if enableSystemHUD {
|
||
Toggle("Volume HUD", isOn: $enableVolumeHUD)
|
||
Toggle("Brightness HUD", isOn: $enableBrightnessHUD)
|
||
Toggle("Keyboard Backlight HUD", isOn: $enableKeyboardBacklightHUD)
|
||
|
||
HStack {
|
||
Text("Sensitivity")
|
||
Spacer()
|
||
Slider(value: Binding(
|
||
get: { Double(systemHUDSensitivity) },
|
||
set: { systemHUDSensitivity = Int($0) }
|
||
), in: 1...10, step: 1) {
|
||
Text("Sensitivity")
|
||
}
|
||
.frame(width: 120)
|
||
Text("\(systemHUDSensitivity)")
|
||
.foregroundStyle(.secondary)
|
||
.font(.caption)
|
||
.frame(width: 20)
|
||
}
|
||
}
|
||
} header: {
|
||
Text("Built-in System Monitoring")
|
||
} footer: {
|
||
if enableSystemHUD {
|
||
Text("Built-in system monitoring detects volume, brightness, and keyboard backlight changes directly without requiring external apps.")
|
||
.foregroundStyle(.secondary)
|
||
.font(.caption)
|
||
} else {
|
||
Text("Enable built-in system monitoring to replace macOS HUD notifications with Dynamic Island displays.")
|
||
.foregroundStyle(.secondary)
|
||
.font(.caption)
|
||
}
|
||
}
|
||
|
||
Section {
|
||
Defaults.Toggle("Show Bluetooth device connections", key: .showBluetoothDeviceConnections)
|
||
Defaults.Toggle("Use circular battery indicator", key: .useCircularBluetoothBatteryIndicator)
|
||
Defaults.Toggle("Show battery percentage text in HUD", key: .showBluetoothBatteryPercentageText)
|
||
Defaults.Toggle("Scroll device name in HUD", key: .showBluetoothDeviceNameMarquee)
|
||
} header: {
|
||
Text("Bluetooth Audio Devices")
|
||
} footer: {
|
||
Text("Displays a HUD notification when Bluetooth audio devices (headphones, AirPods, speakers) connect, showing device name and battery level.")
|
||
.foregroundStyle(.secondary)
|
||
.font(.caption)
|
||
}
|
||
|
||
Section {
|
||
let colorCodingDisabled = progressBarStyle == .segmented
|
||
Defaults.Toggle("Color-coded battery display", key: .useColorCodedBatteryDisplay)
|
||
.disabled(colorCodingDisabled)
|
||
Defaults.Toggle("Color-coded volume display", key: .useColorCodedVolumeDisplay)
|
||
.disabled(colorCodingDisabled)
|
||
|
||
if !colorCodingDisabled && (Defaults[.useColorCodedBatteryDisplay] || Defaults[.useColorCodedVolumeDisplay]) {
|
||
Defaults.Toggle("Smooth color transitions", key: .useSmoothColorGradient)
|
||
}
|
||
|
||
Defaults.Toggle("Show percentages beside progress bars", key: .showProgressPercentages)
|
||
} header: {
|
||
Text("Color-Coded Progress Bars")
|
||
} footer: {
|
||
if progressBarStyle == .segmented {
|
||
Text("Color-coded fills and smooth gradients are unavailable in Segmented mode. Switch to Hierarchical or Gradient to adjust these options.")
|
||
.foregroundStyle(.secondary)
|
||
.font(.caption)
|
||
} else if Defaults[.useSmoothColorGradient] {
|
||
Text("Smooth transitions blend Green (0–60%), Yellow (60–85%), and Red (85–100%) through the entire fill.")
|
||
.foregroundStyle(.secondary)
|
||
.font(.caption)
|
||
} else {
|
||
Text("Discrete transitions snap between Green (0–60%), Yellow (60–85%), and Red (85–100%).")
|
||
.foregroundStyle(.secondary)
|
||
.font(.caption)
|
||
}
|
||
}
|
||
|
||
Section {
|
||
Picker("HUD style", selection: $inlineHUD) {
|
||
Text("Default")
|
||
.tag(false)
|
||
Text("Inline")
|
||
.tag(true)
|
||
}
|
||
.onChange(of: Defaults[.inlineHUD]) {
|
||
if Defaults[.inlineHUD] {
|
||
withAnimation {
|
||
Defaults[.systemEventIndicatorShadow] = false
|
||
Defaults[.progressBarStyle] = .hierarchical
|
||
}
|
||
}
|
||
}
|
||
Picker("Progressbar style", selection: $progressBarStyle) {
|
||
Text("Hierarchical")
|
||
.tag(ProgressBarStyle.hierarchical)
|
||
Text("Gradient")
|
||
.tag(ProgressBarStyle.gradient)
|
||
Text("Segmented")
|
||
.tag(ProgressBarStyle.segmented)
|
||
}
|
||
Defaults.Toggle("Enable glowing effect", key: .systemEventIndicatorShadow)
|
||
Defaults.Toggle("Use accent color", key: .systemEventIndicatorUseAccent)
|
||
} header: {
|
||
HStack {
|
||
Text("Appearance")
|
||
}
|
||
}
|
||
}
|
||
.navigationTitle("HUDs")
|
||
}
|
||
}
|
||
|
||
struct Media: View {
|
||
@Default(.waitInterval) var waitInterval
|
||
@Default(.mediaController) var mediaController
|
||
@ObservedObject var coordinator = DynamicIslandViewCoordinator.shared
|
||
@Default(.hideNotchOption) var hideNotchOption
|
||
@Default(.enableSneakPeek) private var enableSneakPeek
|
||
@Default(.sneakPeekStyles) var sneakPeekStyles
|
||
@Default(.enableMinimalisticUI) var enableMinimalisticUI
|
||
@Default(.showShuffleAndRepeat) private var showShuffleAndRepeat
|
||
@Default(.musicAuxLeftControl) private var musicAuxLeftControl
|
||
@Default(.musicAuxRightControl) private var musicAuxRightControl
|
||
@Default(.musicSkipBehavior) private var musicSkipBehavior
|
||
@Default(.musicControlWindowEnabled) private var musicControlWindowEnabled
|
||
@Default(.enableLockScreenMediaWidget) private var enableLockScreenMediaWidget
|
||
@State private var previousLeftAuxControl: MusicAuxiliaryControl = Defaults[.musicAuxLeftControl]
|
||
@State private var previousRightAuxControl: MusicAuxiliaryControl = Defaults[.musicAuxRightControl]
|
||
|
||
var body: some View {
|
||
Form {
|
||
Section {
|
||
Picker("Music Source", selection: $mediaController) {
|
||
ForEach(availableMediaControllers) { controller in
|
||
Text(controller.rawValue).tag(controller)
|
||
}
|
||
}
|
||
.onChange(of: mediaController) { _, _ in
|
||
NotificationCenter.default.post(
|
||
name: Notification.Name.mediaControllerChanged,
|
||
object: nil
|
||
)
|
||
}
|
||
} header: {
|
||
Text("Media Source")
|
||
} footer: {
|
||
if MusicManager.shared.isNowPlayingDeprecated {
|
||
HStack {
|
||
Text("YouTube Music requires this third-party app to be installed: ")
|
||
.foregroundStyle(.secondary)
|
||
.font(.caption)
|
||
Link("https://github.com/th-ch/youtube-music", destination: URL(string: "https://github.com/th-ch/youtube-music")!)
|
||
.font(.caption)
|
||
.foregroundColor(.blue) // Ensures it's visibly a link
|
||
}
|
||
} else {
|
||
Text("'Now Playing' was the only option on previous versions and works with all media apps.")
|
||
.foregroundStyle(.secondary)
|
||
.font(.caption)
|
||
}
|
||
}
|
||
Section {
|
||
Defaults.Toggle(key: .showShuffleAndRepeat) {
|
||
HStack {
|
||
Text("Show shuffle and repeat buttons")
|
||
customBadge(text: "Beta")
|
||
}
|
||
}
|
||
Defaults.Toggle(key: .showMediaOutputControl) {
|
||
Text("Show media output control in other layouts")
|
||
}
|
||
.disabled(!showShuffleAndRepeat)
|
||
if showShuffleAndRepeat {
|
||
mediaControlsGrid
|
||
}
|
||
} header: {
|
||
Text("Media controls")
|
||
}
|
||
Section {
|
||
Picker("Skip buttons", selection: $musicSkipBehavior) {
|
||
ForEach(MusicSkipBehavior.allCases) { behavior in
|
||
Text(behavior.displayName).tag(behavior)
|
||
}
|
||
}
|
||
.pickerStyle(.segmented)
|
||
|
||
Text(musicSkipBehavior.description)
|
||
.font(.caption)
|
||
.foregroundStyle(.secondary)
|
||
} header: {
|
||
Text("Skip behaviour")
|
||
}
|
||
Section {
|
||
Toggle(
|
||
"Enable music live activity",
|
||
isOn: $coordinator.musicLiveActivityEnabled.animation()
|
||
)
|
||
Defaults.Toggle(
|
||
"Show floating media controls",
|
||
key: .musicControlWindowEnabled
|
||
)
|
||
.disabled(!coordinator.musicLiveActivityEnabled)
|
||
.help("Displays play/pause and skip buttons beside the notch while music is active. Disabled by default.")
|
||
Toggle("Enable sneak peek", isOn: $enableSneakPeek)
|
||
Defaults.Toggle("Enable lyrics", key: .enableLyrics)
|
||
Picker("Sneak Peek Style", selection: $sneakPeekStyles){
|
||
ForEach(SneakPeekStyle.allCases) { style in
|
||
Text(style.rawValue).tag(style)
|
||
}
|
||
}
|
||
.disabled(!enableSneakPeek || enableMinimalisticUI)
|
||
.onChange(of: enableMinimalisticUI) { _, isMinimalistic in
|
||
// Force standard sneak peek style when minimalistic UI is enabled
|
||
if isMinimalistic {
|
||
sneakPeekStyles = .standard
|
||
}
|
||
}
|
||
|
||
if enableMinimalisticUI {
|
||
Text("Sneak peek style is locked to Standard in minimalistic mode")
|
||
.font(.caption)
|
||
.foregroundStyle(.secondary)
|
||
}
|
||
|
||
HStack {
|
||
Stepper(value: $waitInterval, in: 0...10, step: 1) {
|
||
HStack {
|
||
Text("Media inactivity timeout")
|
||
Spacer()
|
||
Text("\(Defaults[.waitInterval], specifier: "%.0f") seconds")
|
||
.foregroundStyle(.secondary)
|
||
}
|
||
}
|
||
}
|
||
} header: {
|
||
Text("Media playback live activity")
|
||
}
|
||
|
||
Section {
|
||
Defaults.Toggle("Show lock screen media panel", key: .enableLockScreenMediaWidget)
|
||
Defaults.Toggle("Show media app icon", key: .lockScreenShowAppIcon)
|
||
.disabled(!enableLockScreenMediaWidget)
|
||
Defaults.Toggle("Show panel border", key: .lockScreenPanelShowsBorder)
|
||
.disabled(!enableLockScreenMediaWidget)
|
||
Defaults.Toggle("Enable media panel blur", key: .lockScreenPanelUsesBlur)
|
||
.disabled(!enableLockScreenMediaWidget)
|
||
} header: {
|
||
Text("Lock Screen Integration")
|
||
} footer: {
|
||
Text("These controls mirror the Lock Screen tab so you can tune the media overlay while focusing on playback settings.")
|
||
}
|
||
|
||
Picker(selection: $hideNotchOption, label:
|
||
HStack {
|
||
Text("Hide DynamicIsland Options")
|
||
customBadge(text: "Beta")
|
||
}) {
|
||
Text("Always hide in fullscreen").tag(HideNotchOption.always)
|
||
Text("Hide only when NowPlaying app is in fullscreen").tag(HideNotchOption.nowPlayingOnly)
|
||
Text("Never hide").tag(HideNotchOption.never)
|
||
}
|
||
.onChange(of: hideNotchOption) {
|
||
Defaults[.enableFullscreenMediaDetection] = hideNotchOption != .never
|
||
}
|
||
}
|
||
.onAppear {
|
||
ensureAuxControlsUnique()
|
||
previousLeftAuxControl = musicAuxLeftControl
|
||
previousRightAuxControl = musicAuxRightControl
|
||
}
|
||
.onChange(of: musicAuxLeftControl) { newValue in
|
||
if newValue == musicAuxRightControl {
|
||
let fallback = MusicAuxiliaryControl.alternative(
|
||
excluding: newValue,
|
||
preferring: previousLeftAuxControl
|
||
)
|
||
if fallback != musicAuxRightControl {
|
||
musicAuxRightControl = fallback
|
||
}
|
||
}
|
||
|
||
previousLeftAuxControl = newValue
|
||
}
|
||
.onChange(of: musicAuxRightControl) { newValue in
|
||
if newValue == musicAuxLeftControl {
|
||
let fallback = MusicAuxiliaryControl.alternative(
|
||
excluding: newValue,
|
||
preferring: previousRightAuxControl
|
||
)
|
||
if fallback != musicAuxLeftControl {
|
||
musicAuxLeftControl = fallback
|
||
}
|
||
}
|
||
|
||
previousRightAuxControl = newValue
|
||
}
|
||
.onChange(of: showShuffleAndRepeat) { isEnabled in
|
||
if isEnabled {
|
||
ensureAuxControlsUnique()
|
||
}
|
||
}
|
||
.navigationTitle("Media")
|
||
}
|
||
|
||
private var mediaControlsGrid: some View {
|
||
VStack(alignment: .leading, spacing: 20) {
|
||
mediaControlsRow(title: "Left button", selection: $musicAuxLeftControl, otherSelection: musicAuxRightControl)
|
||
mediaControlsRow(title: "Right button", selection: $musicAuxRightControl, otherSelection: musicAuxLeftControl)
|
||
}
|
||
.padding(.vertical, 12)
|
||
}
|
||
|
||
private func mediaControlsRow(title: String, selection: Binding<MusicAuxiliaryControl>, otherSelection: MusicAuxiliaryControl) -> some View {
|
||
VStack(alignment: .leading, spacing: 12) {
|
||
Text(title)
|
||
.font(.system(size: 13, weight: .semibold))
|
||
.foregroundStyle(.white)
|
||
|
||
HStack(spacing: 16) {
|
||
ForEach([MusicAuxiliaryControl.mediaOutput, MusicAuxiliaryControl.repeatMode, MusicAuxiliaryControl.lyrics, MusicAuxiliaryControl.shuffle], id: \.self) { control in
|
||
MediaControlButton(
|
||
control: control,
|
||
isSelected: selection.wrappedValue == control,
|
||
disabled: control == otherSelection
|
||
) {
|
||
selection.wrappedValue = control
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
struct MediaControlButton: View {
|
||
let control: MusicAuxiliaryControl
|
||
let isSelected: Bool
|
||
let disabled: Bool
|
||
let action: () -> Void
|
||
|
||
@State private var isHovering = false
|
||
|
||
var body: some View {
|
||
VStack(spacing: 8) {
|
||
ZStack {
|
||
RoundedRectangle(cornerRadius: 12)
|
||
.fill(backgroundColor)
|
||
.overlay(
|
||
RoundedRectangle(cornerRadius: 12)
|
||
.strokeBorder(borderColor, lineWidth: isSelected ? 2 : 1)
|
||
)
|
||
|
||
Image(systemName: control.symbolName)
|
||
.font(.system(size: 22, weight: .medium))
|
||
.foregroundStyle(iconColor)
|
||
}
|
||
.frame(width: 80, height: 60)
|
||
.onHover { hovering in
|
||
if !disabled {
|
||
withAnimation(.easeInOut(duration: 0.2)) {
|
||
isHovering = hovering
|
||
}
|
||
}
|
||
}
|
||
|
||
Text(control.displayName)
|
||
.font(.caption)
|
||
.fontWeight(.medium)
|
||
.lineLimit(2)
|
||
.multilineTextAlignment(.center)
|
||
.frame(width: 100)
|
||
.foregroundStyle(disabled ? .secondary : .primary)
|
||
}
|
||
.contentShape(Rectangle())
|
||
.onTapGesture {
|
||
if !disabled {
|
||
action()
|
||
}
|
||
}
|
||
.opacity(disabled ? 0.5 : 1.0)
|
||
}
|
||
|
||
private var backgroundColor: Color {
|
||
if disabled { return Color(nsColor: .controlBackgroundColor) }
|
||
if isSelected { return Color.accentColor.opacity(0.1) }
|
||
if isHovering { return Color.primary.opacity(0.05) }
|
||
return Color(nsColor: .controlBackgroundColor)
|
||
}
|
||
|
||
private var borderColor: Color {
|
||
if isSelected { return Color.accentColor }
|
||
if isHovering { return Color.primary.opacity(0.1) }
|
||
return Color.clear
|
||
}
|
||
|
||
private var iconColor: Color {
|
||
if disabled { return .secondary }
|
||
if isSelected { return .accentColor }
|
||
return .primary
|
||
}
|
||
}
|
||
|
||
// Only show controller options that are available on this macOS version
|
||
private var availableMediaControllers: [MediaControllerType] {
|
||
if MusicManager.shared.isNowPlayingDeprecated {
|
||
return MediaControllerType.allCases.filter { $0 != .nowPlaying }
|
||
} else {
|
||
return MediaControllerType.allCases
|
||
}
|
||
}
|
||
|
||
private func ensureAuxControlsUnique() {
|
||
guard showShuffleAndRepeat, musicAuxLeftControl == musicAuxRightControl else { return }
|
||
|
||
let fallback = MusicAuxiliaryControl.alternative(excluding: musicAuxLeftControl)
|
||
if fallback != musicAuxRightControl {
|
||
musicAuxRightControl = fallback
|
||
}
|
||
}
|
||
}
|
||
|
||
struct CalendarSettings: View {
|
||
@ObservedObject private var calendarManager = CalendarManager.shared
|
||
@Default(.showCalendar) var showCalendar: Bool
|
||
@Default(.enableReminderLiveActivity) var enableReminderLiveActivity
|
||
@Default(.reminderPresentationStyle) var reminderPresentationStyle
|
||
@Default(.reminderLeadTime) var reminderLeadTime
|
||
@Default(.reminderSneakPeekDuration) var reminderSneakPeekDuration
|
||
@Default(.enableLockScreenReminderWidget) var enableLockScreenReminderWidget
|
||
@Default(.lockScreenReminderChipStyle) var lockScreenReminderChipStyle
|
||
|
||
var body: some View {
|
||
Form {
|
||
if !calendarManager.hasCalendarAccess || !calendarManager.hasReminderAccess {
|
||
Text("Calendar or Reminder access is denied. Please enable it in System Settings.")
|
||
.foregroundColor(.red)
|
||
.multilineTextAlignment(.center)
|
||
.padding()
|
||
|
||
HStack {
|
||
Button("Request Access") {
|
||
Task {
|
||
await calendarManager.checkCalendarAuthorization(forceReload: true)
|
||
await calendarManager.checkReminderAuthorization(forceReload: true)
|
||
}
|
||
}
|
||
.buttonStyle(.borderedProminent)
|
||
|
||
Button("Open System Settings") {
|
||
if let settingsURL = URL(string: "x-apple.systempreferences:com.apple.preference.security?Privacy_Calendars") {
|
||
NSWorkspace.shared.open(settingsURL)
|
||
}
|
||
}
|
||
}
|
||
} else {
|
||
// Permissions status
|
||
Section {
|
||
HStack {
|
||
Text("Calendars")
|
||
Spacer()
|
||
Text(statusText(for: calendarManager.calendarAuthorizationStatus))
|
||
.foregroundColor(color(for: calendarManager.calendarAuthorizationStatus))
|
||
}
|
||
HStack {
|
||
Text("Reminders")
|
||
Spacer()
|
||
Text(statusText(for: calendarManager.reminderAuthorizationStatus))
|
||
.foregroundColor(color(for: calendarManager.reminderAuthorizationStatus))
|
||
}
|
||
} header: {
|
||
Text("Permissions")
|
||
}
|
||
|
||
Defaults.Toggle("Show calendar", key: .showCalendar)
|
||
|
||
Section(header: Text("Reminder Live Activity")) {
|
||
Defaults.Toggle("Enable reminder live activity", key: .enableReminderLiveActivity)
|
||
|
||
Picker("Countdown style", selection: $reminderPresentationStyle) {
|
||
ForEach(ReminderPresentationStyle.allCases) { style in
|
||
Text(style.displayName).tag(style)
|
||
}
|
||
}
|
||
.pickerStyle(.segmented)
|
||
.disabled(!enableReminderLiveActivity)
|
||
|
||
HStack {
|
||
Text("Notify before")
|
||
Slider(
|
||
value: Binding(
|
||
get: { Double(reminderLeadTime) },
|
||
set: { reminderLeadTime = Int($0) }
|
||
),
|
||
in: 1...60,
|
||
step: 1
|
||
)
|
||
.disabled(!enableReminderLiveActivity)
|
||
Text("\(reminderLeadTime) min")
|
||
.foregroundStyle(.secondary)
|
||
.frame(width: 60, alignment: .trailing)
|
||
}
|
||
|
||
HStack {
|
||
Text("Sneak peek duration")
|
||
Slider(
|
||
value: $reminderSneakPeekDuration,
|
||
in: 3...20,
|
||
step: 1
|
||
)
|
||
.disabled(!enableReminderLiveActivity)
|
||
Text("\(Int(reminderSneakPeekDuration)) s")
|
||
.foregroundStyle(.secondary)
|
||
.frame(width: 60, alignment: .trailing)
|
||
}
|
||
}
|
||
|
||
Section(header: Text("Lock Screen Reminder Widget")) {
|
||
Defaults.Toggle("Show lock screen reminder", key: .enableLockScreenReminderWidget)
|
||
|
||
Picker("Chip color", selection: $lockScreenReminderChipStyle) {
|
||
ForEach(LockScreenReminderChipStyle.allCases) { style in
|
||
Text(style.rawValue).tag(style)
|
||
}
|
||
}
|
||
.pickerStyle(.segmented)
|
||
.disabled(!enableLockScreenReminderWidget || !enableReminderLiveActivity)
|
||
}
|
||
|
||
Section(header: Text("Select Calendars")) {
|
||
List {
|
||
ForEach(calendarManager.allCalendars, id: \.id) { calendar in
|
||
Toggle(isOn: Binding(
|
||
get: { calendarManager.getCalendarSelected(calendar) },
|
||
set: { isSelected in
|
||
Task {
|
||
await calendarManager.setCalendarSelected(calendar, isSelected: isSelected)
|
||
}
|
||
}
|
||
)) {
|
||
Text(calendar.title)
|
||
}
|
||
.disabled(!showCalendar)
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
.onAppear {
|
||
Task {
|
||
await calendarManager.checkCalendarAuthorization()
|
||
await calendarManager.checkReminderAuthorization()
|
||
}
|
||
}
|
||
.navigationTitle("Calendar")
|
||
}
|
||
|
||
private func statusText(for status: EKAuthorizationStatus) -> String {
|
||
switch status {
|
||
case .fullAccess, .authorized: return "Full Access"
|
||
case .writeOnly: return "Write Only"
|
||
case .denied: return "Denied"
|
||
case .restricted: return "Restricted"
|
||
case .notDetermined: return "Not Determined"
|
||
@unknown default: return "Unknown"
|
||
}
|
||
}
|
||
|
||
private func color(for status: EKAuthorizationStatus) -> Color {
|
||
switch status {
|
||
case .fullAccess, .authorized: return .green
|
||
case .writeOnly: return .yellow
|
||
case .denied, .restricted: return .red
|
||
case .notDetermined: return .secondary
|
||
@unknown default: return .secondary
|
||
}
|
||
}
|
||
}
|
||
|
||
struct About: View {
|
||
@State private var showBuildNumber: Bool = false
|
||
let updaterController: SPUStandardUpdaterController
|
||
@Environment(\.openWindow) var openWindow
|
||
var body: some View {
|
||
VStack {
|
||
Form {
|
||
Section {
|
||
HStack {
|
||
Text("Release name")
|
||
Spacer()
|
||
Text(Defaults[.releaseName])
|
||
.foregroundStyle(.secondary)
|
||
}
|
||
HStack {
|
||
Text("Version")
|
||
Spacer()
|
||
if showBuildNumber {
|
||
Text("(\(Bundle.main.buildVersionNumber ?? ""))")
|
||
.foregroundStyle(.secondary)
|
||
}
|
||
Text(Bundle.main.releaseVersionNumber ?? "unkown")
|
||
.foregroundStyle(.secondary)
|
||
}
|
||
.onTapGesture {
|
||
withAnimation {
|
||
showBuildNumber.toggle()
|
||
}
|
||
}
|
||
} header: {
|
||
Text("Version info")
|
||
}
|
||
|
||
UpdaterSettingsView(updater: updaterController.updater)
|
||
|
||
HStack(spacing: 30) {
|
||
Spacer(minLength: 0)
|
||
Button {
|
||
NSWorkspace.shared.open(sponsorPage)
|
||
} label: {
|
||
VStack(spacing: 5) {
|
||
Image("LinkedIn")
|
||
.resizable()
|
||
.aspectRatio(contentMode: .fit)
|
||
.frame(width: 18)
|
||
Text("LinkedIn")
|
||
.foregroundStyle(.white)
|
||
}
|
||
.contentShape(Rectangle())
|
||
}
|
||
Spacer(minLength: 0)
|
||
Button {
|
||
NSWorkspace.shared.open(productPage)
|
||
} label: {
|
||
VStack(spacing: 5) {
|
||
Image("Github")
|
||
.resizable()
|
||
.aspectRatio(contentMode: .fit)
|
||
.frame(width: 18)
|
||
Text("GitHub")
|
||
.foregroundStyle(.white)
|
||
}
|
||
.contentShape(Rectangle())
|
||
}
|
||
Spacer(minLength: 0)
|
||
}
|
||
.buttonStyle(PlainButtonStyle())
|
||
}
|
||
VStack(spacing: 0) {
|
||
Divider()
|
||
Text("Made with ❤️ by Ebullioscopic")
|
||
.foregroundStyle(.secondary)
|
||
.padding(.top, 5)
|
||
.padding(.bottom, 7)
|
||
.multilineTextAlignment(.center)
|
||
.padding(.horizontal, 10)
|
||
}
|
||
.frame(maxWidth: .infinity, alignment: .center)
|
||
}
|
||
.toolbar {
|
||
// Button("Welcome window") {
|
||
// openWindow(id: "onboarding")
|
||
// }
|
||
// .controlSize(.extraLarge)
|
||
CheckForUpdatesView(updater: updaterController.updater)
|
||
}
|
||
.navigationTitle("About")
|
||
}
|
||
}
|
||
|
||
struct Shelf: View {
|
||
var body: some View {
|
||
Form {
|
||
Section {
|
||
Defaults.Toggle("Enable shelf", key: .dynamicShelf)
|
||
Defaults.Toggle("Open shelf tab by default if items added", key: .openShelfByDefault)
|
||
} header: {
|
||
HStack {
|
||
Text("General")
|
||
}
|
||
}
|
||
}
|
||
.navigationTitle("Shelf")
|
||
}
|
||
}
|
||
|
||
struct Extensions: View {
|
||
@EnvironmentObject var extensionManager: DynamicIslandExtensionManager
|
||
@State private var effectTrigger: Bool = false
|
||
var body: some View {
|
||
Form {
|
||
//warningBadge("We don't support extensions yet") // Uhhhh You do? <><><> Oori.S
|
||
Section {
|
||
List {
|
||
ForEach(extensionManager.installedExtensions.indices, id: \.self) { index in
|
||
let item = extensionManager.installedExtensions[index]
|
||
HStack {
|
||
AppIcon(for: item.bundleIdentifier)
|
||
.resizable()
|
||
.frame(width: 24, height: 24)
|
||
Text(item.name)
|
||
ListItemPopover {
|
||
Text("Description")
|
||
}
|
||
Spacer(minLength: 0)
|
||
HStack(spacing: 6) {
|
||
Circle()
|
||
.frame(width: 6, height: 6)
|
||
.foregroundColor(isExtensionRunning(item.bundleIdentifier) ? .green : item.status == .disabled ? .gray : .red)
|
||
.conditionalModifier(isExtensionRunning(item.bundleIdentifier)) { view in
|
||
view
|
||
.shadow(color: .green, radius: 3)
|
||
}
|
||
Text(isExtensionRunning(item.bundleIdentifier) ? "Running" : item.status == .disabled ? "Disabled" : "Stopped")
|
||
.contentTransition(.numericText())
|
||
.foregroundStyle(.secondary)
|
||
.font(.footnote)
|
||
}
|
||
.frame(width: 60, alignment: .leading)
|
||
|
||
Menu(content: {
|
||
Button("Restart") {
|
||
let ws = NSWorkspace.shared
|
||
|
||
if let ext = ws.runningApplications.first(where: { $0.bundleIdentifier == item.bundleIdentifier }) {
|
||
ext.terminate()
|
||
}
|
||
|
||
if let appURL = ws.urlForApplication(withBundleIdentifier: item.bundleIdentifier) {
|
||
ws.openApplication(at: appURL, configuration: .init(), completionHandler: nil)
|
||
}
|
||
}
|
||
.keyboardShortcut("R", modifiers: .command)
|
||
Button("Disable") {
|
||
if let ext = NSWorkspace.shared.runningApplications.first(where: { $0.bundleIdentifier == item.bundleIdentifier }) {
|
||
ext.terminate()
|
||
}
|
||
extensionManager.installedExtensions[index].status = .disabled
|
||
}
|
||
.keyboardShortcut("D", modifiers: .command)
|
||
Divider()
|
||
Button("Uninstall", role: .destructive) {
|
||
//
|
||
}
|
||
}, label: {
|
||
Image(systemName: "ellipsis.circle")
|
||
.foregroundStyle(.secondary)
|
||
})
|
||
.controlSize(.regular)
|
||
}
|
||
.buttonStyle(PlainButtonStyle())
|
||
.padding(.vertical, 5)
|
||
}
|
||
}
|
||
.frame(minHeight: 120)
|
||
.actionBar {
|
||
Button {} label: {
|
||
HStack(spacing: 3) {
|
||
Image(systemName: "plus")
|
||
Text("Add manually")
|
||
}
|
||
.foregroundStyle(.secondary)
|
||
}
|
||
.disabled(true)
|
||
Spacer()
|
||
Button {
|
||
withAnimation(.linear(duration: 1)) {
|
||
effectTrigger.toggle()
|
||
} completion: {
|
||
effectTrigger.toggle()
|
||
}
|
||
extensionManager.checkIfExtensionsAreInstalled()
|
||
} label: {
|
||
HStack(spacing: 3) {
|
||
Image(systemName: "arrow.triangle.2.circlepath")
|
||
.rotationEffect(effectTrigger ? .degrees(360) : .zero)
|
||
}
|
||
.foregroundStyle(.secondary)
|
||
}
|
||
}
|
||
.controlSize(.small)
|
||
.buttonStyle(PlainButtonStyle())
|
||
.overlay {
|
||
if extensionManager.installedExtensions.isEmpty {
|
||
Text("No extension installed")
|
||
.foregroundStyle(Color(.secondaryLabelColor))
|
||
.padding(.bottom, 22)
|
||
}
|
||
}
|
||
} header: {
|
||
HStack(spacing: 0) {
|
||
Text("Installed extensions")
|
||
if !extensionManager.installedExtensions.isEmpty {
|
||
Text(" – \(extensionManager.installedExtensions.count)")
|
||
.foregroundStyle(.secondary)
|
||
}
|
||
}
|
||
}
|
||
}
|
||
.navigationTitle("Extensions")
|
||
// TipsView()
|
||
// .padding(.horizontal, 19)
|
||
}
|
||
}
|
||
|
||
struct Appearance: View {
|
||
@ObservedObject var coordinator = DynamicIslandViewCoordinator.shared
|
||
@Default(.mirrorShape) var mirrorShape
|
||
@Default(.sliderColor) var sliderColor
|
||
@Default(.useMusicVisualizer) var useMusicVisualizer
|
||
@Default(.customVisualizers) var customVisualizers
|
||
@Default(.selectedVisualizer) var selectedVisualizer
|
||
let icons: [String] = ["logo2"]
|
||
@State private var selectedIcon: String = "logo2"
|
||
@State private var selectedListVisualizer: CustomVisualizer? = nil
|
||
|
||
@State private var isPresented: Bool = false
|
||
@State private var name: String = ""
|
||
@State private var url: String = ""
|
||
@State private var speed: CGFloat = 1.0
|
||
var body: some View {
|
||
Form {
|
||
Section {
|
||
Toggle("Always show tabs", isOn: $coordinator.alwaysShowTabs)
|
||
Defaults.Toggle("Settings icon in notch", key: .settingsIconInNotch)
|
||
Defaults.Toggle("Enable window shadow", key: .enableShadow)
|
||
Defaults.Toggle("Corner radius scaling", key: .cornerRadiusScaling)
|
||
Defaults.Toggle("Use simpler close animation", key: .useModernCloseAnimation)
|
||
} header: {
|
||
Text("General")
|
||
}
|
||
|
||
Section {
|
||
Defaults.Toggle("Enable colored spectrograms", key: .coloredSpectrogram)
|
||
Defaults
|
||
.Toggle("Player tinting", key: .playerColorTinting)
|
||
Defaults.Toggle("Enable blur effect behind album art", key: .lightingEffect)
|
||
Picker("Slider color", selection: $sliderColor) {
|
||
ForEach(SliderColorEnum.allCases, id: \.self) { option in
|
||
Text(option.rawValue)
|
||
}
|
||
}
|
||
} header: {
|
||
Text("Media")
|
||
}
|
||
|
||
Section {
|
||
Toggle(
|
||
"Use music visualizer spectrogram",
|
||
isOn: $useMusicVisualizer.animation()
|
||
)
|
||
.disabled(true)
|
||
if !useMusicVisualizer {
|
||
if customVisualizers.count > 0 {
|
||
Picker(
|
||
"Selected animation",
|
||
selection: $selectedVisualizer
|
||
) {
|
||
ForEach(
|
||
customVisualizers,
|
||
id: \.self
|
||
) { visualizer in
|
||
Text(visualizer.name)
|
||
.tag(visualizer)
|
||
}
|
||
}
|
||
} else {
|
||
HStack {
|
||
Text("Selected animation")
|
||
Spacer()
|
||
Text("No custom animation available")
|
||
.foregroundStyle(.secondary)
|
||
}
|
||
}
|
||
}
|
||
} header: {
|
||
HStack {
|
||
Text("Custom music live activity animation")
|
||
customBadge(text: "Coming soon")
|
||
}
|
||
}
|
||
|
||
Section {
|
||
List {
|
||
ForEach(customVisualizers, id: \.self) { visualizer in
|
||
HStack {
|
||
LottieView(state: LUStateData(type: .loadedFrom(visualizer.url), speed: visualizer.speed, loopMode: .loop))
|
||
.frame(width: 30, height: 30, alignment: .center)
|
||
Text(visualizer.name)
|
||
Spacer(minLength: 0)
|
||
if selectedVisualizer == visualizer {
|
||
Text("selected")
|
||
.font(.caption)
|
||
.fontWeight(.medium)
|
||
.foregroundStyle(.secondary)
|
||
.padding(.trailing, 8)
|
||
}
|
||
}
|
||
.buttonStyle(PlainButtonStyle())
|
||
.padding(.vertical, 2)
|
||
.background(
|
||
selectedListVisualizer != nil ? selectedListVisualizer == visualizer ? Color.accentColor : Color.clear : Color.clear,
|
||
in: RoundedRectangle(cornerRadius: 5)
|
||
)
|
||
.contentShape(Rectangle())
|
||
.onTapGesture {
|
||
if selectedListVisualizer == visualizer {
|
||
selectedListVisualizer = nil
|
||
return
|
||
}
|
||
selectedListVisualizer = visualizer
|
||
}
|
||
}
|
||
}
|
||
.safeAreaPadding(
|
||
EdgeInsets(top: 5, leading: 0, bottom: 5, trailing: 0)
|
||
)
|
||
.frame(minHeight: 120)
|
||
.actionBar {
|
||
HStack(spacing: 5) {
|
||
Button {
|
||
name = ""
|
||
url = ""
|
||
speed = 1.0
|
||
isPresented.toggle()
|
||
} label: {
|
||
Image(systemName: "plus")
|
||
.foregroundStyle(.secondary)
|
||
.contentShape(Rectangle())
|
||
}
|
||
Divider()
|
||
Button {
|
||
if selectedListVisualizer != nil {
|
||
let visualizer = selectedListVisualizer!
|
||
selectedListVisualizer = nil
|
||
customVisualizers.remove(at: customVisualizers.firstIndex(of: visualizer)!)
|
||
if visualizer == selectedVisualizer && customVisualizers.count > 0 {
|
||
selectedVisualizer = customVisualizers[0]
|
||
}
|
||
}
|
||
} label: {
|
||
Image(systemName: "minus")
|
||
.foregroundStyle(.secondary)
|
||
.contentShape(Rectangle())
|
||
}
|
||
}
|
||
}
|
||
.controlSize(.small)
|
||
.buttonStyle(PlainButtonStyle())
|
||
.overlay {
|
||
if customVisualizers.isEmpty {
|
||
Text("No custom visualizer")
|
||
.foregroundStyle(Color(.secondaryLabelColor))
|
||
.padding(.bottom, 22)
|
||
}
|
||
}
|
||
.sheet(isPresented: $isPresented) {
|
||
VStack(alignment: .leading) {
|
||
Text("Add new visualizer")
|
||
.font(.largeTitle.bold())
|
||
.padding(.vertical)
|
||
TextField("Name", text: $name)
|
||
TextField("Lottie JSON URL", text: $url)
|
||
HStack {
|
||
Text("Speed")
|
||
Spacer(minLength: 80)
|
||
Text("\(speed, specifier: "%.1f")s")
|
||
.multilineTextAlignment(.trailing)
|
||
.foregroundStyle(.secondary)
|
||
Slider(value: $speed, in: 0...2, step: 0.1)
|
||
}
|
||
.padding(.vertical)
|
||
HStack {
|
||
Button {
|
||
isPresented.toggle()
|
||
} label: {
|
||
Text("Cancel")
|
||
.frame(maxWidth: .infinity, alignment: .center)
|
||
}
|
||
|
||
Button {
|
||
let visualizer: CustomVisualizer = .init(
|
||
UUID: UUID(),
|
||
name: name,
|
||
url: URL(string: url)!,
|
||
speed: speed
|
||
)
|
||
|
||
if !customVisualizers.contains(visualizer) {
|
||
customVisualizers.append(visualizer)
|
||
}
|
||
|
||
isPresented.toggle()
|
||
} label: {
|
||
Text("Add")
|
||
.frame(maxWidth: .infinity, alignment: .center)
|
||
}
|
||
.buttonStyle(BorderedProminentButtonStyle())
|
||
}
|
||
}
|
||
.textFieldStyle(RoundedBorderTextFieldStyle())
|
||
.controlSize(.extraLarge)
|
||
.padding()
|
||
}
|
||
} header: {
|
||
HStack(spacing: 0) {
|
||
Text("Custom vizualizers (Lottie)")
|
||
if !Defaults[.customVisualizers].isEmpty {
|
||
Text(" – \(Defaults[.customVisualizers].count)")
|
||
.foregroundStyle(.secondary)
|
||
}
|
||
}
|
||
}
|
||
|
||
Section {
|
||
Defaults.Toggle("Enable Dynamic mirror", key: .showMirror)
|
||
.disabled(!checkVideoInput())
|
||
Picker("Mirror shape", selection: $mirrorShape) {
|
||
Text("Circle")
|
||
.tag(MirrorShapeEnum.circle)
|
||
Text("Square")
|
||
.tag(MirrorShapeEnum.rectangle)
|
||
}
|
||
Defaults.Toggle("Show cool face animation while inactivity", key: .showNotHumanFace)
|
||
} header: {
|
||
HStack {
|
||
Text("Additional features")
|
||
}
|
||
}
|
||
|
||
// MARK: - Custom Idle Animations Section
|
||
IdleAnimationsSettingsSection()
|
||
|
||
Section {
|
||
HStack {
|
||
ForEach(icons, id: \.self) { icon in
|
||
Spacer()
|
||
VStack {
|
||
Image(icon)
|
||
.resizable()
|
||
.frame(width: 80, height: 80)
|
||
.background(
|
||
RoundedRectangle(cornerRadius: 20, style: .circular)
|
||
.strokeBorder(
|
||
icon == selectedIcon ? Color.accentColor : .clear,
|
||
lineWidth: 2.5
|
||
)
|
||
)
|
||
|
||
Text("Default")
|
||
.fontWeight(.medium)
|
||
.font(.caption)
|
||
.foregroundStyle(icon == selectedIcon ? .white : .secondary)
|
||
.padding(.horizontal, 10)
|
||
.padding(.vertical, 3)
|
||
.background(
|
||
Capsule()
|
||
.fill(icon == selectedIcon ? Color.accentColor : .clear)
|
||
)
|
||
}
|
||
.onTapGesture {
|
||
withAnimation {
|
||
selectedIcon = icon
|
||
}
|
||
NSApp.applicationIconImage = NSImage(named: icon)
|
||
}
|
||
Spacer()
|
||
}
|
||
}
|
||
.disabled(true)
|
||
} header: {
|
||
HStack {
|
||
Text("App icon")
|
||
customBadge(text: "Coming soon")
|
||
}
|
||
}
|
||
}
|
||
.navigationTitle("Appearance")
|
||
}
|
||
|
||
func checkVideoInput() -> Bool {
|
||
if let _ = AVCaptureDevice.default(for: .video) {
|
||
return true
|
||
}
|
||
|
||
return false
|
||
}
|
||
}
|
||
|
||
struct LockScreenSettings: View {
|
||
@Default(.lockScreenGlassStyle) private var lockScreenGlassStyle
|
||
@Default(.enableLockScreenMediaWidget) private var enableLockScreenMediaWidget
|
||
@Default(.enableLockScreenTimerWidget) private var enableLockScreenTimerWidget
|
||
@Default(.enableLockScreenWeatherWidget) private var enableLockScreenWeatherWidget
|
||
@Default(.lockScreenWeatherWidgetStyle) private var lockScreenWeatherWidgetStyle
|
||
@Default(.lockScreenWeatherProviderSource) private var lockScreenWeatherProviderSource
|
||
@Default(.lockScreenWeatherTemperatureUnit) private var lockScreenWeatherTemperatureUnit
|
||
@Default(.lockScreenWeatherShowsCharging) private var lockScreenWeatherShowsCharging
|
||
@Default(.lockScreenWeatherShowsBatteryGauge) private var lockScreenWeatherShowsBatteryGauge
|
||
@Default(.lockScreenWeatherShowsAQI) private var lockScreenWeatherShowsAQI
|
||
@Default(.lockScreenWeatherAQIScale) private var lockScreenWeatherAQIScale
|
||
|
||
var body: some View {
|
||
Form {
|
||
Section {
|
||
Defaults.Toggle("Enable lock screen live activity", key: .enableLockScreenLiveActivity)
|
||
Defaults.Toggle("Play lock/unlock sounds", key: .enableLockSounds)
|
||
} header: {
|
||
Text("Live Activity & Feedback")
|
||
} footer: {
|
||
Text("Controls whether Dynamic Island mirrors lock/unlock events with its own live activity and audible chimes.")
|
||
}
|
||
|
||
Section {
|
||
if #available(macOS 26.0, *) {
|
||
Picker("Material", selection: $lockScreenGlassStyle) {
|
||
ForEach(LockScreenGlassStyle.allCases) { style in
|
||
Text(style.rawValue).tag(style)
|
||
}
|
||
}
|
||
} else {
|
||
Picker("Material", selection: $lockScreenGlassStyle) {
|
||
ForEach(LockScreenGlassStyle.allCases) { style in
|
||
Text(style.rawValue).tag(style)
|
||
}
|
||
}
|
||
.disabled(true)
|
||
Text("Liquid Glass requires macOS 26 or later.")
|
||
.font(.caption)
|
||
.foregroundStyle(.secondary)
|
||
}
|
||
Defaults.Toggle("Show lock screen media panel", key: .enableLockScreenMediaWidget)
|
||
Defaults.Toggle("Show media app icon", key: .lockScreenShowAppIcon)
|
||
.disabled(!enableLockScreenMediaWidget)
|
||
Defaults.Toggle("Show panel border", key: .lockScreenPanelShowsBorder)
|
||
.disabled(!enableLockScreenMediaWidget)
|
||
Defaults.Toggle("Enable media panel blur", key: .lockScreenPanelUsesBlur)
|
||
.disabled(!enableLockScreenMediaWidget)
|
||
} header: {
|
||
Text("Media Panel")
|
||
} footer: {
|
||
Text("Enable and style the media controls that appear above the system clock when the screen is locked.")
|
||
}
|
||
|
||
Section {
|
||
Defaults.Toggle("Show lock screen timer", key: .enableLockScreenTimerWidget)
|
||
Defaults.Toggle("Enable timer blur", key: .lockScreenTimerWidgetUsesBlur)
|
||
.disabled(!enableLockScreenTimerWidget)
|
||
} header: {
|
||
Text("Timer Widget")
|
||
} footer: {
|
||
Text("Controls the optional timer widget that floats above the media panel. Blur adds a frosted finish behind the compact view.")
|
||
}
|
||
|
||
Section {
|
||
Defaults.Toggle("Show lock screen weather", key: .enableLockScreenWeatherWidget)
|
||
|
||
if enableLockScreenWeatherWidget {
|
||
Picker("Layout", selection: $lockScreenWeatherWidgetStyle) {
|
||
ForEach(LockScreenWeatherWidgetStyle.allCases) { style in
|
||
Text(style.rawValue).tag(style)
|
||
}
|
||
}
|
||
.pickerStyle(.segmented)
|
||
|
||
Picker("Weather data provider", selection: $lockScreenWeatherProviderSource) {
|
||
ForEach(LockScreenWeatherProviderSource.allCases) { source in
|
||
Text(source.displayName).tag(source)
|
||
}
|
||
}
|
||
.pickerStyle(.segmented)
|
||
|
||
Picker("Temperature unit", selection: $lockScreenWeatherTemperatureUnit) {
|
||
ForEach(LockScreenWeatherTemperatureUnit.allCases) { unit in
|
||
Text(unit.rawValue).tag(unit)
|
||
}
|
||
}
|
||
.pickerStyle(.segmented)
|
||
|
||
Defaults.Toggle("Show location label", key: .lockScreenWeatherShowsLocation)
|
||
.disabled(lockScreenWeatherWidgetStyle == .circular)
|
||
|
||
Defaults.Toggle("Show charging status", key: .lockScreenWeatherShowsCharging)
|
||
|
||
if lockScreenWeatherShowsCharging {
|
||
Defaults.Toggle("Show charging percentage", key: .lockScreenWeatherShowsChargingPercentage)
|
||
}
|
||
|
||
Defaults.Toggle("Show battery indicator", key: .lockScreenWeatherShowsBatteryGauge)
|
||
|
||
if lockScreenWeatherShowsBatteryGauge {
|
||
Defaults.Toggle("Use MacBook icon when on battery", key: .lockScreenWeatherBatteryUsesLaptopSymbol)
|
||
.disabled(lockScreenWeatherWidgetStyle != .circular)
|
||
|
||
if lockScreenWeatherWidgetStyle != .circular {
|
||
Text("The MacBook icon is available only in the circular layout.")
|
||
.font(.caption)
|
||
.foregroundStyle(.secondary)
|
||
}
|
||
}
|
||
|
||
Defaults.Toggle("Show Bluetooth battery", key: .lockScreenWeatherShowsBluetooth)
|
||
|
||
Defaults.Toggle("Show AQI widget", key: .lockScreenWeatherShowsAQI)
|
||
.disabled(!lockScreenWeatherProviderSource.supportsAirQuality)
|
||
|
||
if lockScreenWeatherShowsAQI && lockScreenWeatherProviderSource.supportsAirQuality {
|
||
Picker("Air quality scale", selection: $lockScreenWeatherAQIScale) {
|
||
ForEach(LockScreenWeatherAirQualityScale.allCases) { scale in
|
||
Text(scale.displayName).tag(scale)
|
||
}
|
||
}
|
||
.pickerStyle(.segmented)
|
||
}
|
||
|
||
if !lockScreenWeatherProviderSource.supportsAirQuality {
|
||
Text("Air quality requires the Open Meteo provider.")
|
||
.font(.caption)
|
||
.foregroundStyle(.secondary)
|
||
}
|
||
|
||
Defaults.Toggle("Use colored gauges", key: .lockScreenWeatherUsesGaugeTint)
|
||
}
|
||
} header: {
|
||
Text("Weather Widget")
|
||
} footer: {
|
||
Text("Enable the weather capsule and configure its layout, provider, units, and optional battery/AQI indicators.")
|
||
}
|
||
|
||
LockScreenPositioningControls()
|
||
|
||
Section {
|
||
Button("Copy Latest Crash Report") {
|
||
copyLatestCrashReport()
|
||
}
|
||
} header: {
|
||
Text("Diagnostics")
|
||
} footer: {
|
||
Text("Collect the latest crash report to share with the developer when reporting lock screen or overlay issues.")
|
||
}
|
||
}
|
||
.navigationTitle("Lock Screen")
|
||
}
|
||
}
|
||
|
||
private struct LockScreenPositioningControls: View {
|
||
@Default(.lockScreenWeatherVerticalOffset) private var weatherOffset
|
||
@Default(.lockScreenMusicVerticalOffset) private var musicOffset
|
||
@Default(.lockScreenTimerVerticalOffset) private var timerOffset
|
||
private let offsetRange: ClosedRange<Double> = -160...160
|
||
|
||
var body: some View {
|
||
Section {
|
||
let weatherBinding = Binding<Double>(
|
||
get: { weatherOffset },
|
||
set: { newValue in
|
||
let clampedValue = clamp(newValue)
|
||
if weatherOffset != clampedValue {
|
||
weatherOffset = clampedValue
|
||
}
|
||
propagateWeatherOffsetChange(animated: false)
|
||
}
|
||
)
|
||
|
||
let timerBinding = Binding<Double>(
|
||
get: { timerOffset },
|
||
set: { newValue in
|
||
let clampedValue = clamp(newValue)
|
||
if timerOffset != clampedValue {
|
||
timerOffset = clampedValue
|
||
}
|
||
propagateTimerOffsetChange(animated: false)
|
||
}
|
||
)
|
||
|
||
let musicBinding = Binding<Double>(
|
||
get: { musicOffset },
|
||
set: { newValue in
|
||
let clampedValue = clamp(newValue)
|
||
if musicOffset != clampedValue {
|
||
musicOffset = clampedValue
|
||
}
|
||
propagateMusicOffsetChange(animated: false)
|
||
}
|
||
)
|
||
|
||
LockScreenPositioningPreview(weatherOffset: weatherBinding, timerOffset: timerBinding, musicOffset: musicBinding)
|
||
.frame(height: 260)
|
||
.padding(.vertical, 8)
|
||
|
||
HStack(alignment: .top, spacing: 24) {
|
||
offsetColumn(
|
||
title: "Weather",
|
||
value: weatherOffset,
|
||
resetTitle: "Reset Weather",
|
||
resetAction: resetWeatherOffset
|
||
)
|
||
|
||
Divider()
|
||
.frame(height: 64)
|
||
|
||
offsetColumn(
|
||
title: "Timer",
|
||
value: timerOffset,
|
||
resetTitle: "Reset Timer",
|
||
resetAction: resetTimerOffset
|
||
)
|
||
|
||
Divider()
|
||
.frame(height: 64)
|
||
|
||
offsetColumn(
|
||
title: "Music",
|
||
value: musicOffset,
|
||
resetTitle: "Reset Music",
|
||
resetAction: resetMusicOffset
|
||
)
|
||
|
||
Spacer()
|
||
}
|
||
} header: {
|
||
Text("Lock Screen Positioning")
|
||
} footer: {
|
||
Text("Drag the previews to adjust vertical placement. Positive values lift the panel; negative values lower it. Changes apply instantly while the widgets are visible.")
|
||
.textCase(nil)
|
||
}
|
||
}
|
||
|
||
private func clamp(_ value: Double) -> Double {
|
||
min(max(value, offsetRange.lowerBound), offsetRange.upperBound)
|
||
}
|
||
|
||
private func resetWeatherOffset() {
|
||
weatherOffset = 0
|
||
propagateWeatherOffsetChange(animated: true)
|
||
}
|
||
|
||
private func resetTimerOffset() {
|
||
timerOffset = 0
|
||
propagateTimerOffsetChange(animated: true)
|
||
}
|
||
|
||
private func resetMusicOffset() {
|
||
musicOffset = 0
|
||
propagateMusicOffsetChange(animated: true)
|
||
}
|
||
|
||
private func propagateWeatherOffsetChange(animated: Bool) {
|
||
Task { @MainActor in
|
||
LockScreenWeatherPanelManager.shared.refreshPositionForOffsets(animated: animated)
|
||
}
|
||
}
|
||
|
||
private func propagateTimerOffsetChange(animated: Bool) {
|
||
Task { @MainActor in
|
||
LockScreenTimerWidgetManager.shared.refreshPositionForOffsets(animated: animated)
|
||
}
|
||
}
|
||
|
||
private func propagateMusicOffsetChange(animated: Bool) {
|
||
Task { @MainActor in
|
||
LockScreenPanelManager.shared.applyOffsetAdjustment(animated: animated)
|
||
}
|
||
}
|
||
|
||
@ViewBuilder
|
||
private func offsetColumn(title: String, value: Double, resetTitle: String, resetAction: @escaping () -> Void) -> some View {
|
||
VStack(alignment: .leading, spacing: 6) {
|
||
Text("\(title) Offset")
|
||
.font(.subheadline.weight(.semibold))
|
||
|
||
Text("\(formattedPoints(value)) pt")
|
||
.font(.caption)
|
||
.foregroundStyle(.secondary)
|
||
|
||
Button(resetTitle) {
|
||
resetAction()
|
||
}
|
||
.buttonStyle(.bordered)
|
||
}
|
||
}
|
||
|
||
private func formattedPoints(_ value: Double) -> String {
|
||
String(format: "%+.0f", value)
|
||
}
|
||
}
|
||
|
||
private struct LockScreenPositioningPreview: View {
|
||
@Binding var weatherOffset: Double
|
||
@Binding var timerOffset: Double
|
||
@Binding var musicOffset: Double
|
||
|
||
@State private var weatherStartOffset: Double = 0
|
||
@State private var timerStartOffset: Double = 0
|
||
@State private var musicStartOffset: Double = 0
|
||
@State private var isWeatherDragging = false
|
||
@State private var isTimerDragging = false
|
||
@State private var isMusicDragging = false
|
||
|
||
private let offsetRange: ClosedRange<Double> = -160...160
|
||
|
||
var body: some View {
|
||
GeometryReader { geometry in
|
||
let screenPadding: CGFloat = 26
|
||
let screenCornerRadius: CGFloat = 28
|
||
let screenRect = CGRect(
|
||
x: screenPadding,
|
||
y: screenPadding,
|
||
width: geometry.size.width - (screenPadding * 2),
|
||
height: geometry.size.height - (screenPadding * 2)
|
||
)
|
||
let centerX = screenRect.midX
|
||
let weatherBaseY = screenRect.minY + (screenRect.height * 0.28)
|
||
let timerBaseY = screenRect.minY + (screenRect.height * 0.5)
|
||
let musicBaseY = screenRect.minY + (screenRect.height * 0.78)
|
||
let weatherSize = CGSize(width: screenRect.width * 0.42, height: screenRect.height * 0.22)
|
||
let timerSize = CGSize(width: screenRect.width * 0.5, height: screenRect.height * 0.2)
|
||
let musicSize = CGSize(width: screenRect.width * 0.56, height: screenRect.height * 0.34)
|
||
|
||
ZStack {
|
||
RoundedRectangle(cornerRadius: screenCornerRadius, style: .continuous)
|
||
.fill(Color(nsColor: .windowBackgroundColor).opacity(0.55))
|
||
.frame(width: screenRect.width, height: screenRect.height)
|
||
.overlay(
|
||
RoundedRectangle(cornerRadius: screenCornerRadius, style: .continuous)
|
||
.stroke(Color.white.opacity(0.12), lineWidth: 1)
|
||
)
|
||
.shadow(color: Color.black.opacity(0.22), radius: 20, x: 0, y: 18)
|
||
.position(x: screenRect.midX, y: screenRect.midY)
|
||
|
||
weatherPanel(size: weatherSize)
|
||
.position(x: centerX, y: weatherBaseY - CGFloat(weatherOffset))
|
||
.gesture(weatherDragGesture(in: screenRect, baseY: weatherBaseY, panelSize: weatherSize))
|
||
|
||
timerPanel(size: timerSize)
|
||
.position(x: centerX, y: timerBaseY - CGFloat(timerOffset))
|
||
.gesture(timerDragGesture(in: screenRect, baseY: timerBaseY, panelSize: timerSize))
|
||
|
||
musicPanel(size: musicSize)
|
||
.position(x: centerX, y: musicBaseY - CGFloat(musicOffset))
|
||
.gesture(musicDragGesture(in: screenRect, baseY: musicBaseY, panelSize: musicSize))
|
||
}
|
||
}
|
||
.animation(.interactiveSpring(response: 0.3, dampingFraction: 0.82), value: weatherOffset)
|
||
.animation(.interactiveSpring(response: 0.3, dampingFraction: 0.82), value: musicOffset)
|
||
}
|
||
|
||
private func weatherPanel(size: CGSize) -> some View {
|
||
RoundedRectangle(cornerRadius: 16, style: .continuous)
|
||
.fill(
|
||
LinearGradient(
|
||
colors: [Color.blue.opacity(0.78), Color.blue.opacity(0.52)],
|
||
startPoint: .topLeading,
|
||
endPoint: .bottomTrailing
|
||
)
|
||
)
|
||
.frame(width: size.width, height: size.height)
|
||
.overlay(alignment: .leading) {
|
||
VStack(alignment: .leading, spacing: 4) {
|
||
Label("Weather", systemImage: "cloud.sun.fill")
|
||
.font(.system(size: 12, weight: .semibold))
|
||
.foregroundStyle(Color.white)
|
||
Text("Inline snapshot preview")
|
||
.font(.system(size: 10, weight: .medium))
|
||
.foregroundStyle(Color.white.opacity(0.72))
|
||
}
|
||
.padding(.horizontal, 16)
|
||
}
|
||
.shadow(color: Color.blue.opacity(0.22), radius: 10, x: 0, y: 8)
|
||
}
|
||
|
||
private func musicPanel(size: CGSize) -> some View {
|
||
RoundedRectangle(cornerRadius: 20, style: .continuous)
|
||
.fill(
|
||
LinearGradient(
|
||
colors: [Color.purple.opacity(0.68), Color.pink.opacity(0.5)],
|
||
startPoint: .topLeading,
|
||
endPoint: .bottomTrailing
|
||
)
|
||
)
|
||
.frame(width: size.width, height: size.height)
|
||
.overlay(alignment: .leading) {
|
||
VStack(alignment: .leading, spacing: 6) {
|
||
Label("Media", systemImage: "play.square.stack")
|
||
.font(.system(size: 13, weight: .semibold))
|
||
.foregroundStyle(Color.white)
|
||
Text("Lock screen panel preview")
|
||
.font(.system(size: 10, weight: .medium))
|
||
.foregroundStyle(Color.white.opacity(0.72))
|
||
}
|
||
.padding(.horizontal, 18)
|
||
}
|
||
.shadow(color: Color.purple.opacity(0.24), radius: 12, x: 0, y: 9)
|
||
}
|
||
|
||
private func timerPanel(size: CGSize) -> some View {
|
||
RoundedRectangle(cornerRadius: 18, style: .continuous)
|
||
.fill(
|
||
LinearGradient(
|
||
colors: [Color.orange.opacity(0.75), Color.purple.opacity(0.55)],
|
||
startPoint: .topLeading,
|
||
endPoint: .bottomTrailing
|
||
)
|
||
)
|
||
.frame(width: size.width, height: size.height)
|
||
.overlay {
|
||
VStack(spacing: 6) {
|
||
Text("Timer")
|
||
.font(.system(size: 12, weight: .semibold))
|
||
.foregroundStyle(.white)
|
||
Text("00:05:00")
|
||
.font(.system(size: 18, weight: .bold, design: .monospaced))
|
||
.foregroundStyle(.white)
|
||
}
|
||
}
|
||
.shadow(color: Color.orange.opacity(0.3), radius: 12, x: 0, y: 8)
|
||
}
|
||
|
||
private func weatherDragGesture(in screenRect: CGRect, baseY: CGFloat, panelSize: CGSize) -> some Gesture {
|
||
DragGesture(minimumDistance: 0)
|
||
.onChanged { value in
|
||
if !isWeatherDragging {
|
||
isWeatherDragging = true
|
||
weatherStartOffset = weatherOffset
|
||
}
|
||
|
||
let proposed = weatherStartOffset - Double(value.translation.height)
|
||
weatherOffset = clampedOffset(
|
||
proposed,
|
||
baseCenterY: baseY,
|
||
panelHeight: panelSize.height,
|
||
screenRect: screenRect
|
||
)
|
||
}
|
||
.onEnded { _ in
|
||
isWeatherDragging = false
|
||
}
|
||
}
|
||
|
||
private func musicDragGesture(in screenRect: CGRect, baseY: CGFloat, panelSize: CGSize) -> some Gesture {
|
||
DragGesture(minimumDistance: 0)
|
||
.onChanged { value in
|
||
if !isMusicDragging {
|
||
isMusicDragging = true
|
||
musicStartOffset = musicOffset
|
||
}
|
||
|
||
let proposed = musicStartOffset - Double(value.translation.height)
|
||
musicOffset = clampedOffset(
|
||
proposed,
|
||
baseCenterY: baseY,
|
||
panelHeight: panelSize.height,
|
||
screenRect: screenRect
|
||
)
|
||
}
|
||
.onEnded { _ in
|
||
isMusicDragging = false
|
||
}
|
||
}
|
||
|
||
private func timerDragGesture(in screenRect: CGRect, baseY: CGFloat, panelSize: CGSize) -> some Gesture {
|
||
DragGesture(minimumDistance: 0)
|
||
.onChanged { value in
|
||
if !isTimerDragging {
|
||
isTimerDragging = true
|
||
timerStartOffset = timerOffset
|
||
}
|
||
|
||
let proposed = timerStartOffset - Double(value.translation.height)
|
||
timerOffset = clampedOffset(
|
||
proposed,
|
||
baseCenterY: baseY,
|
||
panelHeight: panelSize.height,
|
||
screenRect: screenRect
|
||
)
|
||
}
|
||
.onEnded { _ in
|
||
isTimerDragging = false
|
||
}
|
||
}
|
||
|
||
private func clampedOffset(
|
||
_ proposed: Double,
|
||
baseCenterY: CGFloat,
|
||
panelHeight: CGFloat,
|
||
screenRect: CGRect
|
||
) -> Double {
|
||
let halfHeight = panelHeight / 2
|
||
let minCenterY = screenRect.minY + halfHeight
|
||
let maxCenterY = screenRect.maxY - halfHeight
|
||
let proposedCenter = baseCenterY - CGFloat(proposed)
|
||
let clampedCenter = min(max(proposedCenter, minCenterY), maxCenterY)
|
||
let derivedOffset = Double(baseCenterY - clampedCenter)
|
||
return min(max(derivedOffset, offsetRange.lowerBound), offsetRange.upperBound)
|
||
}
|
||
}
|
||
|
||
private func copyLatestCrashReport() {
|
||
let crashReportsPath = NSString(string: "~/Library/Logs/DiagnosticReports").expandingTildeInPath
|
||
let fileManager = FileManager.default
|
||
|
||
do {
|
||
let files = try fileManager.contentsOfDirectory(atPath: crashReportsPath)
|
||
let crashFiles = files.filter { $0.contains("DynamicIsland") && $0.hasSuffix(".crash") }
|
||
|
||
guard let latestCrash = crashFiles.sorted(by: >).first else {
|
||
let alert = NSAlert()
|
||
alert.messageText = "No Crash Reports Found"
|
||
alert.informativeText = "No crash reports found for DynamicIsland"
|
||
alert.alertStyle = .informational
|
||
alert.runModal()
|
||
return
|
||
}
|
||
|
||
let crashPath = (crashReportsPath as NSString).appendingPathComponent(latestCrash)
|
||
let crashContent = try String(contentsOfFile: crashPath, encoding: .utf8)
|
||
|
||
NSPasteboard.general.clearContents()
|
||
NSPasteboard.general.setString(crashContent, forType: .string)
|
||
|
||
let alert = NSAlert()
|
||
alert.messageText = "Crash Report Copied"
|
||
alert.informativeText = "Crash report '\(latestCrash)' has been copied to clipboard"
|
||
alert.alertStyle = .informational
|
||
alert.runModal()
|
||
} catch {
|
||
let alert = NSAlert()
|
||
alert.messageText = "Error"
|
||
alert.informativeText = "Failed to read crash reports: \(error.localizedDescription)"
|
||
alert.alertStyle = .warning
|
||
alert.runModal()
|
||
}
|
||
}
|
||
|
||
struct Shortcuts: View {
|
||
@Default(.enableTimerFeature) var enableTimerFeature
|
||
@Default(.enableClipboardManager) var enableClipboardManager
|
||
@Default(.enableShortcuts) var enableShortcuts
|
||
@Default(.enableStatsFeature) var enableStatsFeature
|
||
@Default(.enableColorPickerFeature) var enableColorPickerFeature
|
||
|
||
var body: some View {
|
||
Form {
|
||
Section {
|
||
Defaults.Toggle("Enable global keyboard shortcuts", key: .enableShortcuts)
|
||
} header: {
|
||
Text("General")
|
||
} footer: {
|
||
Text("When disabled, all keyboard shortcuts will be inactive. You can still use the UI controls.")
|
||
.multilineTextAlignment(.trailing)
|
||
.foregroundStyle(.secondary)
|
||
.font(.caption)
|
||
}
|
||
|
||
if enableShortcuts {
|
||
Section {
|
||
KeyboardShortcuts.Recorder("Toggle Sneak Peek:", name: .toggleSneakPeek)
|
||
.disabled(!enableShortcuts)
|
||
} header: {
|
||
Text("Media")
|
||
} footer: {
|
||
Text("Sneak Peek shows the media title and artist under the notch for a few seconds.")
|
||
.multilineTextAlignment(.trailing)
|
||
.foregroundStyle(.secondary)
|
||
.font(.caption)
|
||
}
|
||
|
||
Section {
|
||
KeyboardShortcuts.Recorder("Toggle Notch Open:", name: .toggleNotchOpen)
|
||
.disabled(!enableShortcuts)
|
||
} header: {
|
||
Text("Navigation")
|
||
} footer: {
|
||
Text("Toggle the Dynamic Island open or closed from anywhere.")
|
||
.multilineTextAlignment(.trailing)
|
||
.foregroundStyle(.secondary)
|
||
.font(.caption)
|
||
}
|
||
|
||
Section {
|
||
HStack {
|
||
VStack(alignment: .leading) {
|
||
KeyboardShortcuts.Recorder("Start Demo Timer:", name: .startDemoTimer)
|
||
.disabled(!enableShortcuts || !enableTimerFeature)
|
||
if !enableTimerFeature {
|
||
Text("Timer feature is disabled")
|
||
.font(.caption)
|
||
.foregroundStyle(.secondary)
|
||
.padding(.top, 2)
|
||
}
|
||
}
|
||
Spacer()
|
||
}
|
||
} header: {
|
||
Text("Timer")
|
||
} footer: {
|
||
Text("Starts a 5-minute demo timer to test the timer live activity feature. Only works when timer feature is enabled.")
|
||
.multilineTextAlignment(.trailing)
|
||
.foregroundStyle(.secondary)
|
||
.font(.caption)
|
||
}
|
||
|
||
Section {
|
||
HStack {
|
||
VStack(alignment: .leading) {
|
||
KeyboardShortcuts.Recorder("Clipboard History:", name: .clipboardHistoryPanel)
|
||
.disabled(!enableShortcuts || !enableClipboardManager)
|
||
if !enableClipboardManager {
|
||
Text("Clipboard feature is disabled")
|
||
.font(.caption)
|
||
.foregroundStyle(.secondary)
|
||
.padding(.top, 2)
|
||
}
|
||
}
|
||
Spacer()
|
||
}
|
||
} header: {
|
||
Text("Clipboard")
|
||
} footer: {
|
||
Text("Opens the clipboard history panel. Default is Cmd+Shift+V (similar to Windows+V on PC). Only works when clipboard feature is enabled.")
|
||
.multilineTextAlignment(.trailing)
|
||
.foregroundStyle(.secondary)
|
||
.font(.caption)
|
||
}
|
||
|
||
Section {
|
||
HStack {
|
||
VStack(alignment: .leading) {
|
||
KeyboardShortcuts.Recorder("Screen Assistant:", name: .screenAssistantPanel)
|
||
.disabled(!enableShortcuts || !Defaults[.enableScreenAssistant])
|
||
if !Defaults[.enableScreenAssistant] {
|
||
Text("Screen Assistant feature is disabled")
|
||
.font(.caption)
|
||
.foregroundStyle(.secondary)
|
||
.padding(.top, 2)
|
||
}
|
||
}
|
||
Spacer()
|
||
}
|
||
} header: {
|
||
Text("AI Assistant")
|
||
} footer: {
|
||
Text("Opens the AI assistant panel for file analysis and conversation. Default is Cmd+Shift+A. Only works when screen assistant feature is enabled.")
|
||
.multilineTextAlignment(.trailing)
|
||
.foregroundStyle(.secondary)
|
||
.font(.caption)
|
||
}
|
||
|
||
Section {
|
||
HStack {
|
||
VStack(alignment: .leading) {
|
||
KeyboardShortcuts.Recorder("Color Picker Panel:", name: .colorPickerPanel)
|
||
.disabled(!enableShortcuts || !enableColorPickerFeature)
|
||
if !enableColorPickerFeature {
|
||
Text("Color Picker feature is disabled")
|
||
.font(.caption)
|
||
.foregroundStyle(.secondary)
|
||
.padding(.top, 2)
|
||
}
|
||
}
|
||
Spacer()
|
||
}
|
||
} header: {
|
||
Text("Color Picker")
|
||
} footer: {
|
||
Text("Opens the color picker panel for screen color capture. Default is Cmd+Shift+P. Only works when color picker feature is enabled.")
|
||
.multilineTextAlignment(.trailing)
|
||
.foregroundStyle(.secondary)
|
||
.font(.caption)
|
||
}
|
||
} else {
|
||
Section {
|
||
VStack(alignment: .leading, spacing: 8) {
|
||
Text("Keyboard shortcuts are disabled")
|
||
.font(.headline)
|
||
.foregroundStyle(.secondary)
|
||
|
||
Text("Enable global keyboard shortcuts above to customize your shortcuts.")
|
||
.font(.caption)
|
||
.foregroundStyle(.secondary)
|
||
}
|
||
.padding(.vertical, 8)
|
||
}
|
||
}
|
||
}
|
||
.navigationTitle("Shortcuts")
|
||
}
|
||
}
|
||
|
||
func proFeatureBadge() -> some View {
|
||
Text("Upgrade to Pro")
|
||
.foregroundStyle(Color(red: 0.545, green: 0.196, blue: 0.98))
|
||
.font(.footnote.bold())
|
||
.padding(.vertical, 3)
|
||
.padding(.horizontal, 6)
|
||
.background(RoundedRectangle(cornerRadius: 4).stroke(Color(red: 0.545, green: 0.196, blue: 0.98), lineWidth: 1))
|
||
}
|
||
|
||
func comingSoonTag() -> some View {
|
||
Text("Coming soon")
|
||
.foregroundStyle(.secondary)
|
||
.font(.footnote.bold())
|
||
.padding(.vertical, 3)
|
||
.padding(.horizontal, 6)
|
||
.background(Color(nsColor: .secondarySystemFill))
|
||
.clipShape(.capsule)
|
||
}
|
||
|
||
func customBadge(text: String) -> some View {
|
||
Text(text)
|
||
.foregroundStyle(.secondary)
|
||
.font(.footnote.bold())
|
||
.padding(.vertical, 3)
|
||
.padding(.horizontal, 6)
|
||
.background(Color(nsColor: .secondarySystemFill))
|
||
.clipShape(.capsule)
|
||
}
|
||
|
||
func warningBadge(_ text: String, _ description: String) -> some View {
|
||
Section {
|
||
HStack(spacing: 12) {
|
||
Image(systemName: "exclamationmark.triangle.fill")
|
||
.font(.system(size: 22))
|
||
.foregroundStyle(.yellow)
|
||
VStack(alignment: .leading) {
|
||
Text(text)
|
||
.font(.headline)
|
||
Text(description)
|
||
.foregroundStyle(.secondary)
|
||
}
|
||
Spacer()
|
||
}
|
||
}
|
||
}
|
||
|
||
struct TimerSettings: View {
|
||
@ObservedObject private var coordinator = DynamicIslandViewCoordinator.shared
|
||
@Default(.enableTimerFeature) var enableTimerFeature
|
||
@Default(.timerPresets) private var timerPresets
|
||
@Default(.timerIconColorMode) private var colorMode
|
||
@Default(.timerSolidColor) private var solidColor
|
||
@Default(.timerShowsCountdown) private var showsCountdown
|
||
@Default(.timerShowsLabel) private var showsLabel
|
||
@Default(.timerShowsProgress) private var showsProgress
|
||
@Default(.timerProgressStyle) private var progressStyle
|
||
@Default(.timerControlWindowEnabled) private var controlWindowEnabled
|
||
@Default(.mirrorSystemTimer) private var mirrorSystemTimer
|
||
@Default(.enableLockScreenTimerWidget) private var enableLockScreenTimerWidget
|
||
@AppStorage("customTimerDuration") private var customTimerDuration: Double = 600
|
||
@State private var customHours: Int = 0
|
||
@State private var customMinutes: Int = 10
|
||
@State private var customSeconds: Int = 0
|
||
@State private var showingResetConfirmation = false
|
||
|
||
var body: some View {
|
||
Form {
|
||
Section {
|
||
Defaults.Toggle("Enable timer feature", key: .enableTimerFeature)
|
||
|
||
if enableTimerFeature {
|
||
Toggle("Enable timer live activity", isOn: $coordinator.timerLiveActivityEnabled)
|
||
.animation(.easeInOut, value: coordinator.timerLiveActivityEnabled)
|
||
Defaults.Toggle("Mirror macOS Clock timers", key: .mirrorSystemTimer)
|
||
.help("Shows the system Clock timer in the notch when available. Requires Accessibility permission to read the status item.")
|
||
}
|
||
} header: {
|
||
Text("Timer Feature")
|
||
} footer: {
|
||
Text("Control timer availability, live activity behaviour, and whether the app mirrors timers started from the macOS Clock app.")
|
||
}
|
||
|
||
if enableTimerFeature {
|
||
Group {
|
||
Section {
|
||
Defaults.Toggle("Show lock screen timer widget", key: .enableLockScreenTimerWidget)
|
||
Defaults.Toggle("Enable timer blur", key: .lockScreenTimerWidgetUsesBlur)
|
||
.disabled(!enableLockScreenTimerWidget)
|
||
} header: {
|
||
Text("Lock Screen Integration")
|
||
} footer: {
|
||
Text("Mirrors the toggle found under Lock Screen settings so timer-specific workflows can enable or disable the widget without switching tabs.")
|
||
}
|
||
|
||
Section {
|
||
VStack(alignment: .leading, spacing: 12) {
|
||
Text("Default Custom Timer")
|
||
.font(.headline)
|
||
|
||
TimerDurationStepperRow(title: "Hours", value: $customHours, range: 0...23)
|
||
TimerDurationStepperRow(title: "Minutes", value: $customMinutes, range: 0...59)
|
||
TimerDurationStepperRow(title: "Seconds", value: $customSeconds, range: 0...59)
|
||
|
||
HStack {
|
||
Text("Current default:")
|
||
.foregroundStyle(.secondary)
|
||
Text(customDurationDisplay)
|
||
.font(.system(.body, design: .monospaced))
|
||
.fontWeight(.medium)
|
||
Spacer()
|
||
}
|
||
}
|
||
.padding(.vertical, 4)
|
||
.onChange(of: customHours) { _, _ in updateCustomDuration() }
|
||
.onChange(of: customMinutes) { _, _ in updateCustomDuration() }
|
||
.onChange(of: customSeconds) { _, _ in updateCustomDuration() }
|
||
} header: {
|
||
Text("Custom Timer")
|
||
} footer: {
|
||
Text("This duration powers the \"Custom\" option inside the timer popover for quick access.")
|
||
}
|
||
|
||
Section {
|
||
Picker("Timer tint", selection: $colorMode) {
|
||
ForEach(TimerIconColorMode.allCases) { mode in
|
||
Text(mode.displayName).tag(mode)
|
||
}
|
||
}
|
||
.pickerStyle(.segmented)
|
||
|
||
if colorMode == .solid {
|
||
ColorPicker("Solid colour", selection: $solidColor, supportsOpacity: false)
|
||
}
|
||
|
||
Toggle("Show timer name", isOn: $showsLabel)
|
||
Toggle("Show countdown", isOn: $showsCountdown)
|
||
Toggle("Show progress", isOn: $showsProgress)
|
||
|
||
Toggle("Show floating pause/stop controls", isOn: $controlWindowEnabled)
|
||
.disabled(showsLabel)
|
||
.help("These controls sit beside the notch while a timer runs. They require the timer name to stay hidden for spacing.")
|
||
|
||
Picker("Progress style", selection: $progressStyle) {
|
||
ForEach(TimerProgressStyle.allCases) { style in
|
||
Text(style.rawValue).tag(style)
|
||
}
|
||
}
|
||
.pickerStyle(.segmented)
|
||
.disabled(!showsProgress)
|
||
} header: {
|
||
Text("Appearance")
|
||
} footer: {
|
||
Text("Configure how the timer looks inside the closed notch. Progress can render as a ring around the icon or as horizontal bars.")
|
||
}
|
||
|
||
Section {
|
||
if timerPresets.isEmpty {
|
||
Text("No presets configured. Add a preset to make it appear in the timer popover.")
|
||
.font(.system(size: 12))
|
||
.foregroundStyle(.secondary)
|
||
.padding(.vertical, 4)
|
||
} else {
|
||
ForEach(timerPresets.indices, id: \.self) { index in
|
||
TimerPresetEditorRow(
|
||
preset: $timerPresets[index],
|
||
isFirst: index == timerPresets.startIndex,
|
||
isLast: index == timerPresets.index(before: timerPresets.endIndex),
|
||
moveUp: { movePresetUp(index) },
|
||
moveDown: { movePresetDown(index) },
|
||
remove: { removePreset(index) }
|
||
)
|
||
}
|
||
}
|
||
|
||
HStack {
|
||
Button(action: addPreset) {
|
||
Label("Add Preset", systemImage: "plus")
|
||
}
|
||
.buttonStyle(.bordered)
|
||
|
||
Spacer()
|
||
|
||
Button(role: .destructive, action: { showingResetConfirmation = true }) {
|
||
Label("Restore Defaults", systemImage: "arrow.counterclockwise")
|
||
}
|
||
.buttonStyle(.bordered)
|
||
.confirmationDialog("Restore default timer presets?", isPresented: $showingResetConfirmation, titleVisibility: .visible) {
|
||
Button("Restore", role: .destructive, action: resetPresets)
|
||
}
|
||
}
|
||
} header: {
|
||
Text("Timer Presets")
|
||
} footer: {
|
||
Text("Presets show up inside the timer popover with the configured name, duration, and accent colour. Reorder them to change the display order.")
|
||
}
|
||
|
||
Section {
|
||
VStack(alignment: .leading, spacing: 10) {
|
||
HStack {
|
||
Text("Timer Sound")
|
||
.font(.system(size: 16, weight: .medium))
|
||
Spacer()
|
||
Button("Choose File", action: selectCustomTimerSound)
|
||
.buttonStyle(.bordered)
|
||
}
|
||
|
||
if let customTimerSoundPath = UserDefaults.standard.string(forKey: "customTimerSoundPath") {
|
||
Text("Custom: \(URL(fileURLWithPath: customTimerSoundPath).lastPathComponent)")
|
||
.font(.caption)
|
||
.foregroundColor(.secondary)
|
||
} else {
|
||
Text("Default: dynamic.m4a")
|
||
.font(.caption)
|
||
.foregroundColor(.secondary)
|
||
}
|
||
|
||
Button("Reset to Default") {
|
||
UserDefaults.standard.removeObject(forKey: "customTimerSoundPath")
|
||
}
|
||
.buttonStyle(.bordered)
|
||
.disabled(UserDefaults.standard.string(forKey: "customTimerSoundPath") == nil)
|
||
}
|
||
} header: {
|
||
Text("Timer Sound")
|
||
} footer: {
|
||
Text("Select a custom sound to play when a timer ends. Supported formats include MP3, M4A, WAV, and AIFF.")
|
||
}
|
||
}
|
||
.onAppear {
|
||
if showsLabel {
|
||
controlWindowEnabled = false
|
||
}
|
||
}
|
||
.onChange(of: showsLabel) { _, show in
|
||
if show {
|
||
controlWindowEnabled = false
|
||
}
|
||
}
|
||
}
|
||
}
|
||
.navigationTitle("Timer")
|
||
.onAppear { syncCustomDuration() }
|
||
.onChange(of: customTimerDuration) { _, newValue in syncCustomDuration(newValue) }
|
||
}
|
||
|
||
private var customDurationDisplay: String {
|
||
let formatter = DateComponentsFormatter()
|
||
formatter.allowedUnits = customTimerDuration >= 3600 ? [.hour, .minute, .second] : [.minute, .second]
|
||
formatter.zeroFormattingBehavior = [.pad]
|
||
return formatter.string(from: customTimerDuration) ?? "0:00"
|
||
}
|
||
|
||
private func syncCustomDuration(_ value: Double? = nil) {
|
||
let baseValue = value ?? customTimerDuration
|
||
let components = TimerPreset.components(for: baseValue)
|
||
customHours = components.hours
|
||
customMinutes = components.minutes
|
||
customSeconds = components.seconds
|
||
}
|
||
|
||
private func updateCustomDuration() {
|
||
let duration = TimeInterval(customHours * 3600 + customMinutes * 60 + customSeconds)
|
||
customTimerDuration = duration
|
||
}
|
||
|
||
private func addPreset() {
|
||
let nextIndex = timerPresets.count + 1
|
||
let defaultColor = Defaults[.accentColor]
|
||
let newPreset = TimerPreset(name: "Preset \(nextIndex)", duration: 5 * 60, color: defaultColor)
|
||
withAnimation(.smooth) {
|
||
timerPresets.append(newPreset)
|
||
}
|
||
}
|
||
|
||
private func movePresetUp(_ index: Int) {
|
||
guard index > timerPresets.startIndex else { return }
|
||
withAnimation(.smooth) {
|
||
timerPresets.swapAt(index, index - 1)
|
||
}
|
||
}
|
||
|
||
private func movePresetDown(_ index: Int) {
|
||
guard index < timerPresets.index(before: timerPresets.endIndex) else { return }
|
||
withAnimation(.smooth) {
|
||
timerPresets.swapAt(index, index + 1)
|
||
}
|
||
}
|
||
|
||
private func removePreset(_ index: Int) {
|
||
guard timerPresets.indices.contains(index) else { return }
|
||
withAnimation(.smooth) {
|
||
timerPresets.remove(at: index)
|
||
}
|
||
}
|
||
|
||
private func resetPresets() {
|
||
withAnimation(.smooth) {
|
||
timerPresets = TimerPreset.defaultPresets
|
||
}
|
||
}
|
||
|
||
private func selectCustomTimerSound() {
|
||
let panel = NSOpenPanel()
|
||
panel.title = "Select Timer Sound"
|
||
panel.allowedContentTypes = [.audio]
|
||
panel.allowsMultipleSelection = false
|
||
panel.canChooseDirectories = false
|
||
panel.canChooseFiles = true
|
||
|
||
if panel.runModal() == .OK {
|
||
if let url = panel.url {
|
||
UserDefaults.standard.set(url.path, forKey: "customTimerSoundPath")
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
private struct TimerDurationStepperRow: View {
|
||
let title: String
|
||
@Binding var value: Int
|
||
let range: ClosedRange<Int>
|
||
|
||
var body: some View {
|
||
Stepper(value: $value, in: range) {
|
||
HStack {
|
||
Text(title)
|
||
Spacer()
|
||
Text("\(value)")
|
||
.font(.system(size: 12, weight: .semibold, design: .monospaced))
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
private struct TimerPresetEditorRow: View {
|
||
@Binding var preset: TimerPreset
|
||
let isFirst: Bool
|
||
let isLast: Bool
|
||
let moveUp: () -> Void
|
||
let moveDown: () -> Void
|
||
let remove: () -> Void
|
||
|
||
private var components: TimerPreset.DurationComponents {
|
||
TimerPreset.components(for: preset.duration)
|
||
}
|
||
|
||
private var hoursBinding: Binding<Int> {
|
||
Binding(
|
||
get: { components.hours },
|
||
set: { updateDuration(hours: $0) }
|
||
)
|
||
}
|
||
|
||
private var minutesBinding: Binding<Int> {
|
||
Binding(
|
||
get: { components.minutes },
|
||
set: { updateDuration(minutes: $0) }
|
||
)
|
||
}
|
||
|
||
private var secondsBinding: Binding<Int> {
|
||
Binding(
|
||
get: { components.seconds },
|
||
set: { updateDuration(seconds: $0) }
|
||
)
|
||
}
|
||
|
||
private var colorBinding: Binding<Color> {
|
||
Binding(
|
||
get: { preset.color },
|
||
set: { preset.updateColor($0) }
|
||
)
|
||
}
|
||
|
||
var body: some View {
|
||
VStack(alignment: .leading, spacing: 12) {
|
||
HStack(spacing: 10) {
|
||
Circle()
|
||
.fill(preset.color.gradient)
|
||
.frame(width: 20, height: 20)
|
||
.overlay(
|
||
Circle()
|
||
.stroke(Color.white.opacity(0.3), lineWidth: 1)
|
||
)
|
||
|
||
TextField("Preset name", text: $preset.name)
|
||
.textFieldStyle(.roundedBorder)
|
||
|
||
Spacer()
|
||
|
||
Text(preset.formattedDuration)
|
||
.font(.system(size: 12, weight: .medium, design: .monospaced))
|
||
.foregroundStyle(.secondary)
|
||
}
|
||
|
||
HStack(spacing: 16) {
|
||
TimerPresetComponentControl(title: "Hours", value: hoursBinding, range: 0...23)
|
||
TimerPresetComponentControl(title: "Minutes", value: minutesBinding, range: 0...59)
|
||
TimerPresetComponentControl(title: "Seconds", value: secondsBinding, range: 0...59)
|
||
}
|
||
|
||
ColorPicker("Accent colour", selection: colorBinding, supportsOpacity: false)
|
||
.frame(maxWidth: 240, alignment: .leading)
|
||
|
||
HStack(spacing: 12) {
|
||
Button(action: moveUp) {
|
||
Label("Move Up", systemImage: "chevron.up")
|
||
}
|
||
.buttonStyle(.bordered)
|
||
.disabled(isFirst)
|
||
|
||
Button(action: moveDown) {
|
||
Label("Move Down", systemImage: "chevron.down")
|
||
}
|
||
.buttonStyle(.bordered)
|
||
.disabled(isLast)
|
||
|
||
Spacer()
|
||
|
||
Button(role: .destructive, action: remove) {
|
||
Label("Delete", systemImage: "trash")
|
||
}
|
||
.buttonStyle(.bordered)
|
||
}
|
||
.font(.system(size: 12, weight: .medium))
|
||
}
|
||
.padding(.vertical, 6)
|
||
}
|
||
|
||
private func updateDuration(hours: Int? = nil, minutes: Int? = nil, seconds: Int? = nil) {
|
||
var values = components
|
||
if let hours { values.hours = hours }
|
||
if let minutes { values.minutes = minutes }
|
||
if let seconds { values.seconds = seconds }
|
||
preset.duration = TimerPreset.duration(from: values)
|
||
}
|
||
}
|
||
|
||
private struct TimerPresetComponentControl: View {
|
||
let title: String
|
||
@Binding var value: Int
|
||
let range: ClosedRange<Int>
|
||
|
||
var body: some View {
|
||
Stepper(value: $value, in: range) {
|
||
VStack(alignment: .leading, spacing: 4) {
|
||
Text(title)
|
||
.font(.caption)
|
||
.foregroundStyle(.secondary)
|
||
Text("\(value)")
|
||
.font(.system(size: 13, weight: .semibold, design: .monospaced))
|
||
}
|
||
}
|
||
.frame(width: 110, alignment: .leading)
|
||
}
|
||
}
|
||
|
||
struct StatsSettings: View {
|
||
@ObservedObject var statsManager = StatsManager.shared
|
||
@Default(.enableStatsFeature) var enableStatsFeature
|
||
@Default(.statsStopWhenNotchCloses) var statsStopWhenNotchCloses
|
||
@Default(.statsUpdateInterval) var statsUpdateInterval
|
||
@Default(.showCpuGraph) var showCpuGraph
|
||
@Default(.showMemoryGraph) var showMemoryGraph
|
||
@Default(.showGpuGraph) var showGpuGraph
|
||
@Default(.showNetworkGraph) var showNetworkGraph
|
||
@Default(.showDiskGraph) var showDiskGraph
|
||
|
||
var enabledGraphsCount: Int {
|
||
[showCpuGraph, showMemoryGraph, showGpuGraph, showNetworkGraph, showDiskGraph].filter { $0 }.count
|
||
}
|
||
|
||
private var formattedUpdateInterval: String {
|
||
let seconds = Int(statsUpdateInterval.rounded())
|
||
if seconds >= 60 {
|
||
return "60 s (1 min)"
|
||
} else if seconds == 1 {
|
||
return "1 s"
|
||
} else {
|
||
return "\(seconds) s"
|
||
}
|
||
}
|
||
|
||
private var shouldShowStatsBatteryWarning: Bool {
|
||
!statsStopWhenNotchCloses && statsUpdateInterval <= 5
|
||
}
|
||
|
||
var body: some View {
|
||
Form {
|
||
Section {
|
||
Defaults.Toggle("Enable system stats monitoring", key: .enableStatsFeature)
|
||
.onChange(of: enableStatsFeature) { _, newValue in
|
||
if !newValue {
|
||
statsManager.stopMonitoring()
|
||
}
|
||
// Note: Smart monitoring will handle starting when switching to stats tab
|
||
}
|
||
|
||
} header: {
|
||
Text("General")
|
||
} footer: {
|
||
Text("When enabled, the Stats tab will display real-time system performance graphs. This feature requires system permissions and may use additional battery.")
|
||
.multilineTextAlignment(.trailing)
|
||
.foregroundStyle(.secondary)
|
||
.font(.caption)
|
||
}
|
||
|
||
if enableStatsFeature {
|
||
Section {
|
||
Defaults.Toggle("Stop monitoring after closing the notch", key: .statsStopWhenNotchCloses)
|
||
.help("When enabled, stats monitoring stops a few seconds after the notch closes.")
|
||
|
||
VStack(alignment: .leading, spacing: 8) {
|
||
HStack {
|
||
Text("Update interval")
|
||
Spacer()
|
||
Text(formattedUpdateInterval)
|
||
.foregroundStyle(.secondary)
|
||
}
|
||
|
||
Slider(value: $statsUpdateInterval, in: 1...60, step: 1)
|
||
.accessibilityLabel("Stats update interval")
|
||
|
||
Text("Controls how often system metrics refresh while monitoring is active.")
|
||
.font(.caption)
|
||
.foregroundStyle(.secondary)
|
||
}
|
||
|
||
if shouldShowStatsBatteryWarning {
|
||
Label {
|
||
Text("High-frequency updates without a timeout can increase battery usage.")
|
||
} icon: {
|
||
Image(systemName: "exclamationmark.triangle.fill")
|
||
}
|
||
.font(.caption)
|
||
.foregroundStyle(.orange)
|
||
.padding(.top, 4)
|
||
}
|
||
} header: {
|
||
Text("Monitoring Behavior")
|
||
} footer: {
|
||
Text("Sampling can continue while the notch is closed when the timeout is disabled.")
|
||
.multilineTextAlignment(.trailing)
|
||
.foregroundStyle(.secondary)
|
||
.font(.caption)
|
||
}
|
||
|
||
Section {
|
||
Defaults.Toggle("CPU Usage", key: .showCpuGraph)
|
||
Defaults.Toggle("Memory Usage", key: .showMemoryGraph)
|
||
Defaults.Toggle("GPU Usage", key: .showGpuGraph)
|
||
Defaults.Toggle("Network Activity", key: .showNetworkGraph)
|
||
Defaults.Toggle("Disk I/O", key: .showDiskGraph)
|
||
} header: {
|
||
Text("Graph Visibility")
|
||
} footer: {
|
||
if enabledGraphsCount >= 4 {
|
||
Text("With \(enabledGraphsCount) graphs enabled, the Dynamic Island will expand horizontally to accommodate all graphs in a single row.")
|
||
.multilineTextAlignment(.trailing)
|
||
.foregroundStyle(.secondary)
|
||
.font(.caption)
|
||
} else {
|
||
Text("Each graph can be individually enabled or disabled. Network activity shows download/upload speeds, and disk I/O shows read/write speeds.")
|
||
.multilineTextAlignment(.trailing)
|
||
.foregroundStyle(.secondary)
|
||
.font(.caption)
|
||
}
|
||
}
|
||
|
||
Section {
|
||
HStack {
|
||
Text("Monitoring Status")
|
||
Spacer()
|
||
HStack(spacing: 4) {
|
||
Circle()
|
||
.fill(statsManager.isMonitoring ? .green : .red)
|
||
.frame(width: 8, height: 8)
|
||
Text(statsManager.isMonitoring ? "Active" : "Stopped")
|
||
.foregroundStyle(.secondary)
|
||
}
|
||
}
|
||
|
||
if statsManager.isMonitoring {
|
||
if showCpuGraph {
|
||
HStack {
|
||
Text("CPU Usage")
|
||
Spacer()
|
||
Text(statsManager.cpuUsageString)
|
||
.foregroundStyle(.secondary)
|
||
}
|
||
}
|
||
|
||
if showMemoryGraph {
|
||
HStack {
|
||
Text("Memory Usage")
|
||
Spacer()
|
||
Text(statsManager.memoryUsageString)
|
||
.foregroundStyle(.secondary)
|
||
}
|
||
}
|
||
|
||
if showGpuGraph {
|
||
HStack {
|
||
Text("GPU Usage")
|
||
Spacer()
|
||
Text(statsManager.gpuUsageString)
|
||
.foregroundStyle(.secondary)
|
||
}
|
||
}
|
||
|
||
if showNetworkGraph {
|
||
HStack {
|
||
Text("Network Download")
|
||
Spacer()
|
||
Text(String(format: "%.1f MB/s", statsManager.networkDownload))
|
||
.foregroundStyle(.secondary)
|
||
}
|
||
|
||
HStack {
|
||
Text("Network Upload")
|
||
Spacer()
|
||
Text(String(format: "%.1f MB/s", statsManager.networkUpload))
|
||
.foregroundStyle(.secondary)
|
||
}
|
||
}
|
||
|
||
if showDiskGraph {
|
||
HStack {
|
||
Text("Disk Read")
|
||
Spacer()
|
||
Text(String(format: "%.1f MB/s", statsManager.diskRead))
|
||
.foregroundStyle(.secondary)
|
||
}
|
||
|
||
HStack {
|
||
Text("Disk Write")
|
||
Spacer()
|
||
Text(String(format: "%.1f MB/s", statsManager.diskWrite))
|
||
.foregroundStyle(.secondary)
|
||
}
|
||
}
|
||
|
||
HStack {
|
||
Text("Last Updated")
|
||
Spacer()
|
||
Text(statsManager.lastUpdated, style: .relative)
|
||
.foregroundStyle(.secondary)
|
||
}
|
||
}
|
||
} header: {
|
||
Text("Live Performance Data")
|
||
}
|
||
|
||
Section {
|
||
HStack {
|
||
Button(statsManager.isMonitoring ? "Stop Monitoring" : "Start Monitoring") {
|
||
if statsManager.isMonitoring {
|
||
statsManager.stopMonitoring()
|
||
} else {
|
||
statsManager.startMonitoring()
|
||
}
|
||
}
|
||
.buttonStyle(.borderedProminent)
|
||
.foregroundColor(statsManager.isMonitoring ? .red : .blue)
|
||
|
||
Spacer()
|
||
|
||
Button("Clear Data") {
|
||
statsManager.clearHistory()
|
||
}
|
||
.buttonStyle(.bordered)
|
||
.disabled(statsManager.isMonitoring)
|
||
}
|
||
} header: {
|
||
Text("Controls")
|
||
}
|
||
}
|
||
}
|
||
.navigationTitle("Stats")
|
||
}
|
||
}
|
||
|
||
struct ClipboardSettings: View {
|
||
@ObservedObject var clipboardManager = ClipboardManager.shared
|
||
@Default(.enableClipboardManager) var enableClipboardManager
|
||
@Default(.clipboardHistorySize) var clipboardHistorySize
|
||
@Default(.showClipboardIcon) var showClipboardIcon
|
||
@Default(.clipboardDisplayMode) var clipboardDisplayMode
|
||
|
||
var body: some View {
|
||
Form {
|
||
Section {
|
||
Defaults.Toggle("Enable Clipboard Manager", key: .enableClipboardManager)
|
||
.onChange(of: enableClipboardManager) { _, enabled in
|
||
if enabled {
|
||
clipboardManager.startMonitoring()
|
||
} else {
|
||
clipboardManager.stopMonitoring()
|
||
}
|
||
}
|
||
} header: {
|
||
Text("Clipboard Manager")
|
||
} footer: {
|
||
Text("Monitor clipboard changes and keep a history of recent copies. Use Cmd+Shift+V to quickly access clipboard history.")
|
||
}
|
||
|
||
if enableClipboardManager {
|
||
Section {
|
||
Defaults.Toggle("Show Clipboard Icon", key: .showClipboardIcon)
|
||
|
||
HStack {
|
||
Text("Display Mode")
|
||
Spacer()
|
||
Picker("Display Mode", selection: $clipboardDisplayMode) {
|
||
ForEach(ClipboardDisplayMode.allCases, id: \.self) { mode in
|
||
Text(mode.displayName).tag(mode)
|
||
}
|
||
}
|
||
.pickerStyle(.menu)
|
||
.frame(width: 100)
|
||
}
|
||
|
||
HStack {
|
||
Text("History Size")
|
||
Spacer()
|
||
Picker("History Size", selection: $clipboardHistorySize) {
|
||
Text("3 items").tag(3)
|
||
Text("5 items").tag(5)
|
||
Text("7 items").tag(7)
|
||
Text("10 items").tag(10)
|
||
}
|
||
.pickerStyle(.menu)
|
||
.frame(width: 100)
|
||
}
|
||
|
||
HStack {
|
||
Text("Current Items")
|
||
Spacer()
|
||
Text("\(clipboardManager.clipboardHistory.count)")
|
||
.foregroundColor(.secondary)
|
||
}
|
||
|
||
HStack {
|
||
Text("Pinned Items")
|
||
Spacer()
|
||
Text("\(clipboardManager.pinnedItems.count)")
|
||
.foregroundColor(.secondary)
|
||
}
|
||
|
||
HStack {
|
||
Text("Monitoring Status")
|
||
Spacer()
|
||
Text(clipboardManager.isMonitoring ? "Active" : "Stopped")
|
||
.foregroundColor(clipboardManager.isMonitoring ? .green : .secondary)
|
||
}
|
||
} header: {
|
||
Text("Settings")
|
||
} footer: {
|
||
switch clipboardDisplayMode {
|
||
case .popover:
|
||
Text("Popover mode shows clipboard as a dropdown attached to the clipboard button. Panel mode shows clipboard in a floating window near the notch.")
|
||
case .panel:
|
||
Text("Panel mode shows clipboard in a floating window near the notch. Popover mode shows clipboard as a dropdown attached to the clipboard button.")
|
||
}
|
||
}
|
||
|
||
Section {
|
||
Button("Clear Clipboard History") {
|
||
clipboardManager.clearHistory()
|
||
}
|
||
.foregroundColor(.red)
|
||
.disabled(clipboardManager.clipboardHistory.isEmpty)
|
||
|
||
Button("Clear Pinned Items") {
|
||
clipboardManager.pinnedItems.removeAll()
|
||
clipboardManager.savePinnedItemsToDefaults()
|
||
}
|
||
.foregroundColor(.red)
|
||
.disabled(clipboardManager.pinnedItems.isEmpty)
|
||
} header: {
|
||
Text("Actions")
|
||
} footer: {
|
||
Text("Clear clipboard history removes recent copies. Clear pinned items removes your favorites. Both actions are permanent.")
|
||
}
|
||
|
||
if !clipboardManager.clipboardHistory.isEmpty {
|
||
Section {
|
||
ForEach(clipboardManager.clipboardHistory) { item in
|
||
VStack(alignment: .leading, spacing: 4) {
|
||
HStack {
|
||
Image(systemName: item.type.icon)
|
||
.foregroundColor(.blue)
|
||
.frame(width: 16)
|
||
Text(item.type.displayName)
|
||
.font(.caption)
|
||
.foregroundColor(.secondary)
|
||
Spacer()
|
||
Text(timeAgoString(from: item.timestamp))
|
||
.font(.caption2)
|
||
.foregroundColor(.secondary)
|
||
}
|
||
Text(item.preview)
|
||
.font(.system(.body, design: .monospaced))
|
||
.lineLimit(2)
|
||
}
|
||
.padding(.vertical, 2)
|
||
}
|
||
} header: {
|
||
Text("Current History")
|
||
}
|
||
}
|
||
}
|
||
}
|
||
.formStyle(.grouped)
|
||
.navigationTitle("Clipboard")
|
||
.onAppear {
|
||
if enableClipboardManager && !clipboardManager.isMonitoring {
|
||
clipboardManager.startMonitoring()
|
||
}
|
||
}
|
||
}
|
||
|
||
private func timeAgoString(from date: Date) -> String {
|
||
let interval = Date().timeIntervalSince(date)
|
||
|
||
if interval < 60 {
|
||
return "Just now"
|
||
} else if interval < 3600 {
|
||
let minutes = Int(interval / 60)
|
||
return "\(minutes)m ago"
|
||
} else if interval < 86400 {
|
||
let hours = Int(interval / 3600)
|
||
return "\(hours)h ago"
|
||
} else {
|
||
let days = Int(interval / 86400)
|
||
return "\(days)d ago"
|
||
}
|
||
}
|
||
}
|
||
|
||
struct ScreenAssistantSettings: View {
|
||
@ObservedObject var screenAssistantManager = ScreenAssistantManager.shared
|
||
@Default(.enableScreenAssistant) var enableScreenAssistant
|
||
@Default(.screenAssistantDisplayMode) var screenAssistantDisplayMode
|
||
@Default(.geminiApiKey) var geminiApiKey
|
||
@State private var apiKeyText = ""
|
||
@State private var showingApiKey = false
|
||
|
||
var body: some View {
|
||
Form {
|
||
Section {
|
||
Defaults.Toggle("Enable Screen Assistant", key: .enableScreenAssistant)
|
||
} header: {
|
||
Text("AI Assistant")
|
||
} footer: {
|
||
Text("AI-powered assistant that can analyze files, images, and provide conversational help. Use Cmd+Shift+A to quickly access the assistant.")
|
||
}
|
||
|
||
if enableScreenAssistant {
|
||
Section {
|
||
HStack {
|
||
Text("Gemini API Key")
|
||
Spacer()
|
||
if geminiApiKey.isEmpty {
|
||
Text("Not Set")
|
||
.foregroundColor(.red)
|
||
} else {
|
||
Text("••••••••")
|
||
.foregroundColor(.green)
|
||
}
|
||
|
||
Button(showingApiKey ? "Hide" : (geminiApiKey.isEmpty ? "Set" : "Change")) {
|
||
if showingApiKey {
|
||
showingApiKey = false
|
||
if !apiKeyText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
|
||
Defaults[.geminiApiKey] = apiKeyText
|
||
}
|
||
apiKeyText = ""
|
||
} else {
|
||
showingApiKey = true
|
||
apiKeyText = geminiApiKey
|
||
}
|
||
}
|
||
}
|
||
|
||
if showingApiKey {
|
||
VStack(alignment: .leading, spacing: 8) {
|
||
SecureField("Enter your Gemini API Key", text: $apiKeyText)
|
||
.textFieldStyle(.roundedBorder)
|
||
|
||
Text("Get your free API key from Google AI Studio")
|
||
.font(.caption)
|
||
.foregroundColor(.secondary)
|
||
|
||
HStack {
|
||
Button("Open Google AI Studio") {
|
||
NSWorkspace.shared.open(URL(string: "https://aistudio.google.com/app/apikey")!)
|
||
}
|
||
.buttonStyle(.link)
|
||
|
||
Spacer()
|
||
|
||
Button("Save") {
|
||
Defaults[.geminiApiKey] = apiKeyText
|
||
showingApiKey = false
|
||
apiKeyText = ""
|
||
}
|
||
.disabled(apiKeyText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty)
|
||
}
|
||
}
|
||
}
|
||
|
||
HStack {
|
||
Text("Display Mode")
|
||
Spacer()
|
||
Picker("Display Mode", selection: $screenAssistantDisplayMode) {
|
||
ForEach(ScreenAssistantDisplayMode.allCases, id: \.self) { mode in
|
||
Text(mode.displayName).tag(mode)
|
||
}
|
||
}
|
||
.pickerStyle(.menu)
|
||
.frame(width: 100)
|
||
}
|
||
|
||
HStack {
|
||
Text("Attached Files")
|
||
Spacer()
|
||
Text("\(screenAssistantManager.attachedFiles.count)")
|
||
.foregroundColor(.secondary)
|
||
}
|
||
|
||
HStack {
|
||
Text("Recording Status")
|
||
Spacer()
|
||
Text(screenAssistantManager.isRecording ? "Recording" : "Ready")
|
||
.foregroundColor(screenAssistantManager.isRecording ? .red : .secondary)
|
||
}
|
||
} header: {
|
||
Text("Configuration")
|
||
} footer: {
|
||
switch screenAssistantDisplayMode {
|
||
case .popover:
|
||
Text("Popover mode shows the assistant as a dropdown attached to the AI button. Panel mode shows the assistant in a floating window near the notch.")
|
||
case .panel:
|
||
Text("Panel mode shows the assistant in a floating window near the notch. Popover mode shows the assistant as a dropdown attached to the AI button.")
|
||
}
|
||
}
|
||
|
||
Section {
|
||
Button("Clear All Files") {
|
||
screenAssistantManager.clearAllFiles()
|
||
}
|
||
.foregroundColor(.red)
|
||
.disabled(screenAssistantManager.attachedFiles.isEmpty)
|
||
} header: {
|
||
Text("Actions")
|
||
} footer: {
|
||
Text("Clear all files removes all attached files and audio recordings. This action is permanent.")
|
||
}
|
||
|
||
if !screenAssistantManager.attachedFiles.isEmpty {
|
||
Section {
|
||
ForEach(screenAssistantManager.attachedFiles) { file in
|
||
VStack(alignment: .leading, spacing: 4) {
|
||
HStack {
|
||
Image(systemName: file.type.iconName)
|
||
.foregroundColor(.blue)
|
||
.frame(width: 16)
|
||
Text(file.type.displayName)
|
||
.font(.caption)
|
||
.foregroundColor(.secondary)
|
||
Spacer()
|
||
Text(timeAgoString(from: file.timestamp))
|
||
.font(.caption2)
|
||
.foregroundColor(.secondary)
|
||
}
|
||
Text(file.name)
|
||
.font(.system(.body, design: .monospaced))
|
||
.lineLimit(2)
|
||
}
|
||
.padding(.vertical, 2)
|
||
}
|
||
} header: {
|
||
Text("Attached Files")
|
||
}
|
||
}
|
||
}
|
||
}
|
||
.formStyle(.grouped)
|
||
.navigationTitle("Screen Assistant")
|
||
}
|
||
|
||
private func timeAgoString(from date: Date) -> String {
|
||
let interval = Date().timeIntervalSince(date)
|
||
|
||
if interval < 60 {
|
||
return "Just now"
|
||
} else if interval < 3600 {
|
||
let minutes = Int(interval / 60)
|
||
return "\(minutes)m ago"
|
||
} else if interval < 86400 {
|
||
let hours = Int(interval / 3600)
|
||
return "\(hours)h ago"
|
||
} else {
|
||
let days = Int(interval / 86400)
|
||
return "\(days)d ago"
|
||
}
|
||
}
|
||
}
|
||
|
||
struct ColorPickerSettings: View {
|
||
@ObservedObject var colorPickerManager = ColorPickerManager.shared
|
||
@Default(.enableColorPickerFeature) var enableColorPickerFeature
|
||
@Default(.showColorFormats) var showColorFormats
|
||
@Default(.colorPickerDisplayMode) var colorPickerDisplayMode
|
||
@Default(.colorHistorySize) var colorHistorySize
|
||
@Default(.showColorPickerIcon) var showColorPickerIcon
|
||
|
||
var body: some View {
|
||
Form {
|
||
Section {
|
||
Defaults.Toggle("Enable Color Picker", key: .enableColorPickerFeature)
|
||
} header: {
|
||
Text("Color Picker")
|
||
} footer: {
|
||
Text("Enable screen color picking functionality. Use Cmd+Shift+P to quickly access the color picker.")
|
||
}
|
||
|
||
if enableColorPickerFeature {
|
||
Section {
|
||
Defaults.Toggle("Show Color Picker Icon", key: .showColorPickerIcon)
|
||
|
||
HStack {
|
||
Text("Display Mode")
|
||
Spacer()
|
||
Picker("Display Mode", selection: $colorPickerDisplayMode) {
|
||
ForEach(ColorPickerDisplayMode.allCases, id: \.self) { mode in
|
||
Text(mode.displayName).tag(mode)
|
||
}
|
||
}
|
||
.pickerStyle(.menu)
|
||
.frame(width: 100)
|
||
}
|
||
|
||
HStack {
|
||
Text("History Size")
|
||
Spacer()
|
||
Picker("History Size", selection: $colorHistorySize) {
|
||
Text("5 colors").tag(5)
|
||
Text("10 colors").tag(10)
|
||
Text("15 colors").tag(15)
|
||
Text("20 colors").tag(20)
|
||
}
|
||
.pickerStyle(.menu)
|
||
.frame(width: 100)
|
||
}
|
||
|
||
Defaults.Toggle("Show All Color Formats", key: .showColorFormats)
|
||
|
||
} header: {
|
||
Text("Settings")
|
||
} footer: {
|
||
switch colorPickerDisplayMode {
|
||
case .popover:
|
||
Text("Popover mode shows color picker as a dropdown attached to the color picker button. Panel mode shows color picker in a floating window.")
|
||
case .panel:
|
||
Text("Panel mode shows color picker in a floating window. Popover mode shows color picker as a dropdown attached to the color picker button.")
|
||
}
|
||
}
|
||
|
||
Section {
|
||
HStack {
|
||
Text("Color History")
|
||
Spacer()
|
||
Text("\(colorPickerManager.colorHistory.count)")
|
||
.foregroundColor(.secondary)
|
||
}
|
||
|
||
HStack {
|
||
Text("Picking Status")
|
||
Spacer()
|
||
Text(colorPickerManager.isPickingColor ? "Active" : "Ready")
|
||
.foregroundColor(colorPickerManager.isPickingColor ? .green : .secondary)
|
||
}
|
||
|
||
Button("Show Color Picker Panel") {
|
||
ColorPickerPanelManager.shared.showColorPickerPanel()
|
||
}
|
||
.disabled(!enableColorPickerFeature)
|
||
|
||
} header: {
|
||
Text("Status & Actions")
|
||
}
|
||
|
||
Section {
|
||
Button("Clear Color History") {
|
||
colorPickerManager.clearHistory()
|
||
}
|
||
.foregroundColor(.red)
|
||
.disabled(colorPickerManager.colorHistory.isEmpty)
|
||
|
||
Button("Start Color Picking") {
|
||
colorPickerManager.startColorPicking()
|
||
}
|
||
.disabled(!enableColorPickerFeature || colorPickerManager.isPickingColor)
|
||
|
||
} header: {
|
||
Text("Quick Actions")
|
||
} footer: {
|
||
Text("Clear color history removes all picked colors. Start color picking begins screen color capture mode.")
|
||
}
|
||
}
|
||
}
|
||
.navigationTitle("Color Picker")
|
||
}
|
||
}
|
||
|
||
#Preview {
|
||
HUD()
|
||
}
|