From a9f9ac3aa865788239c51e0cc7d1b023f9f4ae08 Mon Sep 17 00:00:00 2001 From: Hariharan Mudaliar Date: Sat, 1 Nov 2025 15:09:25 +0530 Subject: [PATCH] Improved Timer UI, removed the Tab --- .../xcschemes/DynamicIsland.xcscheme | 6 +- DynamicIsland/ContentView.swift | 6 +- DynamicIsland/Localizable.xcstrings | 106 ++++- .../Live activities/InlineHUD.swift | 22 +- .../Notch/DynamicIslandHeader.swift | 39 ++ .../components/Settings/SettingsView.swift | 377 ++++++++++++++---- .../components/Tabs/TabSelectionView.swift | 6 - .../components/Timer/TimerIconAnimation.swift | 78 +--- .../components/Timer/TimerLiveActivity.swift | 169 ++++++-- .../components/Timer/TimerPopover.swift | 362 +++++++++++++++++ DynamicIsland/managers/TimerManager.swift | 32 +- DynamicIsland/models/Constants.swift | 27 ++ .../models/DynamicIslandViewModel.swift | 1 + DynamicIsland/models/TimerPreset.swift | 93 +++++ 14 files changed, 1081 insertions(+), 243 deletions(-) create mode 100644 DynamicIsland/components/Timer/TimerPopover.swift create mode 100644 DynamicIsland/models/TimerPreset.swift diff --git a/DynamicIsland.xcodeproj/xcshareddata/xcschemes/DynamicIsland.xcscheme b/DynamicIsland.xcodeproj/xcshareddata/xcschemes/DynamicIsland.xcscheme index 80849ad..4a6513a 100644 --- a/DynamicIsland.xcodeproj/xcshareddata/xcschemes/DynamicIsland.xcscheme +++ b/DynamicIsland.xcodeproj/xcshareddata/xcschemes/DynamicIsland.xcscheme @@ -16,7 +16,7 @@ @@ -45,7 +45,7 @@ @@ -62,7 +62,7 @@ diff --git a/DynamicIsland/ContentView.swift b/DynamicIsland/ContentView.swift index b908f3f..b5dc5ee 100644 --- a/DynamicIsland/ContentView.swift +++ b/DynamicIsland/ContentView.swift @@ -630,11 +630,6 @@ struct ContentView: View { // MARK: - Private Methods private func doOpen() { - // Switch to timer tab if timer is active - if timerManager.isTimerActive { - coordinator.currentView = .timer - } - withAnimation(.bouncy.speed(1.2)) { vm.open() } @@ -754,6 +749,7 @@ struct ContentView: View { vm.isClipboardPopoverActive || vm.isColorPickerPopoverActive || vm.isStatsPopoverActive || + vm.isTimerPopoverActive || vm.isMediaOutputPopoverActive } diff --git a/DynamicIsland/Localizable.xcstrings b/DynamicIsland/Localizable.xcstrings index 32116b4..03808d2 100644 --- a/DynamicIsland/Localizable.xcstrings +++ b/DynamicIsland/Localizable.xcstrings @@ -1636,6 +1636,10 @@ } } }, + "Accent colour" : { + "comment" : "A label describing the colour picker for the timer preset.", + "isCommentAutoGenerated" : true + }, "Access Denied" : { "localizations" : { "ar" : { @@ -2144,6 +2148,10 @@ } } }, + "Add Preset" : { + "comment" : "A button label that adds a new timer preset.", + "isCommentAutoGenerated" : true + }, "Additional features" : { "localizations" : { "ar" : { @@ -4677,6 +4685,7 @@ } }, "Choose a custom sound file that will play when the timer completes. Supported formats: MP3, M4A, WAV, AIFF." : { + "extractionState" : "stale", "localizations" : { "fr" : { "stringUnit" : { @@ -5410,6 +5419,13 @@ } } }, + "Configure how the timer looks inside the closed notch. Progress can render as a ring around the icon or as horizontal bars." : { + + }, + "Configure presets in Settings" : { + "comment" : "A message displayed when there are no presets configured. It encourages the user to configure presets in the app settings.", + "isCommentAutoGenerated" : true + }, "Connected" : { "extractionState" : "stale", "localizations" : { @@ -5521,6 +5537,10 @@ } } }, + "Control timer availability and whether progress appears as a live activity while the notch is closed." : { + "comment" : "A footer description for the timer feature settings.", + "isCommentAutoGenerated" : true + }, "Controls" : { "localizations" : { "fr" : { @@ -5584,6 +5604,7 @@ } }, "Current custom timer:" : { + "extractionState" : "stale", "localizations" : { "fr" : { "stringUnit" : { @@ -5593,6 +5614,10 @@ } } }, + "Current default:" : { + "comment" : "A label displayed alongside the currently set default timer duration.", + "isCommentAutoGenerated" : true + }, "Current History" : { "localizations" : { "fr" : { @@ -5963,6 +5988,7 @@ } }, "Custom Timer Duration" : { + "extractionState" : "stale", "localizations" : { "fr" : { "stringUnit" : { @@ -6292,6 +6318,10 @@ } } }, + "Default Custom Timer" : { + "comment" : "A label displayed above the section that lets users configure a custom timer.", + "isCommentAutoGenerated" : true + }, "Default: dynamic.m4a" : { "localizations" : { "fr" : { @@ -7736,9 +7766,6 @@ } } } - }, - "Enable or disable the timer functionality in the Dynamic Island. The live activity toggle controls whether timer progress is shown in the expanded view." : { - }, "Enable screen color picking functionality. Use Cmd+Shift+P to quickly access the color picker." : { @@ -12237,6 +12264,7 @@ } }, "Minutes" : { + "extractionState" : "stale", "localizations" : { "fr" : { "stringUnit" : { @@ -12569,6 +12597,14 @@ } } }, + "Move Down" : { + "comment" : "A button label that says \"Move Down\".", + "isCommentAutoGenerated" : true + }, + "Move Up" : { + "comment" : "A button label that says \"Move Up\".", + "isCommentAutoGenerated" : true + }, "Music Source" : { "localizations" : { "ar" : { @@ -13588,6 +13624,10 @@ "comment" : "A label displayed when no graphs are enabled in the Stats tab.", "isCommentAutoGenerated" : true }, + "No presets configured. Add a preset to make it appear in the timer popover." : { + "comment" : "A message displayed when there are no timer presets configured, encouraging the user to add one.", + "isCommentAutoGenerated" : true + }, "No process data yet. Keep stats open for a moment." : { "comment" : "A message displayed when there is no process data available to display in the CPU stats view. It encourages the user to keep the stats open for a moment to gather more data.", "isCommentAutoGenerated" : true @@ -14560,6 +14600,18 @@ } } }, + "Preset name" : { + "comment" : "A label for the name of a timer preset.", + "isCommentAutoGenerated" : true + }, + "Presets" : { + "comment" : "A header label for the list of timer presets.", + "isCommentAutoGenerated" : true + }, + "Presets show up inside the timer popover with the configured name, duration, and accent colour. Reorder them to change the display order." : { + "comment" : "A footer for the \"Timer Presets\" section in the TimerSettings view, explaining how to manage and reorder timer presets.", + "isCommentAutoGenerated" : true + }, "Pressure" : { "comment" : "A label describing the memory pressure status.", "isCommentAutoGenerated" : true, @@ -14602,6 +14654,9 @@ } } } + }, + "Progress style" : { + }, "Progressbar style" : { "localizations" : { @@ -15374,6 +15429,18 @@ } } }, + "Restore" : { + "comment" : "The text for a button that restores default settings.", + "isCommentAutoGenerated" : true + }, + "Restore default timer presets?" : { + "comment" : "A confirmation dialog title asking if the user wants to restore the default timer presets.", + "isCommentAutoGenerated" : true + }, + "Restore Defaults" : { + "comment" : "A button that, when tapped, presents a confirmation dialog to reset all timer settings to their default values.", + "isCommentAutoGenerated" : true + }, "Resume" : { "localizations" : { "fr" : { @@ -15641,6 +15708,7 @@ } }, "Seconds" : { + "extractionState" : "stale", "localizations" : { "fr" : { "stringUnit" : { @@ -15662,6 +15730,10 @@ }, "Select a color to view details" : { + }, + "Select a custom sound to play when a timer ends. Supported formats include MP3, M4A, WAV, and AIFF." : { + "comment" : "A description below the option to select a custom timer sound.", + "isCommentAutoGenerated" : true }, "Select Calendars" : { "localizations" : { @@ -16026,9 +16098,6 @@ } } } - }, - "Set a custom duration for the timer. This will be used when you press the 'Custom' button in the timer interface." : { - }, "Settings" : { "localizations" : { @@ -16565,6 +16634,9 @@ } } } + }, + "Show countdown" : { + }, "Show download progress" : { "extractionState" : "stale", @@ -16782,6 +16854,9 @@ } } } + }, + "Show progress" : { + }, "Show shuffle and repeat buttons" : { "localizations" : { @@ -17316,6 +17391,9 @@ } } } + }, + "Solid colour" : { + }, "Speed" : { "localizations" : { @@ -17557,6 +17635,10 @@ } } }, + "Start Custom Timer" : { + "comment" : "A button label that starts a custom timer.", + "isCommentAutoGenerated" : true + }, "Start Demo Timer:" : { "localizations" : { "fr" : { @@ -18068,6 +18150,10 @@ }, "These indicators appear in the notch when apps are using your camera or microphone, matching macOS's native privacy indicators." : { + }, + "This duration powers the \"Custom\" option inside the timer popover for quick access." : { + "comment" : "A footer description for the custom timer settings, explaining their purpose.", + "isCommentAutoGenerated" : true }, "Time Range" : { "localizations" : { @@ -18219,6 +18305,10 @@ } } }, + "Timer Presets" : { + "comment" : "A section header in the timer settings view, describing timer presets.", + "isCommentAutoGenerated" : true + }, "Timer Sound" : { "localizations" : { "fr" : { @@ -18228,6 +18318,9 @@ } } } + }, + "Timer tint" : { + }, "Toggle Notch Open:" : { "localizations" : { @@ -18897,6 +18990,7 @@ } }, "Update Timer" : { + "extractionState" : "stale", "localizations" : { "fr" : { "stringUnit" : { diff --git a/DynamicIsland/components/Live activities/InlineHUD.swift b/DynamicIsland/components/Live activities/InlineHUD.swift index e721a41..671e20f 100644 --- a/DynamicIsland/components/Live activities/InlineHUD.swift +++ b/DynamicIsland/components/Live activities/InlineHUD.swift @@ -34,26 +34,36 @@ struct InlineHUD: View { let baseInfoWidth: CGFloat = { if type == .bluetoothAudio { - return showBluetoothDeviceNameMarquee ? 100 : 52 + return showBluetoothDeviceNameMarquee ? 140 : 88 } return 100 }() - let infoWidth = baseInfoWidth - (hoverAnimation ? 0 : 12) + gestureProgress / 2 + let infoWidth: CGFloat = { + var width = baseInfoWidth + gestureProgress / 2 + if !hoverAnimation { width -= 8 } + let minimum: CGFloat = type == .bluetoothAudio ? (showBluetoothDeviceNameMarquee ? 120 : 82) : 88 + return max(width, minimum) + }() let baseTrailingWidth: CGFloat = { if type == .bluetoothAudio { if !hasBatteryLevel { - return showBluetoothDeviceNameMarquee ? 100 : 70 + return showBluetoothDeviceNameMarquee ? 118 : 88 } if useCircularIndicator { - return showBluetoothBatteryPercentageText ? 100 : 70 + return showBluetoothBatteryPercentageText ? 120 : 96 } else { - return showBluetoothBatteryPercentageText ? 112 : 82 + return showBluetoothBatteryPercentageText ? 136 : 108 } } return 100 }() - let trailingWidth = baseTrailingWidth - (hoverAnimation ? 0 : 12) + gestureProgress / 2 + let trailingWidth: CGFloat = { + var width = baseTrailingWidth + gestureProgress / 2 + if !hoverAnimation { width -= 8 } + let minimum: CGFloat = type == .bluetoothAudio ? (showBluetoothBatteryPercentageText ? 110 : 92) : 90 + return max(width, minimum) + }() return HStack { HStack(spacing: 5) { diff --git a/DynamicIsland/components/Notch/DynamicIslandHeader.swift b/DynamicIsland/components/Notch/DynamicIslandHeader.swift index 2a9e3b4..83dc5ec 100644 --- a/DynamicIsland/components/Notch/DynamicIslandHeader.swift +++ b/DynamicIsland/components/Notch/DynamicIslandHeader.swift @@ -15,8 +15,11 @@ struct DynamicIslandHeader: View { @ObservedObject var coordinator = DynamicIslandViewCoordinator.shared @ObservedObject var clipboardManager = ClipboardManager.shared @ObservedObject var tvm = TrayDrop.shared + @ObservedObject var timerManager = TimerManager.shared @State private var showClipboardPopover = false @State private var showColorPickerPopover = false + @State private var showTimerPopover = false + @Default(.enableTimerFeature) var enableTimerFeature var body: some View { HStack(spacing: 0) { @@ -141,6 +144,36 @@ struct DynamicIslandHeader: View { } } + if Defaults[.enableTimerFeature] { + Button(action: { + withAnimation(.smooth) { + showTimerPopover.toggle() + } + }) { + Capsule() + .fill(.black) + .frame(width: 30, height: 30) + .overlay { + Image(systemName: "timer") + .foregroundColor(.white) + .padding() + .imageScale(.medium) + } + } + .buttonStyle(PlainButtonStyle()) + .popover(isPresented: $showTimerPopover, arrowEdge: .bottom) { + TimerPopover() + } + .onChange(of: showTimerPopover) { isActive in + vm.isTimerPopoverActive = isActive + if !isActive { + DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { + vm.shouldRecheckHover.toggle() + } + } + } + } + if Defaults[.settingsIconInNotch] { Button(action: { SettingsWindowController.shared.showWindow() @@ -204,6 +237,12 @@ struct DynamicIslandHeader: View { showClipboardPopover.toggle() } } + .onChange(of: enableTimerFeature) { _, newValue in + if !newValue { + showTimerPopover = false + vm.isTimerPopoverActive = false + } + } } } diff --git a/DynamicIsland/components/Settings/SettingsView.swift b/DynamicIsland/components/Settings/SettingsView.swift index be3e6c6..e52d58b 100644 --- a/DynamicIsland/components/Settings/SettingsView.swift +++ b/DynamicIsland/components/Settings/SettingsView.swift @@ -1709,9 +1709,17 @@ func warningBadge(_ text: String, _ description: String) -> some View { struct TimerSettings: View { @ObservedObject private var coordinator = DynamicIslandViewCoordinator.shared @Default(.enableTimerFeature) var enableTimerFeature - @AppStorage("customTimerDuration") private var customTimerDuration: Double = 600 // 10 minutes default + @Default(.timerPresets) private var timerPresets + @Default(.timerIconColorMode) private var colorMode + @Default(.timerSolidColor) private var solidColor + @Default(.timerShowsCountdown) private var showsCountdown + @Default(.timerShowsProgress) private var showsProgress + @Default(.timerProgressStyle) private var progressStyle + @AppStorage("customTimerDuration") private var customTimerDuration: Double = 600 + @State private var customHours: Int = 0 @State private var customMinutes: Int = 10 @State private var customSeconds: Int = 0 + @State private var showingResetConfirmation = false var body: some View { Form { @@ -1719,92 +1727,121 @@ struct TimerSettings: View { Defaults.Toggle("Enable timer feature", key: .enableTimerFeature) if enableTimerFeature { - Toggle( - "Enable timer live activity", - isOn: $coordinator.timerLiveActivityEnabled - ) - .animation(.easeInOut, value: coordinator.timerLiveActivityEnabled) + Toggle("Enable timer live activity", isOn: $coordinator.timerLiveActivityEnabled) + .animation(.easeInOut, value: coordinator.timerLiveActivityEnabled) } } header: { Text("Timer Feature") } footer: { - Text("Enable or disable the timer functionality in the Dynamic Island. The live activity toggle controls whether timer progress is shown in the expanded view.") + Text("Control timer availability and whether progress appears as a live activity while the notch is closed.") } if enableTimerFeature { Section { - VStack(alignment: .leading, spacing: 16) { - Text("Custom Timer Duration") + VStack(alignment: .leading, spacing: 12) { + Text("Default Custom Timer") .font(.headline) - HStack(spacing: 20) { - VStack(alignment: .center, spacing: 8) { - Text("Minutes") - .font(.system(size: 14, weight: .medium)) - .foregroundStyle(.secondary) - - Picker(selection: $customMinutes, label: Text("Minutes")) { - ForEach(0...59, id: \.self) { minute in - Text("\(minute)").tag(minute) - } - } - .pickerStyle(.menu) - .frame(width: 100) - } - - VStack(alignment: .center, spacing: 8) { - Text("Seconds") - .font(.system(size: 14, weight: .medium)) - .foregroundStyle(.secondary) - - Picker(selection: $customSeconds, label: Text("Seconds")) { - ForEach(0...59, id: \.self) { second in - Text("\(second)").tag(second) - } - } - .pickerStyle(.menu) - .frame(width: 100) - } - } - .frame(maxWidth: .infinity) + TimerDurationStepperRow(title: "Hours", value: $customHours, range: 0...23) + TimerDurationStepperRow(title: "Minutes", value: $customMinutes, range: 0...59) + TimerDurationStepperRow(title: "Seconds", value: $customSeconds, range: 0...59) - VStack(alignment: .leading, spacing: 8) { - HStack { - Text("Current custom timer:") - .foregroundStyle(.secondary) - Text(customTimerDisplayText) - .foregroundStyle(.primary) - .fontWeight(.medium) - Spacer() - } - - HStack { - Spacer() - Button("Update Timer") { - customTimerDuration = Double(customMinutes * 60 + customSeconds) - } - .disabled(customMinutes == 0 && customSeconds == 0) - .buttonStyle(.borderedProminent) - } + HStack { + Text("Current default:") + .foregroundStyle(.secondary) + Text(customDurationDisplay) + .font(.system(.body, design: .monospaced)) + .fontWeight(.medium) + Spacer() } } - .padding(.vertical, 8) + .padding(.vertical, 4) + .onChange(of: customHours) { _, _ in updateCustomDuration() } + .onChange(of: customMinutes) { _, _ in updateCustomDuration() } + .onChange(of: customSeconds) { _, _ in updateCustomDuration() } } header: { Text("Custom Timer") } footer: { - Text("Set a custom duration for the timer. This will be used when you press the 'Custom' button in the timer interface.") + Text("This duration powers the \"Custom\" option inside the timer popover for quick access.") } Section { - VStack(alignment: .leading, spacing: 8) { + Picker("Timer tint", selection: $colorMode) { + ForEach(TimerIconColorMode.allCases) { mode in + Text(mode.displayName).tag(mode) + } + } + .pickerStyle(.segmented) + + if colorMode == .solid { + ColorPicker("Solid colour", selection: $solidColor, supportsOpacity: false) + } + + Toggle("Show countdown", isOn: $showsCountdown) + Toggle("Show progress", isOn: $showsProgress) + + Picker("Progress style", selection: $progressStyle) { + ForEach(TimerProgressStyle.allCases) { style in + Text(style.rawValue).tag(style) + } + } + .pickerStyle(.segmented) + .disabled(!showsProgress) + } header: { + Text("Appearance") + } footer: { + Text("Configure how the timer looks inside the closed notch. Progress can render as a ring around the icon or as horizontal bars.") + } + + Section { + if timerPresets.isEmpty { + Text("No presets configured. Add a preset to make it appear in the timer popover.") + .font(.system(size: 12)) + .foregroundStyle(.secondary) + .padding(.vertical, 4) + } else { + ForEach(timerPresets.indices, id: \.self) { index in + TimerPresetEditorRow( + preset: $timerPresets[index], + isFirst: index == timerPresets.startIndex, + isLast: index == timerPresets.index(before: timerPresets.endIndex), + moveUp: { movePresetUp(index) }, + moveDown: { movePresetDown(index) }, + remove: { removePreset(index) } + ) + } + } + + HStack { + Button(action: addPreset) { + Label("Add Preset", systemImage: "plus") + } + .buttonStyle(.bordered) + + Spacer() + + Button(role: .destructive, action: { showingResetConfirmation = true }) { + Label("Restore Defaults", systemImage: "arrow.counterclockwise") + } + .buttonStyle(.bordered) + .confirmationDialog("Restore default timer presets?", isPresented: $showingResetConfirmation, titleVisibility: .visible) { + Button("Restore", role: .destructive, action: resetPresets) + } + } + } header: { + Text("Timer Presets") + } footer: { + Text("Presets show up inside the timer popover with the configured name, duration, and accent colour. Reorder them to change the display order.") + } + + Section { + VStack(alignment: .leading, spacing: 10) { HStack { Text("Timer Sound") .font(.system(size: 16, weight: .medium)) Spacer() - Button("Choose File") { - selectCustomTimerSound() - } - .buttonStyle(.bordered) + Button("Choose File", action: selectCustomTimerSound) + .buttonStyle(.bordered) } if let customTimerSoundPath = UserDefaults.standard.string(forKey: "customTimerSoundPath") { @@ -1826,30 +1863,68 @@ struct TimerSettings: View { } header: { Text("Timer Sound") } footer: { - Text("Choose a custom sound file that will play when the timer completes. Supported formats: MP3, M4A, WAV, AIFF.") + Text("Select a custom sound to play when a timer ends. Supported formats include MP3, M4A, WAV, and AIFF.") } } } .navigationTitle("Timer") - .onAppear { - let totalMinutes = Int(customTimerDuration) / 60 - customMinutes = totalMinutes - customSeconds = Int(customTimerDuration) % 60 + .onAppear { syncCustomDuration() } + .onChange(of: customTimerDuration) { _, newValue in syncCustomDuration(newValue) } + } + + private var customDurationDisplay: String { + let formatter = DateComponentsFormatter() + formatter.allowedUnits = customTimerDuration >= 3600 ? [.hour, .minute, .second] : [.minute, .second] + formatter.zeroFormattingBehavior = [.pad] + return formatter.string(from: customTimerDuration) ?? "0:00" + } + + private func syncCustomDuration(_ value: Double? = nil) { + let baseValue = value ?? customTimerDuration + let components = TimerPreset.components(for: baseValue) + customHours = components.hours + customMinutes = components.minutes + customSeconds = components.seconds + } + + private func updateCustomDuration() { + let duration = TimeInterval(customHours * 3600 + customMinutes * 60 + customSeconds) + customTimerDuration = duration + } + + private func addPreset() { + let nextIndex = timerPresets.count + 1 + let defaultColor = Defaults[.accentColor] + let newPreset = TimerPreset(name: "Preset \(nextIndex)", duration: 5 * 60, color: defaultColor) + withAnimation(.smooth) { + timerPresets.append(newPreset) } } - private var customTimerDisplayText: String { - let totalMinutes = Int(customTimerDuration) / 60 - let seconds = Int(customTimerDuration) % 60 - let hours = totalMinutes / 60 - let minutes = totalMinutes % 60 - - if hours > 0 { - return "\(hours):\(String(format: "%02d", minutes)):\(String(format: "%02d", seconds))" - } else if minutes > 0 { - return "\(minutes):\(String(format: "%02d", seconds))" - } else { - return "\(seconds)s" + private func movePresetUp(_ index: Int) { + guard index > timerPresets.startIndex else { return } + withAnimation(.smooth) { + timerPresets.swapAt(index, index - 1) + } + } + + private func movePresetDown(_ index: Int) { + guard index < timerPresets.index(before: timerPresets.endIndex) else { return } + withAnimation(.smooth) { + timerPresets.swapAt(index, index + 1) + } + } + + private func removePreset(_ index: Int) { + guard timerPresets.indices.contains(index) else { return } + withAnimation(.smooth) { + timerPresets.remove(at: index) + } + } + + private func resetPresets() { + withAnimation(.smooth) { + timerPresets = TimerPreset.defaultPresets } } @@ -1869,6 +1944,146 @@ struct TimerSettings: View { } } +private struct TimerDurationStepperRow: View { + let title: String + @Binding var value: Int + let range: ClosedRange + + var body: some View { + Stepper(value: $value, in: range) { + HStack { + Text(title) + Spacer() + Text("\(value)") + .font(.system(size: 12, weight: .semibold, design: .monospaced)) + } + } + } +} + +private struct TimerPresetEditorRow: View { + @Binding var preset: TimerPreset + let isFirst: Bool + let isLast: Bool + let moveUp: () -> Void + let moveDown: () -> Void + let remove: () -> Void + + private var components: TimerPreset.DurationComponents { + TimerPreset.components(for: preset.duration) + } + + private var hoursBinding: Binding { + Binding( + get: { components.hours }, + set: { updateDuration(hours: $0) } + ) + } + + private var minutesBinding: Binding { + Binding( + get: { components.minutes }, + set: { updateDuration(minutes: $0) } + ) + } + + private var secondsBinding: Binding { + Binding( + get: { components.seconds }, + set: { updateDuration(seconds: $0) } + ) + } + + private var colorBinding: Binding { + Binding( + get: { preset.color }, + set: { preset.updateColor($0) } + ) + } + + var body: some View { + VStack(alignment: .leading, spacing: 12) { + HStack(spacing: 10) { + Circle() + .fill(preset.color.gradient) + .frame(width: 20, height: 20) + .overlay( + Circle() + .stroke(Color.white.opacity(0.3), lineWidth: 1) + ) + + TextField("Preset name", text: $preset.name) + .textFieldStyle(.roundedBorder) + + Spacer() + + Text(preset.formattedDuration) + .font(.system(size: 12, weight: .medium, design: .monospaced)) + .foregroundStyle(.secondary) + } + + HStack(spacing: 16) { + TimerPresetComponentControl(title: "Hours", value: hoursBinding, range: 0...23) + TimerPresetComponentControl(title: "Minutes", value: minutesBinding, range: 0...59) + TimerPresetComponentControl(title: "Seconds", value: secondsBinding, range: 0...59) + } + + ColorPicker("Accent colour", selection: colorBinding, supportsOpacity: false) + .frame(maxWidth: 240, alignment: .leading) + + HStack(spacing: 12) { + Button(action: moveUp) { + Label("Move Up", systemImage: "chevron.up") + } + .buttonStyle(.bordered) + .disabled(isFirst) + + Button(action: moveDown) { + Label("Move Down", systemImage: "chevron.down") + } + .buttonStyle(.bordered) + .disabled(isLast) + + Spacer() + + Button(role: .destructive, action: remove) { + Label("Delete", systemImage: "trash") + } + .buttonStyle(.bordered) + } + .font(.system(size: 12, weight: .medium)) + } + .padding(.vertical, 6) + } + + private func updateDuration(hours: Int? = nil, minutes: Int? = nil, seconds: Int? = nil) { + var values = components + if let hours { values.hours = hours } + if let minutes { values.minutes = minutes } + if let seconds { values.seconds = seconds } + preset.duration = TimerPreset.duration(from: values) + } +} + +private struct TimerPresetComponentControl: View { + let title: String + @Binding var value: Int + let range: ClosedRange + + var body: some View { + Stepper(value: $value, in: range) { + VStack(alignment: .leading, spacing: 4) { + Text(title) + .font(.caption) + .foregroundStyle(.secondary) + Text("\(value)") + .font(.system(size: 13, weight: .semibold, design: .monospaced)) + } + } + .frame(width: 110, alignment: .leading) + } +} + struct StatsSettings: View { @ObservedObject var statsManager = StatsManager.shared @Default(.enableStatsFeature) var enableStatsFeature diff --git a/DynamicIsland/components/Tabs/TabSelectionView.swift b/DynamicIsland/components/Tabs/TabSelectionView.swift index 7bb3027..a1bd64c 100644 --- a/DynamicIsland/components/Tabs/TabSelectionView.swift +++ b/DynamicIsland/components/Tabs/TabSelectionView.swift @@ -17,7 +17,6 @@ struct TabModel: Identifiable { struct TabSelectionView: View { @ObservedObject var coordinator = DynamicIslandViewCoordinator.shared - @Default(.enableTimerFeature) var enableTimerFeature @Default(.enableStatsFeature) var enableStatsFeature @Default(.enableColorPickerFeature) var enableColorPickerFeature @Namespace var animation @@ -29,11 +28,6 @@ struct TabSelectionView: View { tabsArray.append(TabModel(label: "Shelf", icon: "tray.fill", view: .shelf)) - // Timer tab only shown when timer feature is enabled - if Defaults[.enableTimerFeature] { - tabsArray.append(TabModel(label: "Timer", icon: "timer", view: .timer)) - } - // Stats tab only shown when stats feature is enabled if Defaults[.enableStatsFeature] { tabsArray.append(TabModel(label: "Stats", icon: "chart.xyaxis.line", view: .stats)) diff --git a/DynamicIsland/components/Timer/TimerIconAnimation.swift b/DynamicIsland/components/Timer/TimerIconAnimation.swift index cd32767..e5c4303 100644 --- a/DynamicIsland/components/Timer/TimerIconAnimation.swift +++ b/DynamicIsland/components/Timer/TimerIconAnimation.swift @@ -6,95 +6,25 @@ // import SwiftUI -import Combine +import Defaults struct TimerIconAnimation: View { - @ObservedObject var timerManager = TimerManager.shared - @State private var pulseScale: CGFloat = 1.0 - @State private var rotationAngle: Double = 0 + @Default(.accentColor) private var accentColor var body: some View { ZStack { - // Background circle with timer color Circle() - .fill(timerManager.timerColor.gradient) + .fill(accentColor.gradient) .frame(width: 24, height: 24) - .scaleEffect(pulseScale) - .animation( - timerManager.isTimerActive && !timerManager.isPaused - ? .easeInOut(duration: 1.0).repeatForever(autoreverses: true) - : .easeInOut(duration: 0.3), - value: pulseScale - ) - // Timer icon Image(systemName: "timer") .font(.system(size: 12, weight: .medium)) - .foregroundColor(.white) - .rotationEffect(.degrees(rotationAngle)) - .animation( - timerManager.isTimerActive && !timerManager.isPaused - ? .linear(duration: 2.0).repeatForever(autoreverses: false) - : .easeInOut(duration: 0.3), - value: rotationAngle - ) - - // Progress ring - Circle() - .trim(from: 0, to: timerManager.progress) - .stroke( - Color.white.opacity(0.8), - style: StrokeStyle(lineWidth: 2, lineCap: .round) - ) - .frame(width: 26, height: 26) - .rotationEffect(.degrees(-90)) // Start from top - .animation(.easeInOut(duration: 0.5), value: timerManager.progress) - } - .onAppear { - startAnimations() - } - .onChange(of: timerManager.isTimerActive) { _, isActive in - if isActive { - startAnimations() - } else { - stopAnimations() - } - } - .onChange(of: timerManager.isPaused) { _, isPaused in - if isPaused { - stopAnimations() - } else if timerManager.isTimerActive { - startAnimations() - } - } - } - - private func startAnimations() { - guard timerManager.isTimerActive && !timerManager.isPaused else { return } - - // Start pulsing - pulseScale = 1.1 - - // Start rotation - withAnimation(.linear(duration: 2.0).repeatForever(autoreverses: false)) { - rotationAngle = 360 - } - } - - private func stopAnimations() { - // Stop pulsing - pulseScale = 1.0 - - // Stop rotation - withAnimation(.easeInOut(duration: 0.3)) { - rotationAngle = 0 + .foregroundStyle(.white) } } } struct TimerIconView: NSViewRepresentable { - @ObservedObject var timerManager = TimerManager.shared - func makeNSView(context: Context) -> NSView { let hostingView = NSHostingView(rootView: TimerIconAnimation()) hostingView.translatesAutoresizingMaskIntoConstraints = false diff --git a/DynamicIsland/components/Timer/TimerLiveActivity.swift b/DynamicIsland/components/Timer/TimerLiveActivity.swift index 9e5aff2..666cae9 100644 --- a/DynamicIsland/components/Timer/TimerLiveActivity.swift +++ b/DynamicIsland/components/Timer/TimerLiveActivity.swift @@ -10,63 +10,147 @@ import Defaults struct TimerLiveActivity: View { @EnvironmentObject var vm: DynamicIslandViewModel - @ObservedObject var coordinator = DynamicIslandViewCoordinator.shared @ObservedObject var timerManager = TimerManager.shared @State private var isHovering: Bool = false - @State private var gestureProgress: CGFloat = 0 + @Default(.timerShowsCountdown) private var showsCountdown + @Default(.timerShowsProgress) private var showsProgress + @Default(.timerProgressStyle) private var progressStyle + @Default(.timerIconColorMode) private var colorMode + @Default(.timerSolidColor) private var solidColor + @Default(.timerPresets) private var timerPresets + + private var iconAreaWidth: CGFloat { + max(0, vm.effectiveClosedNotchHeight - (isHovering ? 0 : 12)) + } + + private var countdownWidth: CGFloat { + guard showsCountdown else { return 0 } + let characters = max(timerManager.formattedRemainingTime().count, 4) + let base = CGFloat(characters) * 9.5 + 24 + return max(base, 72) + } + + private var clampedProgress: Double { + min(max(timerManager.progress, 0), 1) + } + + private var glyphColor: Color { + switch colorMode { + case .adaptive: + return activePresetColor ?? timerManager.timerColor + case .solid: + return solidColor + } + } + + private var showsRingProgress: Bool { + showsProgress && progressStyle == .ring + } + + private var showsBarProgress: Bool { + showsProgress && progressStyle == .bar + } + + private var activePresetColor: Color? { + guard let presetId = timerManager.activePresetId else { return nil } + return timerPresets.first { $0.id == presetId }?.color + } var body: some View { - HStack { - // Left side - Timer icon animation - timerIconSection - - // Center - Expandable timer info (similar to music banner) - timerInfoSection - - // Right side - Timer countdown text - timerCountdownSection + HStack(spacing: 10) { + iconSection + infoSection + if showsRingProgress { + ringSection + } + if showsCountdown { + countdownSection + } } .frame(height: vm.effectiveClosedNotchHeight + (isHovering ? 8 : 0), alignment: .center) - } - - private var timerIconSection: some View { - HStack { - // Simple timer icon with color, no background circle or animations - Image(systemName: "timer") - .font(.system(size: 16, weight: .medium)) - .foregroundStyle(timerManager.timerColor) - .frame(width: 24, height: 24) - .frame(width: max(0, vm.effectiveClosedNotchHeight - (isHovering ? 0 : 12) + gestureProgress / 2), - height: max(0, vm.effectiveClosedNotchHeight - (isHovering ? 0 : 12)), alignment: .center) + .contentShape(Rectangle()) + .onHover { hovering in + withAnimation(.smooth(duration: 0.18)) { + isHovering = hovering + } } - .frame(width: max(0, vm.effectiveClosedNotchHeight - (isHovering ? 0 : 12) + gestureProgress / 2), - height: max(0, vm.effectiveClosedNotchHeight - (isHovering ? 0 : 12))) } - private var timerInfoSection: some View { + private var iconSection: some View { + let iconSize = max(20, iconAreaWidth - 4) + return Image(systemName: "timer") + .font(.system(size: 16, weight: .semibold)) + .foregroundStyle(glyphColor) + .frame(width: iconSize, height: iconSize) + .frame(width: iconAreaWidth, height: max(0, vm.effectiveClosedNotchHeight - (isHovering ? 0 : 12)), alignment: .center) + } + + private var infoSection: some View { Rectangle() .fill(.black) + .overlay( + VStack(alignment: .leading, spacing: 4) { + if timerManager.isFinished || timerManager.isOvertime { + GeometryReader { geo in + MarqueeText( + .constant(timerManager.timerName), + textColor: .white, + minDuration: 0.25, + frameWidth: geo.size.width - 12 + ) + } + .frame(height: 16) + } else { + Text(timerManager.timerName) + .font(.system(size: 12, weight: .medium)) + .lineLimit(1) + .foregroundStyle(.white) + .transition(.opacity.combined(with: .move(edge: .top))) + } + } + .padding(.horizontal, 12) + .padding(.vertical, 6) + .animation(.smooth, value: timerManager.isFinished) + ) .frame(width: vm.closedNotchSize.width + (isHovering ? 8 : 0)) } - private var timerCountdownSection: some View { - HStack { - VStack(spacing: 2) { - // Remaining time - Text(timerManager.formattedRemainingTime()) - .font(.system(size: 11, weight: .medium, design: .monospaced)) - .foregroundColor(timerManager.isOvertime ? .red : .white) - - // Progress indicator - if timerManager.totalDuration > 0 { - ProgressView(value: timerManager.progress) - .progressViewStyle(LinearProgressViewStyle(tint: timerManager.timerColor)) - .frame(width: 30, height: 2) - } - } - .frame(maxWidth: .infinity) + private var ringSection: some View { + ZStack { + Circle() + .stroke(Color.white.opacity(0.15), lineWidth: 3) + Circle() + .trim(from: 0, to: clampedProgress) + .stroke(glyphColor, style: StrokeStyle(lineWidth: 3, lineCap: .round)) + .rotationEffect(.degrees(-90)) + .animation(.smooth(duration: 0.25), value: clampedProgress) } - .frame(width: max(0, vm.effectiveClosedNotchHeight - (isHovering ? 0 : 12) + gestureProgress / 2), + .frame(width: 26, height: 26) + } + + private var countdownSection: some View { + VStack(spacing: 4) { + Text(timerManager.formattedRemainingTime()) + .font(.system(size: 13, weight: .semibold, design: .monospaced)) + .foregroundColor(timerManager.isOvertime ? .red : .white) + .contentTransition(.numericText()) + .animation(.smooth(duration: 0.25), value: timerManager.remainingTime) + .frame(maxWidth: .infinity, alignment: .trailing) + + if showsBarProgress { + Capsule() + .fill(Color.white.opacity(0.12)) + .frame(height: 3) + .overlay(alignment: .leading) { + Capsule() + .fill(glyphColor) + .frame(width: max(0, countdownWidth - 20) * max(0, CGFloat(clampedProgress))) + .animation(.smooth(duration: 0.25), value: clampedProgress) + } + } + } + .padding(.trailing, 8) + .frame(width: countdownWidth, height: max(0, vm.effectiveClosedNotchHeight - (isHovering ? 0 : 12)), alignment: .center) } } @@ -77,7 +161,6 @@ struct TimerLiveActivity: View { .frame(width: 300, height: 32) .background(.black) .onAppear { - // Start a demo timer for preview TimerManager.shared.startDemoTimer(duration: 300) } } diff --git a/DynamicIsland/components/Timer/TimerPopover.swift b/DynamicIsland/components/Timer/TimerPopover.swift new file mode 100644 index 0000000..c5dcbfc --- /dev/null +++ b/DynamicIsland/components/Timer/TimerPopover.swift @@ -0,0 +1,362 @@ +// +// TimerPopover.swift +// DynamicIsland +// +// Created by GitHub Copilot on 2025-11-01. +// + +import SwiftUI +import Defaults + +struct TimerPopover: View { + @ObservedObject var timerManager = TimerManager.shared + @Default(.timerPresets) private var timerPresets + @Default(.timerShowsCountdown) private var showsCountdown + @Default(.timerShowsProgress) private var showsProgress + @Default(.timerProgressStyle) private var progressStyle + @Default(.timerIconColorMode) private var colorMode + @Default(.timerSolidColor) private var solidColor + @AppStorage("customTimerDuration") private var customTimerDuration: Double = 600 + @Environment(\.dismiss) private var dismiss + + @State private var customHours: Int = 0 + @State private var customMinutes: Int = 10 + @State private var customSeconds: Int = 0 + + private var customDurationInSeconds: TimeInterval { + TimeInterval(customHours * 3600 + customMinutes * 60 + customSeconds) + } + + var body: some View { + VStack(spacing: 16) { + HeaderView(statusText: statusText) + + if timerManager.isTimerActive { + ActiveTimerSection(timerManager: timerManager) + } else { + CustomTimerSection(hours: $customHours, minutes: $customMinutes, seconds: $customSeconds, startAction: startCustomTimer) + .onChange(of: customHours) { _, _ in updateStoredCustomDuration() } + .onChange(of: customMinutes) { _, _ in updateStoredCustomDuration() } + .onChange(of: customSeconds) { _, _ in updateStoredCustomDuration() } + } + + Divider() + .padding(.horizontal, -8) + + PresetList(presets: timerPresets, activePresetId: timerManager.activePresetId, startAction: startPreset) + .animation(.smooth, value: timerManager.activePresetId) + .frame(maxHeight: 200) + } + .padding(16) + .frame(width: 300) + .background( + VisualEffectView(material: .hudWindow, blendingMode: .behindWindow) + .cornerRadius(12) + ) + .onAppear { + syncCustomDuration(with: customTimerDuration) + } + .onChange(of: customTimerDuration) { _, newValue in + syncCustomDuration(with: newValue) + } + } + + private var statusText: String { + if timerManager.isOvertime { + return "Overtime" + } else if timerManager.isPaused { + return "Paused" + } else if timerManager.isTimerActive { + return "Running" + } else { + return "Ready" + } + } + + private func syncCustomDuration(with value: Double) { + let components = TimerPreset.components(for: value) + customHours = components.hours + customMinutes = components.minutes + customSeconds = components.seconds + } + + private func updateStoredCustomDuration() { + let newValue = customDurationInSeconds + customTimerDuration = newValue + } + + private func startCustomTimer() { + let duration = customDurationInSeconds + guard duration > 0 else { return } + withAnimation(.smooth) { + timerManager.startTimer(duration: duration, name: "Custom Timer") + } + dismiss() + } + + private func startPreset(_ preset: TimerPreset) { + withAnimation(.smooth) { + timerManager.startTimer(duration: preset.duration, name: preset.name, preset: preset) + } + dismiss() + } +} + +private struct HeaderView: View { + let statusText: String + + var body: some View { + HStack(spacing: 12) { + TimerIconAnimation() + .frame(width: 32, height: 32) + + VStack(alignment: .leading, spacing: 2) { + Text("Timer") + .font(.system(size: 14, weight: .semibold)) + Text(statusText) + .font(.system(size: 11, weight: .medium)) + .foregroundStyle(.secondary) + } + + Spacer() + } + } +} + +private struct ActiveTimerSection: View { + @ObservedObject var timerManager: TimerManager + @Default(.timerShowsProgress) private var showsProgress + @Default(.timerProgressStyle) private var progressStyle + @Default(.timerIconColorMode) private var colorMode + @Default(.timerSolidColor) private var solidColor + @Default(.timerPresets) private var timerPresets + + var body: some View { + VStack(alignment: .leading, spacing: 12) { + VStack(alignment: .leading, spacing: 4) { + Text(timerManager.timerName) + .font(.system(size: 15, weight: .semibold)) + .lineLimit(1) + .foregroundColor(.primary) + + Text(timerManager.formattedRemainingTime()) + .font(.system(size: 28, weight: .bold, design: .monospaced)) + .foregroundStyle(timerManager.isOvertime ? Color.red : Color.primary) + .contentTransition(.numericText()) + } + + if showsProgress && progressStyle == .bar { + ProgressView(value: min(timerManager.progress, 1.0)) + .progressViewStyle(LinearProgressViewStyle(tint: progressTint)) + .animation(.smooth(duration: 0.2), value: timerManager.progress) + } + + HStack(spacing: 8) { + Button(action: togglePause) { + Label(timerManager.isPaused ? "Resume" : "Pause", systemImage: timerManager.isPaused ? "play.fill" : "pause.fill") + .font(.system(size: 12, weight: .medium)) + .labelStyle(.titleAndIcon) + } + .buttonStyle(.bordered) + + Button(role: .destructive, action: stopTimer) { + Label("Stop", systemImage: "stop.fill") + .font(.system(size: 12, weight: .medium)) + .labelStyle(.titleAndIcon) + } + .buttonStyle(.borderedProminent) + } + } + .frame(maxWidth: .infinity, alignment: .leading) + .padding(12) + .background(Color.white.opacity(0.08)) + .clipShape(RoundedRectangle(cornerRadius: 10, style: .continuous)) + .animation(.smooth, value: timerManager.isPaused) + } + + private func togglePause() { + withAnimation(.smooth) { + if timerManager.isPaused { + timerManager.resumeTimer() + } else { + timerManager.pauseTimer() + } + } + } + + private func stopTimer() { + withAnimation(.smooth) { + timerManager.stopTimer() + } + } + + private var progressTint: Color { + switch colorMode { + case .adaptive: + return activePresetColor ?? timerManager.timerColor + case .solid: + return solidColor + } + } + + private var activePresetColor: Color? { + guard let presetId = timerManager.activePresetId else { return nil } + return timerPresets.first { $0.id == presetId }?.color + } +} + +private struct CustomTimerSection: View { + @Binding var hours: Int + @Binding var minutes: Int + @Binding var seconds: Int + let startAction: () -> Void + + private var totalSeconds: Int { + hours * 3600 + minutes * 60 + seconds + } + + var body: some View { + VStack(alignment: .leading, spacing: 12) { + Text("Custom Timer") + .font(.system(size: 14, weight: .semibold)) + + Grid(alignment: .leading, horizontalSpacing: 12, verticalSpacing: 8) { + GridRow { + DurationStepper(title: "Hours", value: $hours, range: 0...23) + DurationStepper(title: "Minutes", value: $minutes, range: 0...59) + DurationStepper(title: "Seconds", value: $seconds, range: 0...59) + } + } + + Text(formattedDuration) + .font(.system(size: 12, weight: .medium, design: .monospaced)) + .foregroundStyle(.secondary) + + Button(action: startAction) { + Label("Start Custom Timer", systemImage: "play.fill") + .font(.system(size: 13, weight: .medium)) + .labelStyle(.titleAndIcon) + } + .buttonStyle(.borderedProminent) + .disabled(totalSeconds == 0) + } + .frame(maxWidth: .infinity, alignment: .leading) + .padding(12) + .background(Color.white.opacity(0.05)) + .clipShape(RoundedRectangle(cornerRadius: 10, style: .continuous)) + } + + private var formattedDuration: String { + let components = TimerPreset.DurationComponents(hours: hours, minutes: minutes, seconds: seconds) + let interval = TimerPreset.duration(from: components) + return TimerPreset(name: "", duration: interval, color: .clear).formattedDuration + } +} + +private struct DurationStepper: View { + let title: String + @Binding var value: Int + let range: ClosedRange + + var body: some View { + Stepper(value: $value, in: range) { + HStack { + Text(title) + .font(.system(size: 12, weight: .medium)) + Spacer() + Text("\(value)") + .font(.system(size: 12, weight: .semibold, design: .monospaced)) + } + } + .frame(width: 88) + } +} + +private struct PresetList: View { + let presets: [TimerPreset] + let activePresetId: UUID? + let startAction: (TimerPreset) -> Void + + var body: some View { + VStack(alignment: .leading, spacing: 8) { + Text("Presets") + .font(.system(size: 13, weight: .semibold)) + .padding(.leading, 4) + + if presets.isEmpty { + Text("Configure presets in Settings") + .font(.system(size: 12)) + .foregroundStyle(.secondary) + .padding(12) + .frame(maxWidth: .infinity, alignment: .leading) + .background(Color.white.opacity(0.05)) + .clipShape(RoundedRectangle(cornerRadius: 10, style: .continuous)) + } else { + ScrollView { + VStack(spacing: 8) { + ForEach(presets) { preset in + TimerPresetRow(preset: preset, isActive: activePresetId == preset.id) { + startAction(preset) + } + } + } + .padding(.vertical, 4) + } + } + } + } +} + +private struct TimerPresetRow: View { + let preset: TimerPreset + let isActive: Bool + let action: () -> Void + + var body: some View { + Button(action: action) { + HStack(spacing: 12) { + Circle() + .fill(preset.color.gradient) + .frame(width: 20, height: 20) + .overlay( + Circle() + .stroke(Color.white.opacity(0.5), lineWidth: 1) + ) + + VStack(alignment: .leading, spacing: 2) { + Text(preset.name) + .font(.system(size: 13, weight: .medium)) + .foregroundStyle(.primary) + .lineLimit(1) + Text(preset.formattedDuration) + .font(.system(size: 11, weight: .medium, design: .monospaced)) + .foregroundStyle(.secondary) + } + + Spacer() + + Image(systemName: isActive ? "checkmark" : "play.fill") + .font(.system(size: 11, weight: .semibold)) + .foregroundStyle(isActive ? preset.color : Color.secondary) + .padding(6) + .background(isActive ? preset.color.opacity(0.2) : Color.clear) + .clipShape(Circle()) + } + .padding(.horizontal, 12) + .padding(.vertical, 10) + .contentShape(RoundedRectangle(cornerRadius: 10, style: .continuous)) + .background( + RoundedRectangle(cornerRadius: 10, style: .continuous) + .fill(isActive ? preset.color.opacity(0.18) : Color.white.opacity(0.05)) + ) + } + .buttonStyle(.plain) + } +} + +#Preview { + TimerPopover() + .frame(width: 300) + .padding() + .background(Color.black) +} diff --git a/DynamicIsland/managers/TimerManager.swift b/DynamicIsland/managers/TimerManager.swift index 06adfd9..f133c5d 100644 --- a/DynamicIsland/managers/TimerManager.swift +++ b/DynamicIsland/managers/TimerManager.swift @@ -10,6 +10,7 @@ import Combine import SwiftUI import AVFoundation import AppKit +import Defaults class TimerManager: ObservableObject { // MARK: - Properties @@ -24,6 +25,7 @@ class TimerManager: ObservableObject { @Published var isFinished: Bool = false @Published var isOvertime: Bool = false // Timer has gone past 0 and is counting negative @Published var lastUpdated: Date = .distantPast + @Published var activePresetId: UUID? // Timer progress (0.0 to 1.0, or >1.0 for overtime) var progress: Double { @@ -124,7 +126,7 @@ class TimerManager: ObservableObject { } // MARK: - Timer Methods - func startTimer(duration: TimeInterval, name: String = "Timer") { + func startTimer(duration: TimeInterval, name: String = "Timer", preset: TimerPreset? = nil) { // Stop any existing timer timerInstance?.invalidate() @@ -140,8 +142,8 @@ class TimerManager: ObservableObject { elapsedTime = 0 isPaused = false lastUpdated = Date() - - triggerTimerSneakPeek() + + activePresetId = preset?.id // Start countdown timer timerInstance = Timer.scheduledTimer(withTimeInterval: 1.0, repeats: true) { [weak self] _ in @@ -250,8 +252,15 @@ class TimerManager: ObservableObject { isPaused = false isFinished = false isOvertime = false + activePresetId = nil } - + + // MARK: - Derived State + var activePreset: TimerPreset? { + guard let presetId = activePresetId else { return nil } + return Defaults[.timerPresets].first { $0.id == presetId } + } + private func scheduleSmoothClose() { // Wait 3 seconds then smoothly close the live activity DispatchQueue.main.asyncAfter(deadline: .now() + 3.0) { @@ -297,21 +306,6 @@ class TimerManager: ObservableObject { } } - private func triggerTimerSneakPeek() { - let coordinator = DynamicIslandViewCoordinator.shared - - DispatchQueue.main.async { - coordinator.sneakPeek.show = true - coordinator.sneakPeek.type = .timer - coordinator.sneakPeek.value = CGFloat(self.progress) // Use timer progress (0.0 to 1.0) - - // Hide sneak peek after delay - DispatchQueue.main.asyncAfter(deadline: .now() + 3.0) { - coordinator.sneakPeek.show = false - } - } - } - // MARK: - Formatted Time Strings func formattedRemainingTime() -> String { if isOvertime && remainingTime < 0 { diff --git a/DynamicIsland/models/Constants.swift b/DynamicIsland/models/Constants.swift index 87f6d67..6e27980 100644 --- a/DynamicIsland/models/Constants.swift +++ b/DynamicIsland/models/Constants.swift @@ -196,6 +196,27 @@ enum ProgressBarStyle: String, CaseIterable, Identifiable, Defaults.Serializable var id: String { self.rawValue } } +enum TimerIconColorMode: String, CaseIterable, Identifiable, Defaults.Serializable { + case adaptive = "Adaptive" + case solid = "Solid" + + var id: String { rawValue } + + var displayName: String { + switch self { + case .adaptive: return "Adaptive gradient" + case .solid: return "Solid colour" + } + } +} + +enum TimerProgressStyle: String, CaseIterable, Identifiable, Defaults.Serializable { + case bar = "Bar" + case ring = "Ring" + + var id: String { rawValue } +} + // AI Model types for screen assistant enum AIModelProvider: String, CaseIterable, Identifiable, Defaults.Serializable { case gemini = "Gemini" @@ -407,6 +428,12 @@ extension Defaults.Keys { // MARK: Timer Feature static let enableTimerFeature = Key("enableTimerFeature", default: true) + static let timerPresets = Key<[TimerPreset]>("timerPresets", default: TimerPreset.defaultPresets) + static let timerIconColorMode = Key("timerIconColorMode", default: .adaptive) + static let timerSolidColor = Key("timerSolidColor", default: .blue) + static let timerShowsCountdown = Key("timerShowsCountdown", default: true) + static let timerShowsProgress = Key("timerShowsProgress", default: true) + static let timerProgressStyle = Key("timerProgressStyle", default: .bar) // MARK: ColorPicker Feature static let enableColorPickerFeature = Key("enableColorPickerFeature", default: true) diff --git a/DynamicIsland/models/DynamicIslandViewModel.swift b/DynamicIsland/models/DynamicIslandViewModel.swift index 22775d0..c596a55 100644 --- a/DynamicIsland/models/DynamicIslandViewModel.swift +++ b/DynamicIsland/models/DynamicIslandViewModel.swift @@ -33,6 +33,7 @@ class DynamicIslandViewModel: NSObject, ObservableObject { @Published var isColorPickerPopoverActive: Bool = false @Published var isStatsPopoverActive: Bool = false @Published var isMediaOutputPopoverActive: Bool = false + @Published var isTimerPopoverActive: Bool = false @Published var shouldRecheckHover: Bool = false let webcamManager = WebcamManager.shared diff --git a/DynamicIsland/models/TimerPreset.swift b/DynamicIsland/models/TimerPreset.swift new file mode 100644 index 0000000..e2630fb --- /dev/null +++ b/DynamicIsland/models/TimerPreset.swift @@ -0,0 +1,93 @@ +// +// TimerPreset.swift +// DynamicIsland +// +// Created by GitHub Copilot on 2025-11-01. +// + +import SwiftUI +import AppKit +import Defaults + +struct TimerPreset: Identifiable, Codable, Hashable, Defaults.Serializable { + struct ColorData: Codable, Hashable, Defaults.Serializable { + var red: Double + var green: Double + var blue: Double + var alpha: Double + + init(red: Double, green: Double, blue: Double, alpha: Double = 1.0) { + self.red = red + self.green = green + self.blue = blue + self.alpha = alpha + } + + init(color: Color) { + let nsColor = NSColor(color).usingColorSpace(.sRGB) ?? NSColor(color) + self.red = nsColor.redComponent + self.green = nsColor.greenComponent + self.blue = nsColor.blueComponent + self.alpha = nsColor.alphaComponent + } + + var color: Color { + Color(red: red, green: green, blue: blue, opacity: alpha) + } + } + + struct DurationComponents { + var hours: Int + var minutes: Int + var seconds: Int + } + + var id: UUID + var name: String + var duration: TimeInterval + var colorData: ColorData + + init(id: UUID = UUID(), name: String, duration: TimeInterval, colorData: ColorData) { + self.id = id + self.name = name + self.duration = duration + self.colorData = colorData + } + + init(id: UUID = UUID(), name: String, duration: TimeInterval, color: Color) { + self.init(id: id, name: name, duration: duration, colorData: ColorData(color: color)) + } + + var color: Color { + colorData.color + } + + mutating func updateColor(_ newColor: Color) { + colorData = ColorData(color: newColor) + } + + var formattedDuration: String { + let formatter = DateComponentsFormatter() + formatter.allowedUnits = duration >= 3600 ? [.hour, .minute, .second] : [.minute, .second] + formatter.zeroFormattingBehavior = [.pad] + return formatter.string(from: duration) ?? "0:00" + } + + static func components(for duration: TimeInterval) -> DurationComponents { + let totalSeconds = max(0, Int(duration)) + let hours = totalSeconds / 3600 + let minutes = (totalSeconds % 3600) / 60 + let seconds = totalSeconds % 60 + return DurationComponents(hours: hours, minutes: minutes, seconds: seconds) + } + + static func duration(from components: DurationComponents) -> TimeInterval { + TimeInterval(components.hours * 3600 + components.minutes * 60 + components.seconds) + } + + static let defaultPresets: [TimerPreset] = [ + TimerPreset(name: "Focus", duration: 25 * 60, color: Color.orange), + TimerPreset(name: "Break", duration: 5 * 60, color: Color.green), + TimerPreset(name: "Deep Work", duration: 45 * 60, color: Color.purple) + ] +}