Improved Timer UI, removed the Tab

This commit is contained in:
Hariharan Mudaliar 2025-11-01 15:09:25 +05:30
commit a9f9ac3aa8
14 changed files with 1079 additions and 241 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",
@ -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" : {

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

@ -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,91 +1727,120 @@ struct TimerSettings: View {
Defaults.Toggle("Enable timer feature", key: .enableTimerFeature)
if enableTimerFeature {
Toggle(
"Enable timer live activity",
isOn: $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)
TimerDurationStepperRow(title: "Hours", value: $customHours, range: 0...23)
TimerDurationStepperRow(title: "Minutes", value: $customMinutes, range: 0...59)
TimerDurationStepperRow(title: "Seconds", value: $customSeconds, range: 0...59)
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) {
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, 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()
}
Button("Choose File", action: selectCustomTimerSound)
.buttonStyle(.bordered)
}
@ -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
private func movePresetUp(_ index: Int) {
guard index > timerPresets.startIndex else { return }
withAnimation(.smooth) {
timerPresets.swapAt(index, index - 1)
}
}
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 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<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

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

View file

@ -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)
.contentShape(Rectangle())
.onHover { hovering in
withAnimation(.smooth(duration: 0.18)) {
isHovering = hovering
}
}
}
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)
}
.frame(width: max(0, vm.effectiveClosedNotchHeight - (isHovering ? 0 : 12) + gestureProgress / 2),
height: max(0, vm.effectiveClosedNotchHeight - (isHovering ? 0 : 12)))
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 timerInfoSection: some View {
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)
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: 26, height: 26)
}
// Progress indicator
if timerManager.totalDuration > 0 {
ProgressView(value: timerManager.progress)
.progressViewStyle(LinearProgressViewStyle(tint: timerManager.timerColor))
.frame(width: 30, height: 2)
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)
}
}
.frame(maxWidth: .infinity)
}
.frame(width: max(0, vm.effectiveClosedNotchHeight - (isHovering ? 0 : 12) + gestureProgress / 2),
.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)
}
}

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

@ -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()
@ -141,7 +143,7 @@ class TimerManager: ObservableObject {
isPaused = false
lastUpdated = Date()
triggerTimerSneakPeek()
activePresetId = preset?.id
// Start countdown timer
timerInstance = Timer.scheduledTimer(withTimeInterval: 1.0, repeats: true) { [weak self] _ in
@ -250,6 +252,13 @@ 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() {
@ -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,12 @@ 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 timerShowsProgress = Key<Bool>("timerShowsProgress", default: true)
static let timerProgressStyle = Key<TimerProgressStyle>("timerProgressStyle", default: .bar)
// 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)
]
}