diff --git a/DynamicIsland/Localizable.xcstrings b/DynamicIsland/Localizable.xcstrings index 19538a6..2bfc34f 100644 --- a/DynamicIsland/Localizable.xcstrings +++ b/DynamicIsland/Localizable.xcstrings @@ -250,9 +250,15 @@ }, "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 File" : { + }, "Circle" : { @@ -299,12 +305,18 @@ }, "CPU Usage" : { + }, + "Current custom timer: %@" : { + }, "Current History" : { }, "Current Items" : { + }, + "Custom" : { + }, "Custom color" : { "extractionState" : "stale", @@ -325,9 +337,18 @@ }, "Custom notch size - %.0f" : { + }, + "Custom Timer" : { + + }, + "Custom Timer Duration" : { + }, "Custom vizualizers (Lottie)" : { + }, + "Custom: %@" : { + }, "Customize in Settings" : { @@ -335,7 +356,7 @@ "Default" : { }, - "Demo (10s)" : { + "Default: dynamic.m4a" : { }, "Description" : { @@ -428,12 +449,18 @@ }, "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 stats monitoring in Settings to view system performance data." : { + }, + "Enable timer feature in Settings to use this tab" : { + }, "Enable timer live activity" : { @@ -649,6 +676,9 @@ }, "Minimum hover duration" : { + }, + "Minutes" : { + }, "Mirror" : { @@ -824,6 +854,9 @@ }, "Remember last tab" : { + }, + "Reset to Default" : { + }, "Restart" : { @@ -847,6 +880,9 @@ } } } + }, + "Seconds" : { + }, "Select Calendars" : { @@ -859,6 +895,9 @@ }, "Selected animation" : { + }, + "Set a custom duration for the timer. This will be used when you press the 'Custom' button in the timer interface." : { + }, "Settings" : { "localizations" : { @@ -956,9 +995,6 @@ }, "Start" : { - }, - "Start 5-min Demo Timer" : { - }, "Start Demo Timer:" : { @@ -980,9 +1016,6 @@ }, "Stop Monitoring" : { - }, - "Stop Timer" : { - }, "Stopped" : { @@ -1017,6 +1050,15 @@ }, "Timer" : { + }, + "Timer Disabled" : { + + }, + "Timer Feature" : { + + }, + "Timer Sound" : { + }, "Toggle Notch Open:" : { @@ -1038,6 +1080,9 @@ }, "unmuted" : { + }, + "Update" : { + }, "Upgrade to Pro" : { diff --git a/DynamicIsland/components/Notch/NotchTimerView.swift b/DynamicIsland/components/Notch/NotchTimerView.swift index 0a65b12..27db6f5 100644 --- a/DynamicIsland/components/Notch/NotchTimerView.swift +++ b/DynamicIsland/components/Notch/NotchTimerView.swift @@ -12,17 +12,39 @@ struct NotchTimerView: View { @EnvironmentObject var vm: DynamicIslandViewModel @ObservedObject var timerManager = TimerManager.shared @ObservedObject var coordinator = DynamicIslandViewCoordinator.shared + @Default(.enableTimerFeature) var enableTimerFeature + + @AppStorage("customTimerDuration") private var customTimerDuration: Double = 600 // 10 minutes default var body: some View { - HStack(alignment: .center, spacing: 32) { - // Timer Progress and Controls - timerProgressSection - - // Timer Control Panel - controlsSection + if enableTimerFeature { + HStack(alignment: .center, spacing: 32) { + // Timer Progress and Controls + timerProgressSection + + // 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 { @@ -49,8 +71,13 @@ struct NotchTimerView: View { Image(systemName: "timer") .font(.system(size: 32, weight: .medium)) .foregroundStyle(timerManager.currentColor) - .scaleEffect(timerManager.isRunning ? 1.1 : 1.0) - .animation(.easeInOut(duration: 0.3).repeatForever(autoreverses: true), value: timerManager.isRunning) + .scaleEffect(timerManager.isRunning && timerManager.isTimerActive ? 1.1 : 1.0) + .animation( + timerManager.isRunning && timerManager.isTimerActive ? + .easeInOut(duration: 0.3).repeatForever(autoreverses: true) : + .easeInOut(duration: 0.3), + value: timerManager.isRunning && timerManager.isTimerActive + ) } // Time display @@ -179,41 +206,59 @@ struct NotchTimerView: View { quickTimerButton(minutes: 15, title: "15 min") } - // Demo timer - Button(action: { - timerManager.startDemoTimer(duration: 10) // 10 second demo - }) { - HStack(spacing: 8) { - Image(systemName: "flask") - .font(.system(size: 14, weight: .medium)) - Text("Demo (10s)") - .font(.system(size: 16, weight: .medium)) + HStack(spacing: 12) { + // Custom timer button + Button(action: { + timerManager.startTimer(duration: customTimerDuration, name: "Custom Timer") + }) { + VStack(spacing: 4) { + Text(customTimerDisplayText) + .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) - .frame(height: 40) - .frame(minWidth: 120) - .background(.white.opacity(0.08)) - .clipShape(RoundedRectangle(cornerRadius: 12)) - .overlay( - RoundedRectangle(cornerRadius: 12) - .stroke(.white.opacity(0.1), lineWidth: 1) - ) - } - .buttonStyle(PlainButtonStyle()) - .onHover { isHovering in - if isHovering { - NSCursor.pointingHand.push() - } else { - NSCursor.pop() + .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 { Button(action: { - timerManager.startDemoTimer(duration: TimeInterval(minutes * 60)) + timerManager.startTimer(duration: TimeInterval(minutes * 60), name: "\(minutes) Min Timer") }) { VStack(spacing: 4) { Text("\(minutes)") diff --git a/DynamicIsland/components/Settings/SettingsView.swift b/DynamicIsland/components/Settings/SettingsView.swift index 9c2e710..9040fb2 100644 --- a/DynamicIsland/components/Settings/SettingsView.swift +++ b/DynamicIsland/components/Settings/SettingsView.swift @@ -14,6 +14,7 @@ import LottieUI import Sparkle import SwiftUI import SwiftUIIntrospect +import UniformTypeIdentifiers struct SettingsView: View { @StateObject var extensionManager = DynamicIslandExtensionManager() @@ -39,6 +40,9 @@ struct SettingsView: View { NavigationLink(value: "Media") { Label("Media", systemImage: "play.laptopcomputer") } + NavigationLink(value: "Timer") { + Label("Timer", systemImage: "timer") + } NavigationLink(value: "Calendar") { Label("Calendar", systemImage: "calendar") } @@ -88,6 +92,8 @@ struct SettingsView: View { Appearance() case "Media": Media() + case "Timer": + TimerSettings() case "Calendar": CalendarSettings() case "HUD": @@ -504,27 +510,6 @@ struct Media: View { ) .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) Picker("Sneak Peek Style", selection: $sneakPeekStyles){ 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 { @State private var showBuildNumber: Bool = false let updaterController: SPUStandardUpdaterController diff --git a/DynamicIsland/components/Tabs/TabSelectionView.swift b/DynamicIsland/components/Tabs/TabSelectionView.swift index f35dd7b..fbf0246 100644 --- a/DynamicIsland/components/Tabs/TabSelectionView.swift +++ b/DynamicIsland/components/Tabs/TabSelectionView.swift @@ -6,6 +6,7 @@ // Modified by Hariharan Mudaliar import SwiftUI +import Defaults struct TabModel: Identifiable { let id = UUID() @@ -14,19 +15,31 @@ struct TabModel: Identifiable { 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 { @ObservedObject var coordinator = DynamicIslandViewCoordinator.shared + @Default(.enableTimerFeature) var enableTimerFeature + @Default(.enableStatsFeature) var enableStatsFeature @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 { HStack(spacing: 0) { - ForEach(tabs) { tab in + ForEach(availableTabs) { tab in TabButton(label: tab.label, icon: tab.icon, selected: coordinator.currentView == tab.view) { withAnimation(.smooth) { coordinator.currentView = tab.view diff --git a/DynamicIsland/managers/TimerManager.swift b/DynamicIsland/managers/TimerManager.swift index 1901bff..0921a9d 100644 --- a/DynamicIsland/managers/TimerManager.swift +++ b/DynamicIsland/managers/TimerManager.swift @@ -256,19 +256,37 @@ class TimerManager: ObservableObject { } private func playTimerSound() { - // Play default macOS timer sound - guard let soundURL = Bundle.main.url(forResource: "dynamic", withExtension: "m4a") else { - // Fallback to system sound + var soundURL: URL? + + // 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() return } do { - soundPlayer = try AVAudioPlayer(contentsOf: soundURL) + soundPlayer = try AVAudioPlayer(contentsOf: finalSoundURL) soundPlayer?.numberOfLoops = -1 // Loop indefinitely soundPlayer?.play() } catch { - // Fallback to system sound + // Fallback to system sound if there's an error playing the custom sound NSSound.beep() } } diff --git a/DynamicIsland/models/Constants.swift b/DynamicIsland/models/Constants.swift index e04f7e5..e2ac275 100644 --- a/DynamicIsland/models/Constants.swift +++ b/DynamicIsland/models/Constants.swift @@ -157,6 +157,9 @@ extension Defaults.Keys { // MARK: Stats Feature static let enableStatsFeature = Key("enableStatsFeature", default: false) + // MARK: Timer Feature + static let enableTimerFeature = Key("enableTimerFeature", default: true) + // MARK: Clipboard Feature static let enableClipboardManager = Key("enableClipboardManager", default: true) static let clipboardHistorySize = Key("clipboardHistorySize", default: 3)