Merge pull request #77 from Ebullioscopic/feature/timer

Feature/timer
This commit is contained in:
Hariharan Mudaliar 2025-11-02 15:44:29 +05:30 • committed by GitHub
commit bd1f4cc6ed
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
17 changed files with 1761 additions and 261 deletions

View file

@ -16,7 +16,7 @@
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "F54406E62CD4987A00C7AFD9"
BuildableName = "DynamicIsland.app"
BuildableName = "Atoll.app"
BlueprintName = "DynamicIsland"
ReferencedContainer = "container:DynamicIsland.xcodeproj">
</BuildableReference>
@ -45,7 +45,7 @@
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "F54406E62CD4987A00C7AFD9"
BuildableName = "DynamicIsland.app"
BuildableName = "Atoll.app"
BlueprintName = "DynamicIsland"
ReferencedContainer = "container:DynamicIsland.xcodeproj">
</BuildableReference>
@ -62,7 +62,7 @@
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "F54406E62CD4987A00C7AFD9"
BuildableName = "DynamicIsland.app"
BuildableName = "Atoll.app"
BlueprintName = "DynamicIsland"
ReferencedContainer = "container:DynamicIsland.xcodeproj">
</BuildableReference>

View file

@ -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
}

View file

@ -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" : {

View file

@ -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) {

View file

@ -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
}
}
}
}

View file

@ -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())

View file

@ -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<Int>
var body: some View {
Stepper(value: $value, in: range) {
HStack {
Text(title)
Spacer()
Text("\(value)")
.font(.system(size: 12, weight: .semibold, design: .monospaced))
}
}
}
}
private struct TimerPresetEditorRow: View {
@Binding var preset: TimerPreset
let isFirst: Bool
let isLast: Bool
let moveUp: () -> Void
let moveDown: () -> Void
let remove: () -> Void
private var components: TimerPreset.DurationComponents {
TimerPreset.components(for: preset.duration)
}
private var hoursBinding: Binding<Int> {
Binding(
get: { components.hours },
set: { updateDuration(hours: $0) }
)
}
private var minutesBinding: Binding<Int> {
Binding(
get: { components.minutes },
set: { updateDuration(minutes: $0) }
)
}
private var secondsBinding: Binding<Int> {
Binding(
get: { components.seconds },
set: { updateDuration(seconds: $0) }
)
}
private var colorBinding: Binding<Color> {
Binding(
get: { preset.color },
set: { preset.updateColor($0) }
)
}
var body: some View {
VStack(alignment: .leading, spacing: 12) {
HStack(spacing: 10) {
Circle()
.fill(preset.color.gradient)
.frame(width: 20, height: 20)
.overlay(
Circle()
.stroke(Color.white.opacity(0.3), lineWidth: 1)
)
TextField("Preset name", text: $preset.name)
.textFieldStyle(.roundedBorder)
Spacer()
Text(preset.formattedDuration)
.font(.system(size: 12, weight: .medium, design: .monospaced))
.foregroundStyle(.secondary)
}
HStack(spacing: 16) {
TimerPresetComponentControl(title: "Hours", value: hoursBinding, range: 0...23)
TimerPresetComponentControl(title: "Minutes", value: minutesBinding, range: 0...59)
TimerPresetComponentControl(title: "Seconds", value: secondsBinding, range: 0...59)
}
ColorPicker("Accent colour", selection: colorBinding, supportsOpacity: false)
.frame(maxWidth: 240, alignment: .leading)
HStack(spacing: 12) {
Button(action: moveUp) {
Label("Move Up", systemImage: "chevron.up")
}
.buttonStyle(.bordered)
.disabled(isFirst)
Button(action: moveDown) {
Label("Move Down", systemImage: "chevron.down")
}
.buttonStyle(.bordered)
.disabled(isLast)
Spacer()
Button(role: .destructive, action: remove) {
Label("Delete", systemImage: "trash")
}
.buttonStyle(.bordered)
}
.font(.system(size: 12, weight: .medium))
}
.padding(.vertical, 6)
}
private func updateDuration(hours: Int? = nil, minutes: Int? = nil, seconds: Int? = nil) {
var values = components
if let hours { values.hours = hours }
if let minutes { values.minutes = minutes }
if let seconds { values.seconds = seconds }
preset.duration = TimerPreset.duration(from: values)
}
}
private struct TimerPresetComponentControl: View {
let title: String
@Binding var value: Int
let range: ClosedRange<Int>
var body: some View {
Stepper(value: $value, in: range) {
VStack(alignment: .leading, spacing: 4) {
Text(title)
.font(.caption)
.foregroundStyle(.secondary)
Text("\(value)")
.font(.system(size: 13, weight: .semibold, design: .monospaced))
}
}
.frame(width: 110, alignment: .leading)
}
}
struct StatsSettings: View {
@ObservedObject var statsManager = StatsManager.shared
@Default(.enableStatsFeature) var enableStatsFeature

View file

@ -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))

View file

@ -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))
}

View file

@ -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

View file

@ -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)
}
}

View file

@ -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<Int>
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)
}

View file

@ -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<TimerControlOverlay>?
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<TimerControlOverlay> {
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<TimerControlOverlay>) -> 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

View file

@ -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 {

View file

@ -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<Bool>("enableTimerFeature", default: true)
static let timerPresets = Key<[TimerPreset]>("timerPresets", default: TimerPreset.defaultPresets)
static let timerIconColorMode = Key<TimerIconColorMode>("timerIconColorMode", default: .adaptive)
static let timerSolidColor = Key<Color>("timerSolidColor", default: .blue)
static let timerShowsCountdown = Key<Bool>("timerShowsCountdown", default: true)
static let timerShowsLabel = Key<Bool>("timerShowsLabel", default: true)
static let timerShowsProgress = Key<Bool>("timerShowsProgress", default: true)
static let timerProgressStyle = Key<TimerProgressStyle>("timerProgressStyle", default: .bar)
static let timerControlWindowEnabled = Key<Bool>("timerControlWindowEnabled", default: true)
// MARK: ColorPicker Feature
static let enableColorPickerFeature = Key<Bool>("enableColorPickerFeature", default: true)

View file

@ -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

View file

@ -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)
]
}