diff --git a/DynamicIsland.xcodeproj/xcshareddata/xcschemes/DynamicIsland.xcscheme b/DynamicIsland.xcodeproj/xcshareddata/xcschemes/DynamicIsland.xcscheme
index 80849ad..4a6513a 100644
--- a/DynamicIsland.xcodeproj/xcshareddata/xcschemes/DynamicIsland.xcscheme
+++ b/DynamicIsland.xcodeproj/xcshareddata/xcschemes/DynamicIsland.xcscheme
@@ -16,7 +16,7 @@
@@ -45,7 +45,7 @@
@@ -62,7 +62,7 @@
diff --git a/DynamicIsland/ContentView.swift b/DynamicIsland/ContentView.swift
index b908f3f..b5dc5ee 100644
--- a/DynamicIsland/ContentView.swift
+++ b/DynamicIsland/ContentView.swift
@@ -630,11 +630,6 @@ struct ContentView: View {
// MARK: - Private Methods
private func doOpen() {
- // Switch to timer tab if timer is active
- if timerManager.isTimerActive {
- coordinator.currentView = .timer
- }
-
withAnimation(.bouncy.speed(1.2)) {
vm.open()
}
@@ -754,6 +749,7 @@ struct ContentView: View {
vm.isClipboardPopoverActive ||
vm.isColorPickerPopoverActive ||
vm.isStatsPopoverActive ||
+ vm.isTimerPopoverActive ||
vm.isMediaOutputPopoverActive
}
diff --git a/DynamicIsland/Localizable.xcstrings b/DynamicIsland/Localizable.xcstrings
index 32116b4..9a627ef 100644
--- a/DynamicIsland/Localizable.xcstrings
+++ b/DynamicIsland/Localizable.xcstrings
@@ -1636,6 +1636,10 @@
}
}
},
+ "Accent colour" : {
+ "comment" : "A label describing the colour picker for the timer preset.",
+ "isCommentAutoGenerated" : true
+ },
"Access Denied" : {
"localizations" : {
"ar" : {
@@ -2144,6 +2148,10 @@
}
}
},
+ "Add Preset" : {
+ "comment" : "A button label that adds a new timer preset.",
+ "isCommentAutoGenerated" : true
+ },
"Additional features" : {
"localizations" : {
"ar" : {
@@ -4677,6 +4685,7 @@
}
},
"Choose a custom sound file that will play when the timer completes. Supported formats: MP3, M4A, WAV, AIFF." : {
+ "extractionState" : "stale",
"localizations" : {
"fr" : {
"stringUnit" : {
@@ -5410,6 +5419,13 @@
}
}
},
+ "Configure how the timer looks inside the closed notch. Progress can render as a ring around the icon or as horizontal bars." : {
+
+ },
+ "Configure presets in Settings" : {
+ "comment" : "A message displayed when there are no presets configured. It encourages the user to configure presets in the app settings.",
+ "isCommentAutoGenerated" : true
+ },
"Connected" : {
"extractionState" : "stale",
"localizations" : {
@@ -5521,6 +5537,10 @@
}
}
},
+ "Control timer availability and whether progress appears as a live activity while the notch is closed." : {
+ "comment" : "A footer description for the timer feature settings.",
+ "isCommentAutoGenerated" : true
+ },
"Controls" : {
"localizations" : {
"fr" : {
@@ -5584,6 +5604,7 @@
}
},
"Current custom timer:" : {
+ "extractionState" : "stale",
"localizations" : {
"fr" : {
"stringUnit" : {
@@ -5593,6 +5614,10 @@
}
}
},
+ "Current default:" : {
+ "comment" : "A label displayed alongside the currently set default timer duration.",
+ "isCommentAutoGenerated" : true
+ },
"Current History" : {
"localizations" : {
"fr" : {
@@ -5963,6 +5988,7 @@
}
},
"Custom Timer Duration" : {
+ "extractionState" : "stale",
"localizations" : {
"fr" : {
"stringUnit" : {
@@ -6292,6 +6318,10 @@
}
}
},
+ "Default Custom Timer" : {
+ "comment" : "A label displayed above the section that lets users configure a custom timer.",
+ "isCommentAutoGenerated" : true
+ },
"Default: dynamic.m4a" : {
"localizations" : {
"fr" : {
@@ -7736,9 +7766,6 @@
}
}
}
- },
- "Enable or disable the timer functionality in the Dynamic Island. The live activity toggle controls whether timer progress is shown in the expanded view." : {
-
},
"Enable screen color picking functionality. Use Cmd+Shift+P to quickly access the color picker." : {
@@ -12237,6 +12264,7 @@
}
},
"Minutes" : {
+ "extractionState" : "stale",
"localizations" : {
"fr" : {
"stringUnit" : {
@@ -12569,6 +12597,14 @@
}
}
},
+ "Move Down" : {
+ "comment" : "A button label that says \"Move Down\".",
+ "isCommentAutoGenerated" : true
+ },
+ "Move Up" : {
+ "comment" : "A button label that says \"Move Up\".",
+ "isCommentAutoGenerated" : true
+ },
"Music Source" : {
"localizations" : {
"ar" : {
@@ -13588,6 +13624,10 @@
"comment" : "A label displayed when no graphs are enabled in the Stats tab.",
"isCommentAutoGenerated" : true
},
+ "No presets configured. Add a preset to make it appear in the timer popover." : {
+ "comment" : "A message displayed when there are no timer presets configured, encouraging the user to add one.",
+ "isCommentAutoGenerated" : true
+ },
"No process data yet. Keep stats open for a moment." : {
"comment" : "A message displayed when there is no process data available to display in the CPU stats view. It encourages the user to keep the stats open for a moment to gather more data.",
"isCommentAutoGenerated" : true
@@ -14560,6 +14600,18 @@
}
}
},
+ "Preset name" : {
+ "comment" : "A label for the name of a timer preset.",
+ "isCommentAutoGenerated" : true
+ },
+ "Presets" : {
+ "comment" : "A header label for the list of timer presets.",
+ "isCommentAutoGenerated" : true
+ },
+ "Presets show up inside the timer popover with the configured name, duration, and accent colour. Reorder them to change the display order." : {
+ "comment" : "A footer for the \"Timer Presets\" section in the TimerSettings view, explaining how to manage and reorder timer presets.",
+ "isCommentAutoGenerated" : true
+ },
"Pressure" : {
"comment" : "A label describing the memory pressure status.",
"isCommentAutoGenerated" : true,
@@ -14602,6 +14654,9 @@
}
}
}
+ },
+ "Progress style" : {
+
},
"Progressbar style" : {
"localizations" : {
@@ -15374,6 +15429,18 @@
}
}
},
+ "Restore" : {
+ "comment" : "The text for a button that restores default settings.",
+ "isCommentAutoGenerated" : true
+ },
+ "Restore default timer presets?" : {
+ "comment" : "A confirmation dialog title asking if the user wants to restore the default timer presets.",
+ "isCommentAutoGenerated" : true
+ },
+ "Restore Defaults" : {
+ "comment" : "A button that, when tapped, presents a confirmation dialog to reset all timer settings to their default values.",
+ "isCommentAutoGenerated" : true
+ },
"Resume" : {
"localizations" : {
"fr" : {
@@ -15641,6 +15708,7 @@
}
},
"Seconds" : {
+ "extractionState" : "stale",
"localizations" : {
"fr" : {
"stringUnit" : {
@@ -15662,6 +15730,10 @@
},
"Select a color to view details" : {
+ },
+ "Select a custom sound to play when a timer ends. Supported formats include MP3, M4A, WAV, and AIFF." : {
+ "comment" : "A description below the option to select a custom timer sound.",
+ "isCommentAutoGenerated" : true
},
"Select Calendars" : {
"localizations" : {
@@ -16026,9 +16098,6 @@
}
}
}
- },
- "Set a custom duration for the timer. This will be used when you press the 'Custom' button in the timer interface." : {
-
},
"Settings" : {
"localizations" : {
@@ -16565,6 +16634,9 @@
}
}
}
+ },
+ "Show countdown" : {
+
},
"Show download progress" : {
"extractionState" : "stale",
@@ -16582,6 +16654,9 @@
}
}
}
+ },
+ "Show floating pause/stop controls" : {
+
},
"Show on a specific display" : {
"localizations" : {
@@ -16782,6 +16857,9 @@
}
}
}
+ },
+ "Show progress" : {
+
},
"Show shuffle and repeat buttons" : {
"localizations" : {
@@ -16882,6 +16960,9 @@
}
}
}
+ },
+ "Show timer name" : {
+
},
"Shows a lock icon in the Dynamic Island when the screen is locked. Use the toggles above to control the lock screen media panel and weather capsule." : {
@@ -17316,6 +17397,9 @@
}
}
}
+ },
+ "Solid colour" : {
+
},
"Speed" : {
"localizations" : {
@@ -17557,6 +17641,10 @@
}
}
},
+ "Start Custom Timer" : {
+ "comment" : "A button label that starts a custom timer.",
+ "isCommentAutoGenerated" : true
+ },
"Start Demo Timer:" : {
"localizations" : {
"fr" : {
@@ -18065,9 +18153,16 @@
},
"TheDynamicIsland" : {
+ },
+ "These controls sit beside the notch while a timer runs. They require the timer name to stay hidden for spacing." : {
+
},
"These indicators appear in the notch when apps are using your camera or microphone, matching macOS's native privacy indicators." : {
+ },
+ "This duration powers the \"Custom\" option inside the timer popover for quick access." : {
+ "comment" : "A footer description for the custom timer settings, explaining their purpose.",
+ "isCommentAutoGenerated" : true
},
"Time Range" : {
"localizations" : {
@@ -18219,6 +18314,10 @@
}
}
},
+ "Timer Presets" : {
+ "comment" : "A section header in the timer settings view, describing timer presets.",
+ "isCommentAutoGenerated" : true
+ },
"Timer Sound" : {
"localizations" : {
"fr" : {
@@ -18228,6 +18327,9 @@
}
}
}
+ },
+ "Timer tint" : {
+
},
"Toggle Notch Open:" : {
"localizations" : {
@@ -18897,6 +18999,7 @@
}
},
"Update Timer" : {
+ "extractionState" : "stale",
"localizations" : {
"fr" : {
"stringUnit" : {
diff --git a/DynamicIsland/components/Live activities/InlineHUD.swift b/DynamicIsland/components/Live activities/InlineHUD.swift
index e721a41..671e20f 100644
--- a/DynamicIsland/components/Live activities/InlineHUD.swift
+++ b/DynamicIsland/components/Live activities/InlineHUD.swift
@@ -34,26 +34,36 @@ struct InlineHUD: View {
let baseInfoWidth: CGFloat = {
if type == .bluetoothAudio {
- return showBluetoothDeviceNameMarquee ? 100 : 52
+ return showBluetoothDeviceNameMarquee ? 140 : 88
}
return 100
}()
- let infoWidth = baseInfoWidth - (hoverAnimation ? 0 : 12) + gestureProgress / 2
+ let infoWidth: CGFloat = {
+ var width = baseInfoWidth + gestureProgress / 2
+ if !hoverAnimation { width -= 8 }
+ let minimum: CGFloat = type == .bluetoothAudio ? (showBluetoothDeviceNameMarquee ? 120 : 82) : 88
+ return max(width, minimum)
+ }()
let baseTrailingWidth: CGFloat = {
if type == .bluetoothAudio {
if !hasBatteryLevel {
- return showBluetoothDeviceNameMarquee ? 100 : 70
+ return showBluetoothDeviceNameMarquee ? 118 : 88
}
if useCircularIndicator {
- return showBluetoothBatteryPercentageText ? 100 : 70
+ return showBluetoothBatteryPercentageText ? 120 : 96
} else {
- return showBluetoothBatteryPercentageText ? 112 : 82
+ return showBluetoothBatteryPercentageText ? 136 : 108
}
}
return 100
}()
- let trailingWidth = baseTrailingWidth - (hoverAnimation ? 0 : 12) + gestureProgress / 2
+ let trailingWidth: CGFloat = {
+ var width = baseTrailingWidth + gestureProgress / 2
+ if !hoverAnimation { width -= 8 }
+ let minimum: CGFloat = type == .bluetoothAudio ? (showBluetoothBatteryPercentageText ? 110 : 92) : 90
+ return max(width, minimum)
+ }()
return HStack {
HStack(spacing: 5) {
diff --git a/DynamicIsland/components/Notch/DynamicIslandHeader.swift b/DynamicIsland/components/Notch/DynamicIslandHeader.swift
index 2a9e3b4..83dc5ec 100644
--- a/DynamicIsland/components/Notch/DynamicIslandHeader.swift
+++ b/DynamicIsland/components/Notch/DynamicIslandHeader.swift
@@ -15,8 +15,11 @@ struct DynamicIslandHeader: View {
@ObservedObject var coordinator = DynamicIslandViewCoordinator.shared
@ObservedObject var clipboardManager = ClipboardManager.shared
@ObservedObject var tvm = TrayDrop.shared
+ @ObservedObject var timerManager = TimerManager.shared
@State private var showClipboardPopover = false
@State private var showColorPickerPopover = false
+ @State private var showTimerPopover = false
+ @Default(.enableTimerFeature) var enableTimerFeature
var body: some View {
HStack(spacing: 0) {
@@ -141,6 +144,36 @@ struct DynamicIslandHeader: View {
}
}
+ if Defaults[.enableTimerFeature] {
+ Button(action: {
+ withAnimation(.smooth) {
+ showTimerPopover.toggle()
+ }
+ }) {
+ Capsule()
+ .fill(.black)
+ .frame(width: 30, height: 30)
+ .overlay {
+ Image(systemName: "timer")
+ .foregroundColor(.white)
+ .padding()
+ .imageScale(.medium)
+ }
+ }
+ .buttonStyle(PlainButtonStyle())
+ .popover(isPresented: $showTimerPopover, arrowEdge: .bottom) {
+ TimerPopover()
+ }
+ .onChange(of: showTimerPopover) { isActive in
+ vm.isTimerPopoverActive = isActive
+ if !isActive {
+ DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) {
+ vm.shouldRecheckHover.toggle()
+ }
+ }
+ }
+ }
+
if Defaults[.settingsIconInNotch] {
Button(action: {
SettingsWindowController.shared.showWindow()
@@ -204,6 +237,12 @@ struct DynamicIslandHeader: View {
showClipboardPopover.toggle()
}
}
+ .onChange(of: enableTimerFeature) { _, newValue in
+ if !newValue {
+ showTimerPopover = false
+ vm.isTimerPopoverActive = false
+ }
+ }
}
}
diff --git a/DynamicIsland/components/Notch/NotchTimerView.swift b/DynamicIsland/components/Notch/NotchTimerView.swift
index 27db6f5..2650198 100644
--- a/DynamicIsland/components/Notch/NotchTimerView.swift
+++ b/DynamicIsland/components/Notch/NotchTimerView.swift
@@ -70,7 +70,7 @@ struct NotchTimerView: View {
// Timer icon in center
Image(systemName: "timer")
.font(.system(size: 32, weight: .medium))
- .foregroundStyle(timerManager.currentColor)
+ .foregroundStyle(openIconColor)
.scaleEffect(timerManager.isRunning && timerManager.isTimerActive ? 1.1 : 1.0)
.animation(
timerManager.isRunning && timerManager.isTimerActive ?
@@ -287,6 +287,13 @@ struct NotchTimerView: View {
}
}
+private extension NotchTimerView {
+ var openIconColor: Color {
+ guard timerManager.isTimerActive && timerManager.isRunning else { return .white.opacity(0.9) }
+ return timerManager.currentColor
+ }
+}
+
#Preview {
NotchTimerView()
.environmentObject(DynamicIslandViewModel())
diff --git a/DynamicIsland/components/Settings/SettingsView.swift b/DynamicIsland/components/Settings/SettingsView.swift
index be3e6c6..e5b0dc4 100644
--- a/DynamicIsland/components/Settings/SettingsView.swift
+++ b/DynamicIsland/components/Settings/SettingsView.swift
@@ -1709,9 +1709,19 @@ func warningBadge(_ text: String, _ description: String) -> some View {
struct TimerSettings: View {
@ObservedObject private var coordinator = DynamicIslandViewCoordinator.shared
@Default(.enableTimerFeature) var enableTimerFeature
- @AppStorage("customTimerDuration") private var customTimerDuration: Double = 600 // 10 minutes default
+ @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
+ @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 {
@@ -1719,137 +1729,221 @@ struct TimerSettings: View {
Defaults.Toggle("Enable timer feature", key: .enableTimerFeature)
if enableTimerFeature {
- Toggle(
- "Enable timer live activity",
- isOn: $coordinator.timerLiveActivityEnabled
- )
- .animation(.easeInOut, value: coordinator.timerLiveActivityEnabled)
+ Toggle("Enable timer live activity", isOn: $coordinator.timerLiveActivityEnabled)
+ .animation(.easeInOut, value: coordinator.timerLiveActivityEnabled)
}
} header: {
Text("Timer Feature")
} footer: {
- Text("Enable or disable the timer functionality in the Dynamic Island. The live activity toggle controls whether timer progress is shown in the expanded view.")
+ Text("Control timer availability and whether progress appears as a live activity while the notch is closed.")
}
if enableTimerFeature {
- Section {
- VStack(alignment: .leading, spacing: 16) {
- Text("Custom Timer Duration")
- .font(.headline)
-
- HStack(spacing: 20) {
- VStack(alignment: .center, spacing: 8) {
- Text("Minutes")
- .font(.system(size: 14, weight: .medium))
- .foregroundStyle(.secondary)
-
- Picker(selection: $customMinutes, label: Text("Minutes")) {
- ForEach(0...59, id: \.self) { minute in
- Text("\(minute)").tag(minute)
- }
- }
- .pickerStyle(.menu)
- .frame(width: 100)
- }
-
- VStack(alignment: .center, spacing: 8) {
- Text("Seconds")
- .font(.system(size: 14, weight: .medium))
- .foregroundStyle(.secondary)
-
- Picker(selection: $customSeconds, label: Text("Seconds")) {
- ForEach(0...59, id: \.self) { second in
- Text("\(second)").tag(second)
- }
- }
- .pickerStyle(.menu)
- .frame(width: 100)
- }
- }
- .frame(maxWidth: .infinity)
-
- VStack(alignment: .leading, spacing: 8) {
+ Group {
+ 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 custom timer:")
+ Text("Current default:")
.foregroundStyle(.secondary)
- Text(customTimerDisplayText)
- .foregroundStyle(.primary)
+ Text(customDurationDisplay)
+ .font(.system(.body, design: .monospaced))
.fontWeight(.medium)
Spacer()
}
-
- HStack {
- Spacer()
- Button("Update Timer") {
- customTimerDuration = Double(customMinutes * 60 + customSeconds)
- }
- .disabled(customMinutes == 0 && customSeconds == 0)
- .buttonStyle(.borderedProminent)
+ }
+ .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.")
}
- .padding(.vertical, 8)
- } header: {
- Text("Custom Timer")
- } footer: {
- Text("Set a custom duration for the timer. This will be used when you press the 'Custom' button in the timer interface.")
- }
-
- Section {
- VStack(alignment: .leading, spacing: 8) {
+
+ 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 {
- Text("Timer Sound")
- .font(.system(size: 16, weight: .medium))
- Spacer()
- Button("Choose File") {
- selectCustomTimerSound()
+ 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)
+ }
}
-
- 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 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
}
- } header: {
- Text("Timer Sound")
- } footer: {
- Text("Choose a custom sound file that will play when the timer completes. Supported formats: MP3, M4A, WAV, AIFF.")
}
}
}
.navigationTitle("Timer")
- .onAppear {
- let totalMinutes = Int(customTimerDuration) / 60
- customMinutes = totalMinutes
- customSeconds = Int(customTimerDuration) % 60
+ .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 var customTimerDisplayText: String {
- let totalMinutes = Int(customTimerDuration) / 60
- let seconds = Int(customTimerDuration) % 60
- let hours = totalMinutes / 60
- let minutes = totalMinutes % 60
-
- if hours > 0 {
- return "\(hours):\(String(format: "%02d", minutes)):\(String(format: "%02d", seconds))"
- } else if minutes > 0 {
- return "\(minutes):\(String(format: "%02d", seconds))"
- } else {
- return "\(seconds)s"
+ 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
}
}
@@ -1869,6 +1963,146 @@ struct TimerSettings: View {
}
}
+private struct TimerDurationStepperRow: View {
+ let title: String
+ @Binding var value: Int
+ let range: ClosedRange
+
+ 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 {
+ Binding(
+ get: { components.hours },
+ set: { updateDuration(hours: $0) }
+ )
+ }
+
+ private var minutesBinding: Binding {
+ Binding(
+ get: { components.minutes },
+ set: { updateDuration(minutes: $0) }
+ )
+ }
+
+ private var secondsBinding: Binding {
+ Binding(
+ get: { components.seconds },
+ set: { updateDuration(seconds: $0) }
+ )
+ }
+
+ private var colorBinding: Binding {
+ 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
+
+ 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
diff --git a/DynamicIsland/components/Tabs/TabSelectionView.swift b/DynamicIsland/components/Tabs/TabSelectionView.swift
index 7bb3027..a1bd64c 100644
--- a/DynamicIsland/components/Tabs/TabSelectionView.swift
+++ b/DynamicIsland/components/Tabs/TabSelectionView.swift
@@ -17,7 +17,6 @@ struct TabModel: Identifiable {
struct TabSelectionView: View {
@ObservedObject var coordinator = DynamicIslandViewCoordinator.shared
- @Default(.enableTimerFeature) var enableTimerFeature
@Default(.enableStatsFeature) var enableStatsFeature
@Default(.enableColorPickerFeature) var enableColorPickerFeature
@Namespace var animation
@@ -29,11 +28,6 @@ struct TabSelectionView: View {
tabsArray.append(TabModel(label: "Shelf", icon: "tray.fill", view: .shelf))
- // Timer tab only shown when timer feature is enabled
- if Defaults[.enableTimerFeature] {
- tabsArray.append(TabModel(label: "Timer", icon: "timer", view: .timer))
- }
-
// Stats tab only shown when stats feature is enabled
if Defaults[.enableStatsFeature] {
tabsArray.append(TabModel(label: "Stats", icon: "chart.xyaxis.line", view: .stats))
diff --git a/DynamicIsland/components/Timer/TimerControlOverlay.swift b/DynamicIsland/components/Timer/TimerControlOverlay.swift
new file mode 100644
index 0000000..86453e6
--- /dev/null
+++ b/DynamicIsland/components/Timer/TimerControlOverlay.swift
@@ -0,0 +1,88 @@
+import SwiftUI
+
+struct TimerControlOverlay: View {
+ let notchHeight: CGFloat
+ let cornerRadius: CGFloat
+
+ @ObservedObject private var timerManager = TimerManager.shared
+
+ private var pauseIcon: String {
+ timerManager.isPaused ? "play.fill" : "pause.fill"
+ }
+
+ private var pauseForeground: Color {
+ .white
+ }
+
+ private var helpText: String {
+ timerManager.isPaused ? "Resume" : "Pause"
+ }
+
+ private var buttonSize: CGFloat {
+ max(notchHeight - 20, 22)
+ }
+
+ private var iconSize: CGFloat {
+ 14
+ }
+
+ private var windowCornerRadius: CGFloat {
+ max(cornerRadius - 6, 12)
+ }
+
+ var body: some View {
+ HStack(spacing: 12) {
+ Button(action: togglePause) {
+ Image(systemName: pauseIcon)
+ .font(.system(size: iconSize, weight: .semibold))
+ .frame(width: buttonSize, height: buttonSize)
+ .foregroundStyle(pauseForeground)
+ }
+ .buttonStyle(.plain)
+ .contentShape(RoundedRectangle(cornerRadius: 10, style: .continuous))
+ .help(helpText)
+
+ Button(action: stopTimer) {
+ Image(systemName: "stop.fill")
+ .font(.system(size: iconSize, weight: .semibold))
+ .frame(width: buttonSize, height: buttonSize)
+ .foregroundStyle(Color.red)
+ }
+ .buttonStyle(.plain)
+ .contentShape(RoundedRectangle(cornerRadius: 10, style: .continuous))
+ .help("Stop")
+ }
+ .padding(.horizontal, 12)
+ .frame(height: notchHeight)
+ .frame(minWidth: buttonSize * 2 + 32)
+ .background {
+ RoundedRectangle(cornerRadius: windowCornerRadius, style: .continuous)
+ .fill(Color.black.opacity(0.9))
+ }
+ .compositingGroup()
+ .animation(.smooth(duration: 0.2), value: timerManager.isPaused)
+ .animation(.smooth(duration: 0.2), value: timerManager.isFinished)
+ .animation(.smooth(duration: 0.2), value: timerManager.isOvertime)
+ }
+
+ private func togglePause() {
+ if timerManager.isPaused {
+ timerManager.resumeTimer()
+ } else {
+ timerManager.pauseTimer()
+ }
+ }
+
+ private func stopTimer() {
+#if os(macOS)
+ TimerControlWindowManager.shared.hide(animated: true)
+#endif
+ timerManager.stopTimer()
+ }
+}
+
+#Preview {
+ TimerControlOverlay(notchHeight: 34, cornerRadius: 14)
+ .padding()
+ .background(Color.gray.opacity(0.2))
+}
diff --git a/DynamicIsland/components/Timer/TimerIconAnimation.swift b/DynamicIsland/components/Timer/TimerIconAnimation.swift
index cd32767..9eb6bd8 100644
--- a/DynamicIsland/components/Timer/TimerIconAnimation.swift
+++ b/DynamicIsland/components/Timer/TimerIconAnimation.swift
@@ -6,95 +6,43 @@
//
import SwiftUI
-import Combine
+import Defaults
struct TimerIconAnimation: View {
@ObservedObject var timerManager = TimerManager.shared
- @State private var pulseScale: CGFloat = 1.0
- @State private var rotationAngle: Double = 0
+ @Default(.timerIconColorMode) private var colorMode
+ @Default(.timerSolidColor) private var solidColor
+ @Default(.timerPresets) private var timerPresets
+ @Default(.accentColor) private var accentColor
var body: some View {
ZStack {
- // Background circle with timer color
Circle()
- .fill(timerManager.timerColor.gradient)
+ .fill(resolvedColor.gradient)
.frame(width: 24, height: 24)
- .scaleEffect(pulseScale)
- .animation(
- timerManager.isTimerActive && !timerManager.isPaused
- ? .easeInOut(duration: 1.0).repeatForever(autoreverses: true)
- : .easeInOut(duration: 0.3),
- value: pulseScale
- )
- // Timer icon
Image(systemName: "timer")
.font(.system(size: 12, weight: .medium))
- .foregroundColor(.white)
- .rotationEffect(.degrees(rotationAngle))
- .animation(
- timerManager.isTimerActive && !timerManager.isPaused
- ? .linear(duration: 2.0).repeatForever(autoreverses: false)
- : .easeInOut(duration: 0.3),
- value: rotationAngle
- )
-
- // Progress ring
- Circle()
- .trim(from: 0, to: timerManager.progress)
- .stroke(
- Color.white.opacity(0.8),
- style: StrokeStyle(lineWidth: 2, lineCap: .round)
- )
- .frame(width: 26, height: 26)
- .rotationEffect(.degrees(-90)) // Start from top
- .animation(.easeInOut(duration: 0.5), value: timerManager.progress)
- }
- .onAppear {
- startAnimations()
- }
- .onChange(of: timerManager.isTimerActive) { _, isActive in
- if isActive {
- startAnimations()
- } else {
- stopAnimations()
- }
- }
- .onChange(of: timerManager.isPaused) { _, isPaused in
- if isPaused {
- stopAnimations()
- } else if timerManager.isTimerActive {
- startAnimations()
- }
+ .foregroundStyle(.white)
}
}
-
- private func startAnimations() {
- guard timerManager.isTimerActive && !timerManager.isPaused else { return }
-
- // Start pulsing
- pulseScale = 1.1
-
- // Start rotation
- withAnimation(.linear(duration: 2.0).repeatForever(autoreverses: false)) {
- rotationAngle = 360
- }
- }
-
- private func stopAnimations() {
- // Stop pulsing
- pulseScale = 1.0
-
- // Stop rotation
- withAnimation(.easeInOut(duration: 0.3)) {
- rotationAngle = 0
+
+ private var resolvedColor: Color {
+ guard timerManager.isTimerActive else { return accentColor }
+ switch colorMode {
+ case .adaptive:
+ if let presetId = timerManager.activePresetId,
+ let preset = timerPresets.first(where: { $0.id == presetId }) {
+ return preset.color
+ }
+ return timerManager.timerColor
+ case .solid:
+ return solidColor
}
}
}
struct TimerIconView: NSViewRepresentable {
- @ObservedObject var timerManager = TimerManager.shared
-
func makeNSView(context: Context) -> NSView {
let hostingView = NSHostingView(rootView: TimerIconAnimation())
hostingView.translatesAutoresizingMaskIntoConstraints = false
diff --git a/DynamicIsland/components/Timer/TimerLiveActivity.swift b/DynamicIsland/components/Timer/TimerLiveActivity.swift
index 9e5aff2..e9bd040 100644
--- a/DynamicIsland/components/Timer/TimerLiveActivity.swift
+++ b/DynamicIsland/components/Timer/TimerLiveActivity.swift
@@ -8,66 +8,470 @@
import SwiftUI
import Defaults
+#if canImport(AppKit)
+import AppKit
+typealias PlatformFont = NSFont
+#elseif canImport(UIKit)
+import UIKit
+typealias PlatformFont = UIFont
+#endif
+
struct TimerLiveActivity: View {
@EnvironmentObject var vm: DynamicIslandViewModel
- @ObservedObject var coordinator = DynamicIslandViewCoordinator.shared
@ObservedObject var timerManager = TimerManager.shared
@State private var isHovering: Bool = false
- @State private var gestureProgress: CGFloat = 0
+ @State private var showTransientLabel: Bool = false
+ @State private var labelHideTask: DispatchWorkItem?
+ @State private var isControlWindowVisible: Bool = false
+ @Default(.timerShowsCountdown) private var showsCountdown
+ @Default(.timerShowsProgress) private var showsProgress
+ @Default(.timerShowsLabel) private var showsLabel
+ @Default(.timerProgressStyle) private var progressStyle
+ @Default(.timerIconColorMode) private var colorMode
+ @Default(.timerSolidColor) private var solidColor
+ @Default(.timerPresets) private var timerPresets
+ @Default(.timerControlWindowEnabled) private var controlWindowEnabled
+ private var notchContentHeight: CGFloat {
+ max(0, vm.effectiveClosedNotchHeight - (isHovering ? 0 : 12))
+ }
+
+ private var wingPadding: CGFloat { 22 }
+ private var ringStrokeWidth: CGFloat { 3 }
+ private var transientLabelDuration: TimeInterval { 4 }
+
+ private var ringWrapsIcon: Bool {
+ showsRingProgress && showsCountdown
+ }
+
+ private var ringOnRight: Bool {
+ showsRingProgress && !ringWrapsIcon
+ }
+
+ private var iconWidth: CGFloat {
+ ringWrapsIcon ? max(notchContentHeight - 6, 28) : max(0, notchContentHeight)
+ }
+
+ private var infoContentWidth: CGFloat {
+ guard showsInfoSection else { return 0 }
+ if shouldDisplayLabel {
+ let textWidth = min(max(titleTextWidth, 44), 220)
+ return textWidth
+ } else {
+ return min(max(notchContentHeight * 1.4, 64), 220)
+ }
+ }
+
+ private var infoWidth: CGFloat {
+ guard showsInfoSection else { return 0 }
+ return infoContentWidth + 18
+ }
+
+ private var leftWingWidth: CGFloat {
+ var width = iconWidth + wingPadding
+ if showsInfoSection {
+ width += 8 + infoWidth
+ }
+ return width
+ }
+
+ private var ringWidth: CGFloat {
+ ringOnRight ? 30 : 0
+ }
+
+ private var rightWingWidth: CGFloat {
+ var width = wingPadding
+ if ringOnRight {
+ width += ringWidth
+ }
+ if ringOnRight && showsCountdown {
+ width += 8
+ }
+ if showsCountdown {
+ width += countdownWidth
+ }
+ return width
+ }
+
+ private var titleTextWidth: CGFloat {
+ measureTextWidth(timerManager.timerName, font: systemFont(size: 12, weight: .medium))
+ }
+
+ private var countdownTextWidth: CGFloat {
+ measureTextWidth(timerManager.formattedRemainingTime(), font: monospacedDigitFont(size: 13, weight: .semibold))
+ }
+
+ private var countdownWidth: CGFloat {
+ guard showsCountdown else { return 0 }
+ return max(countdownTextWidth + 16, 72)
+ }
+
+ private var clampedProgress: Double {
+ min(max(timerManager.progress, 0), 1)
+ }
+
+ private var glyphColor: Color {
+ switch colorMode {
+ case .adaptive:
+ return activePresetColor ?? timerManager.timerColor
+ case .solid:
+ return solidColor
+ }
+ }
+
+ private var showsRingProgress: Bool {
+ showsProgress && progressStyle == .ring
+ }
+
+ private var showsBarProgress: Bool {
+ showsProgress && progressStyle == .bar
+ }
+
+ private var shouldDisplayLabel: Bool {
+ showsLabel || showTransientLabel
+ }
+
+ private var showsInfoSection: Bool {
+ shouldDisplayLabel || (showsBarProgress && !showsCountdown)
+ }
+
+ private var activePresetColor: Color? {
+ guard let presetId = timerManager.activePresetId else { return nil }
+ return timerPresets.first { $0.id == presetId }?.color
+ }
+
+ private var shouldShowControlWindow: Bool {
+ controlWindowEnabled && !shouldDisplayLabel && timerManager.isTimerActive
+ }
+
+ private func measureTextWidth(_ text: String, font: PlatformFont) -> CGFloat {
+ let attributes: [NSAttributedString.Key: Any] = [.font: font]
+ let width = NSAttributedString(string: text, attributes: attributes).size().width
+ return CGFloat(ceil(width))
+ }
+
+ private func systemFont(size: CGFloat, weight: PlatformFont.Weight) -> PlatformFont {
+ #if canImport(AppKit)
+ return NSFont.systemFont(ofSize: size, weight: weight)
+ #else
+ return UIFont.systemFont(ofSize: size, weight: weight)
+ #endif
+ }
+
+ private func monospacedDigitFont(size: CGFloat, weight: PlatformFont.Weight) -> PlatformFont {
+ #if canImport(AppKit)
+ return NSFont.monospacedDigitSystemFont(ofSize: size, weight: weight)
+ #else
+ return UIFont.monospacedDigitSystemFont(ofSize: size, weight: weight)
+ #endif
+ }
+
var body: some View {
- HStack {
- // Left side - Timer icon animation
- timerIconSection
-
- // Center - Expandable timer info (similar to music banner)
- timerInfoSection
-
- // Right side - Timer countdown text
- timerCountdownSection
+ HStack(spacing: 0) {
+ Color.clear
+ .frame(width: leftWingWidth, height: notchContentHeight)
+ .background(alignment: .leading) {
+ HStack(spacing: showsInfoSection ? 8 : 0) {
+ iconSection
+ if showsInfoSection {
+ infoSection
+ }
+ }
+ .padding(.leading, wingPadding / 2)
+ .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .leading)
+ }
+
+ Rectangle()
+ .fill(.black)
+ .frame(width: vm.closedNotchSize.width + (isHovering ? 8 : 0), height: notchContentHeight)
+
+ Color.clear
+ .frame(width: rightWingWidth, height: notchContentHeight)
+ .background(alignment: .trailing) {
+ HStack(spacing: ringOnRight && showsCountdown ? 8 : 0) {
+ if ringOnRight {
+ ringSection
+ }
+ if showsCountdown {
+ countdownSection
+ }
+ }
+ .padding(.trailing, wingPadding / 2)
+ .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .trailing)
+ }
}
.frame(height: vm.effectiveClosedNotchHeight + (isHovering ? 8 : 0), alignment: .center)
- }
-
- private var timerIconSection: some View {
- HStack {
- // Simple timer icon with color, no background circle or animations
- Image(systemName: "timer")
- .font(.system(size: 16, weight: .medium))
- .foregroundStyle(timerManager.timerColor)
- .frame(width: 24, height: 24)
- .frame(width: max(0, vm.effectiveClosedNotchHeight - (isHovering ? 0 : 12) + gestureProgress / 2),
- height: max(0, vm.effectiveClosedNotchHeight - (isHovering ? 0 : 12)), alignment: .center)
+ .contentShape(Rectangle())
+ .onHover { hovering in
+ withAnimation(.smooth(duration: 0.18)) {
+ isHovering = hovering
+ }
+ syncControlWindow(forceRefresh: true)
+ }
+ .onAppear {
+ syncControlWindow(forceRefresh: true)
+ if timerManager.isTimerActive && !timerManager.isFinished && !timerManager.isOvertime {
+ triggerTransientLabel()
+ }
+ }
+ .onDisappear {
+ hideControlWindow()
+ }
+ .onChange(of: timerManager.isTimerActive) { _, isActive in
+ if isActive {
+ if !timerManager.isFinished && !timerManager.isOvertime {
+ syncControlWindow(forceRefresh: true)
+ triggerTransientLabel()
+ }
+ } else {
+ hideControlWindow()
+ cancelTransientLabel()
+ showTransientLabel = false
+ isHovering = false
+ }
+ }
+ .onChange(of: timerManager.timerName) { _, _ in
+ if timerManager.isTimerActive && !timerManager.isFinished && !timerManager.isOvertime {
+ syncControlWindow(forceRefresh: true)
+ triggerTransientLabel()
+ }
+ }
+ .onChange(of: timerManager.isFinished) { _, finished in
+ if finished {
+ cancelTransientLabel()
+ withAnimation(.smooth) {
+ showTransientLabel = true
+ isHovering = false
+ }
+ syncControlWindow()
+ }
+ }
+ .onChange(of: timerManager.isOvertime) { _, overtime in
+ if overtime {
+ cancelTransientLabel()
+ withAnimation(.smooth) {
+ showTransientLabel = true
+ isHovering = false
+ }
+ syncControlWindow()
+ }
+ }
+ .onChange(of: timerManager.isPaused) { _, _ in
+ syncControlWindow(forceRefresh: true)
+ }
+ .onChange(of: vm.closedNotchSize) { _, _ in
+ syncControlWindow(forceRefresh: true)
+ }
+ .onChange(of: vm.screen) { _, _ in
+ syncControlWindow(forceRefresh: true)
+ }
+ .onChange(of: vm.hideOnClosed) { _, _ in
+ syncControlWindow(forceRefresh: true)
+ }
+ .onChange(of: controlWindowEnabled) { _, _ in
+ syncControlWindow(forceRefresh: true)
+ }
+ .onChange(of: showsLabel) { _, _ in
+ syncControlWindow(forceRefresh: true)
+ }
+ .onChange(of: showTransientLabel) { _, _ in
+ syncControlWindow(forceRefresh: true)
+ }
+ .onChange(of: showsCountdown) { _, _ in
+ syncControlWindow(forceRefresh: true)
+ }
+ .onChange(of: showsProgress) { _, _ in
+ syncControlWindow(forceRefresh: true)
+ }
+ .onChange(of: progressStyle) { _, _ in
+ syncControlWindow(forceRefresh: true)
}
- .frame(width: max(0, vm.effectiveClosedNotchHeight - (isHovering ? 0 : 12) + gestureProgress / 2),
- height: max(0, vm.effectiveClosedNotchHeight - (isHovering ? 0 : 12)))
}
- private var timerInfoSection: some View {
- Rectangle()
+ private var iconSection: some View {
+ let baseDiameter = ringWrapsIcon ? iconWidth : iconWidth
+ let ringDiameter = ringWrapsIcon ? max(min(baseDiameter, notchContentHeight - 2), 22) : iconWidth
+ let iconSize = ringWrapsIcon ? max(ringDiameter - 12, 16) : max(18, iconWidth - 6)
+
+ return ZStack {
+ if ringWrapsIcon {
+ Circle()
+ .stroke(Color.white.opacity(0.15), lineWidth: ringStrokeWidth)
+ .frame(width: ringDiameter, height: ringDiameter)
+
+ Circle()
+ .trim(from: 0, to: clampedProgress)
+ .stroke(glyphColor, style: StrokeStyle(lineWidth: ringStrokeWidth, lineCap: .round))
+ .rotationEffect(.degrees(-90))
+ .animation(.smooth(duration: 0.25), value: clampedProgress)
+ .frame(width: ringDiameter, height: ringDiameter)
+ }
+
+ Image(systemName: "timer")
+ .font(.system(size: 16, weight: .semibold))
+ .foregroundStyle(glyphColor)
+ .frame(width: iconSize, height: iconSize)
+ }
+ .frame(width: ringWrapsIcon ? ringDiameter : iconWidth,
+ height: notchContentHeight,
+ alignment: .center)
+ }
+
+ private var infoSection: some View {
+ let availableWidth = max(0, infoWidth - 10)
+ let safeWidth = max(44, availableWidth - 6)
+ let resolvedTextWidth = min(max(titleTextWidth, 44), safeWidth)
+ let marqueeLabel = shouldDisplayLabel && (timerManager.isFinished || timerManager.isOvertime || titleTextWidth > availableWidth)
+ let showsBarHere = showsBarProgress && !showsCountdown
+ let barWidth = shouldDisplayLabel ? resolvedTextWidth : availableWidth
+
+ return Rectangle()
.fill(.black)
- .frame(width: vm.closedNotchSize.width + (isHovering ? 8 : 0))
+ .frame(width: infoWidth, height: notchContentHeight)
+ .overlay(alignment: .leading) {
+ VStack(alignment: .leading, spacing: showsBarHere ? 4 : 0) {
+ if shouldDisplayLabel {
+ if marqueeLabel {
+ MarqueeText(
+ .constant(timerManager.timerName),
+ font: .system(size: 12, weight: .medium),
+ nsFont: .callout,
+ textColor: .white,
+ minDuration: 0.25,
+ frameWidth: resolvedTextWidth
+ )
+ } else {
+ Text(timerManager.timerName)
+ .font(.system(size: 12, weight: .medium))
+ .lineLimit(1)
+ .foregroundStyle(.white)
+ .transition(.opacity.combined(with: .move(edge: .top)))
+ .frame(width: resolvedTextWidth, alignment: .leading)
+ }
+ }
+
+ if showsBarHere {
+ Capsule()
+ .fill(Color.white.opacity(0.12))
+ .frame(width: barWidth, height: 3)
+ .overlay(alignment: .leading) {
+ Capsule()
+ .fill(glyphColor)
+ .frame(width: barWidth * max(0, CGFloat(clampedProgress)))
+ .animation(.smooth(duration: 0.25), value: clampedProgress)
+ }
+ }
+ }
+ .padding(.leading, 12)
+ .padding(.trailing, 6)
+ }
+ .animation(.smooth, value: timerManager.isFinished)
}
- private var timerCountdownSection: some View {
- HStack {
- VStack(spacing: 2) {
- // Remaining time
- Text(timerManager.formattedRemainingTime())
- .font(.system(size: 11, weight: .medium, design: .monospaced))
- .foregroundColor(timerManager.isOvertime ? .red : .white)
-
- // Progress indicator
- if timerManager.totalDuration > 0 {
- ProgressView(value: timerManager.progress)
- .progressViewStyle(LinearProgressViewStyle(tint: timerManager.timerColor))
- .frame(width: 30, height: 2)
+ private var ringSection: some View {
+ let diameter = max(min(notchContentHeight - 4, 26), 20)
+ return ZStack {
+ Circle()
+ .stroke(Color.white.opacity(0.15), lineWidth: ringStrokeWidth)
+ Circle()
+ .trim(from: 0, to: clampedProgress)
+ .stroke(glyphColor, style: StrokeStyle(lineWidth: ringStrokeWidth, lineCap: .round))
+ .rotationEffect(.degrees(-90))
+ .animation(.smooth(duration: 0.25), value: clampedProgress)
+ }
+ .frame(width: diameter, height: diameter)
+ .frame(width: ringWidth, height: notchContentHeight, alignment: .center)
+ }
+
+ private var countdownSection: some View {
+ let barWidth = max(countdownTextWidth, 1)
+ return VStack(spacing: 4) {
+ Text(timerManager.formattedRemainingTime())
+ .font(.system(size: 13, weight: .semibold, design: .monospaced))
+ .foregroundColor(timerManager.isOvertime ? .red : .white)
+ .contentTransition(.numericText())
+ .animation(.smooth(duration: 0.25), value: timerManager.remainingTime)
+ .frame(maxWidth: .infinity, alignment: .trailing)
+
+ if showsBarProgress {
+ Capsule()
+ .fill(Color.white.opacity(0.12))
+ .frame(width: barWidth, height: 3)
+ .overlay(alignment: .leading) {
+ Capsule()
+ .fill(glyphColor)
+ .frame(width: barWidth * max(0, CGFloat(clampedProgress)))
+ .animation(.smooth(duration: 0.25), value: clampedProgress)
+ }
+ .frame(maxWidth: .infinity, alignment: .trailing)
+ }
+ }
+ .padding(.trailing, 8)
+ .frame(width: countdownWidth,
+ height: notchContentHeight, alignment: .center)
+ }
+
+ private func triggerTransientLabel() {
+ guard !showsLabel else { return }
+ cancelTransientLabel()
+ withAnimation(.smooth) {
+ showTransientLabel = true
+ }
+ let task = DispatchWorkItem {
+ withAnimation(.smooth) {
+ showTransientLabel = false
+ }
+ syncControlWindow(forceRefresh: true)
+ }
+ labelHideTask = task
+ DispatchQueue.main.asyncAfter(deadline: .now() + transientLabelDuration, execute: task)
+ }
+
+ private func cancelTransientLabel() {
+ labelHideTask?.cancel()
+ labelHideTask = nil
+ }
+
+ private func syncControlWindow(forceRefresh: Bool = false) {
+#if os(macOS)
+ let notchAvailable = vm.effectiveClosedNotchHeight > 0 && vm.closedNotchSize.width > 0
+ let targetVisible = shouldShowControlWindow && notchAvailable
+ if targetVisible {
+ let metrics = currentControlWindowMetrics()
+ if !isControlWindowVisible {
+ let didPresent = TimerControlWindowManager.shared.present(using: vm, metrics: metrics)
+ isControlWindowVisible = didPresent
+ } else if forceRefresh {
+ let didRefresh = TimerControlWindowManager.shared.refresh(using: vm, metrics: metrics)
+ isControlWindowVisible = didRefresh
+ if !didRefresh {
+ TimerControlWindowManager.shared.hide()
}
}
- .frame(maxWidth: .infinity)
+ } else if isControlWindowVisible {
+ TimerControlWindowManager.shared.hide()
+ isControlWindowVisible = false
}
- .frame(width: max(0, vm.effectiveClosedNotchHeight - (isHovering ? 0 : 12) + gestureProgress / 2),
- height: max(0, vm.effectiveClosedNotchHeight - (isHovering ? 0 : 12)), alignment: .center)
+#endif
+ }
+
+ private func hideControlWindow() {
+#if os(macOS)
+ if isControlWindowVisible {
+ TimerControlWindowManager.shared.hide()
+ isControlWindowVisible = false
+ }
+#endif
+ }
+
+ private func currentControlWindowMetrics() -> TimerControlWindowMetrics {
+ TimerControlWindowMetrics(
+ notchHeight: max(vm.closedNotchSize.height, vm.effectiveClosedNotchHeight),
+ notchWidth: vm.closedNotchSize.width + (isHovering ? 8 : 0),
+ rightWingWidth: rightWingWidth,
+ cornerRadius: cornerRadiusInsets.closed.bottom,
+ spacing: 6
+ )
}
}
@@ -77,7 +481,6 @@ struct TimerLiveActivity: View {
.frame(width: 300, height: 32)
.background(.black)
.onAppear {
- // Start a demo timer for preview
TimerManager.shared.startDemoTimer(duration: 300)
}
}
diff --git a/DynamicIsland/components/Timer/TimerPopover.swift b/DynamicIsland/components/Timer/TimerPopover.swift
new file mode 100644
index 0000000..c5dcbfc
--- /dev/null
+++ b/DynamicIsland/components/Timer/TimerPopover.swift
@@ -0,0 +1,362 @@
+//
+// TimerPopover.swift
+// DynamicIsland
+//
+// Created by GitHub Copilot on 2025-11-01.
+//
+
+import SwiftUI
+import Defaults
+
+struct TimerPopover: View {
+ @ObservedObject var timerManager = TimerManager.shared
+ @Default(.timerPresets) private var timerPresets
+ @Default(.timerShowsCountdown) private var showsCountdown
+ @Default(.timerShowsProgress) private var showsProgress
+ @Default(.timerProgressStyle) private var progressStyle
+ @Default(.timerIconColorMode) private var colorMode
+ @Default(.timerSolidColor) private var solidColor
+ @AppStorage("customTimerDuration") private var customTimerDuration: Double = 600
+ @Environment(\.dismiss) private var dismiss
+
+ @State private var customHours: Int = 0
+ @State private var customMinutes: Int = 10
+ @State private var customSeconds: Int = 0
+
+ private var customDurationInSeconds: TimeInterval {
+ TimeInterval(customHours * 3600 + customMinutes * 60 + customSeconds)
+ }
+
+ var body: some View {
+ VStack(spacing: 16) {
+ HeaderView(statusText: statusText)
+
+ if timerManager.isTimerActive {
+ ActiveTimerSection(timerManager: timerManager)
+ } else {
+ CustomTimerSection(hours: $customHours, minutes: $customMinutes, seconds: $customSeconds, startAction: startCustomTimer)
+ .onChange(of: customHours) { _, _ in updateStoredCustomDuration() }
+ .onChange(of: customMinutes) { _, _ in updateStoredCustomDuration() }
+ .onChange(of: customSeconds) { _, _ in updateStoredCustomDuration() }
+ }
+
+ Divider()
+ .padding(.horizontal, -8)
+
+ PresetList(presets: timerPresets, activePresetId: timerManager.activePresetId, startAction: startPreset)
+ .animation(.smooth, value: timerManager.activePresetId)
+ .frame(maxHeight: 200)
+ }
+ .padding(16)
+ .frame(width: 300)
+ .background(
+ VisualEffectView(material: .hudWindow, blendingMode: .behindWindow)
+ .cornerRadius(12)
+ )
+ .onAppear {
+ syncCustomDuration(with: customTimerDuration)
+ }
+ .onChange(of: customTimerDuration) { _, newValue in
+ syncCustomDuration(with: newValue)
+ }
+ }
+
+ private var statusText: String {
+ if timerManager.isOvertime {
+ return "Overtime"
+ } else if timerManager.isPaused {
+ return "Paused"
+ } else if timerManager.isTimerActive {
+ return "Running"
+ } else {
+ return "Ready"
+ }
+ }
+
+ private func syncCustomDuration(with value: Double) {
+ let components = TimerPreset.components(for: value)
+ customHours = components.hours
+ customMinutes = components.minutes
+ customSeconds = components.seconds
+ }
+
+ private func updateStoredCustomDuration() {
+ let newValue = customDurationInSeconds
+ customTimerDuration = newValue
+ }
+
+ private func startCustomTimer() {
+ let duration = customDurationInSeconds
+ guard duration > 0 else { return }
+ withAnimation(.smooth) {
+ timerManager.startTimer(duration: duration, name: "Custom Timer")
+ }
+ dismiss()
+ }
+
+ private func startPreset(_ preset: TimerPreset) {
+ withAnimation(.smooth) {
+ timerManager.startTimer(duration: preset.duration, name: preset.name, preset: preset)
+ }
+ dismiss()
+ }
+}
+
+private struct HeaderView: View {
+ let statusText: String
+
+ var body: some View {
+ HStack(spacing: 12) {
+ TimerIconAnimation()
+ .frame(width: 32, height: 32)
+
+ VStack(alignment: .leading, spacing: 2) {
+ Text("Timer")
+ .font(.system(size: 14, weight: .semibold))
+ Text(statusText)
+ .font(.system(size: 11, weight: .medium))
+ .foregroundStyle(.secondary)
+ }
+
+ Spacer()
+ }
+ }
+}
+
+private struct ActiveTimerSection: View {
+ @ObservedObject var timerManager: TimerManager
+ @Default(.timerShowsProgress) private var showsProgress
+ @Default(.timerProgressStyle) private var progressStyle
+ @Default(.timerIconColorMode) private var colorMode
+ @Default(.timerSolidColor) private var solidColor
+ @Default(.timerPresets) private var timerPresets
+
+ var body: some View {
+ VStack(alignment: .leading, spacing: 12) {
+ VStack(alignment: .leading, spacing: 4) {
+ Text(timerManager.timerName)
+ .font(.system(size: 15, weight: .semibold))
+ .lineLimit(1)
+ .foregroundColor(.primary)
+
+ Text(timerManager.formattedRemainingTime())
+ .font(.system(size: 28, weight: .bold, design: .monospaced))
+ .foregroundStyle(timerManager.isOvertime ? Color.red : Color.primary)
+ .contentTransition(.numericText())
+ }
+
+ if showsProgress && progressStyle == .bar {
+ ProgressView(value: min(timerManager.progress, 1.0))
+ .progressViewStyle(LinearProgressViewStyle(tint: progressTint))
+ .animation(.smooth(duration: 0.2), value: timerManager.progress)
+ }
+
+ HStack(spacing: 8) {
+ Button(action: togglePause) {
+ Label(timerManager.isPaused ? "Resume" : "Pause", systemImage: timerManager.isPaused ? "play.fill" : "pause.fill")
+ .font(.system(size: 12, weight: .medium))
+ .labelStyle(.titleAndIcon)
+ }
+ .buttonStyle(.bordered)
+
+ Button(role: .destructive, action: stopTimer) {
+ Label("Stop", systemImage: "stop.fill")
+ .font(.system(size: 12, weight: .medium))
+ .labelStyle(.titleAndIcon)
+ }
+ .buttonStyle(.borderedProminent)
+ }
+ }
+ .frame(maxWidth: .infinity, alignment: .leading)
+ .padding(12)
+ .background(Color.white.opacity(0.08))
+ .clipShape(RoundedRectangle(cornerRadius: 10, style: .continuous))
+ .animation(.smooth, value: timerManager.isPaused)
+ }
+
+ private func togglePause() {
+ withAnimation(.smooth) {
+ if timerManager.isPaused {
+ timerManager.resumeTimer()
+ } else {
+ timerManager.pauseTimer()
+ }
+ }
+ }
+
+ private func stopTimer() {
+ withAnimation(.smooth) {
+ timerManager.stopTimer()
+ }
+ }
+
+ private var progressTint: Color {
+ switch colorMode {
+ case .adaptive:
+ return activePresetColor ?? timerManager.timerColor
+ case .solid:
+ return solidColor
+ }
+ }
+
+ private var activePresetColor: Color? {
+ guard let presetId = timerManager.activePresetId else { return nil }
+ return timerPresets.first { $0.id == presetId }?.color
+ }
+}
+
+private struct CustomTimerSection: View {
+ @Binding var hours: Int
+ @Binding var minutes: Int
+ @Binding var seconds: Int
+ let startAction: () -> Void
+
+ private var totalSeconds: Int {
+ hours * 3600 + minutes * 60 + seconds
+ }
+
+ var body: some View {
+ VStack(alignment: .leading, spacing: 12) {
+ Text("Custom Timer")
+ .font(.system(size: 14, weight: .semibold))
+
+ Grid(alignment: .leading, horizontalSpacing: 12, verticalSpacing: 8) {
+ GridRow {
+ DurationStepper(title: "Hours", value: $hours, range: 0...23)
+ DurationStepper(title: "Minutes", value: $minutes, range: 0...59)
+ DurationStepper(title: "Seconds", value: $seconds, range: 0...59)
+ }
+ }
+
+ Text(formattedDuration)
+ .font(.system(size: 12, weight: .medium, design: .monospaced))
+ .foregroundStyle(.secondary)
+
+ Button(action: startAction) {
+ Label("Start Custom Timer", systemImage: "play.fill")
+ .font(.system(size: 13, weight: .medium))
+ .labelStyle(.titleAndIcon)
+ }
+ .buttonStyle(.borderedProminent)
+ .disabled(totalSeconds == 0)
+ }
+ .frame(maxWidth: .infinity, alignment: .leading)
+ .padding(12)
+ .background(Color.white.opacity(0.05))
+ .clipShape(RoundedRectangle(cornerRadius: 10, style: .continuous))
+ }
+
+ private var formattedDuration: String {
+ let components = TimerPreset.DurationComponents(hours: hours, minutes: minutes, seconds: seconds)
+ let interval = TimerPreset.duration(from: components)
+ return TimerPreset(name: "", duration: interval, color: .clear).formattedDuration
+ }
+}
+
+private struct DurationStepper: View {
+ let title: String
+ @Binding var value: Int
+ let range: ClosedRange
+
+ var body: some View {
+ Stepper(value: $value, in: range) {
+ HStack {
+ Text(title)
+ .font(.system(size: 12, weight: .medium))
+ Spacer()
+ Text("\(value)")
+ .font(.system(size: 12, weight: .semibold, design: .monospaced))
+ }
+ }
+ .frame(width: 88)
+ }
+}
+
+private struct PresetList: View {
+ let presets: [TimerPreset]
+ let activePresetId: UUID?
+ let startAction: (TimerPreset) -> Void
+
+ var body: some View {
+ VStack(alignment: .leading, spacing: 8) {
+ Text("Presets")
+ .font(.system(size: 13, weight: .semibold))
+ .padding(.leading, 4)
+
+ if presets.isEmpty {
+ Text("Configure presets in Settings")
+ .font(.system(size: 12))
+ .foregroundStyle(.secondary)
+ .padding(12)
+ .frame(maxWidth: .infinity, alignment: .leading)
+ .background(Color.white.opacity(0.05))
+ .clipShape(RoundedRectangle(cornerRadius: 10, style: .continuous))
+ } else {
+ ScrollView {
+ VStack(spacing: 8) {
+ ForEach(presets) { preset in
+ TimerPresetRow(preset: preset, isActive: activePresetId == preset.id) {
+ startAction(preset)
+ }
+ }
+ }
+ .padding(.vertical, 4)
+ }
+ }
+ }
+ }
+}
+
+private struct TimerPresetRow: 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: 20, height: 20)
+ .overlay(
+ Circle()
+ .stroke(Color.white.opacity(0.5), lineWidth: 1)
+ )
+
+ VStack(alignment: .leading, spacing: 2) {
+ Text(preset.name)
+ .font(.system(size: 13, weight: .medium))
+ .foregroundStyle(.primary)
+ .lineLimit(1)
+ Text(preset.formattedDuration)
+ .font(.system(size: 11, weight: .medium, design: .monospaced))
+ .foregroundStyle(.secondary)
+ }
+
+ Spacer()
+
+ Image(systemName: isActive ? "checkmark" : "play.fill")
+ .font(.system(size: 11, weight: .semibold))
+ .foregroundStyle(isActive ? preset.color : Color.secondary)
+ .padding(6)
+ .background(isActive ? preset.color.opacity(0.2) : Color.clear)
+ .clipShape(Circle())
+ }
+ .padding(.horizontal, 12)
+ .padding(.vertical, 10)
+ .contentShape(RoundedRectangle(cornerRadius: 10, style: .continuous))
+ .background(
+ RoundedRectangle(cornerRadius: 10, style: .continuous)
+ .fill(isActive ? preset.color.opacity(0.18) : Color.white.opacity(0.05))
+ )
+ }
+ .buttonStyle(.plain)
+ }
+}
+
+#Preview {
+ TimerPopover()
+ .frame(width: 300)
+ .padding()
+ .background(Color.black)
+}
diff --git a/DynamicIsland/managers/TimerControlWindowManager.swift b/DynamicIsland/managers/TimerControlWindowManager.swift
new file mode 100644
index 0000000..5eed75f
--- /dev/null
+++ b/DynamicIsland/managers/TimerControlWindowManager.swift
@@ -0,0 +1,199 @@
+#if os(macOS)
+import AppKit
+import SwiftUI
+import SkyLightWindow
+import QuartzCore
+
+struct TimerControlWindowMetrics {
+ let notchHeight: CGFloat
+ let notchWidth: CGFloat
+ let rightWingWidth: CGFloat
+ let cornerRadius: CGFloat
+ let spacing: CGFloat
+}
+
+@MainActor
+final class TimerControlWindowManager {
+ static let shared = TimerControlWindowManager()
+
+ private var window: NSWindow?
+ private var hostingView: NSHostingView?
+ private var hasDelegated = false
+ private var lastMetrics: TimerControlWindowMetrics?
+
+ private init() {}
+
+ @discardableResult
+ func present(using viewModel: DynamicIslandViewModel, metrics: TimerControlWindowMetrics) -> Bool {
+ guard let screen = resolveScreen(from: viewModel) else { return false }
+ guard viewModel.effectiveClosedNotchHeight > 0, viewModel.closedNotchSize.width > 0 else {
+ hide()
+ return false
+ }
+
+ let overlay = TimerControlOverlay(
+ notchHeight: metrics.notchHeight,
+ cornerRadius: metrics.cornerRadius
+ )
+ let hosting = ensureHostingView(with: overlay)
+ let fittingSize = measuredSize(for: hosting)
+ hosting.frame = NSRect(origin: .zero, size: fittingSize)
+
+ let window = ensureWindow(on: screen)
+ if window.contentView !== hosting {
+ window.contentView = hosting
+ }
+
+ let targetFrame = frame(for: fittingSize, viewModel: viewModel, screen: screen, metrics: metrics)
+ lastMetrics = metrics
+
+ if !hasDelegated {
+ SkyLightOperator.shared.delegateWindow(window)
+ hasDelegated = true
+ }
+
+ if window.alphaValue <= 0.01 {
+ let startFrame = initialFrame(for: targetFrame, metrics: metrics)
+ window.setFrame(startFrame, display: true)
+ window.alphaValue = 0
+ window.orderFrontRegardless()
+
+ NSAnimationContext.runAnimationGroup { context in
+ context.duration = 0.24
+ context.timingFunction = CAMediaTimingFunction(name: .easeOut)
+ window.animator().setFrame(targetFrame, display: true)
+ window.animator().alphaValue = 1
+ } completionHandler: {
+ window.setFrame(targetFrame, display: false)
+ }
+ } else {
+ NSAnimationContext.runAnimationGroup { context in
+ context.duration = 0.16
+ context.timingFunction = CAMediaTimingFunction(name: .easeInEaseOut)
+ window.animator().setFrame(targetFrame, display: true)
+ } completionHandler: {
+ window.setFrame(targetFrame, display: false)
+ }
+ window.orderFrontRegardless()
+ window.alphaValue = 1
+ }
+
+ return true
+ }
+
+ @discardableResult
+ func refresh(using viewModel: DynamicIslandViewModel, metrics: TimerControlWindowMetrics) -> Bool {
+ guard window != nil else {
+ return present(using: viewModel, metrics: metrics)
+ }
+ return present(using: viewModel, metrics: metrics)
+ }
+
+ func hide(animated: Bool = true) {
+ guard let window else { return }
+
+ guard animated, window.alphaValue > 0.01 else {
+ window.orderOut(nil)
+ window.alphaValue = 0
+ return
+ }
+
+ let retreatFrame = notchRetreatFrame(from: window.frame)
+ let originalFrame = window.frame
+
+ NSAnimationContext.runAnimationGroup { context in
+ context.duration = 0.18
+ context.timingFunction = CAMediaTimingFunction(name: .easeIn)
+ window.animator().setFrame(retreatFrame, display: true)
+ window.animator().alphaValue = 0
+ } completionHandler: {
+ window.setFrame(originalFrame, display: false)
+ window.orderOut(nil)
+ window.alphaValue = 0
+ }
+ }
+
+ private func ensureHostingView(with overlay: TimerControlOverlay) -> NSHostingView {
+ if let hostingView {
+ hostingView.rootView = overlay
+ return hostingView
+ }
+ let view = NSHostingView(rootView: overlay)
+ hostingView = view
+ return view
+ }
+
+ private func ensureWindow(on screen: NSScreen) -> NSWindow {
+ if let window {
+ return window
+ }
+
+ let window = NSWindow(
+ contentRect: NSRect(x: screen.frame.midX, y: screen.frame.midY, width: 220, height: 64),
+ styleMask: [.borderless, .nonactivatingPanel],
+ backing: .buffered,
+ defer: false
+ )
+
+ window.isReleasedWhenClosed = false
+ window.isOpaque = false
+ window.backgroundColor = .clear
+ window.hasShadow = false
+ window.level = NSWindow.Level(rawValue: Int(CGShieldingWindowLevel()))
+ window.collectionBehavior = [.canJoinAllSpaces, .stationary, .fullScreenAuxiliary]
+ window.ignoresMouseEvents = false
+ window.isMovable = false
+ window.alphaValue = 0
+
+ self.window = window
+ self.hasDelegated = false
+ return window
+ }
+
+ private func measuredSize(for hosting: NSHostingView) -> CGSize {
+ hosting.layoutSubtreeIfNeeded()
+ let size = hosting.fittingSize
+ return CGSize(width: ceil(size.width), height: ceil(size.height))
+ }
+
+ private func resolveScreen(from viewModel: DynamicIslandViewModel) -> NSScreen? {
+ if let screenName = viewModel.screen,
+ let targetScreen = NSScreen.screens.first(where: { $0.localizedName == screenName }) {
+ return targetScreen
+ }
+ return NSScreen.main
+ }
+
+ private func frame(for size: CGSize, viewModel: DynamicIslandViewModel, screen: NSScreen, metrics: TimerControlWindowMetrics) -> NSRect {
+ let screenFrame = screen.frame
+ let notchOriginX = screenFrame.midX - (metrics.notchWidth / 2)
+ let originY = screenFrame.maxY - size.height
+
+ let rightEdge = notchOriginX + metrics.notchWidth + metrics.rightWingWidth
+ let rawOriginX = rightEdge + metrics.spacing
+
+ let clampedOriginX = max(screenFrame.minX + 8, min(rawOriginX, screenFrame.maxX - size.width - 8))
+
+ return NSRect(x: clampedOriginX, y: originY, width: size.width, height: size.height)
+ }
+
+ private func initialFrame(for targetFrame: NSRect, metrics: TimerControlWindowMetrics) -> NSRect {
+ var frame = targetFrame
+ let notchRightEdge = targetFrame.minX - metrics.spacing
+ frame.origin.x = notchRightEdge - targetFrame.width
+ return frame
+ }
+
+ private func notchRetreatFrame(from currentFrame: NSRect) -> NSRect {
+ guard let metrics = lastMetrics else {
+ return currentFrame.offsetBy(dx: -currentFrame.width * 0.75, dy: 0)
+ }
+
+ let notchRightEdge = currentFrame.minX - metrics.spacing
+ var frame = currentFrame
+ frame.origin.x = notchRightEdge - currentFrame.width
+ return frame
+ }
+}
+
+#endif
diff --git a/DynamicIsland/managers/TimerManager.swift b/DynamicIsland/managers/TimerManager.swift
index 06adfd9..f133c5d 100644
--- a/DynamicIsland/managers/TimerManager.swift
+++ b/DynamicIsland/managers/TimerManager.swift
@@ -10,6 +10,7 @@ import Combine
import SwiftUI
import AVFoundation
import AppKit
+import Defaults
class TimerManager: ObservableObject {
// MARK: - Properties
@@ -24,6 +25,7 @@ class TimerManager: ObservableObject {
@Published var isFinished: Bool = false
@Published var isOvertime: Bool = false // Timer has gone past 0 and is counting negative
@Published var lastUpdated: Date = .distantPast
+ @Published var activePresetId: UUID?
// Timer progress (0.0 to 1.0, or >1.0 for overtime)
var progress: Double {
@@ -124,7 +126,7 @@ class TimerManager: ObservableObject {
}
// MARK: - Timer Methods
- func startTimer(duration: TimeInterval, name: String = "Timer") {
+ func startTimer(duration: TimeInterval, name: String = "Timer", preset: TimerPreset? = nil) {
// Stop any existing timer
timerInstance?.invalidate()
@@ -140,8 +142,8 @@ class TimerManager: ObservableObject {
elapsedTime = 0
isPaused = false
lastUpdated = Date()
-
- triggerTimerSneakPeek()
+
+ activePresetId = preset?.id
// Start countdown timer
timerInstance = Timer.scheduledTimer(withTimeInterval: 1.0, repeats: true) { [weak self] _ in
@@ -250,8 +252,15 @@ class TimerManager: ObservableObject {
isPaused = false
isFinished = false
isOvertime = false
+ activePresetId = nil
}
-
+
+ // MARK: - Derived State
+ var activePreset: TimerPreset? {
+ guard let presetId = activePresetId else { return nil }
+ return Defaults[.timerPresets].first { $0.id == presetId }
+ }
+
private func scheduleSmoothClose() {
// Wait 3 seconds then smoothly close the live activity
DispatchQueue.main.asyncAfter(deadline: .now() + 3.0) {
@@ -297,21 +306,6 @@ class TimerManager: ObservableObject {
}
}
- private func triggerTimerSneakPeek() {
- let coordinator = DynamicIslandViewCoordinator.shared
-
- DispatchQueue.main.async {
- coordinator.sneakPeek.show = true
- coordinator.sneakPeek.type = .timer
- coordinator.sneakPeek.value = CGFloat(self.progress) // Use timer progress (0.0 to 1.0)
-
- // Hide sneak peek after delay
- DispatchQueue.main.asyncAfter(deadline: .now() + 3.0) {
- coordinator.sneakPeek.show = false
- }
- }
- }
-
// MARK: - Formatted Time Strings
func formattedRemainingTime() -> String {
if isOvertime && remainingTime < 0 {
diff --git a/DynamicIsland/models/Constants.swift b/DynamicIsland/models/Constants.swift
index 87f6d67..22cda55 100644
--- a/DynamicIsland/models/Constants.swift
+++ b/DynamicIsland/models/Constants.swift
@@ -196,6 +196,27 @@ enum ProgressBarStyle: String, CaseIterable, Identifiable, Defaults.Serializable
var id: String { self.rawValue }
}
+enum TimerIconColorMode: String, CaseIterable, Identifiable, Defaults.Serializable {
+ case adaptive = "Adaptive"
+ case solid = "Solid"
+
+ var id: String { rawValue }
+
+ var displayName: String {
+ switch self {
+ case .adaptive: return "Adaptive gradient"
+ case .solid: return "Solid colour"
+ }
+ }
+}
+
+enum TimerProgressStyle: String, CaseIterable, Identifiable, Defaults.Serializable {
+ case bar = "Bar"
+ case ring = "Ring"
+
+ var id: String { rawValue }
+}
+
// AI Model types for screen assistant
enum AIModelProvider: String, CaseIterable, Identifiable, Defaults.Serializable {
case gemini = "Gemini"
@@ -407,6 +428,14 @@ extension Defaults.Keys {
// MARK: Timer Feature
static let enableTimerFeature = Key("enableTimerFeature", default: true)
+ static let timerPresets = Key<[TimerPreset]>("timerPresets", default: TimerPreset.defaultPresets)
+ static let timerIconColorMode = Key("timerIconColorMode", default: .adaptive)
+ static let timerSolidColor = Key("timerSolidColor", default: .blue)
+ static let timerShowsCountdown = Key("timerShowsCountdown", default: true)
+ static let timerShowsLabel = Key("timerShowsLabel", default: true)
+ static let timerShowsProgress = Key("timerShowsProgress", default: true)
+ static let timerProgressStyle = Key("timerProgressStyle", default: .bar)
+ static let timerControlWindowEnabled = Key("timerControlWindowEnabled", default: true)
// MARK: ColorPicker Feature
static let enableColorPickerFeature = Key("enableColorPickerFeature", default: true)
diff --git a/DynamicIsland/models/DynamicIslandViewModel.swift b/DynamicIsland/models/DynamicIslandViewModel.swift
index 22775d0..c596a55 100644
--- a/DynamicIsland/models/DynamicIslandViewModel.swift
+++ b/DynamicIsland/models/DynamicIslandViewModel.swift
@@ -33,6 +33,7 @@ class DynamicIslandViewModel: NSObject, ObservableObject {
@Published var isColorPickerPopoverActive: Bool = false
@Published var isStatsPopoverActive: Bool = false
@Published var isMediaOutputPopoverActive: Bool = false
+ @Published var isTimerPopoverActive: Bool = false
@Published var shouldRecheckHover: Bool = false
let webcamManager = WebcamManager.shared
diff --git a/DynamicIsland/models/TimerPreset.swift b/DynamicIsland/models/TimerPreset.swift
new file mode 100644
index 0000000..e2630fb
--- /dev/null
+++ b/DynamicIsland/models/TimerPreset.swift
@@ -0,0 +1,93 @@
+//
+// TimerPreset.swift
+// DynamicIsland
+//
+// Created by GitHub Copilot on 2025-11-01.
+//
+
+import SwiftUI
+import AppKit
+import Defaults
+
+struct TimerPreset: Identifiable, Codable, Hashable, Defaults.Serializable {
+ struct ColorData: Codable, Hashable, Defaults.Serializable {
+ var red: Double
+ var green: Double
+ var blue: Double
+ var alpha: Double
+
+ init(red: Double, green: Double, blue: Double, alpha: Double = 1.0) {
+ self.red = red
+ self.green = green
+ self.blue = blue
+ self.alpha = alpha
+ }
+
+ init(color: Color) {
+ let nsColor = NSColor(color).usingColorSpace(.sRGB) ?? NSColor(color)
+ self.red = nsColor.redComponent
+ self.green = nsColor.greenComponent
+ self.blue = nsColor.blueComponent
+ self.alpha = nsColor.alphaComponent
+ }
+
+ var color: Color {
+ Color(red: red, green: green, blue: blue, opacity: alpha)
+ }
+ }
+
+ struct DurationComponents {
+ var hours: Int
+ var minutes: Int
+ var seconds: Int
+ }
+
+ var id: UUID
+ var name: String
+ var duration: TimeInterval
+ var colorData: ColorData
+
+ init(id: UUID = UUID(), name: String, duration: TimeInterval, colorData: ColorData) {
+ self.id = id
+ self.name = name
+ self.duration = duration
+ self.colorData = colorData
+ }
+
+ init(id: UUID = UUID(), name: String, duration: TimeInterval, color: Color) {
+ self.init(id: id, name: name, duration: duration, colorData: ColorData(color: color))
+ }
+
+ var color: Color {
+ colorData.color
+ }
+
+ mutating func updateColor(_ newColor: Color) {
+ colorData = ColorData(color: newColor)
+ }
+
+ var formattedDuration: String {
+ let formatter = DateComponentsFormatter()
+ formatter.allowedUnits = duration >= 3600 ? [.hour, .minute, .second] : [.minute, .second]
+ formatter.zeroFormattingBehavior = [.pad]
+ return formatter.string(from: duration) ?? "0:00"
+ }
+
+ static func components(for duration: TimeInterval) -> DurationComponents {
+ let totalSeconds = max(0, Int(duration))
+ let hours = totalSeconds / 3600
+ let minutes = (totalSeconds % 3600) / 60
+ let seconds = totalSeconds % 60
+ return DurationComponents(hours: hours, minutes: minutes, seconds: seconds)
+ }
+
+ static func duration(from components: DurationComponents) -> TimeInterval {
+ TimeInterval(components.hours * 3600 + components.minutes * 60 + components.seconds)
+ }
+
+ static let defaultPresets: [TimerPreset] = [
+ TimerPreset(name: "Focus", duration: 25 * 60, color: Color.orange),
+ TimerPreset(name: "Break", duration: 5 * 60, color: Color.green),
+ TimerPreset(name: "Deep Work", duration: 45 * 60, color: Color.purple)
+ ]
+}