mirror of
https://github.com/FBukovina/Atoll.git
synced 2026-10-27 15:14:33 +00:00
Working stuff with revamped UI
This commit is contained in:
parent
8659a338dd
commit
5a8c592157
11 changed files with 646 additions and 279 deletions
|
|
@ -37,6 +37,8 @@ struct ContentView: View {
|
||||||
@Default(.showNetworkGraph) var showNetworkGraph
|
@Default(.showNetworkGraph) var showNetworkGraph
|
||||||
@Default(.showDiskGraph) var showDiskGraph
|
@Default(.showDiskGraph) var showDiskGraph
|
||||||
@Default(.enableReminderLiveActivity) var enableReminderLiveActivity
|
@Default(.enableReminderLiveActivity) var enableReminderLiveActivity
|
||||||
|
@Default(.enableTimerFeature) var enableTimerFeature
|
||||||
|
@Default(.timerDisplayMode) var timerDisplayMode
|
||||||
|
|
||||||
// Dynamic sizing based on view type and graph count with smooth transitions
|
// Dynamic sizing based on view type and graph count with smooth transitions
|
||||||
var dynamicNotchSize: CGSize {
|
var dynamicNotchSize: CGSize {
|
||||||
|
|
@ -765,9 +767,11 @@ struct ContentView: View {
|
||||||
triggerHapticIfAllowed()
|
triggerHapticIfAllowed()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let shouldFocusTimerTab = enableTimerFeature && timerDisplayMode == .tab && timerManager.isTimerActive
|
||||||
|
|
||||||
guard vm.notchState == .closed,
|
guard vm.notchState == .closed,
|
||||||
!coordinator.sneakPeek.show,
|
!coordinator.sneakPeek.show,
|
||||||
Defaults[.openNotchOnHover] else { return }
|
(Defaults[.openNotchOnHover] || shouldFocusTimerTab) else { return }
|
||||||
|
|
||||||
hoverTask = Task {
|
hoverTask = Task {
|
||||||
try? await Task.sleep(for: .seconds(Defaults[.minimumHoverDuration]))
|
try? await Task.sleep(for: .seconds(Defaults[.minimumHoverDuration]))
|
||||||
|
|
@ -778,6 +782,11 @@ struct ContentView: View {
|
||||||
self.isHovering,
|
self.isHovering,
|
||||||
!self.coordinator.sneakPeek.show else { return }
|
!self.coordinator.sneakPeek.show else { return }
|
||||||
|
|
||||||
|
if shouldFocusTimerTab {
|
||||||
|
withAnimation(.smooth) {
|
||||||
|
self.coordinator.currentView = .timer
|
||||||
|
}
|
||||||
|
}
|
||||||
self.openNotch()
|
self.openNotch()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -47,6 +47,7 @@ struct ExpandedItem {
|
||||||
|
|
||||||
class DynamicIslandViewCoordinator: ObservableObject {
|
class DynamicIslandViewCoordinator: ObservableObject {
|
||||||
static let shared = DynamicIslandViewCoordinator()
|
static let shared = DynamicIslandViewCoordinator()
|
||||||
|
private var cancellables = Set<AnyCancellable>()
|
||||||
|
|
||||||
@Published var currentView: NotchViews = .home {
|
@Published var currentView: NotchViews = .home {
|
||||||
didSet {
|
didSet {
|
||||||
|
|
@ -64,6 +65,9 @@ class DynamicIslandViewCoordinator: ObservableObject {
|
||||||
@AppStorage("showWhatsNew") var showWhatsNew: Bool = true
|
@AppStorage("showWhatsNew") var showWhatsNew: Bool = true
|
||||||
@AppStorage("musicLiveActivityEnabled") var musicLiveActivityEnabled: Bool = true
|
@AppStorage("musicLiveActivityEnabled") var musicLiveActivityEnabled: Bool = true
|
||||||
@AppStorage("timerLiveActivityEnabled") var timerLiveActivityEnabled: Bool = true
|
@AppStorage("timerLiveActivityEnabled") var timerLiveActivityEnabled: Bool = true
|
||||||
|
|
||||||
|
@Default(.enableTimerFeature) private var enableTimerFeature
|
||||||
|
@Default(.timerDisplayMode) private var timerDisplayMode
|
||||||
|
|
||||||
@AppStorage("alwaysShowTabs") var alwaysShowTabs: Bool = true {
|
@AppStorage("alwaysShowTabs") var alwaysShowTabs: Bool = true {
|
||||||
didSet {
|
didSet {
|
||||||
|
|
@ -99,6 +103,19 @@ class DynamicIslandViewCoordinator: ObservableObject {
|
||||||
|
|
||||||
private init() {
|
private init() {
|
||||||
selectedScreen = preferredScreen
|
selectedScreen = preferredScreen
|
||||||
|
Defaults.publisher(.timerDisplayMode)
|
||||||
|
.receive(on: DispatchQueue.main)
|
||||||
|
.sink { [weak self] change in
|
||||||
|
self?.handleTimerDisplayModeChange(change.newValue)
|
||||||
|
}
|
||||||
|
.store(in: &cancellables)
|
||||||
|
|
||||||
|
Defaults.publisher(.enableTimerFeature)
|
||||||
|
.receive(on: DispatchQueue.main)
|
||||||
|
.sink { [weak self] change in
|
||||||
|
self?.handleTimerFeatureToggle(change.newValue)
|
||||||
|
}
|
||||||
|
.store(in: &cancellables)
|
||||||
}
|
}
|
||||||
|
|
||||||
private func handleStatsTabTransition(from oldValue: NotchViews, to newValue: NotchViews) {
|
private func handleStatsTabTransition(from oldValue: NotchViews, to newValue: NotchViews) {
|
||||||
|
|
@ -121,6 +138,20 @@ class DynamicIslandViewCoordinator: ObservableObject {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func handleTimerDisplayModeChange(_ mode: TimerDisplayMode) {
|
||||||
|
guard mode == .popover, currentView == .timer else { return }
|
||||||
|
withAnimation(.smooth) {
|
||||||
|
currentView = .home
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func handleTimerFeatureToggle(_ isEnabled: Bool) {
|
||||||
|
guard !isEnabled, currentView == .timer else { return }
|
||||||
|
withAnimation(.smooth) {
|
||||||
|
currentView = .home
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func toggleSneakPeek(status: Bool, type: SneakContentType, duration: TimeInterval = 1.5, value: CGFloat = 0, icon: String = "") {
|
func toggleSneakPeek(status: Bool, type: SneakContentType, duration: TimeInterval = 1.5, value: CGFloat = 0, icon: String = "") {
|
||||||
let resolvedDuration: TimeInterval
|
let resolvedDuration: TimeInterval
|
||||||
|
|
|
||||||
|
|
@ -230,6 +230,10 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
":" : {
|
||||||
|
"comment" : "A colon used in the timer interface.",
|
||||||
|
"isCommentAutoGenerated" : true
|
||||||
|
},
|
||||||
"'Now Playing' was the only option on previous versions and works with all media apps." : {
|
"'Now Playing' was the only option on previous versions and works with all media apps." : {
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
"ar" : {
|
"ar" : {
|
||||||
|
|
@ -1374,6 +1378,10 @@
|
||||||
"comment" : "A label displayed in a circular gauge to indicate the minimum battery level.",
|
"comment" : "A label displayed in a circular gauge to indicate the minimum battery level.",
|
||||||
"isCommentAutoGenerated" : true
|
"isCommentAutoGenerated" : true
|
||||||
},
|
},
|
||||||
|
"00" : {
|
||||||
|
"comment" : "A placeholder text for the minutes in the timer.",
|
||||||
|
"isCommentAutoGenerated" : true
|
||||||
|
},
|
||||||
"00:05:00" : {
|
"00:05:00" : {
|
||||||
"comment" : "A time displayed in the timer panel on a lock screen.",
|
"comment" : "A time displayed in the timer panel on a lock screen.",
|
||||||
"isCommentAutoGenerated" : true
|
"isCommentAutoGenerated" : true
|
||||||
|
|
@ -5713,6 +5721,10 @@
|
||||||
"comment" : "A message displayed when there are no presets configured. It encourages the user to configure presets in the app settings.",
|
"comment" : "A message displayed when there are no presets configured. It encourages the user to configure presets in the app settings.",
|
||||||
"isCommentAutoGenerated" : true
|
"isCommentAutoGenerated" : true
|
||||||
},
|
},
|
||||||
|
"Configure presets in Settings to see them here." : {
|
||||||
|
"comment" : "A message displayed when there are no timer presets configured. It instructs the user to configure presets in the app settings to see them here.",
|
||||||
|
"isCommentAutoGenerated" : true
|
||||||
|
},
|
||||||
"Connected" : {
|
"Connected" : {
|
||||||
"extractionState" : "stale",
|
"extractionState" : "stale",
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
|
|
@ -5963,6 +5975,7 @@
|
||||||
"isCommentAutoGenerated" : true
|
"isCommentAutoGenerated" : true
|
||||||
},
|
},
|
||||||
"Custom" : {
|
"Custom" : {
|
||||||
|
"extractionState" : "stale",
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
"fr" : {
|
"fr" : {
|
||||||
"stringUnit" : {
|
"stringUnit" : {
|
||||||
|
|
@ -8143,6 +8156,10 @@
|
||||||
},
|
},
|
||||||
"Enable stats monitoring in Settings to view system performance data." : {
|
"Enable stats monitoring in Settings to view system performance data." : {
|
||||||
|
|
||||||
|
},
|
||||||
|
"Enable the timer feature in Settings to access this tab." : {
|
||||||
|
"comment" : "A message explaining that the timer feature must be enabled in settings to access this tab.",
|
||||||
|
"isCommentAutoGenerated" : true
|
||||||
},
|
},
|
||||||
"Enable the weather capsule and configure its layout, provider, units, and optional battery/AQI indicators." : {
|
"Enable the weather capsule and configure its layout, provider, units, and optional battery/AQI indicators." : {
|
||||||
"comment" : "A footer for the \"Weather Widget\" section in the Lock Screen Settings view, describing its purpose.",
|
"comment" : "A footer for the \"Weather Widget\" section in the Lock Screen Settings view, describing its purpose.",
|
||||||
|
|
@ -8150,9 +8167,6 @@
|
||||||
},
|
},
|
||||||
"Enable Thinking Mode" : {
|
"Enable Thinking Mode" : {
|
||||||
|
|
||||||
},
|
|
||||||
"Enable timer feature in Settings to use this tab" : {
|
|
||||||
|
|
||||||
},
|
},
|
||||||
"Enable timer live activity" : {
|
"Enable timer live activity" : {
|
||||||
|
|
||||||
|
|
@ -9636,10 +9650,6 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"Hide from Notch" : {
|
|
||||||
"comment" : "A button label that hides the Clock timer from the Notch app.",
|
|
||||||
"isCommentAutoGenerated" : true
|
|
||||||
},
|
|
||||||
"Hide only when NowPlaying app is in fullscreen" : {
|
"Hide only when NowPlaying app is in fullscreen" : {
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
"ar" : {
|
"ar" : {
|
||||||
|
|
@ -11268,8 +11278,9 @@
|
||||||
"comment" : "A description of how to manage the microphone privacy indicator preferences.",
|
"comment" : "A description of how to manage the microphone privacy indicator preferences.",
|
||||||
"isCommentAutoGenerated" : true
|
"isCommentAutoGenerated" : true
|
||||||
},
|
},
|
||||||
"Manage the Clock timer from the Clock app." : {
|
"Managed in Clock app" : {
|
||||||
|
"comment" : "A notice displayed below the custom timer section, explaining that the timer is managed by the Clock app.",
|
||||||
|
"isCommentAutoGenerated" : true
|
||||||
},
|
},
|
||||||
"Mark as complete" : {
|
"Mark as complete" : {
|
||||||
"comment" : "An accessibility label for a button that marks an item as complete.",
|
"comment" : "An accessibility label for a button that marks an item as complete.",
|
||||||
|
|
@ -12559,6 +12570,7 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"min" : {
|
"min" : {
|
||||||
|
"extractionState" : "stale",
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
"fr" : {
|
"fr" : {
|
||||||
"stringUnit" : {
|
"stringUnit" : {
|
||||||
|
|
@ -14859,6 +14871,10 @@
|
||||||
"comment" : "Label describing the system volume control inside the media output selector.",
|
"comment" : "Label describing the system volume control inside the media output selector.",
|
||||||
"isCommentAutoGenerated" : true
|
"isCommentAutoGenerated" : true
|
||||||
},
|
},
|
||||||
|
"Overtime" : {
|
||||||
|
"comment" : "A label indicating that the timer is running overtime.",
|
||||||
|
"isCommentAutoGenerated" : true
|
||||||
|
},
|
||||||
"Panel mode shows clipboard in a floating window near the notch. Popover mode shows clipboard as a dropdown attached to the clipboard button." : {
|
"Panel mode shows clipboard in a floating window near the notch. Popover mode shows clipboard as a dropdown attached to the clipboard button." : {
|
||||||
|
|
||||||
},
|
},
|
||||||
|
|
@ -14870,9 +14886,6 @@
|
||||||
},
|
},
|
||||||
"Pause" : {
|
"Pause" : {
|
||||||
|
|
||||||
},
|
|
||||||
"Paused" : {
|
|
||||||
|
|
||||||
},
|
},
|
||||||
"Percentage" : {
|
"Percentage" : {
|
||||||
"extractionState" : "stale",
|
"extractionState" : "stale",
|
||||||
|
|
@ -15214,10 +15227,6 @@
|
||||||
"comment" : "A picker that lets the user select a quick share service to use when sharing files from the shelf.",
|
"comment" : "A picker that lets the user select a quick share service to use when sharing files from the shelf.",
|
||||||
"isCommentAutoGenerated" : true
|
"isCommentAutoGenerated" : true
|
||||||
},
|
},
|
||||||
"Quick Start" : {
|
|
||||||
"comment" : "A label displayed above the quick start options in the timer tab.",
|
|
||||||
"isCommentAutoGenerated" : true
|
|
||||||
},
|
|
||||||
"Quit" : {
|
"Quit" : {
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
"ar" : {
|
"ar" : {
|
||||||
|
|
@ -18164,6 +18173,10 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"Start Timer" : {
|
||||||
|
"comment" : "A button label that starts a custom timer.",
|
||||||
|
"isCommentAutoGenerated" : true
|
||||||
|
},
|
||||||
"Starts a 5-minute demo timer to test the timer live activity feature. Only works when timer feature is enabled." : {
|
"Starts a 5-minute demo timer to test the timer live activity feature. Only works when timer feature is enabled." : {
|
||||||
|
|
||||||
},
|
},
|
||||||
|
|
@ -18782,6 +18795,10 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"Timer controls appear as" : {
|
||||||
|
"comment" : "A picker that lets the user choose how the timer controls are displayed.",
|
||||||
|
"isCommentAutoGenerated" : true
|
||||||
|
},
|
||||||
"Timer Disabled" : {
|
"Timer Disabled" : {
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
"fr" : {
|
"fr" : {
|
||||||
|
|
|
||||||
|
|
@ -21,6 +21,7 @@ struct DynamicIslandHeader: View {
|
||||||
@State private var showColorPickerPopover = false
|
@State private var showColorPickerPopover = false
|
||||||
@State private var showTimerPopover = false
|
@State private var showTimerPopover = false
|
||||||
@Default(.enableTimerFeature) var enableTimerFeature
|
@Default(.enableTimerFeature) var enableTimerFeature
|
||||||
|
@Default(.timerDisplayMode) var timerDisplayMode
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
HStack(spacing: 0) {
|
HStack(spacing: 0) {
|
||||||
|
|
@ -144,7 +145,7 @@ struct DynamicIslandHeader: View {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if Defaults[.enableTimerFeature] {
|
if Defaults[.enableTimerFeature] && timerDisplayMode == .popover {
|
||||||
Button(action: {
|
Button(action: {
|
||||||
withAnimation(.smooth) {
|
withAnimation(.smooth) {
|
||||||
showTimerPopover.toggle()
|
showTimerPopover.toggle()
|
||||||
|
|
@ -252,6 +253,12 @@ struct DynamicIslandHeader: View {
|
||||||
vm.isTimerPopoverActive = false
|
vm.isTimerPopoverActive = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
.onChange(of: timerDisplayMode) { _, mode in
|
||||||
|
if mode == .tab {
|
||||||
|
showTimerPopover = false
|
||||||
|
vm.isTimerPopoverActive = false
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -7,298 +7,547 @@
|
||||||
|
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
import Defaults
|
import Defaults
|
||||||
|
#if canImport(AppKit)
|
||||||
|
import AppKit
|
||||||
|
#endif
|
||||||
|
|
||||||
struct NotchTimerView: View {
|
struct NotchTimerView: View {
|
||||||
@EnvironmentObject var vm: DynamicIslandViewModel
|
@EnvironmentObject var vm: DynamicIslandViewModel
|
||||||
@ObservedObject var timerManager = TimerManager.shared
|
@ObservedObject var timerManager = TimerManager.shared
|
||||||
@ObservedObject var coordinator = DynamicIslandViewCoordinator.shared
|
@ObservedObject var coordinator = DynamicIslandViewCoordinator.shared
|
||||||
@Default(.enableTimerFeature) var enableTimerFeature
|
@Default(.enableTimerFeature) var enableTimerFeature
|
||||||
|
@Default(.timerPresets) private var timerPresets
|
||||||
@AppStorage("customTimerDuration") private var customTimerDuration: Double = 600 // 10 minutes default
|
@Default(.timerIconColorMode) private var colorMode
|
||||||
|
@Default(.timerSolidColor) private var solidColor
|
||||||
|
@Default(.timerShowsProgress) private var showsProgress
|
||||||
|
@Default(.timerProgressStyle) private var progressStyle
|
||||||
|
|
||||||
|
@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 isSyncingCustomDuration = false
|
||||||
|
@State private var lockedAccentColor: Color?
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
if enableTimerFeature {
|
Group {
|
||||||
HStack(alignment: .center, spacing: 32) {
|
if enableTimerFeature {
|
||||||
// Timer Progress and Controls
|
HStack(alignment: .top, spacing: timerManager.isTimerActive ? 0 : 20) {
|
||||||
timerProgressSection
|
leftColumn
|
||||||
|
if shouldShowPresetColumn {
|
||||||
// Timer Control Panel
|
Divider()
|
||||||
controlsSection
|
.frame(height: max(0, maxTabContentHeight - 8))
|
||||||
}
|
.opacity(0.2)
|
||||||
.padding(.horizontal, 20)
|
presetColumn
|
||||||
.transition(.opacity.combined(with: .blurReplace))
|
}
|
||||||
} else {
|
|
||||||
VStack(spacing: 16) {
|
|
||||||
Image(systemName: "timer.slash")
|
|
||||||
.font(.system(size: 48, weight: .light))
|
|
||||||
.foregroundStyle(.secondary)
|
|
||||||
|
|
||||||
Text("Timer Disabled")
|
|
||||||
.font(.title2)
|
|
||||||
.fontWeight(.medium)
|
|
||||||
.foregroundStyle(.primary)
|
|
||||||
|
|
||||||
Text("Enable timer feature in Settings to use this tab")
|
|
||||||
.font(.body)
|
|
||||||
.foregroundStyle(.secondary)
|
|
||||||
.multilineTextAlignment(.center)
|
|
||||||
}
|
|
||||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private var timerProgressSection: some View {
|
|
||||||
VStack(spacing: 16) {
|
|
||||||
// Circular progress display
|
|
||||||
ZStack {
|
|
||||||
// Background ring
|
|
||||||
Circle()
|
|
||||||
.stroke(.white.opacity(0.1), lineWidth: 8)
|
|
||||||
.frame(width: 100, height: 100)
|
|
||||||
|
|
||||||
// Progress ring
|
|
||||||
Circle()
|
|
||||||
.trim(from: 0, to: timerManager.progress)
|
|
||||||
.stroke(
|
|
||||||
timerManager.currentColor,
|
|
||||||
style: StrokeStyle(lineWidth: 8, lineCap: .round)
|
|
||||||
)
|
|
||||||
.frame(width: 100, height: 100)
|
|
||||||
.rotationEffect(.degrees(-90))
|
|
||||||
.animation(.easeInOut(duration: 0.5), value: timerManager.progress)
|
|
||||||
|
|
||||||
// Timer icon in center
|
|
||||||
Image(systemName: "timer")
|
|
||||||
.font(.system(size: 32, weight: .medium))
|
|
||||||
.foregroundStyle(openIconColor)
|
|
||||||
.scaleEffect(timerManager.isRunning && timerManager.isTimerActive ? 1.1 : 1.0)
|
|
||||||
.animation(
|
|
||||||
timerManager.isRunning && timerManager.isTimerActive ?
|
|
||||||
.easeInOut(duration: 0.3).repeatForever(autoreverses: true) :
|
|
||||||
.easeInOut(duration: 0.3),
|
|
||||||
value: timerManager.isRunning && timerManager.isTimerActive
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Time display
|
|
||||||
VStack(spacing: 4) {
|
|
||||||
Text(timerManager.formattedTimeRemaining)
|
|
||||||
.font(.system(size: 24, weight: .semibold, design: .monospaced))
|
|
||||||
.foregroundStyle(timerManager.isOvertime ? .red : .white)
|
|
||||||
|
|
||||||
if timerManager.isTimerActive && timerManager.isPaused {
|
|
||||||
Text("Paused")
|
|
||||||
.font(.system(size: 14, weight: .medium))
|
|
||||||
.foregroundStyle(.secondary)
|
|
||||||
}
|
}
|
||||||
|
.frame(maxHeight: maxTabContentHeight, alignment: .top)
|
||||||
|
.padding(.horizontal, 16)
|
||||||
|
.padding(.vertical, 6)
|
||||||
|
.transition(.opacity.combined(with: .blurReplace))
|
||||||
|
.onAppear { syncCustomDuration(with: customTimerDuration) }
|
||||||
|
.onChange(of: customTimerDuration) { _, newValue in syncCustomDuration(with: newValue) }
|
||||||
|
.onChange(of: customHours) { _, _ in updateStoredCustomDuration() }
|
||||||
|
.onChange(of: customMinutes) { _, _ in updateStoredCustomDuration() }
|
||||||
|
.onChange(of: customSeconds) { _, _ in updateStoredCustomDuration() }
|
||||||
|
} else {
|
||||||
|
disabledState
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.onAppear {
|
||||||
|
lockAccentColorIfNeeded()
|
||||||
|
}
|
||||||
|
.onChange(of: timerManager.isTimerActive) { _, isActive in
|
||||||
|
if isActive {
|
||||||
|
lockAccentColorIfNeeded()
|
||||||
|
} else {
|
||||||
|
lockedAccentColor = nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.onChange(of: timerManager.activePresetId) { _, _ in
|
||||||
|
if timerManager.isTimerActive && lockedAccentColor == nil {
|
||||||
|
lockAccentColorIfNeeded()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private var controlsSection: some View {
|
private var leftColumn: some View {
|
||||||
VStack(alignment: .leading, spacing: 20) {
|
VStack(alignment: .leading, spacing: 16) {
|
||||||
if timerManager.allowsManualInteraction {
|
if timerManager.isTimerActive {
|
||||||
manualControlButtons
|
activeTimerCard
|
||||||
quickTimerControls
|
|
||||||
} else {
|
} else {
|
||||||
externalTimerNotice
|
customTimerComposer
|
||||||
}
|
}
|
||||||
|
Spacer(minLength: 0)
|
||||||
}
|
}
|
||||||
.frame(maxWidth: .infinity, alignment: .leading)
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
.frame(maxHeight: maxTabContentHeight, alignment: .top)
|
||||||
|
.padding(.bottom, 2)
|
||||||
}
|
}
|
||||||
|
|
||||||
private var manualControlButtons: some View {
|
private var presetColumn: some View {
|
||||||
VStack(alignment: .leading, spacing: 16) {
|
VStack(spacing: 6) {
|
||||||
if timerManager.isOvertime {
|
if timerPresets.isEmpty {
|
||||||
Button(action: {
|
Text("Configure presets in Settings to see them here.")
|
||||||
timerManager.forceStopTimer()
|
.font(.system(size: 12))
|
||||||
}) {
|
.foregroundStyle(.secondary)
|
||||||
HStack(spacing: 8) {
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
Image(systemName: "stop.fill")
|
.padding(14)
|
||||||
.font(.system(size: 16, weight: .medium))
|
.background(Color.white.opacity(0.05))
|
||||||
.foregroundStyle(.red)
|
.clipShape(RoundedRectangle(cornerRadius: 14, style: .continuous))
|
||||||
Text("Stop")
|
|
||||||
.font(.system(size: 16, weight: .medium))
|
|
||||||
.foregroundStyle(.red)
|
|
||||||
}
|
|
||||||
.frame(height: 40)
|
|
||||||
.frame(minWidth: 100)
|
|
||||||
.background(.white.opacity(0.15))
|
|
||||||
.clipShape(RoundedRectangle(cornerRadius: 12))
|
|
||||||
}
|
|
||||||
.buttonStyle(PlainButtonStyle())
|
|
||||||
.onHover(perform: cursorHover)
|
|
||||||
} else {
|
} else {
|
||||||
HStack(spacing: 16) {
|
let computedHeight = CGFloat(timerPresets.count) * 60 + 4
|
||||||
Button(action: {
|
let listHeight = min(max(0, maxTabContentHeight - 16), computedHeight)
|
||||||
if timerManager.isPaused {
|
List {
|
||||||
timerManager.resumeTimer()
|
ForEach(timerPresets) { preset in
|
||||||
} else {
|
TimerPresetCard(preset: preset, isActive: timerManager.activePresetId == preset.id) {
|
||||||
timerManager.pauseTimer()
|
timerManager.startTimer(duration: preset.duration, name: preset.name, preset: preset)
|
||||||
|
coordinator.currentView = .timer
|
||||||
}
|
}
|
||||||
}) {
|
.listRowInsets(EdgeInsets(top: 2, leading: 0, bottom: 2, trailing: 0))
|
||||||
HStack(spacing: 8) {
|
.listRowBackground(Color.clear)
|
||||||
Image(systemName: timerManager.isPaused ? "play.fill" : "pause.fill")
|
.listRowSeparator(.hidden)
|
||||||
.font(.system(size: 16, weight: .medium))
|
|
||||||
Text(timerManager.isPaused ? "Resume" : "Pause")
|
|
||||||
.font(.system(size: 16, weight: .medium))
|
|
||||||
}
|
|
||||||
.foregroundStyle(.white)
|
|
||||||
.frame(height: 40)
|
|
||||||
.frame(minWidth: 100)
|
|
||||||
.background(.white.opacity(0.1))
|
|
||||||
.clipShape(RoundedRectangle(cornerRadius: 12))
|
|
||||||
}
|
}
|
||||||
.buttonStyle(PlainButtonStyle())
|
|
||||||
.onHover(perform: cursorHover)
|
|
||||||
|
|
||||||
Button(action: {
|
|
||||||
timerManager.stopTimer()
|
|
||||||
}) {
|
|
||||||
HStack(spacing: 8) {
|
|
||||||
Image(systemName: "stop.fill")
|
|
||||||
.font(.system(size: 16, weight: .medium))
|
|
||||||
Text("Stop")
|
|
||||||
.font(.system(size: 16, weight: .medium))
|
|
||||||
}
|
|
||||||
.foregroundStyle(.white)
|
|
||||||
.frame(height: 40)
|
|
||||||
.frame(minWidth: 100)
|
|
||||||
.background(.white.opacity(0.1))
|
|
||||||
.clipShape(RoundedRectangle(cornerRadius: 12))
|
|
||||||
}
|
|
||||||
.buttonStyle(PlainButtonStyle())
|
|
||||||
.onHover(perform: cursorHover)
|
|
||||||
}
|
}
|
||||||
|
.listStyle(.plain)
|
||||||
|
.scrollContentBackground(.hidden)
|
||||||
|
.scrollIndicators(.never)
|
||||||
|
.frame(height: listHeight)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.frame(width: 210, alignment: .leading)
|
||||||
|
.frame(maxHeight: maxTabContentHeight, alignment: .top)
|
||||||
|
.padding(.bottom, 2)
|
||||||
|
}
|
||||||
|
|
||||||
|
private var activeTimerCard: some View {
|
||||||
|
VStack(alignment: .leading, spacing: 12) {
|
||||||
|
HStack(alignment: .center, spacing: 14) {
|
||||||
|
leadingControlSection
|
||||||
|
.frame(width: 128, alignment: .leading)
|
||||||
|
|
||||||
|
timerTitleSection
|
||||||
|
|
||||||
|
countdownSection
|
||||||
|
}
|
||||||
|
|
||||||
|
if showsProgress {
|
||||||
|
progressSection
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(16)
|
||||||
|
.background(Color.white.opacity(0.06))
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: 18, style: .continuous))
|
||||||
|
}
|
||||||
|
|
||||||
|
@ViewBuilder
|
||||||
|
private var leadingControlSection: some View {
|
||||||
|
if timerManager.allowsManualInteraction {
|
||||||
|
HStack(spacing: 10) {
|
||||||
|
TimerControlButton(
|
||||||
|
icon: pauseIconName,
|
||||||
|
foreground: .white.opacity(0.95),
|
||||||
|
background: timerAccentColor.opacity(0.32),
|
||||||
|
accessibilityLabel: pauseAccessibilityLabel,
|
||||||
|
action: togglePauseAction
|
||||||
|
)
|
||||||
|
|
||||||
|
TimerControlButton(
|
||||||
|
icon: "xmark",
|
||||||
|
foreground: .white.opacity(0.95),
|
||||||
|
background: Color.white.opacity(0.16),
|
||||||
|
accessibilityLabel: "Stop",
|
||||||
|
action: stopTimerAction
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
externalTimerNotice
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var timerTitleSection: some View {
|
||||||
|
GeometryReader { geometry in
|
||||||
|
let status = timerStatusText
|
||||||
|
let spacing: CGFloat = status == nil ? 0 : 8
|
||||||
|
let badgeWidth: CGFloat = status.map(statusBadgeWidth) ?? 0
|
||||||
|
let marqueeWidth = max(48, geometry.size.width - badgeWidth - spacing)
|
||||||
|
|
||||||
|
HStack(alignment: .center, spacing: spacing) {
|
||||||
|
MarqueeText(
|
||||||
|
.constant(timerDisplayName),
|
||||||
|
font: .system(size: 20, weight: .semibold),
|
||||||
|
nsFont: .title3,
|
||||||
|
textColor: .white,
|
||||||
|
minDuration: 0.2,
|
||||||
|
frameWidth: marqueeWidth
|
||||||
|
)
|
||||||
|
.frame(width: marqueeWidth, height: 24, alignment: .leading)
|
||||||
|
|
||||||
|
if let status {
|
||||||
|
statusBadge(status)
|
||||||
|
.frame(width: badgeWidth)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
}
|
||||||
|
.frame(height: 32)
|
||||||
|
}
|
||||||
|
|
||||||
|
private var countdownSection: some View {
|
||||||
|
VStack(alignment: .trailing, spacing: 4) {
|
||||||
|
Text(timerManager.formattedRemainingTime())
|
||||||
|
.font(.system(size: 36, weight: .black, design: .monospaced))
|
||||||
|
.foregroundStyle(timerManager.isOvertime ? Color.red : .white)
|
||||||
|
.contentTransition(.numericText())
|
||||||
|
.animation(.smooth(duration: 0.25), value: timerManager.remainingTime)
|
||||||
|
|
||||||
|
if timerManager.isOvertime {
|
||||||
|
Text("Overtime")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.red)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.frame(width: 150, alignment: .trailing)
|
||||||
|
}
|
||||||
|
|
||||||
|
@ViewBuilder
|
||||||
|
private var progressSection: some View {
|
||||||
|
if progressStyle == .bar {
|
||||||
|
GeometryReader { geometry in
|
||||||
|
Capsule()
|
||||||
|
.fill(Color.white.opacity(0.12))
|
||||||
|
.frame(height: 4)
|
||||||
|
.overlay(alignment: .leading) {
|
||||||
|
Capsule()
|
||||||
|
.fill(timerAccentColor)
|
||||||
|
.frame(width: geometry.size.width * max(0, CGFloat(min(timerManager.progress, 1.0))))
|
||||||
|
.animation(.smooth(duration: 0.25), value: timerManager.progress)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.frame(height: 4)
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
} else {
|
||||||
|
HStack(spacing: 12) {
|
||||||
|
TimerProgressRing(progress: timerManager.progress, tint: timerAccentColor)
|
||||||
|
.frame(height: 56)
|
||||||
|
Spacer()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private var quickTimerControls: some View {
|
private func togglePauseAction() {
|
||||||
VStack(alignment: .leading, spacing: 16) {
|
guard timerManager.allowsManualInteraction else { return }
|
||||||
Text("Quick Start")
|
timerManager.isPaused ? timerManager.resumeTimer() : timerManager.pauseTimer()
|
||||||
.font(.system(size: 14, weight: .medium))
|
}
|
||||||
.foregroundStyle(.secondary)
|
|
||||||
|
|
||||||
VStack(spacing: 12) {
|
private func stopTimerAction() {
|
||||||
HStack(spacing: 12) {
|
if timerManager.allowsManualInteraction {
|
||||||
quickTimerButton(minutes: 1)
|
timerManager.stopTimer()
|
||||||
quickTimerButton(minutes: 5)
|
} else {
|
||||||
quickTimerButton(minutes: 15)
|
timerManager.endExternalTimer(triggerSmoothClose: false)
|
||||||
}
|
|
||||||
|
|
||||||
HStack(spacing: 12) {
|
|
||||||
Button(action: {
|
|
||||||
timerManager.startTimer(duration: customTimerDuration, name: "Custom Timer")
|
|
||||||
}) {
|
|
||||||
VStack(spacing: 4) {
|
|
||||||
Text(customTimerDisplayText)
|
|
||||||
.font(.system(size: 16, weight: .medium))
|
|
||||||
Text("Custom")
|
|
||||||
.font(.system(size: 12, weight: .regular))
|
|
||||||
.opacity(0.7)
|
|
||||||
}
|
|
||||||
.foregroundStyle(.white)
|
|
||||||
.frame(height: 60)
|
|
||||||
.frame(minWidth: 80)
|
|
||||||
.background(.white.opacity(0.08))
|
|
||||||
.clipShape(RoundedRectangle(cornerRadius: 12))
|
|
||||||
.overlay(
|
|
||||||
RoundedRectangle(cornerRadius: 12)
|
|
||||||
.stroke(.white.opacity(0.1), lineWidth: 1)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
.buttonStyle(PlainButtonStyle())
|
|
||||||
.onHover(perform: cursorHover)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private var customTimerComposer: some View {
|
||||||
|
VStack(alignment: .leading, spacing: 10) {
|
||||||
|
DurationInputRow(hours: $customHours, minutes: $customMinutes, seconds: $customSeconds)
|
||||||
|
|
||||||
|
Button {
|
||||||
|
withAnimation(.smooth) {
|
||||||
|
timerManager.startTimer(duration: customDurationInSeconds, name: "Custom Timer")
|
||||||
|
coordinator.currentView = .timer
|
||||||
|
}
|
||||||
|
} label: {
|
||||||
|
Label("Start Timer", systemImage: "play.fill")
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
}
|
||||||
|
.buttonStyle(.borderedProminent)
|
||||||
|
.disabled(customDurationInSeconds == 0)
|
||||||
|
}
|
||||||
|
.padding(12)
|
||||||
|
.background(Color.white.opacity(0.04))
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: 14, style: .continuous))
|
||||||
|
}
|
||||||
|
|
||||||
private var externalTimerNotice: some View {
|
private var externalTimerNotice: some View {
|
||||||
VStack(alignment: .leading, spacing: 12) {
|
VStack(alignment: .leading, spacing: 6) {
|
||||||
Text("Manage the Clock timer from the Clock app.")
|
Label("Managed in Clock app", systemImage: "arrow.triangle.2.circlepath")
|
||||||
.font(.system(size: 14, weight: .medium))
|
.font(.caption)
|
||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
|
|
||||||
Button(action: {
|
Button("Hide", role: .destructive) {
|
||||||
timerManager.endExternalTimer(triggerSmoothClose: false)
|
timerManager.endExternalTimer(triggerSmoothClose: false)
|
||||||
}) {
|
|
||||||
HStack(spacing: 8) {
|
|
||||||
Image(systemName: "eye.slash")
|
|
||||||
.font(.system(size: 16, weight: .medium))
|
|
||||||
Text("Hide from Notch")
|
|
||||||
.font(.system(size: 16, weight: .medium))
|
|
||||||
}
|
|
||||||
.foregroundStyle(.white)
|
|
||||||
.frame(height: 40)
|
|
||||||
.frame(minWidth: 140)
|
|
||||||
.background(.white.opacity(0.12))
|
|
||||||
.clipShape(RoundedRectangle(cornerRadius: 12))
|
|
||||||
}
|
}
|
||||||
.buttonStyle(PlainButtonStyle())
|
.buttonStyle(.borderless)
|
||||||
.onHover(perform: cursorHover)
|
.font(.caption)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private var customTimerDisplayText: String {
|
private var disabledState: some View {
|
||||||
let totalMinutes = Int(customTimerDuration) / 60
|
VStack(spacing: 16) {
|
||||||
let seconds = Int(customTimerDuration) % 60
|
Image(systemName: "timer.slash")
|
||||||
let hours = totalMinutes / 60
|
.font(.system(size: 48, weight: .light))
|
||||||
let minutes = totalMinutes % 60
|
.foregroundStyle(.secondary)
|
||||||
|
|
||||||
if hours > 0 {
|
Text("Timer Disabled")
|
||||||
return "\(hours):\(String(format: "%02d", minutes))"
|
.font(.title2)
|
||||||
} else if minutes > 0 {
|
.fontWeight(.medium)
|
||||||
return "\(minutes) min"
|
|
||||||
} else {
|
Text("Enable the timer feature in Settings to access this tab.")
|
||||||
return "\(seconds)s"
|
.font(.body)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
.multilineTextAlignment(.center)
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||||
|
}
|
||||||
|
|
||||||
|
private var shouldShowPresetColumn: Bool {
|
||||||
|
!timerManager.isTimerActive
|
||||||
|
}
|
||||||
|
|
||||||
|
private var resolvedNotchHeight: CGFloat {
|
||||||
|
let height = vm.notchSize.height
|
||||||
|
return height > 0 ? height : openNotchSize.height
|
||||||
|
}
|
||||||
|
|
||||||
|
private var headerHeight: CGFloat {
|
||||||
|
max(24, vm.effectiveClosedNotchHeight)
|
||||||
|
}
|
||||||
|
|
||||||
|
private var maxTabContentHeight: CGFloat {
|
||||||
|
let available = resolvedNotchHeight - headerHeight - 36
|
||||||
|
return max(130, available)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func lockAccentColorIfNeeded() {
|
||||||
|
if timerManager.isTimerActive {
|
||||||
|
lockedAccentColor = resolvedAccentColor
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func quickTimerButton(minutes: Int) -> some View {
|
private var timerAccentColor: Color {
|
||||||
Button(action: {
|
lockedAccentColor ?? resolvedAccentColor
|
||||||
timerManager.startTimer(duration: TimeInterval(minutes * 60), name: "\(minutes) Min Timer")
|
}
|
||||||
}) {
|
|
||||||
VStack(spacing: 4) {
|
private var resolvedAccentColor: Color {
|
||||||
Text("\(minutes)")
|
switch colorMode {
|
||||||
.font(.system(size: 22, weight: .bold))
|
case .adaptive:
|
||||||
.foregroundStyle(.white)
|
return timerManager.activePreset?.color ?? timerManager.timerColor
|
||||||
Text("min")
|
case .solid:
|
||||||
.font(.system(size: 12, weight: .medium))
|
return solidColor
|
||||||
.foregroundStyle(.secondary)
|
|
||||||
}
|
|
||||||
.frame(width: 80, height: 60)
|
|
||||||
.background(.white.opacity(0.1))
|
|
||||||
.clipShape(RoundedRectangle(cornerRadius: 12))
|
|
||||||
.overlay(
|
|
||||||
RoundedRectangle(cornerRadius: 12)
|
|
||||||
.stroke(.white.opacity(0.1), lineWidth: 1)
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
.buttonStyle(PlainButtonStyle())
|
}
|
||||||
.onHover(perform: cursorHover)
|
|
||||||
|
private var timerDisplayName: String {
|
||||||
|
timerManager.timerName.isEmpty ? "Timer" : timerManager.timerName
|
||||||
|
}
|
||||||
|
|
||||||
|
private var timerStatusText: String? {
|
||||||
|
if timerManager.isOvertime {
|
||||||
|
return "Overtime"
|
||||||
|
} else if timerManager.isPaused {
|
||||||
|
return "Paused"
|
||||||
|
} else if timerManager.isFinished {
|
||||||
|
return "Completed"
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
private var timerStatusColor: Color {
|
||||||
|
timerManager.isOvertime ? .red : timerAccentColor
|
||||||
|
}
|
||||||
|
|
||||||
|
private func statusBadge(_ text: String) -> some View {
|
||||||
|
Text(text)
|
||||||
|
.font(.caption.weight(.semibold))
|
||||||
|
.foregroundStyle(timerStatusColor)
|
||||||
|
.padding(.horizontal, 12)
|
||||||
|
.padding(.vertical, 4)
|
||||||
|
.background(timerStatusColor.opacity(0.18))
|
||||||
|
.clipShape(Capsule())
|
||||||
|
}
|
||||||
|
|
||||||
|
private func statusBadgeWidth(for text: String) -> CGFloat {
|
||||||
|
#if canImport(AppKit)
|
||||||
|
let font = NSFont.systemFont(ofSize: 12, weight: .semibold)
|
||||||
|
#elseif canImport(UIKit)
|
||||||
|
let font = UIFont.systemFont(ofSize: 12, weight: .semibold)
|
||||||
|
#else
|
||||||
|
return 80
|
||||||
|
#endif
|
||||||
|
let width = (text as NSString).size(withAttributes: [.font: font]).width
|
||||||
|
return width + 24
|
||||||
|
}
|
||||||
|
|
||||||
|
private var pauseIconName: String {
|
||||||
|
timerManager.isPaused ? "play.fill" : "pause.fill"
|
||||||
|
}
|
||||||
|
|
||||||
|
private var pauseAccessibilityLabel: String {
|
||||||
|
timerManager.isPaused ? "Resume" : "Pause"
|
||||||
|
}
|
||||||
|
|
||||||
|
private var customDurationInSeconds: TimeInterval {
|
||||||
|
TimeInterval(customHours * 3600 + customMinutes * 60 + customSeconds)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func syncCustomDuration(with value: Double) {
|
||||||
|
isSyncingCustomDuration = true
|
||||||
|
let components = TimerPreset.components(for: value)
|
||||||
|
customHours = components.hours
|
||||||
|
customMinutes = components.minutes
|
||||||
|
customSeconds = components.seconds
|
||||||
|
isSyncingCustomDuration = false
|
||||||
|
}
|
||||||
|
|
||||||
|
private func updateStoredCustomDuration() {
|
||||||
|
guard !isSyncingCustomDuration else { return }
|
||||||
|
customTimerDuration = customDurationInSeconds
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private extension NotchTimerView {
|
private struct TimerControlButton: View {
|
||||||
var openIconColor: Color {
|
let icon: String
|
||||||
guard timerManager.isTimerActive && timerManager.isRunning else { return .white.opacity(0.9) }
|
let foreground: Color
|
||||||
return timerManager.currentColor
|
let background: Color
|
||||||
|
let accessibilityLabel: String
|
||||||
|
let action: () -> Void
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
Button(action: action) {
|
||||||
|
Image(systemName: icon)
|
||||||
|
.font(.system(size: 18, weight: .semibold))
|
||||||
|
.foregroundStyle(foreground)
|
||||||
|
.frame(width: 46, height: 46)
|
||||||
|
.background(background)
|
||||||
|
.clipShape(Circle())
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
.help(accessibilityLabel)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct TimerProgressRing: View {
|
||||||
|
let progress: Double
|
||||||
|
let tint: Color
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
ZStack {
|
||||||
|
Circle()
|
||||||
|
.stroke(Color.white.opacity(0.12), lineWidth: 6)
|
||||||
|
Circle()
|
||||||
|
.trim(from: 0, to: min(progress, 1.0))
|
||||||
|
.stroke(tint, style: StrokeStyle(lineWidth: 6, lineCap: .round))
|
||||||
|
.rotationEffect(.degrees(-90))
|
||||||
|
.animation(.smooth(duration: 0.3), value: progress)
|
||||||
|
}
|
||||||
|
.frame(width: 60, height: 60)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct DurationInputRow: View {
|
||||||
|
@Binding var hours: Int
|
||||||
|
@Binding var minutes: Int
|
||||||
|
@Binding var seconds: Int
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
HStack(alignment: .center, spacing: 8) {
|
||||||
|
DurationField(label: "HH", value: $hours, range: 0...23)
|
||||||
|
colon
|
||||||
|
DurationField(label: "MM", value: $minutes, range: 0...59)
|
||||||
|
colon
|
||||||
|
DurationField(label: "SS", value: $seconds, range: 0...59)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func cursorHover(_ isHovering: Bool) {
|
private var colon: some View {
|
||||||
if isHovering {
|
Text(":")
|
||||||
NSCursor.pointingHand.push()
|
.font(.system(size: 26, weight: .black, design: .monospaced))
|
||||||
} else {
|
.foregroundStyle(.secondary)
|
||||||
NSCursor.pop()
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct DurationField: View {
|
||||||
|
let label: String
|
||||||
|
@Binding var value: Int
|
||||||
|
let range: ClosedRange<Int>
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(spacing: 6) {
|
||||||
|
TextField("00", text: binding)
|
||||||
|
.font(.system(size: 28, weight: .semibold, design: .monospaced))
|
||||||
|
.multilineTextAlignment(.center)
|
||||||
|
.textFieldStyle(.plain)
|
||||||
|
.frame(width: 64, height: 46)
|
||||||
|
.background(Color.white.opacity(0.08))
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: 12, style: .continuous))
|
||||||
|
|
||||||
|
Text(label)
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private var binding: Binding<String> {
|
||||||
|
Binding<String>(
|
||||||
|
get: { String(format: "%02d", value) },
|
||||||
|
set: { newValue in
|
||||||
|
let digits = newValue.filter { $0.isNumber }
|
||||||
|
let number = min(max(range.lowerBound, Int(digits) ?? 0), range.upperBound)
|
||||||
|
value = number
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct TimerPresetCard: View {
|
||||||
|
let preset: TimerPreset
|
||||||
|
let isActive: Bool
|
||||||
|
let action: () -> Void
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
Button(action: action) {
|
||||||
|
HStack(spacing: 12) {
|
||||||
|
Circle()
|
||||||
|
.fill(preset.color.gradient)
|
||||||
|
.frame(width: 30, height: 30)
|
||||||
|
.overlay(
|
||||||
|
Circle()
|
||||||
|
.stroke(Color.white.opacity(0.3), lineWidth: 1)
|
||||||
|
)
|
||||||
|
|
||||||
|
VStack(alignment: .leading, spacing: 2) {
|
||||||
|
Text(preset.name)
|
||||||
|
.font(.system(size: 14, weight: .semibold))
|
||||||
|
.lineLimit(1)
|
||||||
|
Text(preset.formattedDuration)
|
||||||
|
.font(.caption.monospaced())
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer()
|
||||||
|
|
||||||
|
Image(systemName: isActive ? "checkmark" : "play.fill")
|
||||||
|
.font(.system(size: 12, weight: .bold))
|
||||||
|
.foregroundStyle(isActive ? preset.color : Color.secondary)
|
||||||
|
.padding(6)
|
||||||
|
.background(isActive ? preset.color.opacity(0.2) : Color.white.opacity(0.08))
|
||||||
|
.clipShape(Circle())
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 12)
|
||||||
|
.padding(.vertical, 6)
|
||||||
|
.background(
|
||||||
|
RoundedRectangle(cornerRadius: 16, style: .continuous)
|
||||||
|
.fill(isActive ? preset.color.opacity(0.12) : Color.white.opacity(0.04))
|
||||||
|
)
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#Preview {
|
#Preview {
|
||||||
NotchTimerView()
|
NotchTimerView()
|
||||||
.environmentObject(DynamicIslandViewModel())
|
.environmentObject(DynamicIslandViewModel())
|
||||||
.frame(width: 500, height: 200)
|
.frame(width: 600, height: 320)
|
||||||
.background(.black)
|
.background(.black)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -3354,6 +3354,7 @@ struct TimerSettings: View {
|
||||||
@Default(.timerProgressStyle) private var progressStyle
|
@Default(.timerProgressStyle) private var progressStyle
|
||||||
@Default(.timerControlWindowEnabled) private var controlWindowEnabled
|
@Default(.timerControlWindowEnabled) private var controlWindowEnabled
|
||||||
@Default(.mirrorSystemTimer) private var mirrorSystemTimer
|
@Default(.mirrorSystemTimer) private var mirrorSystemTimer
|
||||||
|
@Default(.timerDisplayMode) private var timerDisplayMode
|
||||||
@Default(.enableLockScreenTimerWidget) private var enableLockScreenTimerWidget
|
@Default(.enableLockScreenTimerWidget) private var enableLockScreenTimerWidget
|
||||||
@AppStorage("customTimerDuration") private var customTimerDuration: Double = 600
|
@AppStorage("customTimerDuration") private var customTimerDuration: Double = 600
|
||||||
@State private var customHours: Int = 0
|
@State private var customHours: Int = 0
|
||||||
|
|
@ -3390,6 +3391,15 @@ struct TimerSettings: View {
|
||||||
Defaults.Toggle("Mirror macOS Clock timers", key: .mirrorSystemTimer)
|
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.")
|
.help("Shows the system Clock timer in the notch when available. Requires Accessibility permission to read the status item.")
|
||||||
.settingsHighlight(id: highlightID("Mirror macOS Clock timers"))
|
.settingsHighlight(id: highlightID("Mirror macOS Clock timers"))
|
||||||
|
|
||||||
|
Picker("Timer controls appear as", selection: $timerDisplayMode) {
|
||||||
|
ForEach(TimerDisplayMode.allCases) { mode in
|
||||||
|
Text(mode.displayName).tag(mode)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.pickerStyle(.segmented)
|
||||||
|
.help(timerDisplayMode.description)
|
||||||
|
.settingsHighlight(id: highlightID("Timer controls appear as"))
|
||||||
}
|
}
|
||||||
} header: {
|
} header: {
|
||||||
Text("Timer Feature")
|
Text("Timer Feature")
|
||||||
|
|
|
||||||
|
|
@ -17,8 +17,10 @@ struct TabModel: Identifiable {
|
||||||
|
|
||||||
struct TabSelectionView: View {
|
struct TabSelectionView: View {
|
||||||
@ObservedObject var coordinator = DynamicIslandViewCoordinator.shared
|
@ObservedObject var coordinator = DynamicIslandViewCoordinator.shared
|
||||||
|
@Default(.enableTimerFeature) var enableTimerFeature
|
||||||
@Default(.enableStatsFeature) var enableStatsFeature
|
@Default(.enableStatsFeature) var enableStatsFeature
|
||||||
@Default(.enableColorPickerFeature) var enableColorPickerFeature
|
@Default(.enableColorPickerFeature) var enableColorPickerFeature
|
||||||
|
@Default(.timerDisplayMode) var timerDisplayMode
|
||||||
@Namespace var animation
|
@Namespace var animation
|
||||||
|
|
||||||
private var tabs: [TabModel] {
|
private var tabs: [TabModel] {
|
||||||
|
|
@ -30,6 +32,10 @@ struct TabSelectionView: View {
|
||||||
tabsArray.append(TabModel(label: "Shelf", icon: "tray.fill", view: .shelf))
|
tabsArray.append(TabModel(label: "Shelf", icon: "tray.fill", view: .shelf))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if enableTimerFeature && timerDisplayMode == .tab {
|
||||||
|
tabsArray.append(TabModel(label: "Timer", icon: "timer", view: .timer))
|
||||||
|
}
|
||||||
|
|
||||||
// Stats tab only shown when stats feature is enabled
|
// Stats tab only shown when stats feature is enabled
|
||||||
if Defaults[.enableStatsFeature] {
|
if Defaults[.enableStatsFeature] {
|
||||||
tabsArray.append(TabModel(label: "Stats", icon: "chart.xyaxis.line", view: .stats))
|
tabsArray.append(TabModel(label: "Stats", icon: "chart.xyaxis.line", view: .stats))
|
||||||
|
|
|
||||||
26
DynamicIsland/enums/TimerDisplayMode.swift
Normal file
26
DynamicIsland/enums/TimerDisplayMode.swift
Normal file
|
|
@ -0,0 +1,26 @@
|
||||||
|
import Defaults
|
||||||
|
|
||||||
|
public enum TimerDisplayMode: String, CaseIterable, Defaults.Serializable, Identifiable {
|
||||||
|
case tab
|
||||||
|
case popover
|
||||||
|
|
||||||
|
public var id: String { rawValue }
|
||||||
|
|
||||||
|
var displayName: String {
|
||||||
|
switch self {
|
||||||
|
case .tab:
|
||||||
|
return "Tab"
|
||||||
|
case .popover:
|
||||||
|
return "Popover"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var description: String {
|
||||||
|
switch self {
|
||||||
|
case .tab:
|
||||||
|
return "Shows timer controls as a dedicated tab inside the open notch."
|
||||||
|
case .popover:
|
||||||
|
return "Keeps the current popover button beside the notch instead of adding a tab."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1425,13 +1425,15 @@ class BluetoothAudioManager: ObservableObject {
|
||||||
|
|
||||||
HUDSuppressionCoordinator.shared.suppressVolumeHUD(for: 1.5)
|
HUDSuppressionCoordinator.shared.suppressVolumeHUD(for: 1.5)
|
||||||
|
|
||||||
coordinator.toggleSneakPeek(
|
Task { @MainActor in
|
||||||
status: true,
|
coordinator.toggleSneakPeek(
|
||||||
type: .bluetoothAudio,
|
status: true,
|
||||||
duration: 2.5,
|
type: .bluetoothAudio,
|
||||||
value: batteryValue,
|
duration: 2.5,
|
||||||
icon: device.deviceType.sfSymbol
|
value: batteryValue,
|
||||||
)
|
icon: device.deviceType.sfSymbol
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Cleanup
|
// MARK: - Cleanup
|
||||||
|
|
|
||||||
|
|
@ -155,12 +155,15 @@ final class SystemChangesObserver: MediaKeyInterceptorDelegate {
|
||||||
|
|
||||||
// Send to notch HUD if enabled and OSD is not enabled
|
// Send to notch HUD if enabled and OSD is not enabled
|
||||||
if Defaults[.enableSystemHUD] && !Defaults[.enableCustomOSD] && Defaults[.enableVolumeHUD] {
|
if Defaults[.enableSystemHUD] && !Defaults[.enableCustomOSD] && Defaults[.enableVolumeHUD] {
|
||||||
coordinator?.toggleSneakPeek(
|
Task { @MainActor in
|
||||||
status: true,
|
guard let coordinator else { return }
|
||||||
type: .volume,
|
coordinator.toggleSneakPeek(
|
||||||
value: CGFloat(value),
|
status: true,
|
||||||
icon: ""
|
type: .volume,
|
||||||
)
|
value: CGFloat(value),
|
||||||
|
icon: ""
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -174,12 +177,15 @@ final class SystemChangesObserver: MediaKeyInterceptorDelegate {
|
||||||
|
|
||||||
// Send to notch HUD if enabled and OSD is not enabled
|
// Send to notch HUD if enabled and OSD is not enabled
|
||||||
if Defaults[.enableSystemHUD] && !Defaults[.enableCustomOSD] && Defaults[.enableBrightnessHUD] {
|
if Defaults[.enableSystemHUD] && !Defaults[.enableCustomOSD] && Defaults[.enableBrightnessHUD] {
|
||||||
coordinator?.toggleSneakPeek(
|
Task { @MainActor in
|
||||||
status: true,
|
guard let coordinator else { return }
|
||||||
type: .brightness,
|
coordinator.toggleSneakPeek(
|
||||||
value: CGFloat(value),
|
status: true,
|
||||||
icon: ""
|
type: .brightness,
|
||||||
)
|
value: CGFloat(value),
|
||||||
|
icon: ""
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -193,12 +199,15 @@ final class SystemChangesObserver: MediaKeyInterceptorDelegate {
|
||||||
|
|
||||||
// Send to notch HUD if enabled and OSD is not enabled
|
// Send to notch HUD if enabled and OSD is not enabled
|
||||||
if Defaults[.enableSystemHUD] && !Defaults[.enableCustomOSD] && Defaults[.enableKeyboardBacklightHUD] {
|
if Defaults[.enableSystemHUD] && !Defaults[.enableCustomOSD] && Defaults[.enableKeyboardBacklightHUD] {
|
||||||
coordinator?.toggleSneakPeek(
|
Task { @MainActor in
|
||||||
status: true,
|
guard let coordinator else { return }
|
||||||
type: .backlight,
|
coordinator.toggleSneakPeek(
|
||||||
value: CGFloat(value),
|
status: true,
|
||||||
icon: ""
|
type: .backlight,
|
||||||
)
|
value: CGFloat(value),
|
||||||
|
icon: ""
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -545,6 +545,7 @@ extension Defaults.Keys {
|
||||||
|
|
||||||
// MARK: Timer Feature
|
// MARK: Timer Feature
|
||||||
static let enableTimerFeature = Key<Bool>("enableTimerFeature", default: true)
|
static let enableTimerFeature = Key<Bool>("enableTimerFeature", default: true)
|
||||||
|
static let timerDisplayMode = Key<TimerDisplayMode>("timerDisplayMode", default: .tab)
|
||||||
static let timerPresets = Key<[TimerPreset]>("timerPresets", default: TimerPreset.defaultPresets)
|
static let timerPresets = Key<[TimerPreset]>("timerPresets", default: TimerPreset.defaultPresets)
|
||||||
static let timerIconColorMode = Key<TimerIconColorMode>("timerIconColorMode", default: .adaptive)
|
static let timerIconColorMode = Key<TimerIconColorMode>("timerIconColorMode", default: .adaptive)
|
||||||
static let timerSolidColor = Key<Color>("timerSolidColor", default: .blue)
|
static let timerSolidColor = Key<Color>("timerSolidColor", default: .blue)
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue