From 19b1977fa8dc986796ea112a31cc3326cf1a0aeb Mon Sep 17 00:00:00 2001 From: Hariharan Mudaliar Date: Mon, 14 Jul 2025 08:50:50 +0530 Subject: [PATCH] Working Timer, need to enable fulscreen mode --- DynamicIsland/ContentView.swift | 45 ++- DynamicIsland/DynamicIslandApp.swift | 6 + .../DynamicIslandViewCoordinator.swift | 6 +- DynamicIsland/Localizable.xcstrings | 39 +++ .../Shortcuts/ShortcutConstants.swift | 1 + .../Live activities/InlineHUD.swift | 13 + .../components/Notch/NotchTimerView.swift | 250 ++++++++++++++ .../components/Settings/SettingsView.swift | 35 +- .../components/Tabs/TabSelectionView.swift | 3 +- .../components/Timer/TimerIconAnimation.swift | 119 +++++++ .../components/Timer/TimerLiveActivity.swift | 106 ++++++ DynamicIsland/enums/generic.swift | 1 + DynamicIsland/managers/TimerManager.swift | 320 ++++++++++++++++++ 13 files changed, 937 insertions(+), 7 deletions(-) create mode 100644 DynamicIsland/components/Notch/NotchTimerView.swift create mode 100644 DynamicIsland/components/Timer/TimerIconAnimation.swift create mode 100644 DynamicIsland/components/Timer/TimerLiveActivity.swift create mode 100644 DynamicIsland/managers/TimerManager.swift diff --git a/DynamicIsland/ContentView.swift b/DynamicIsland/ContentView.swift index fbb7861..ff587ec 100644 --- a/DynamicIsland/ContentView.swift +++ b/DynamicIsland/ContentView.swift @@ -19,7 +19,9 @@ struct ContentView: View { @ObservedObject var coordinator = DynamicIslandViewCoordinator.shared @ObservedObject var musicManager = MusicManager.shared + @ObservedObject var timerManager = TimerManager.shared @ObservedObject var batteryModel = BatteryStatusViewModel.shared + @State private var isHovering: Bool = false @State private var hoverWorkItem: DispatchWorkItem? @@ -212,6 +214,8 @@ struct ContentView: View { .transition(.opacity) } else if (!coordinator.expandingView.show || coordinator.expandingView.type == .music) && vm.notchState == .closed && (musicManager.isPlaying || !musicManager.isPlayerIdle) && coordinator.musicLiveActivityEnabled && !vm.hideOnClosed { MusicLiveActivity() + } else if (!coordinator.expandingView.show || coordinator.expandingView.type == .timer) && vm.notchState == .closed && timerManager.isTimerActive && coordinator.timerLiveActivityEnabled && !vm.hideOnClosed { + TimerLiveActivity() } else if !coordinator.expandingView.show && vm.notchState == .closed && (!musicManager.isPlaying && musicManager.isPlayerIdle) && Defaults[.showNotHumanFace] && !vm.hideOnClosed { DynamicIslandFaceAnimation().animation(.interactiveSpring, value: musicManager.isPlayerIdle) } else if vm.notchState == .open { @@ -224,7 +228,7 @@ struct ContentView: View { } if coordinator.sneakPeek.show { - if (coordinator.sneakPeek.type != .music) && (coordinator.sneakPeek.type != .battery) && !Defaults[.inlineHUD] { + if (coordinator.sneakPeek.type != .music) && (coordinator.sneakPeek.type != .battery) && (coordinator.sneakPeek.type != .timer) && !Defaults[.inlineHUD] { SystemEventIndicatorModifier(eventType: $coordinator.sneakPeek.type, value: $coordinator.sneakPeek.value, icon: $coordinator.sneakPeek.icon, sendEventBack: { _ in // }) @@ -245,10 +249,23 @@ struct ContentView: View { .padding(.bottom, 10) } } + // Timer sneak peek + else if coordinator.sneakPeek.type == .timer { + if !vm.hideOnClosed && Defaults[.sneakPeekStyles] == .standard { + HStack(alignment: .center) { + Image(systemName: "timer") + GeometryReader { geo in + MarqueeText(.constant(timerManager.timerName + " - " + timerManager.formattedRemainingTime()), textColor: timerManager.timerColor, minDuration: 1, frameWidth: geo.size.width) + } + } + .foregroundStyle(timerManager.timerColor) + .padding(.bottom, 10) + } + } } } } - .conditionalModifier((coordinator.sneakPeek.show && (coordinator.sneakPeek.type == .music) && vm.notchState == .closed && !vm.hideOnClosed && Defaults[.sneakPeekStyles] == .standard) || (coordinator.sneakPeek.show && (coordinator.sneakPeek.type != .music) && (vm.notchState == .closed))) { view in + .conditionalModifier((coordinator.sneakPeek.show && coordinator.sneakPeek.type == .music && vm.notchState == .closed && !vm.hideOnClosed && Defaults[.sneakPeekStyles] == .standard) || (coordinator.sneakPeek.show && coordinator.sneakPeek.type == .timer && !vm.hideOnClosed && Defaults[.sneakPeekStyles] == .standard) || (coordinator.sneakPeek.show && (coordinator.sneakPeek.type != .music && coordinator.sneakPeek.type != .timer) && (vm.notchState == .closed))) { view in view .fixedSize() } @@ -261,6 +278,8 @@ struct ContentView: View { NotchHomeView(albumArtNamespace: albumArtNamespace) case .shelf: NotchShelfView() + case .timer: + NotchTimerView() } } } @@ -323,10 +342,25 @@ struct ContentView: View { .truncationMode(.tail) .foregroundStyle(Defaults[.coloredSpectrogram] ? Color(nsColor: musicManager.avgColor) : Color.gray) .opacity((coordinator.expandingView.show && coordinator.expandingView.type == .music && Defaults[.enableSneakPeek] && Defaults[.sneakPeekStyles] == .inline) ? 1 : 0) + } else if(coordinator.expandingView.show && coordinator.expandingView.type == .timer) { + MarqueeText( + .constant(timerManager.timerName), + textColor: timerManager.timerColor, + minDuration: 0.4, + frameWidth: 100 + ) + .opacity((coordinator.expandingView.show && Defaults[.enableSneakPeek] && Defaults[.sneakPeekStyles] == .inline) ? 1 : 0) + Spacer(minLength: vm.closedNotchSize.width) + // Timer Status + Text(timerManager.formattedRemainingTime()) + .lineLimit(1) + .truncationMode(.tail) + .foregroundStyle(timerManager.timerColor) + .opacity((coordinator.expandingView.show && coordinator.expandingView.type == .timer && Defaults[.enableSneakPeek] && Defaults[.sneakPeekStyles] == .inline) ? 1 : 0) } } ) - .frame(width: (coordinator.expandingView.show && coordinator.expandingView.type == .music && Defaults[.enableSneakPeek] && Defaults[.sneakPeekStyles] == .inline) ? 380 : vm.closedNotchSize.width + (isHovering ? 8 : 0)) + .frame(width: (coordinator.expandingView.show && (coordinator.expandingView.type == .music || coordinator.expandingView.type == .timer) && Defaults[.enableSneakPeek] && Defaults[.sneakPeekStyles] == .inline) ? 380 : vm.closedNotchSize.width + (isHovering ? 8 : 0)) HStack { @@ -380,6 +414,11 @@ struct ContentView: View { } private func doOpen() { + // Switch to timer tab if timer is active + if timerManager.isTimerActive { + coordinator.currentView = .timer + } + withAnimation(.bouncy.speed(1.2)) { vm.open() } diff --git a/DynamicIsland/DynamicIslandApp.swift b/DynamicIsland/DynamicIslandApp.swift index 3e7cae2..40db3ca 100644 --- a/DynamicIsland/DynamicIslandApp.swift +++ b/DynamicIsland/DynamicIslandApp.swift @@ -262,6 +262,12 @@ class AppDelegate: NSObject, NSApplicationDelegate { } } + KeyboardShortcuts.onKeyDown(for: .startDemoTimer) { [weak self] in + guard let self = self else { return } + // Start a 5-minute demo timer + TimerManager.shared.startDemoTimer(duration: 300) + } + if !Defaults[.showOnAllDisplays] { let viewModel = self.vm let window = createDynamicIslandWindow( diff --git a/DynamicIsland/DynamicIslandViewCoordinator.swift b/DynamicIsland/DynamicIslandViewCoordinator.swift index 8e5ace5..420eedc 100644 --- a/DynamicIsland/DynamicIslandViewCoordinator.swift +++ b/DynamicIsland/DynamicIslandViewCoordinator.swift @@ -18,6 +18,7 @@ enum SneakContentType { case mic case battery case download + case timer } struct sneakPeek { @@ -58,6 +59,7 @@ class DynamicIslandViewCoordinator: ObservableObject { @AppStorage("firstLaunch") var firstLaunch: Bool = true @AppStorage("showWhatsNew") var showWhatsNew: Bool = true @AppStorage("musicLiveActivityEnabled") var musicLiveActivityEnabled: Bool = true + @AppStorage("timerLiveActivityEnabled") var timerLiveActivityEnabled: Bool = true @AppStorage("currentMicStatus") var currentMicStatus: Bool = true @AppStorage("alwaysShowTabs") var alwaysShowTabs: Bool = true { @@ -109,7 +111,7 @@ class DynamicIslandViewCoordinator: ObservableObject { @objc func sneakPeekEvent(_ notification: Notification) { let decoder = JSONDecoder() if let decodedData = try? decoder.decode(SharedSneakPeek.self, from: notification.userInfo?.first?.value as! Data) { - let contentType = decodedData.type == "brightness" ? SneakContentType.brightness : decodedData.type == "volume" ? SneakContentType.volume : decodedData.type == "backlight" ? SneakContentType.backlight : decodedData.type == "mic" ? SneakContentType.mic : SneakContentType.brightness + let contentType = decodedData.type == "brightness" ? SneakContentType.brightness : decodedData.type == "volume" ? SneakContentType.volume : decodedData.type == "backlight" ? SneakContentType.backlight : decodedData.type == "mic" ? SneakContentType.mic : decodedData.type == "timer" ? SneakContentType.timer : SneakContentType.brightness let value = CGFloat((NumberFormatter().number(from: decodedData.value) ?? 0.0).floatValue) let icon = decodedData.icon @@ -125,7 +127,7 @@ class DynamicIslandViewCoordinator: ObservableObject { func toggleSneakPeek(status: Bool, type: SneakContentType, duration: TimeInterval = 1.5, value: CGFloat = 0, icon: String = "") { sneakPeekDuration = duration - if type != .music { + if type != .music && type != .timer { // close() if !hudReplacement { return diff --git a/DynamicIsland/Localizable.xcstrings b/DynamicIsland/Localizable.xcstrings index 4404e98..19358cf 100644 --- a/DynamicIsland/Localizable.xcstrings +++ b/DynamicIsland/Localizable.xcstrings @@ -79,6 +79,9 @@ }, "Activation" : { + }, + "Active" : { + }, "Add" : { @@ -283,6 +286,9 @@ }, "Default" : { + }, + "Demo (10s)" : { + }, "Description" : { @@ -377,6 +383,9 @@ }, "Enable sneak peek" : { + }, + "Enable timer live activity" : { + }, "Enhance your experience with HUDs" : { @@ -568,6 +577,9 @@ }, "Mic %@" : { + }, + "min" : { + }, "Minimum hover duration" : { @@ -665,6 +677,12 @@ }, "Open System Settings" : { + }, + "Pause" : { + + }, + "Paused" : { + }, "Percentage" : { "localizations" : { @@ -734,6 +752,9 @@ }, "Restart Dynamic Island" : { + }, + "Resume" : { + }, "Running" : { @@ -854,6 +875,21 @@ }, "Square" : { + }, + "Start 5-min Demo Timer" : { + + }, + "Start Demo Timer:" : { + + }, + "Starts a 5-minute demo timer to test the timer live activity feature." : { + + }, + "Stop" : { + + }, + "Stop Timer" : { + }, "Stopped" : { @@ -876,6 +912,9 @@ }, "Time to Full Charge: %lld min" : { + }, + "Timer" : { + }, "Toggle Notch Open:" : { diff --git a/DynamicIsland/Shortcuts/ShortcutConstants.swift b/DynamicIsland/Shortcuts/ShortcutConstants.swift index dd83fee..0f765ea 100644 --- a/DynamicIsland/Shortcuts/ShortcutConstants.swift +++ b/DynamicIsland/Shortcuts/ShortcutConstants.swift @@ -15,4 +15,5 @@ extension KeyboardShortcuts.Name { static let increaseBacklight = Self("increaseBacklight", default: .init(.f2, modifiers: [.command])) static let toggleSneakPeek = Self("toggleSneakPeek", default: .init(.h, modifiers: [.command, .shift])) static let toggleNotchOpen = Self("toggleNotchOpen", default: .init(.i, modifiers: [.command, .shift])) + static let startDemoTimer = Self("startDemoTimer", default: .init(.t, modifiers: [.command, .shift])) } diff --git a/DynamicIsland/components/Live activities/InlineHUD.swift b/DynamicIsland/components/Live activities/InlineHUD.swift index 9bca7ad..f41e469 100644 --- a/DynamicIsland/components/Live activities/InlineHUD.swift +++ b/DynamicIsland/components/Live activities/InlineHUD.swift @@ -46,6 +46,11 @@ struct InlineHUD: View { .symbolVariant(value > 0 ? .none : .slash) .contentTransition(.interpolate) .frame(width: 20, height: 15, alignment: .center) + case .timer: + Image(systemName: "timer") + .symbolRenderingMode(.hierarchical) + .contentTransition(.interpolate) + .frame(width: 20, height: 15, alignment: .center) default: EmptyView() } @@ -75,6 +80,14 @@ struct InlineHUD: View { .multilineTextAlignment(.trailing) .frame(maxWidth: .infinity, alignment: .trailing) .contentTransition(.interpolate) + } else if (type == .timer) { + Text(TimerManager.shared.formattedRemainingTime()) + .foregroundStyle(TimerManager.shared.timerColor) + .lineLimit(1) + .allowsTightening(true) + .multilineTextAlignment(.trailing) + .frame(maxWidth: .infinity, alignment: .trailing) + .contentTransition(.interpolate) } else { HStack { DraggableProgressBar(value: $value) diff --git a/DynamicIsland/components/Notch/NotchTimerView.swift b/DynamicIsland/components/Notch/NotchTimerView.swift new file mode 100644 index 0000000..0a65b12 --- /dev/null +++ b/DynamicIsland/components/Notch/NotchTimerView.swift @@ -0,0 +1,250 @@ +// +// NotchTimerView.swift +// DynamicIsland +// +// Timer tab interface for the Dynamic Island +// + +import SwiftUI +import Defaults + +struct NotchTimerView: View { + @EnvironmentObject var vm: DynamicIslandViewModel + @ObservedObject var timerManager = TimerManager.shared + @ObservedObject var coordinator = DynamicIslandViewCoordinator.shared + + var body: some View { + HStack(alignment: .center, spacing: 32) { + // Timer Progress and Controls + timerProgressSection + + // Timer Control Panel + controlsSection + } + .padding(.horizontal, 20) + .transition(.opacity.combined(with: .blurReplace)) + } + + private var timerProgressSection: some View { + VStack(spacing: 16) { + // Circular progress display + ZStack { + // Background ring + Circle() + .stroke(.white.opacity(0.1), lineWidth: 8) + .frame(width: 100, height: 100) + + // Progress ring + Circle() + .trim(from: 0, to: timerManager.progress) + .stroke( + timerManager.currentColor, + style: StrokeStyle(lineWidth: 8, lineCap: .round) + ) + .frame(width: 100, height: 100) + .rotationEffect(.degrees(-90)) + .animation(.easeInOut(duration: 0.5), value: timerManager.progress) + + // Timer icon in center + 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) + } + + // Time display + VStack(spacing: 4) { + Text(timerManager.formattedTimeRemaining) + .font(.system(size: 24, weight: .semibold, design: .monospaced)) + .foregroundStyle(timerManager.isOvertime ? .red : .white) + + if timerManager.isTimerActive && timerManager.isPaused { + Text("Paused") + .font(.system(size: 14, weight: .medium)) + .foregroundStyle(.secondary) + } + } + } + } + + private var controlsSection: some View { + VStack(spacing: 20) { + if timerManager.isTimerActive { + // Active timer controls + activeTimerControls + } else { + // Quick timer setup + quickTimerControls + } + } + } + + private var activeTimerControls: some View { + VStack(spacing: 16) { + if timerManager.isOvertime { + // Overtime controls - only show stop button + Button(action: { + timerManager.forceStopTimer() + }) { + HStack(spacing: 8) { + Image(systemName: "stop.fill") + .font(.system(size: 16, weight: .medium)) + .foregroundStyle(.red) + Text("Stop") + .font(.system(size: 16, weight: .medium)) + .foregroundStyle(.red) + } + .frame(height: 40) + .frame(minWidth: 100) + .background(.white.opacity(0.15)) + .clipShape(RoundedRectangle(cornerRadius: 12)) + } + .buttonStyle(PlainButtonStyle()) + .onHover { isHovering in + if isHovering { + NSCursor.pointingHand.push() + } else { + NSCursor.pop() + } + } + } else { + // Normal timer controls + HStack(spacing: 16) { + // Pause/Resume button + Button(action: { + if timerManager.isPaused { + timerManager.resumeTimer() + } else { + timerManager.pauseTimer() + } + }) { + HStack(spacing: 8) { + Image(systemName: timerManager.isPaused ? "play.fill" : "pause.fill") + .font(.system(size: 16, weight: .medium)) + Text(timerManager.isPaused ? "Resume" : "Pause") + .font(.system(size: 16, weight: .medium)) + } + .foregroundStyle(.white) + .frame(height: 40) + .frame(minWidth: 100) + .background(.white.opacity(0.1)) + .clipShape(RoundedRectangle(cornerRadius: 12)) + } + .buttonStyle(PlainButtonStyle()) + .onHover { isHovering in + if isHovering { + NSCursor.pointingHand.push() + } else { + NSCursor.pop() + } + } + + // Stop button + Button(action: { + timerManager.stopTimer() + }) { + HStack(spacing: 8) { + Image(systemName: "stop.fill") + .font(.system(size: 16, weight: .medium)) + Text("Stop") + .font(.system(size: 16, weight: .medium)) + } + .foregroundStyle(.white) + .frame(height: 40) + .frame(minWidth: 100) + .background(.white.opacity(0.15)) + .clipShape(RoundedRectangle(cornerRadius: 12)) + } + .buttonStyle(PlainButtonStyle()) + .onHover { isHovering in + if isHovering { + NSCursor.pointingHand.push() + } else { + NSCursor.pop() + } + } + } + } + } + } + + private var quickTimerControls: some View { + VStack(spacing: 16) { + // Quick timer grid + VStack(spacing: 12) { + HStack(spacing: 12) { + quickTimerButton(minutes: 1, title: "1 min") + quickTimerButton(minutes: 5, title: "5 min") + 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)) + } + .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() + } + } + } + } + } + + private func quickTimerButton(minutes: Int, title: String) -> some View { + Button(action: { + timerManager.startDemoTimer(duration: TimeInterval(minutes * 60)) + }) { + VStack(spacing: 4) { + Text("\(minutes)") + .font(.system(size: 22, weight: .bold)) + .foregroundStyle(.white) + Text("min") + .font(.system(size: 12, weight: .medium)) + .foregroundStyle(.secondary) + } + .frame(width: 80, height: 60) + .background(.white.opacity(0.1)) + .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() + } + } + } +} + +#Preview { + NotchTimerView() + .environmentObject(DynamicIslandViewModel()) + .frame(width: 500, height: 200) + .background(.black) +} diff --git a/DynamicIsland/components/Settings/SettingsView.swift b/DynamicIsland/components/Settings/SettingsView.swift index a45fbbd..74a360e 100644 --- a/DynamicIsland/components/Settings/SettingsView.swift +++ b/DynamicIsland/components/Settings/SettingsView.swift @@ -490,8 +490,31 @@ struct Media: View { Section { Toggle( "Enable music live activity", - isOn: $coordinator.musicLiveActivityEnabled.animation() + isOn: $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) Picker("Sneak Peek Style", selection: $sneakPeekStyles){ ForEach(SneakPeekStyle.allCases) { style in @@ -1105,6 +1128,16 @@ struct Shortcuts: View { Section { KeyboardShortcuts.Recorder("Toggle Notch Open:", name: .toggleNotchOpen) } + Section { + KeyboardShortcuts.Recorder("Start Demo Timer:", name: .startDemoTimer) + } header: { + Text("Timer") + } footer: { + Text("Starts a 5-minute demo timer to test the timer live activity feature.") + .multilineTextAlignment(.trailing) + .foregroundStyle(.secondary) + .font(.caption) + } } .navigationTitle("Shortcuts") } diff --git a/DynamicIsland/components/Tabs/TabSelectionView.swift b/DynamicIsland/components/Tabs/TabSelectionView.swift index af4e22c..46b6f75 100644 --- a/DynamicIsland/components/Tabs/TabSelectionView.swift +++ b/DynamicIsland/components/Tabs/TabSelectionView.swift @@ -16,7 +16,8 @@ struct TabModel: Identifiable { let tabs = [ TabModel(label: "Home", icon: "house.fill", view: .home), - TabModel(label: "Shelf", icon: "tray.fill", view: .shelf) + TabModel(label: "Shelf", icon: "tray.fill", view: .shelf), + TabModel(label: "Timer", icon: "timer", view: .timer) ] struct TabSelectionView: View { diff --git a/DynamicIsland/components/Timer/TimerIconAnimation.swift b/DynamicIsland/components/Timer/TimerIconAnimation.swift new file mode 100644 index 0000000..7f83cb7 --- /dev/null +++ b/DynamicIsland/components/Timer/TimerIconAnimation.swift @@ -0,0 +1,119 @@ +// +// TimerIconAnimation.swift +// DynamicIsland +// +// Created by GitHub Copilot on 2025-01-13. +// + +import SwiftUI +import Combine + +struct TimerIconAnimation: View { + @ObservedObject var timerManager = TimerManager.shared + @State private var pulseScale: CGFloat = 1.0 + @State private var rotationAngle: Double = 0 + + var body: some View { + ZStack { + // Background circle with timer color + Circle() + .fill(timerManager.timerColor.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 + } + } +} + +struct TimerIconView: NSViewRepresentable { + @ObservedObject var timerManager = TimerManager.shared + + func makeNSView(context: Context) -> NSView { + let hostingView = NSHostingView(rootView: TimerIconAnimation()) + hostingView.translatesAutoresizingMaskIntoConstraints = false + return hostingView + } + + func updateNSView(_ nsView: NSView, context: Context) { + if let hostingView = nsView as? NSHostingView { + hostingView.rootView = TimerIconAnimation() + } + } +} + +#Preview { + TimerIconAnimation() + .frame(width: 50, height: 50) + .background(.black) + .onAppear { + // Start a demo timer for preview + TimerManager.shared.startDemoTimer(duration: 300) + } +} diff --git a/DynamicIsland/components/Timer/TimerLiveActivity.swift b/DynamicIsland/components/Timer/TimerLiveActivity.swift new file mode 100644 index 0000000..6595402 --- /dev/null +++ b/DynamicIsland/components/Timer/TimerLiveActivity.swift @@ -0,0 +1,106 @@ +// +// TimerLiveActivity.swift +// DynamicIsland +// +// Created by GitHub Copilot on 2025-01-13. +// + +import SwiftUI +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 + + 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 + } + .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) + } + .frame(width: max(0, vm.effectiveClosedNotchHeight - (isHovering ? 0 : 12) + gestureProgress / 2), + height: max(0, vm.effectiveClosedNotchHeight - (isHovering ? 0 : 12))) + } + + private var timerInfoSection: some View { + Rectangle() + .fill(.black) + .overlay( + HStack(alignment: .center) { + if coordinator.expandingView.show && coordinator.expandingView.type == .timer { + // Timer name + MarqueeText( + .constant(timerManager.timerName), + textColor: timerManager.timerColor, + minDuration: 0.4, + frameWidth: 100 + ) + .opacity((coordinator.expandingView.show && Defaults[.enableSneakPeek] && Defaults[.sneakPeekStyles] == .inline) ? 1 : 0) + + Spacer(minLength: vm.closedNotchSize.width) + + // Timer status + Text(timerManager.isPaused ? "Paused" : "Active") + .lineLimit(1) + .truncationMode(.tail) + .foregroundStyle(timerManager.isOvertime ? .red : timerManager.timerColor) + .opacity((coordinator.expandingView.show && coordinator.expandingView.type == .timer && Defaults[.enableSneakPeek] && Defaults[.sneakPeekStyles] == .inline) ? 1 : 0) + } + } + ) + .frame(width: (coordinator.expandingView.show && coordinator.expandingView.type == .timer && Defaults[.enableSneakPeek] && Defaults[.sneakPeekStyles] == .inline) ? 380 : 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) + } + .frame(width: max(0, vm.effectiveClosedNotchHeight - (isHovering ? 0 : 12) + gestureProgress / 2), + height: max(0, vm.effectiveClosedNotchHeight - (isHovering ? 0 : 12)), alignment: .center) + } +} + +#Preview { + TimerLiveActivity() + .environmentObject(DynamicIslandViewModel()) + .frame(width: 300, height: 32) + .background(.black) + .onAppear { + // Start a demo timer for preview + TimerManager.shared.startDemoTimer(duration: 300) + } +} diff --git a/DynamicIsland/enums/generic.swift b/DynamicIsland/enums/generic.swift index 7cb065f..49bf069 100644 --- a/DynamicIsland/enums/generic.swift +++ b/DynamicIsland/enums/generic.swift @@ -27,6 +27,7 @@ public enum NotchState { public enum NotchViews { case home case shelf + case timer } enum SettingsEnum { diff --git a/DynamicIsland/managers/TimerManager.swift b/DynamicIsland/managers/TimerManager.swift new file mode 100644 index 0000000..1901bff --- /dev/null +++ b/DynamicIsland/managers/TimerManager.swift @@ -0,0 +1,320 @@ +// +// TimerManager.swift +// DynamicIsland +// +// Timer management for the Dynamic Island +// + +import Foundation +import Combine +import SwiftUI +import AVFoundation +import AppKit + +class TimerManager: ObservableObject { + // MARK: - Properties + static let shared = TimerManager() + + @Published var isTimerActive: Bool = false + @Published var timerName: String = "Timer" + @Published var totalDuration: TimeInterval = 0 + @Published var remainingTime: TimeInterval = 0 + @Published var elapsedTime: TimeInterval = 0 + @Published var isPaused: Bool = false + @Published var isFinished: Bool = false + @Published var isOvertime: Bool = false // Timer has gone past 0 and is counting negative + @Published var lastUpdated: Date = .distantPast + + // Timer progress (0.0 to 1.0, or >1.0 for overtime) + var progress: Double { + guard totalDuration > 0 else { return 0.0 } + if isOvertime { + // For overtime, progress goes beyond 1.0 + return 1.0 + min(1.0, abs(remainingTime) / totalDuration) + } else { + return min(1.0, max(0.0, elapsedTime / totalDuration)) + } + } + + // Color based on progress: green -> yellow -> red -> flashing red for overtime + var timerColor: Color { + if isOvertime { + // Flashing red for overtime + return .red + } + + let p = progress + if p < 0.6 { + // Green phase (0-60%) + return .green + } else if p < 0.9 { + // Yellow phase (60-90%) + let yellowProgress = (p - 0.6) / 0.3 + return Color( + red: 1.0 - (1.0 - yellowProgress) * 0.5, // Gradually more red + green: 1.0, + blue: 0.0 + ) + } else { + // Red phase (90-100%) + return .red + } + } + + // Computed properties for UI + var isRunning: Bool { + return isTimerActive && !isPaused + } + + var currentColor: Color { + return timerColor + } + + var formattedTimeRemaining: String { + return formattedRemainingTime() + } + + var statusText: String { + if isOvertime { + return "Overtime" + } else if isPaused { + return "Paused" + } else if isTimerActive { + return "Running" + } else { + return "Ready" + } + } + + // NSColor version for compatibility + var timerNSColor: NSColor { + if isOvertime { + return NSColor.systemRed + } + + let p = progress + if p < 0.6 { + return NSColor.systemGreen + } else if p < 0.9 { + let yellowProgress = (p - 0.6) / 0.3 + return NSColor( + red: 1.0 - (1.0 - yellowProgress) * 0.5, + green: 1.0, + blue: 0.0, + alpha: 1.0 + ) + } else { + return NSColor.systemRed + } + } + + private var timerInstance: Timer? + private var cancellables = Set() + private var soundPlayer: AVAudioPlayer? + + // MARK: - Initialization + private init() { + // Simple initialization + } + + deinit { + timerInstance?.invalidate() + soundPlayer?.stop() + cancellables.removeAll() + } + + // MARK: - Timer Methods + func startTimer(duration: TimeInterval, name: String = "Timer") { + // Stop any existing timer + timerInstance?.invalidate() + + // Start new timer + isTimerActive = true + isFinished = false + isOvertime = false + timerName = name + totalDuration = duration + remainingTime = duration + elapsedTime = 0 + isPaused = false + lastUpdated = Date() + + triggerTimerSneakPeek() + + // Start countdown timer + timerInstance = Timer.scheduledTimer(withTimeInterval: 1.0, repeats: true) { [weak self] _ in + guard let self = self else { return } + + Task { @MainActor in + if !self.isPaused { + if self.remainingTime > 0 { + // Normal countdown + self.remainingTime -= 1 + self.elapsedTime = self.totalDuration - self.remainingTime + self.lastUpdated = Date() + } else if self.remainingTime == 0 { + // Timer just finished - play sound and start overtime + self.isFinished = true + self.isOvertime = true + self.playTimerSound() + self.remainingTime = -1 + self.lastUpdated = Date() + } else { + // Overtime - count negative + self.remainingTime -= 1 + self.lastUpdated = Date() + } + } + } + } + } + + func startDemoTimer(duration: TimeInterval) { + startTimer(duration: duration, name: "Demo Timer") + } + + func stopTimer() { + timerInstance?.invalidate() + timerInstance = nil + soundPlayer?.stop() + + // Smooth close animation for live activity + if isTimerActive { + scheduleSmoothClose() + } + + resetTimer() + } + + func forceStopTimer() { + // Immediate stop for user action (stop button) + timerInstance?.invalidate() + timerInstance = nil + soundPlayer?.stop() + isTimerActive = false + resetTimer() + } + + func pauseTimer() { + guard isTimerActive && !isPaused else { return } + isPaused = true + timerInstance?.invalidate() + timerInstance = nil + } + + func resumeTimer() { + guard isTimerActive && isPaused else { return } + isPaused = false + lastUpdated = Date() + + // Resume countdown timer with same logic as start timer + timerInstance = Timer.scheduledTimer(withTimeInterval: 1.0, repeats: true) { [weak self] _ in + guard let self = self else { return } + + Task { @MainActor in + if !self.isPaused { + if self.remainingTime > 0 { + // Normal countdown + self.remainingTime -= 1 + self.elapsedTime = self.totalDuration - self.remainingTime + self.lastUpdated = Date() + } else if self.remainingTime == 0 { + // Timer just finished - play sound and start overtime + self.isFinished = true + self.isOvertime = true + self.playTimerSound() + self.remainingTime = -1 + self.lastUpdated = Date() + } else { + // Overtime - count negative + self.remainingTime -= 1 + self.lastUpdated = Date() + } + } + } + } + } + + private func resetTimer() { + isTimerActive = false + timerName = "Timer" + totalDuration = 0 + remainingTime = 0 + elapsedTime = 0 + isPaused = false + isFinished = false + isOvertime = false + } + + private func scheduleSmoothClose() { + // Wait 3 seconds then smoothly close the live activity + DispatchQueue.main.asyncAfter(deadline: .now() + 3.0) { + withAnimation(.easeInOut(duration: 1.0)) { + self.isTimerActive = false + } + } + } + + private func playTimerSound() { + // Play default macOS timer sound + guard let soundURL = Bundle.main.url(forResource: "dynamic", withExtension: "m4a") else { + // Fallback to system sound + NSSound.beep() + return + } + + do { + soundPlayer = try AVAudioPlayer(contentsOf: soundURL) + soundPlayer?.numberOfLoops = -1 // Loop indefinitely + soundPlayer?.play() + } catch { + // Fallback to system sound + NSSound.beep() + } + } + + 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 { + return "-" + timeString(from: abs(remainingTime)) + } else { + return timeString(from: remainingTime) + } + } + + func formattedElapsedTime() -> String { + return timeString(from: elapsedTime) + } + + func formattedTotalDuration() -> String { + return timeString(from: totalDuration) + } + + private func timeString(from seconds: TimeInterval) -> String { + let totalMinutes = Int(seconds) / 60 + let remainingSeconds = Int(seconds) % 60 + let hours = totalMinutes / 60 + let minutes = totalMinutes % 60 + + if hours > 0 { + return String(format: "%d:%02d:%02d", hours, minutes, remainingSeconds) + } else { + return String(format: "%d:%02d", minutes, remainingSeconds) + } + } +}