Merge pull request #1 from Ebullioscopic/feature/timer

Working Timer, need to enable fulscreen mode
This commit is contained in:
Hariharan Mudaliar 2025-07-14 08:51:51 +05:30 • committed by GitHub
commit 87a9da5c51
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
13 changed files with 937 additions and 7 deletions

View file

@ -19,7 +19,9 @@ struct ContentView: View {
@ObservedObject var coordinator = DynamicIslandViewCoordinator.shared @ObservedObject var coordinator = DynamicIslandViewCoordinator.shared
@ObservedObject var musicManager = MusicManager.shared @ObservedObject var musicManager = MusicManager.shared
@ObservedObject var timerManager = TimerManager.shared
@ObservedObject var batteryModel = BatteryStatusViewModel.shared @ObservedObject var batteryModel = BatteryStatusViewModel.shared
@State private var isHovering: Bool = false @State private var isHovering: Bool = false
@State private var hoverWorkItem: DispatchWorkItem? @State private var hoverWorkItem: DispatchWorkItem?
@ -212,6 +214,8 @@ struct ContentView: View {
.transition(.opacity) .transition(.opacity)
} else if (!coordinator.expandingView.show || coordinator.expandingView.type == .music) && vm.notchState == .closed && (musicManager.isPlaying || !musicManager.isPlayerIdle) && coordinator.musicLiveActivityEnabled && !vm.hideOnClosed { } else if (!coordinator.expandingView.show || coordinator.expandingView.type == .music) && vm.notchState == .closed && (musicManager.isPlaying || !musicManager.isPlayerIdle) && coordinator.musicLiveActivityEnabled && !vm.hideOnClosed {
MusicLiveActivity() 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 { } else if !coordinator.expandingView.show && vm.notchState == .closed && (!musicManager.isPlaying && musicManager.isPlayerIdle) && Defaults[.showNotHumanFace] && !vm.hideOnClosed {
DynamicIslandFaceAnimation().animation(.interactiveSpring, value: musicManager.isPlayerIdle) DynamicIslandFaceAnimation().animation(.interactiveSpring, value: musicManager.isPlayerIdle)
} else if vm.notchState == .open { } else if vm.notchState == .open {
@ -224,7 +228,7 @@ struct ContentView: View {
} }
if coordinator.sneakPeek.show { 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 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) .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 view
.fixedSize() .fixedSize()
} }
@ -261,6 +278,8 @@ struct ContentView: View {
NotchHomeView(albumArtNamespace: albumArtNamespace) NotchHomeView(albumArtNamespace: albumArtNamespace)
case .shelf: case .shelf:
NotchShelfView() NotchShelfView()
case .timer:
NotchTimerView()
} }
} }
} }
@ -323,10 +342,25 @@ struct ContentView: View {
.truncationMode(.tail) .truncationMode(.tail)
.foregroundStyle(Defaults[.coloredSpectrogram] ? Color(nsColor: musicManager.avgColor) : Color.gray) .foregroundStyle(Defaults[.coloredSpectrogram] ? Color(nsColor: musicManager.avgColor) : Color.gray)
.opacity((coordinator.expandingView.show && coordinator.expandingView.type == .music && Defaults[.enableSneakPeek] && Defaults[.sneakPeekStyles] == .inline) ? 1 : 0) .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 { HStack {
@ -380,6 +414,11 @@ struct ContentView: View {
} }
private func doOpen() { private func doOpen() {
// Switch to timer tab if timer is active
if timerManager.isTimerActive {
coordinator.currentView = .timer
}
withAnimation(.bouncy.speed(1.2)) { withAnimation(.bouncy.speed(1.2)) {
vm.open() vm.open()
} }

View file

@ -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] { if !Defaults[.showOnAllDisplays] {
let viewModel = self.vm let viewModel = self.vm
let window = createDynamicIslandWindow( let window = createDynamicIslandWindow(

View file

@ -18,6 +18,7 @@ enum SneakContentType {
case mic case mic
case battery case battery
case download case download
case timer
} }
struct sneakPeek { struct sneakPeek {
@ -58,6 +59,7 @@ class DynamicIslandViewCoordinator: ObservableObject {
@AppStorage("firstLaunch") var firstLaunch: Bool = true @AppStorage("firstLaunch") var firstLaunch: Bool = true
@AppStorage("showWhatsNew") var showWhatsNew: Bool = true @AppStorage("showWhatsNew") var showWhatsNew: Bool = true
@AppStorage("musicLiveActivityEnabled") var musicLiveActivityEnabled: Bool = true @AppStorage("musicLiveActivityEnabled") var musicLiveActivityEnabled: Bool = true
@AppStorage("timerLiveActivityEnabled") var timerLiveActivityEnabled: Bool = true
@AppStorage("currentMicStatus") var currentMicStatus: Bool = true @AppStorage("currentMicStatus") var currentMicStatus: Bool = true
@AppStorage("alwaysShowTabs") var alwaysShowTabs: Bool = true { @AppStorage("alwaysShowTabs") var alwaysShowTabs: Bool = true {
@ -109,7 +111,7 @@ class DynamicIslandViewCoordinator: ObservableObject {
@objc func sneakPeekEvent(_ notification: Notification) { @objc func sneakPeekEvent(_ notification: Notification) {
let decoder = JSONDecoder() let decoder = JSONDecoder()
if let decodedData = try? decoder.decode(SharedSneakPeek.self, from: notification.userInfo?.first?.value as! Data) { 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 value = CGFloat((NumberFormatter().number(from: decodedData.value) ?? 0.0).floatValue)
let icon = decodedData.icon 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 = "") { func toggleSneakPeek(status: Bool, type: SneakContentType, duration: TimeInterval = 1.5, value: CGFloat = 0, icon: String = "") {
sneakPeekDuration = duration sneakPeekDuration = duration
if type != .music { if type != .music && type != .timer {
// close() // close()
if !hudReplacement { if !hudReplacement {
return return

View file

@ -79,6 +79,9 @@
}, },
"Activation" : { "Activation" : {
},
"Active" : {
}, },
"Add" : { "Add" : {
@ -283,6 +286,9 @@
}, },
"Default" : { "Default" : {
},
"Demo (10s)" : {
}, },
"Description" : { "Description" : {
@ -377,6 +383,9 @@
}, },
"Enable sneak peek" : { "Enable sneak peek" : {
},
"Enable timer live activity" : {
}, },
"Enhance your experience with HUDs" : { "Enhance your experience with HUDs" : {
@ -568,6 +577,9 @@
}, },
"Mic %@" : { "Mic %@" : {
},
"min" : {
}, },
"Minimum hover duration" : { "Minimum hover duration" : {
@ -665,6 +677,12 @@
}, },
"Open System Settings" : { "Open System Settings" : {
},
"Pause" : {
},
"Paused" : {
}, },
"Percentage" : { "Percentage" : {
"localizations" : { "localizations" : {
@ -734,6 +752,9 @@
}, },
"Restart Dynamic Island" : { "Restart Dynamic Island" : {
},
"Resume" : {
}, },
"Running" : { "Running" : {
@ -854,6 +875,21 @@
}, },
"Square" : { "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" : { "Stopped" : {
@ -876,6 +912,9 @@
}, },
"Time to Full Charge: %lld min" : { "Time to Full Charge: %lld min" : {
},
"Timer" : {
}, },
"Toggle Notch Open:" : { "Toggle Notch Open:" : {

View file

@ -15,4 +15,5 @@ extension KeyboardShortcuts.Name {
static let increaseBacklight = Self("increaseBacklight", default: .init(.f2, modifiers: [.command])) static let increaseBacklight = Self("increaseBacklight", default: .init(.f2, modifiers: [.command]))
static let toggleSneakPeek = Self("toggleSneakPeek", default: .init(.h, modifiers: [.command, .shift])) static let toggleSneakPeek = Self("toggleSneakPeek", default: .init(.h, modifiers: [.command, .shift]))
static let toggleNotchOpen = Self("toggleNotchOpen", default: .init(.i, 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]))
} }

View file

@ -46,6 +46,11 @@ struct InlineHUD: View {
.symbolVariant(value > 0 ? .none : .slash) .symbolVariant(value > 0 ? .none : .slash)
.contentTransition(.interpolate) .contentTransition(.interpolate)
.frame(width: 20, height: 15, alignment: .center) .frame(width: 20, height: 15, alignment: .center)
case .timer:
Image(systemName: "timer")
.symbolRenderingMode(.hierarchical)
.contentTransition(.interpolate)
.frame(width: 20, height: 15, alignment: .center)
default: default:
EmptyView() EmptyView()
} }
@ -75,6 +80,14 @@ struct InlineHUD: View {
.multilineTextAlignment(.trailing) .multilineTextAlignment(.trailing)
.frame(maxWidth: .infinity, alignment: .trailing) .frame(maxWidth: .infinity, alignment: .trailing)
.contentTransition(.interpolate) .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 { } else {
HStack { HStack {
DraggableProgressBar(value: $value) DraggableProgressBar(value: $value)

View file

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

View file

@ -490,8 +490,31 @@ struct Media: View {
Section { Section {
Toggle( Toggle(
"Enable music live activity", "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) 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
@ -1105,6 +1128,16 @@ struct Shortcuts: View {
Section { Section {
KeyboardShortcuts.Recorder("Toggle Notch Open:", name: .toggleNotchOpen) 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") .navigationTitle("Shortcuts")
} }

View file

@ -16,7 +16,8 @@ struct TabModel: Identifiable {
let tabs = [ let tabs = [
TabModel(label: "Home", icon: "house.fill", view: .home), 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 { struct TabSelectionView: View {

View file

@ -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<TimerIconAnimation> {
hostingView.rootView = TimerIconAnimation()
}
}
}
#Preview {
TimerIconAnimation()
.frame(width: 50, height: 50)
.background(.black)
.onAppear {
// Start a demo timer for preview
TimerManager.shared.startDemoTimer(duration: 300)
}
}

View file

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

View file

@ -27,6 +27,7 @@ public enum NotchState {
public enum NotchViews { public enum NotchViews {
case home case home
case shelf case shelf
case timer
} }
enum SettingsEnum { enum SettingsEnum {

View file

@ -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<AnyCancellable>()
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)
}
}
}