mirror of
https://github.com/FBukovina/Atoll.git
synced 2026-10-27 15:14:33 +00:00
Merge pull request #1 from Ebullioscopic/feature/timer
Working Timer, need to enable fulscreen mode
This commit is contained in:
commit
87a9da5c51
13 changed files with 937 additions and 7 deletions
|
|
@ -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()
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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(
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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:" : {
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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]))
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
250
DynamicIsland/components/Notch/NotchTimerView.swift
Normal file
250
DynamicIsland/components/Notch/NotchTimerView.swift
Normal 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)
|
||||||
|
}
|
||||||
|
|
@ -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")
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
119
DynamicIsland/components/Timer/TimerIconAnimation.swift
Normal file
119
DynamicIsland/components/Timer/TimerIconAnimation.swift
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
106
DynamicIsland/components/Timer/TimerLiveActivity.swift
Normal file
106
DynamicIsland/components/Timer/TimerLiveActivity.swift
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
320
DynamicIsland/managers/TimerManager.swift
Normal file
320
DynamicIsland/managers/TimerManager.swift
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue