Merge pull request #5 from Ebullioscopic/feature/timer

Custom Timer
This commit is contained in:
Hariharan Mudaliar 2025-07-23 18:47:55 +05:30 • committed by GitHub
commit 37a81a17bd
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 337 additions and 77 deletions

View file

@ -250,9 +250,15 @@
}, },
"Check for Updates…" : { "Check for Updates…" : {
},
"Choose a custom sound file that will play when the timer completes. Supported formats: MP3, M4A, WAV, AIFF." : {
}, },
"Choose a Music Source" : { "Choose a Music Source" : {
},
"Choose File" : {
}, },
"Circle" : { "Circle" : {
@ -299,12 +305,18 @@
}, },
"CPU Usage" : { "CPU Usage" : {
},
"Current custom timer: %@" : {
}, },
"Current History" : { "Current History" : {
}, },
"Current Items" : { "Current Items" : {
},
"Custom" : {
}, },
"Custom color" : { "Custom color" : {
"extractionState" : "stale", "extractionState" : "stale",
@ -325,9 +337,18 @@
}, },
"Custom notch size - %.0f" : { "Custom notch size - %.0f" : {
},
"Custom Timer" : {
},
"Custom Timer Duration" : {
}, },
"Custom vizualizers (Lottie)" : { "Custom vizualizers (Lottie)" : {
},
"Custom: %@" : {
}, },
"Customize in Settings" : { "Customize in Settings" : {
@ -335,7 +356,7 @@
"Default" : { "Default" : {
}, },
"Demo (10s)" : { "Default: dynamic.m4a" : {
}, },
"Description" : { "Description" : {
@ -428,12 +449,18 @@
}, },
"Enable music live activity" : { "Enable music live activity" : {
},
"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 sneak peek" : { "Enable sneak peek" : {
}, },
"Enable stats monitoring in Settings to view system performance data." : { "Enable stats monitoring in Settings to view system performance data." : {
},
"Enable timer feature in Settings to use this tab" : {
}, },
"Enable timer live activity" : { "Enable timer live activity" : {
@ -649,6 +676,9 @@
}, },
"Minimum hover duration" : { "Minimum hover duration" : {
},
"Minutes" : {
}, },
"Mirror" : { "Mirror" : {
@ -824,6 +854,9 @@
}, },
"Remember last tab" : { "Remember last tab" : {
},
"Reset to Default" : {
}, },
"Restart" : { "Restart" : {
@ -847,6 +880,9 @@
} }
} }
} }
},
"Seconds" : {
}, },
"Select Calendars" : { "Select Calendars" : {
@ -859,6 +895,9 @@
}, },
"Selected animation" : { "Selected animation" : {
},
"Set a custom duration for the timer. This will be used when you press the 'Custom' button in the timer interface." : {
}, },
"Settings" : { "Settings" : {
"localizations" : { "localizations" : {
@ -956,9 +995,6 @@
}, },
"Start" : { "Start" : {
},
"Start 5-min Demo Timer" : {
}, },
"Start Demo Timer:" : { "Start Demo Timer:" : {
@ -980,9 +1016,6 @@
}, },
"Stop Monitoring" : { "Stop Monitoring" : {
},
"Stop Timer" : {
}, },
"Stopped" : { "Stopped" : {
@ -1017,6 +1050,15 @@
}, },
"Timer" : { "Timer" : {
},
"Timer Disabled" : {
},
"Timer Feature" : {
},
"Timer Sound" : {
}, },
"Toggle Notch Open:" : { "Toggle Notch Open:" : {
@ -1038,6 +1080,9 @@
}, },
"unmuted" : { "unmuted" : {
},
"Update" : {
}, },
"Upgrade to Pro" : { "Upgrade to Pro" : {

View file

@ -12,17 +12,39 @@ struct NotchTimerView: View {
@EnvironmentObject var vm: DynamicIslandViewModel @EnvironmentObject var vm: DynamicIslandViewModel
@ObservedObject var timerManager = TimerManager.shared @ObservedObject var timerManager = TimerManager.shared
@ObservedObject var coordinator = DynamicIslandViewCoordinator.shared @ObservedObject var coordinator = DynamicIslandViewCoordinator.shared
@Default(.enableTimerFeature) var enableTimerFeature
@AppStorage("customTimerDuration") private var customTimerDuration: Double = 600 // 10 minutes default
var body: some View { var body: some View {
HStack(alignment: .center, spacing: 32) { if enableTimerFeature {
// Timer Progress and Controls HStack(alignment: .center, spacing: 32) {
timerProgressSection // Timer Progress and Controls
timerProgressSection
// Timer Control Panel
controlsSection // Timer Control Panel
controlsSection
}
.padding(.horizontal, 20)
.transition(.opacity.combined(with: .blurReplace))
} else {
VStack(spacing: 16) {
Image(systemName: "timer.slash")
.font(.system(size: 48, weight: .light))
.foregroundStyle(.secondary)
Text("Timer Disabled")
.font(.title2)
.fontWeight(.medium)
.foregroundStyle(.primary)
Text("Enable timer feature in Settings to use this tab")
.font(.body)
.foregroundStyle(.secondary)
.multilineTextAlignment(.center)
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
} }
.padding(.horizontal, 20)
.transition(.opacity.combined(with: .blurReplace))
} }
private var timerProgressSection: some View { private var timerProgressSection: some View {
@ -49,8 +71,13 @@ struct NotchTimerView: View {
Image(systemName: "timer") Image(systemName: "timer")
.font(.system(size: 32, weight: .medium)) .font(.system(size: 32, weight: .medium))
.foregroundStyle(timerManager.currentColor) .foregroundStyle(timerManager.currentColor)
.scaleEffect(timerManager.isRunning ? 1.1 : 1.0) .scaleEffect(timerManager.isRunning && timerManager.isTimerActive ? 1.1 : 1.0)
.animation(.easeInOut(duration: 0.3).repeatForever(autoreverses: true), value: timerManager.isRunning) .animation(
timerManager.isRunning && timerManager.isTimerActive ?
.easeInOut(duration: 0.3).repeatForever(autoreverses: true) :
.easeInOut(duration: 0.3),
value: timerManager.isRunning && timerManager.isTimerActive
)
} }
// Time display // Time display
@ -179,41 +206,59 @@ struct NotchTimerView: View {
quickTimerButton(minutes: 15, title: "15 min") quickTimerButton(minutes: 15, title: "15 min")
} }
// Demo timer HStack(spacing: 12) {
Button(action: { // Custom timer button
timerManager.startDemoTimer(duration: 10) // 10 second demo Button(action: {
}) { timerManager.startTimer(duration: customTimerDuration, name: "Custom Timer")
HStack(spacing: 8) { }) {
Image(systemName: "flask") VStack(spacing: 4) {
.font(.system(size: 14, weight: .medium)) Text(customTimerDisplayText)
Text("Demo (10s)") .font(.system(size: 16, weight: .medium))
.font(.system(size: 16, weight: .medium)) Text("Custom")
.font(.system(size: 12, weight: .regular))
.opacity(0.7)
}
.foregroundStyle(.white)
.frame(height: 60)
.frame(minWidth: 80)
.background(.white.opacity(0.08))
.clipShape(RoundedRectangle(cornerRadius: 12))
.overlay(
RoundedRectangle(cornerRadius: 12)
.stroke(.white.opacity(0.1), lineWidth: 1)
)
} }
.foregroundStyle(.white) .buttonStyle(PlainButtonStyle())
.frame(height: 40) .onHover { isHovering in
.frame(minWidth: 120) if isHovering {
.background(.white.opacity(0.08)) NSCursor.pointingHand.push()
.clipShape(RoundedRectangle(cornerRadius: 12)) } else {
.overlay( NSCursor.pop()
RoundedRectangle(cornerRadius: 12) }
.stroke(.white.opacity(0.1), lineWidth: 1)
)
}
.buttonStyle(PlainButtonStyle())
.onHover { isHovering in
if isHovering {
NSCursor.pointingHand.push()
} else {
NSCursor.pop()
} }
} }
} }
} }
}
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))"
} else if minutes > 0 {
return "\(minutes) min"
} else {
return "\(seconds)s"
}
} }
private func quickTimerButton(minutes: Int, title: String) -> some View { private func quickTimerButton(minutes: Int, title: String) -> some View {
Button(action: { Button(action: {
timerManager.startDemoTimer(duration: TimeInterval(minutes * 60)) timerManager.startTimer(duration: TimeInterval(minutes * 60), name: "\(minutes) Min Timer")
}) { }) {
VStack(spacing: 4) { VStack(spacing: 4) {
Text("\(minutes)") Text("\(minutes)")

View file

@ -14,6 +14,7 @@ import LottieUI
import Sparkle import Sparkle
import SwiftUI import SwiftUI
import SwiftUIIntrospect import SwiftUIIntrospect
import UniformTypeIdentifiers
struct SettingsView: View { struct SettingsView: View {
@StateObject var extensionManager = DynamicIslandExtensionManager() @StateObject var extensionManager = DynamicIslandExtensionManager()
@ -39,6 +40,9 @@ struct SettingsView: View {
NavigationLink(value: "Media") { NavigationLink(value: "Media") {
Label("Media", systemImage: "play.laptopcomputer") Label("Media", systemImage: "play.laptopcomputer")
} }
NavigationLink(value: "Timer") {
Label("Timer", systemImage: "timer")
}
NavigationLink(value: "Calendar") { NavigationLink(value: "Calendar") {
Label("Calendar", systemImage: "calendar") Label("Calendar", systemImage: "calendar")
} }
@ -88,6 +92,8 @@ struct SettingsView: View {
Appearance() Appearance()
case "Media": case "Media":
Media() Media()
case "Timer":
TimerSettings()
case "Calendar": case "Calendar":
CalendarSettings() CalendarSettings()
case "HUD": case "HUD":
@ -504,27 +510,6 @@ struct Media: View {
) )
.animation(.easeInOut, value: coordinator.musicLiveActivityEnabled) .animation(.easeInOut, value: coordinator.musicLiveActivityEnabled)
Toggle(
"Enable timer live activity",
isOn: $coordinator.timerLiveActivityEnabled
)
.animation(.easeInOut, value: coordinator.timerLiveActivityEnabled)
// Demo timer button
HStack {
Button("Start 5-min Demo Timer") {
TimerManager.shared.startDemoTimer(duration: 300)
}
.buttonStyle(.borderedProminent)
Spacer()
Button("Stop Timer") {
TimerManager.shared.stopTimer()
}
.buttonStyle(.bordered)
}
Toggle("Enable sneak peek", isOn: $enableSneakPeek) Toggle("Enable sneak peek", isOn: $enableSneakPeek)
Picker("Sneak Peek Style", selection: $sneakPeekStyles){ Picker("Sneak Peek Style", selection: $sneakPeekStyles){
ForEach(SneakPeekStyle.allCases) { style in ForEach(SneakPeekStyle.allCases) { style in
@ -618,6 +603,157 @@ struct CalendarSettings: View {
} }
} }
struct TimerSettings: View {
@ObservedObject private var coordinator = DynamicIslandViewCoordinator.shared
@Default(.enableTimerFeature) var enableTimerFeature
@AppStorage("customTimerDuration") private var customTimerDuration: Double = 600 // 10 minutes default
@State private var customMinutes: Int = 10
@State private var customSeconds: Int = 0
var body: some View {
Form {
Section {
Defaults.Toggle("Enable timer feature", key: .enableTimerFeature)
if enableTimerFeature {
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.")
}
if enableTimerFeature {
Section {
VStack(alignment: .leading, spacing: 16) {
Text("Custom Timer Duration")
.font(.headline)
HStack(spacing: 16) {
VStack {
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: 80, height: 40)
}
VStack {
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: 80, height: 40)
}
}
HStack {
Text("Current custom timer: \(customTimerDisplayText)")
.foregroundStyle(.secondary)
Spacer()
Button("Update") {
customTimerDuration = Double(customMinutes * 60 + customSeconds)
}
.disabled(customMinutes == 0 && customSeconds == 0)
}
}
} header: {
Text("Custom Timer")
} footer: {
Text("Set a custom duration for the timer. This will be used when you press the 'Custom' button in the timer interface.")
}
Section {
VStack(alignment: .leading, spacing: 8) {
HStack {
Text("Timer Sound")
.font(.system(size: 16, weight: .medium))
Spacer()
Button("Choose File") {
selectCustomTimerSound()
}
.buttonStyle(.bordered)
}
if let customTimerSoundPath = UserDefaults.standard.string(forKey: "customTimerSoundPath") {
Text("Custom: \(URL(fileURLWithPath: customTimerSoundPath).lastPathComponent)")
.font(.caption)
.foregroundColor(.secondary)
} else {
Text("Default: dynamic.m4a")
.font(.caption)
.foregroundColor(.secondary)
}
Button("Reset to Default") {
UserDefaults.standard.removeObject(forKey: "customTimerSoundPath")
}
.buttonStyle(.bordered)
.disabled(UserDefaults.standard.string(forKey: "customTimerSoundPath") == nil)
}
} header: {
Text("Timer Sound")
} footer: {
Text("Choose a custom sound file that will play when the timer completes. Supported formats: MP3, M4A, WAV, AIFF.")
}
}
}
.navigationTitle("Timer")
.onAppear {
let totalMinutes = Int(customTimerDuration) / 60
customMinutes = totalMinutes
customSeconds = Int(customTimerDuration) % 60
}
}
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 selectCustomTimerSound() {
let panel = NSOpenPanel()
panel.title = "Select Timer Sound"
panel.allowedContentTypes = [.audio]
panel.allowsMultipleSelection = false
panel.canChooseDirectories = false
panel.canChooseFiles = true
if panel.runModal() == .OK {
if let url = panel.url {
UserDefaults.standard.set(url.path, forKey: "customTimerSoundPath")
}
}
}
}
struct About: View { struct About: View {
@State private var showBuildNumber: Bool = false @State private var showBuildNumber: Bool = false
let updaterController: SPUStandardUpdaterController let updaterController: SPUStandardUpdaterController

View file

@ -6,6 +6,7 @@
// Modified by Hariharan Mudaliar // Modified by Hariharan Mudaliar
import SwiftUI import SwiftUI
import Defaults
struct TabModel: Identifiable { struct TabModel: Identifiable {
let id = UUID() let id = UUID()
@ -14,19 +15,31 @@ struct TabModel: Identifiable {
let view: NotchViews let view: NotchViews
} }
let tabs = [
TabModel(label: "Home", icon: "house.fill", view: .home),
TabModel(label: "Shelf", icon: "tray.fill", view: .shelf),
TabModel(label: "Timer", icon: "timer", view: .timer),
TabModel(label: "Stats", icon: "chart.line.uptrend.xyaxis", view: .stats)
]
struct TabSelectionView: View { struct TabSelectionView: View {
@ObservedObject var coordinator = DynamicIslandViewCoordinator.shared @ObservedObject var coordinator = DynamicIslandViewCoordinator.shared
@Default(.enableTimerFeature) var enableTimerFeature
@Default(.enableStatsFeature) var enableStatsFeature
@Namespace var animation @Namespace var animation
var availableTabs: [TabModel] {
var tabs = [
TabModel(label: "Home", icon: "house.fill", view: .home),
TabModel(label: "Shelf", icon: "tray.fill", view: .shelf)
]
if enableTimerFeature {
tabs.append(TabModel(label: "Timer", icon: "timer", view: .timer))
}
if enableStatsFeature {
tabs.append(TabModel(label: "Stats", icon: "chart.line.uptrend.xyaxis", view: .stats))
}
return tabs
}
var body: some View { var body: some View {
HStack(spacing: 0) { HStack(spacing: 0) {
ForEach(tabs) { tab in ForEach(availableTabs) { tab in
TabButton(label: tab.label, icon: tab.icon, selected: coordinator.currentView == tab.view) { TabButton(label: tab.label, icon: tab.icon, selected: coordinator.currentView == tab.view) {
withAnimation(.smooth) { withAnimation(.smooth) {
coordinator.currentView = tab.view coordinator.currentView = tab.view

View file

@ -256,19 +256,37 @@ class TimerManager: ObservableObject {
} }
private func playTimerSound() { private func playTimerSound() {
// Play default macOS timer sound var soundURL: URL?
guard let soundURL = Bundle.main.url(forResource: "dynamic", withExtension: "m4a") else {
// Fallback to system sound // Check for custom timer sound first
let customTimerSoundPath = UserDefaults.standard.string(forKey: "customTimerSoundPath")
if let customPath = customTimerSoundPath, !customPath.isEmpty {
// Use custom sound file
soundURL = URL(fileURLWithPath: customPath)
// Verify the file exists
if !FileManager.default.fileExists(atPath: customPath) {
soundURL = nil
}
}
// Fall back to default sound if no custom sound or custom sound doesn't exist
if soundURL == nil {
soundURL = Bundle.main.url(forResource: "dynamic", withExtension: "m4a")
}
guard let finalSoundURL = soundURL else {
// Final fallback to system sound
NSSound.beep() NSSound.beep()
return return
} }
do { do {
soundPlayer = try AVAudioPlayer(contentsOf: soundURL) soundPlayer = try AVAudioPlayer(contentsOf: finalSoundURL)
soundPlayer?.numberOfLoops = -1 // Loop indefinitely soundPlayer?.numberOfLoops = -1 // Loop indefinitely
soundPlayer?.play() soundPlayer?.play()
} catch { } catch {
// Fallback to system sound // Fallback to system sound if there's an error playing the custom sound
NSSound.beep() NSSound.beep()
} }
} }

View file

@ -157,6 +157,9 @@ extension Defaults.Keys {
// MARK: Stats Feature // MARK: Stats Feature
static let enableStatsFeature = Key<Bool>("enableStatsFeature", default: false) static let enableStatsFeature = Key<Bool>("enableStatsFeature", default: false)
// MARK: Timer Feature
static let enableTimerFeature = Key<Bool>("enableTimerFeature", default: true)
// MARK: Clipboard Feature // MARK: Clipboard Feature
static let enableClipboardManager = Key<Bool>("enableClipboardManager", default: true) static let enableClipboardManager = Key<Bool>("enableClipboardManager", default: true)
static let clipboardHistorySize = Key<Int>("clipboardHistorySize", default: 3) static let clipboardHistorySize = Key<Int>("clipboardHistorySize", default: 3)