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..03808d2 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",
@@ -16782,6 +16854,9 @@
}
}
}
+ },
+ "Show progress" : {
+
},
"Show shuffle and repeat buttons" : {
"localizations" : {
@@ -17316,6 +17391,9 @@
}
}
}
+ },
+ "Solid colour" : {
+
},
"Speed" : {
"localizations" : {
@@ -17557,6 +17635,10 @@
}
}
},
+ "Start Custom Timer" : {
+ "comment" : "A button label that starts a custom timer.",
+ "isCommentAutoGenerated" : true
+ },
"Start Demo Timer:" : {
"localizations" : {
"fr" : {
@@ -18068,6 +18150,10 @@
},
"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 +18305,10 @@
}
}
},
+ "Timer Presets" : {
+ "comment" : "A section header in the timer settings view, describing timer presets.",
+ "isCommentAutoGenerated" : true
+ },
"Timer Sound" : {
"localizations" : {
"fr" : {
@@ -18228,6 +18318,9 @@
}
}
}
+ },
+ "Timer tint" : {
+
},
"Toggle Notch Open:" : {
"localizations" : {
@@ -18897,6 +18990,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/Settings/SettingsView.swift b/DynamicIsland/components/Settings/SettingsView.swift
index be3e6c6..e52d58b 100644
--- a/DynamicIsland/components/Settings/SettingsView.swift
+++ b/DynamicIsland/components/Settings/SettingsView.swift
@@ -1709,9 +1709,17 @@ 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(.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 showingResetConfirmation = false
var body: some View {
Form {
@@ -1719,92 +1727,121 @@ 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")
+ VStack(alignment: .leading, spacing: 12) {
+ Text("Default Custom Timer")
.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)
+ TimerDurationStepperRow(title: "Hours", value: $customHours, range: 0...23)
+ TimerDurationStepperRow(title: "Minutes", value: $customMinutes, range: 0...59)
+ TimerDurationStepperRow(title: "Seconds", value: $customSeconds, range: 0...59)
- VStack(alignment: .leading, spacing: 8) {
- HStack {
- Text("Current custom timer:")
- .foregroundStyle(.secondary)
- Text(customTimerDisplayText)
- .foregroundStyle(.primary)
- .fontWeight(.medium)
- Spacer()
- }
-
- HStack {
- Spacer()
- Button("Update Timer") {
- customTimerDuration = Double(customMinutes * 60 + customSeconds)
- }
- .disabled(customMinutes == 0 && customSeconds == 0)
- .buttonStyle(.borderedProminent)
- }
+ HStack {
+ Text("Current default:")
+ .foregroundStyle(.secondary)
+ Text(customDurationDisplay)
+ .font(.system(.body, design: .monospaced))
+ .fontWeight(.medium)
+ Spacer()
}
}
- .padding(.vertical, 8)
+ .padding(.vertical, 4)
+ .onChange(of: customHours) { _, _ in updateCustomDuration() }
+ .onChange(of: customMinutes) { _, _ in updateCustomDuration() }
+ .onChange(of: customSeconds) { _, _ in updateCustomDuration() }
} 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.")
+ Text("This duration powers the \"Custom\" option inside the timer popover for quick access.")
}
Section {
- VStack(alignment: .leading, spacing: 8) {
+ 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 countdown", isOn: $showsCountdown)
+ Toggle("Show progress", isOn: $showsProgress)
+
+ Picker("Progress style", selection: $progressStyle) {
+ ForEach(TimerProgressStyle.allCases) { style in
+ Text(style.rawValue).tag(style)
+ }
+ }
+ .pickerStyle(.segmented)
+ .disabled(!showsProgress)
+ } header: {
+ Text("Appearance")
+ } footer: {
+ Text("Configure how the timer looks inside the closed notch. Progress can render as a ring around the icon or as horizontal bars.")
+ }
+
+ Section {
+ if timerPresets.isEmpty {
+ Text("No presets configured. Add a preset to make it appear in the timer popover.")
+ .font(.system(size: 12))
+ .foregroundStyle(.secondary)
+ .padding(.vertical, 4)
+ } else {
+ ForEach(timerPresets.indices, id: \.self) { index in
+ TimerPresetEditorRow(
+ preset: $timerPresets[index],
+ isFirst: index == timerPresets.startIndex,
+ isLast: index == timerPresets.index(before: timerPresets.endIndex),
+ moveUp: { movePresetUp(index) },
+ moveDown: { movePresetDown(index) },
+ remove: { removePreset(index) }
+ )
+ }
+ }
+
+ HStack {
+ Button(action: addPreset) {
+ Label("Add Preset", systemImage: "plus")
+ }
+ .buttonStyle(.bordered)
+
+ Spacer()
+
+ Button(role: .destructive, action: { showingResetConfirmation = true }) {
+ Label("Restore Defaults", systemImage: "arrow.counterclockwise")
+ }
+ .buttonStyle(.bordered)
+ .confirmationDialog("Restore default timer presets?", isPresented: $showingResetConfirmation, titleVisibility: .visible) {
+ Button("Restore", role: .destructive, action: resetPresets)
+ }
+ }
+ } header: {
+ Text("Timer Presets")
+ } footer: {
+ Text("Presets show up inside the timer popover with the configured name, duration, and accent colour. Reorder them to change the display order.")
+ }
+
+ Section {
+ VStack(alignment: .leading, spacing: 10) {
HStack {
Text("Timer Sound")
.font(.system(size: 16, weight: .medium))
Spacer()
- Button("Choose File") {
- selectCustomTimerSound()
- }
- .buttonStyle(.bordered)
+ Button("Choose File", action: selectCustomTimerSound)
+ .buttonStyle(.bordered)
}
if let customTimerSoundPath = UserDefaults.standard.string(forKey: "customTimerSoundPath") {
@@ -1826,30 +1863,68 @@ struct TimerSettings: View {
} header: {
Text("Timer Sound")
} footer: {
- Text("Choose a custom sound file that will play when the timer completes. Supported formats: MP3, M4A, WAV, AIFF.")
+ Text("Select a custom sound to play when a timer ends. Supported formats include MP3, M4A, WAV, and 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 +1944,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/TimerIconAnimation.swift b/DynamicIsland/components/Timer/TimerIconAnimation.swift
index cd32767..e5c4303 100644
--- a/DynamicIsland/components/Timer/TimerIconAnimation.swift
+++ b/DynamicIsland/components/Timer/TimerIconAnimation.swift
@@ -6,95 +6,25 @@
//
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(.accentColor) private var accentColor
var body: some View {
ZStack {
- // Background circle with timer color
Circle()
- .fill(timerManager.timerColor.gradient)
+ .fill(accentColor.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()
- }
- }
- }
-
- 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
+ .foregroundStyle(.white)
}
}
}
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..666cae9 100644
--- a/DynamicIsland/components/Timer/TimerLiveActivity.swift
+++ b/DynamicIsland/components/Timer/TimerLiveActivity.swift
@@ -10,63 +10,147 @@ import Defaults
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
+ @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
+ @Default(.timerPresets) private var timerPresets
+
+ private var iconAreaWidth: CGFloat {
+ max(0, vm.effectiveClosedNotchHeight - (isHovering ? 0 : 12))
+ }
+
+ private var countdownWidth: CGFloat {
+ guard showsCountdown else { return 0 }
+ let characters = max(timerManager.formattedRemainingTime().count, 4)
+ let base = CGFloat(characters) * 9.5 + 24
+ return max(base, 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 activePresetColor: Color? {
+ guard let presetId = timerManager.activePresetId else { return nil }
+ return timerPresets.first { $0.id == presetId }?.color
+ }
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: 10) {
+ iconSection
+ infoSection
+ if showsRingProgress {
+ ringSection
+ }
+ if showsCountdown {
+ countdownSection
+ }
}
.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
+ }
}
- .frame(width: max(0, vm.effectiveClosedNotchHeight - (isHovering ? 0 : 12) + gestureProgress / 2),
- height: max(0, vm.effectiveClosedNotchHeight - (isHovering ? 0 : 12)))
}
- private var timerInfoSection: some View {
+ private var iconSection: some View {
+ let iconSize = max(20, iconAreaWidth - 4)
+ return Image(systemName: "timer")
+ .font(.system(size: 16, weight: .semibold))
+ .foregroundStyle(glyphColor)
+ .frame(width: iconSize, height: iconSize)
+ .frame(width: iconAreaWidth, height: max(0, vm.effectiveClosedNotchHeight - (isHovering ? 0 : 12)), alignment: .center)
+ }
+
+ private var infoSection: some View {
Rectangle()
.fill(.black)
+ .overlay(
+ VStack(alignment: .leading, spacing: 4) {
+ if timerManager.isFinished || timerManager.isOvertime {
+ GeometryReader { geo in
+ MarqueeText(
+ .constant(timerManager.timerName),
+ textColor: .white,
+ minDuration: 0.25,
+ frameWidth: geo.size.width - 12
+ )
+ }
+ .frame(height: 16)
+ } else {
+ Text(timerManager.timerName)
+ .font(.system(size: 12, weight: .medium))
+ .lineLimit(1)
+ .foregroundStyle(.white)
+ .transition(.opacity.combined(with: .move(edge: .top)))
+ }
+ }
+ .padding(.horizontal, 12)
+ .padding(.vertical, 6)
+ .animation(.smooth, value: timerManager.isFinished)
+ )
.frame(width: vm.closedNotchSize.width + (isHovering ? 8 : 0))
}
- 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)
- }
- }
- .frame(maxWidth: .infinity)
+ private var ringSection: some View {
+ ZStack {
+ Circle()
+ .stroke(Color.white.opacity(0.15), lineWidth: 3)
+ Circle()
+ .trim(from: 0, to: clampedProgress)
+ .stroke(glyphColor, style: StrokeStyle(lineWidth: 3, lineCap: .round))
+ .rotationEffect(.degrees(-90))
+ .animation(.smooth(duration: 0.25), value: clampedProgress)
}
- .frame(width: max(0, vm.effectiveClosedNotchHeight - (isHovering ? 0 : 12) + gestureProgress / 2),
+ .frame(width: 26, height: 26)
+ }
+
+ private var countdownSection: some View {
+ 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(height: 3)
+ .overlay(alignment: .leading) {
+ Capsule()
+ .fill(glyphColor)
+ .frame(width: max(0, countdownWidth - 20) * max(0, CGFloat(clampedProgress)))
+ .animation(.smooth(duration: 0.25), value: clampedProgress)
+ }
+ }
+ }
+ .padding(.trailing, 8)
+ .frame(width: countdownWidth,
height: max(0, vm.effectiveClosedNotchHeight - (isHovering ? 0 : 12)), alignment: .center)
}
}
@@ -77,7 +161,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/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..6e27980 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,12 @@ 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 timerShowsProgress = Key("timerShowsProgress", default: true)
+ static let timerProgressStyle = Key("timerProgressStyle", default: .bar)
// 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)
+ ]
+}