mirror of
https://github.com/FBukovina/Atoll.git
synced 2026-10-27 15:14:33 +00:00
commit
bd1f4cc6ed
17 changed files with 1761 additions and 261 deletions
|
|
@ -16,7 +16,7 @@
|
|||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "F54406E62CD4987A00C7AFD9"
|
||||
BuildableName = "DynamicIsland.app"
|
||||
BuildableName = "Atoll.app"
|
||||
BlueprintName = "DynamicIsland"
|
||||
ReferencedContainer = "container:DynamicIsland.xcodeproj">
|
||||
</BuildableReference>
|
||||
|
|
@ -45,7 +45,7 @@
|
|||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "F54406E62CD4987A00C7AFD9"
|
||||
BuildableName = "DynamicIsland.app"
|
||||
BuildableName = "Atoll.app"
|
||||
BlueprintName = "DynamicIsland"
|
||||
ReferencedContainer = "container:DynamicIsland.xcodeproj">
|
||||
</BuildableReference>
|
||||
|
|
@ -62,7 +62,7 @@
|
|||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "F54406E62CD4987A00C7AFD9"
|
||||
BuildableName = "DynamicIsland.app"
|
||||
BuildableName = "Atoll.app"
|
||||
BlueprintName = "DynamicIsland"
|
||||
ReferencedContainer = "container:DynamicIsland.xcodeproj">
|
||||
</BuildableReference>
|
||||
|
|
|
|||
|
|
@ -630,11 +630,6 @@ struct ContentView: View {
|
|||
|
||||
// MARK: - Private Methods
|
||||
private func doOpen() {
|
||||
// Switch to timer tab if timer is active
|
||||
if timerManager.isTimerActive {
|
||||
coordinator.currentView = .timer
|
||||
}
|
||||
|
||||
withAnimation(.bouncy.speed(1.2)) {
|
||||
vm.open()
|
||||
}
|
||||
|
|
@ -754,6 +749,7 @@ struct ContentView: View {
|
|||
vm.isClipboardPopoverActive ||
|
||||
vm.isColorPickerPopoverActive ||
|
||||
vm.isStatsPopoverActive ||
|
||||
vm.isTimerPopoverActive ||
|
||||
vm.isMediaOutputPopoverActive
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1636,6 +1636,10 @@
|
|||
}
|
||||
}
|
||||
},
|
||||
"Accent colour" : {
|
||||
"comment" : "A label describing the colour picker for the timer preset.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"Access Denied" : {
|
||||
"localizations" : {
|
||||
"ar" : {
|
||||
|
|
@ -2144,6 +2148,10 @@
|
|||
}
|
||||
}
|
||||
},
|
||||
"Add Preset" : {
|
||||
"comment" : "A button label that adds a new timer preset.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"Additional features" : {
|
||||
"localizations" : {
|
||||
"ar" : {
|
||||
|
|
@ -4677,6 +4685,7 @@
|
|||
}
|
||||
},
|
||||
"Choose a custom sound file that will play when the timer completes. Supported formats: MP3, M4A, WAV, AIFF." : {
|
||||
"extractionState" : "stale",
|
||||
"localizations" : {
|
||||
"fr" : {
|
||||
"stringUnit" : {
|
||||
|
|
@ -5410,6 +5419,13 @@
|
|||
}
|
||||
}
|
||||
},
|
||||
"Configure how the timer looks inside the closed notch. Progress can render as a ring around the icon or as horizontal bars." : {
|
||||
|
||||
},
|
||||
"Configure presets in Settings" : {
|
||||
"comment" : "A message displayed when there are no presets configured. It encourages the user to configure presets in the app settings.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"Connected" : {
|
||||
"extractionState" : "stale",
|
||||
"localizations" : {
|
||||
|
|
@ -5521,6 +5537,10 @@
|
|||
}
|
||||
}
|
||||
},
|
||||
"Control timer availability and whether progress appears as a live activity while the notch is closed." : {
|
||||
"comment" : "A footer description for the timer feature settings.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"Controls" : {
|
||||
"localizations" : {
|
||||
"fr" : {
|
||||
|
|
@ -5584,6 +5604,7 @@
|
|||
}
|
||||
},
|
||||
"Current custom timer:" : {
|
||||
"extractionState" : "stale",
|
||||
"localizations" : {
|
||||
"fr" : {
|
||||
"stringUnit" : {
|
||||
|
|
@ -5593,6 +5614,10 @@
|
|||
}
|
||||
}
|
||||
},
|
||||
"Current default:" : {
|
||||
"comment" : "A label displayed alongside the currently set default timer duration.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"Current History" : {
|
||||
"localizations" : {
|
||||
"fr" : {
|
||||
|
|
@ -5963,6 +5988,7 @@
|
|||
}
|
||||
},
|
||||
"Custom Timer Duration" : {
|
||||
"extractionState" : "stale",
|
||||
"localizations" : {
|
||||
"fr" : {
|
||||
"stringUnit" : {
|
||||
|
|
@ -6292,6 +6318,10 @@
|
|||
}
|
||||
}
|
||||
},
|
||||
"Default Custom Timer" : {
|
||||
"comment" : "A label displayed above the section that lets users configure a custom timer.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"Default: dynamic.m4a" : {
|
||||
"localizations" : {
|
||||
"fr" : {
|
||||
|
|
@ -7736,9 +7766,6 @@
|
|||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"Enable or disable the timer functionality in the Dynamic Island. The live activity toggle controls whether timer progress is shown in the expanded view." : {
|
||||
|
||||
},
|
||||
"Enable screen color picking functionality. Use Cmd+Shift+P to quickly access the color picker." : {
|
||||
|
||||
|
|
@ -12237,6 +12264,7 @@
|
|||
}
|
||||
},
|
||||
"Minutes" : {
|
||||
"extractionState" : "stale",
|
||||
"localizations" : {
|
||||
"fr" : {
|
||||
"stringUnit" : {
|
||||
|
|
@ -12569,6 +12597,14 @@
|
|||
}
|
||||
}
|
||||
},
|
||||
"Move Down" : {
|
||||
"comment" : "A button label that says \"Move Down\".",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"Move Up" : {
|
||||
"comment" : "A button label that says \"Move Up\".",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"Music Source" : {
|
||||
"localizations" : {
|
||||
"ar" : {
|
||||
|
|
@ -13588,6 +13624,10 @@
|
|||
"comment" : "A label displayed when no graphs are enabled in the Stats tab.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"No presets configured. Add a preset to make it appear in the timer popover." : {
|
||||
"comment" : "A message displayed when there are no timer presets configured, encouraging the user to add one.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"No process data yet. Keep stats open for a moment." : {
|
||||
"comment" : "A message displayed when there is no process data available to display in the CPU stats view. It encourages the user to keep the stats open for a moment to gather more data.",
|
||||
"isCommentAutoGenerated" : true
|
||||
|
|
@ -14560,6 +14600,18 @@
|
|||
}
|
||||
}
|
||||
},
|
||||
"Preset name" : {
|
||||
"comment" : "A label for the name of a timer preset.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"Presets" : {
|
||||
"comment" : "A header label for the list of timer presets.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"Presets show up inside the timer popover with the configured name, duration, and accent colour. Reorder them to change the display order." : {
|
||||
"comment" : "A footer for the \"Timer Presets\" section in the TimerSettings view, explaining how to manage and reorder timer presets.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"Pressure" : {
|
||||
"comment" : "A label describing the memory pressure status.",
|
||||
"isCommentAutoGenerated" : true,
|
||||
|
|
@ -14602,6 +14654,9 @@
|
|||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"Progress style" : {
|
||||
|
||||
},
|
||||
"Progressbar style" : {
|
||||
"localizations" : {
|
||||
|
|
@ -15374,6 +15429,18 @@
|
|||
}
|
||||
}
|
||||
},
|
||||
"Restore" : {
|
||||
"comment" : "The text for a button that restores default settings.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"Restore default timer presets?" : {
|
||||
"comment" : "A confirmation dialog title asking if the user wants to restore the default timer presets.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"Restore Defaults" : {
|
||||
"comment" : "A button that, when tapped, presents a confirmation dialog to reset all timer settings to their default values.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"Resume" : {
|
||||
"localizations" : {
|
||||
"fr" : {
|
||||
|
|
@ -15641,6 +15708,7 @@
|
|||
}
|
||||
},
|
||||
"Seconds" : {
|
||||
"extractionState" : "stale",
|
||||
"localizations" : {
|
||||
"fr" : {
|
||||
"stringUnit" : {
|
||||
|
|
@ -15662,6 +15730,10 @@
|
|||
},
|
||||
"Select a color to view details" : {
|
||||
|
||||
},
|
||||
"Select a custom sound to play when a timer ends. Supported formats include MP3, M4A, WAV, and AIFF." : {
|
||||
"comment" : "A description below the option to select a custom timer sound.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"Select Calendars" : {
|
||||
"localizations" : {
|
||||
|
|
@ -16026,9 +16098,6 @@
|
|||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"Set a custom duration for the timer. This will be used when you press the 'Custom' button in the timer interface." : {
|
||||
|
||||
},
|
||||
"Settings" : {
|
||||
"localizations" : {
|
||||
|
|
@ -16565,6 +16634,9 @@
|
|||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"Show countdown" : {
|
||||
|
||||
},
|
||||
"Show download progress" : {
|
||||
"extractionState" : "stale",
|
||||
|
|
@ -16582,6 +16654,9 @@
|
|||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"Show floating pause/stop controls" : {
|
||||
|
||||
},
|
||||
"Show on a specific display" : {
|
||||
"localizations" : {
|
||||
|
|
@ -16782,6 +16857,9 @@
|
|||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"Show progress" : {
|
||||
|
||||
},
|
||||
"Show shuffle and repeat buttons" : {
|
||||
"localizations" : {
|
||||
|
|
@ -16882,6 +16960,9 @@
|
|||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"Show timer name" : {
|
||||
|
||||
},
|
||||
"Shows a lock icon in the Dynamic Island when the screen is locked. Use the toggles above to control the lock screen media panel and weather capsule." : {
|
||||
|
||||
|
|
@ -17316,6 +17397,9 @@
|
|||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"Solid colour" : {
|
||||
|
||||
},
|
||||
"Speed" : {
|
||||
"localizations" : {
|
||||
|
|
@ -17557,6 +17641,10 @@
|
|||
}
|
||||
}
|
||||
},
|
||||
"Start Custom Timer" : {
|
||||
"comment" : "A button label that starts a custom timer.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"Start Demo Timer:" : {
|
||||
"localizations" : {
|
||||
"fr" : {
|
||||
|
|
@ -18065,9 +18153,16 @@
|
|||
},
|
||||
"TheDynamicIsland" : {
|
||||
|
||||
},
|
||||
"These controls sit beside the notch while a timer runs. They require the timer name to stay hidden for spacing." : {
|
||||
|
||||
},
|
||||
"These indicators appear in the notch when apps are using your camera or microphone, matching macOS's native privacy indicators." : {
|
||||
|
||||
},
|
||||
"This duration powers the \"Custom\" option inside the timer popover for quick access." : {
|
||||
"comment" : "A footer description for the custom timer settings, explaining their purpose.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"Time Range" : {
|
||||
"localizations" : {
|
||||
|
|
@ -18219,6 +18314,10 @@
|
|||
}
|
||||
}
|
||||
},
|
||||
"Timer Presets" : {
|
||||
"comment" : "A section header in the timer settings view, describing timer presets.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"Timer Sound" : {
|
||||
"localizations" : {
|
||||
"fr" : {
|
||||
|
|
@ -18228,6 +18327,9 @@
|
|||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"Timer tint" : {
|
||||
|
||||
},
|
||||
"Toggle Notch Open:" : {
|
||||
"localizations" : {
|
||||
|
|
@ -18897,6 +18999,7 @@
|
|||
}
|
||||
},
|
||||
"Update Timer" : {
|
||||
"extractionState" : "stale",
|
||||
"localizations" : {
|
||||
"fr" : {
|
||||
"stringUnit" : {
|
||||
|
|
|
|||
|
|
@ -34,26 +34,36 @@ struct InlineHUD: View {
|
|||
|
||||
let baseInfoWidth: CGFloat = {
|
||||
if type == .bluetoothAudio {
|
||||
return showBluetoothDeviceNameMarquee ? 100 : 52
|
||||
return showBluetoothDeviceNameMarquee ? 140 : 88
|
||||
}
|
||||
return 100
|
||||
}()
|
||||
let infoWidth = baseInfoWidth - (hoverAnimation ? 0 : 12) + gestureProgress / 2
|
||||
let infoWidth: CGFloat = {
|
||||
var width = baseInfoWidth + gestureProgress / 2
|
||||
if !hoverAnimation { width -= 8 }
|
||||
let minimum: CGFloat = type == .bluetoothAudio ? (showBluetoothDeviceNameMarquee ? 120 : 82) : 88
|
||||
return max(width, minimum)
|
||||
}()
|
||||
let baseTrailingWidth: CGFloat = {
|
||||
if type == .bluetoothAudio {
|
||||
if !hasBatteryLevel {
|
||||
return showBluetoothDeviceNameMarquee ? 100 : 70
|
||||
return showBluetoothDeviceNameMarquee ? 118 : 88
|
||||
}
|
||||
|
||||
if useCircularIndicator {
|
||||
return showBluetoothBatteryPercentageText ? 100 : 70
|
||||
return showBluetoothBatteryPercentageText ? 120 : 96
|
||||
} else {
|
||||
return showBluetoothBatteryPercentageText ? 112 : 82
|
||||
return showBluetoothBatteryPercentageText ? 136 : 108
|
||||
}
|
||||
}
|
||||
return 100
|
||||
}()
|
||||
let trailingWidth = baseTrailingWidth - (hoverAnimation ? 0 : 12) + gestureProgress / 2
|
||||
let trailingWidth: CGFloat = {
|
||||
var width = baseTrailingWidth + gestureProgress / 2
|
||||
if !hoverAnimation { width -= 8 }
|
||||
let minimum: CGFloat = type == .bluetoothAudio ? (showBluetoothBatteryPercentageText ? 110 : 92) : 90
|
||||
return max(width, minimum)
|
||||
}()
|
||||
|
||||
return HStack {
|
||||
HStack(spacing: 5) {
|
||||
|
|
|
|||
|
|
@ -15,8 +15,11 @@ struct DynamicIslandHeader: View {
|
|||
@ObservedObject var coordinator = DynamicIslandViewCoordinator.shared
|
||||
@ObservedObject var clipboardManager = ClipboardManager.shared
|
||||
@ObservedObject var tvm = TrayDrop.shared
|
||||
@ObservedObject var timerManager = TimerManager.shared
|
||||
@State private var showClipboardPopover = false
|
||||
@State private var showColorPickerPopover = false
|
||||
@State private var showTimerPopover = false
|
||||
@Default(.enableTimerFeature) var enableTimerFeature
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: 0) {
|
||||
|
|
@ -141,6 +144,36 @@ struct DynamicIslandHeader: View {
|
|||
}
|
||||
}
|
||||
|
||||
if Defaults[.enableTimerFeature] {
|
||||
Button(action: {
|
||||
withAnimation(.smooth) {
|
||||
showTimerPopover.toggle()
|
||||
}
|
||||
}) {
|
||||
Capsule()
|
||||
.fill(.black)
|
||||
.frame(width: 30, height: 30)
|
||||
.overlay {
|
||||
Image(systemName: "timer")
|
||||
.foregroundColor(.white)
|
||||
.padding()
|
||||
.imageScale(.medium)
|
||||
}
|
||||
}
|
||||
.buttonStyle(PlainButtonStyle())
|
||||
.popover(isPresented: $showTimerPopover, arrowEdge: .bottom) {
|
||||
TimerPopover()
|
||||
}
|
||||
.onChange(of: showTimerPopover) { isActive in
|
||||
vm.isTimerPopoverActive = isActive
|
||||
if !isActive {
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) {
|
||||
vm.shouldRecheckHover.toggle()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if Defaults[.settingsIconInNotch] {
|
||||
Button(action: {
|
||||
SettingsWindowController.shared.showWindow()
|
||||
|
|
@ -204,6 +237,12 @@ struct DynamicIslandHeader: View {
|
|||
showClipboardPopover.toggle()
|
||||
}
|
||||
}
|
||||
.onChange(of: enableTimerFeature) { _, newValue in
|
||||
if !newValue {
|
||||
showTimerPopover = false
|
||||
vm.isTimerPopoverActive = false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -70,7 +70,7 @@ struct NotchTimerView: View {
|
|||
// Timer icon in center
|
||||
Image(systemName: "timer")
|
||||
.font(.system(size: 32, weight: .medium))
|
||||
.foregroundStyle(timerManager.currentColor)
|
||||
.foregroundStyle(openIconColor)
|
||||
.scaleEffect(timerManager.isRunning && timerManager.isTimerActive ? 1.1 : 1.0)
|
||||
.animation(
|
||||
timerManager.isRunning && timerManager.isTimerActive ?
|
||||
|
|
@ -287,6 +287,13 @@ struct NotchTimerView: View {
|
|||
}
|
||||
}
|
||||
|
||||
private extension NotchTimerView {
|
||||
var openIconColor: Color {
|
||||
guard timerManager.isTimerActive && timerManager.isRunning else { return .white.opacity(0.9) }
|
||||
return timerManager.currentColor
|
||||
}
|
||||
}
|
||||
|
||||
#Preview {
|
||||
NotchTimerView()
|
||||
.environmentObject(DynamicIslandViewModel())
|
||||
|
|
|
|||
|
|
@ -1709,9 +1709,19 @@ func warningBadge(_ text: String, _ description: String) -> some View {
|
|||
struct TimerSettings: View {
|
||||
@ObservedObject private var coordinator = DynamicIslandViewCoordinator.shared
|
||||
@Default(.enableTimerFeature) var enableTimerFeature
|
||||
@AppStorage("customTimerDuration") private var customTimerDuration: Double = 600 // 10 minutes default
|
||||
@Default(.timerPresets) private var timerPresets
|
||||
@Default(.timerIconColorMode) private var colorMode
|
||||
@Default(.timerSolidColor) private var solidColor
|
||||
@Default(.timerShowsCountdown) private var showsCountdown
|
||||
@Default(.timerShowsLabel) private var showsLabel
|
||||
@Default(.timerShowsProgress) private var showsProgress
|
||||
@Default(.timerProgressStyle) private var progressStyle
|
||||
@Default(.timerControlWindowEnabled) private var controlWindowEnabled
|
||||
@AppStorage("customTimerDuration") private var customTimerDuration: Double = 600
|
||||
@State private var customHours: Int = 0
|
||||
@State private var customMinutes: Int = 10
|
||||
@State private var customSeconds: Int = 0
|
||||
@State private var showingResetConfirmation = false
|
||||
|
||||
var body: some View {
|
||||
Form {
|
||||
|
|
@ -1719,137 +1729,221 @@ struct TimerSettings: View {
|
|||
Defaults.Toggle("Enable timer feature", key: .enableTimerFeature)
|
||||
|
||||
if enableTimerFeature {
|
||||
Toggle(
|
||||
"Enable timer live activity",
|
||||
isOn: $coordinator.timerLiveActivityEnabled
|
||||
)
|
||||
.animation(.easeInOut, value: coordinator.timerLiveActivityEnabled)
|
||||
Toggle("Enable timer live activity", isOn: $coordinator.timerLiveActivityEnabled)
|
||||
.animation(.easeInOut, value: coordinator.timerLiveActivityEnabled)
|
||||
}
|
||||
} header: {
|
||||
Text("Timer Feature")
|
||||
} footer: {
|
||||
Text("Enable or disable the timer functionality in the Dynamic Island. The live activity toggle controls whether timer progress is shown in the expanded view.")
|
||||
Text("Control timer availability and whether progress appears as a live activity while the notch is closed.")
|
||||
}
|
||||
|
||||
if enableTimerFeature {
|
||||
Section {
|
||||
VStack(alignment: .leading, spacing: 16) {
|
||||
Text("Custom Timer Duration")
|
||||
.font(.headline)
|
||||
|
||||
HStack(spacing: 20) {
|
||||
VStack(alignment: .center, spacing: 8) {
|
||||
Text("Minutes")
|
||||
.font(.system(size: 14, weight: .medium))
|
||||
.foregroundStyle(.secondary)
|
||||
|
||||
Picker(selection: $customMinutes, label: Text("Minutes")) {
|
||||
ForEach(0...59, id: \.self) { minute in
|
||||
Text("\(minute)").tag(minute)
|
||||
}
|
||||
}
|
||||
.pickerStyle(.menu)
|
||||
.frame(width: 100)
|
||||
}
|
||||
|
||||
VStack(alignment: .center, spacing: 8) {
|
||||
Text("Seconds")
|
||||
.font(.system(size: 14, weight: .medium))
|
||||
.foregroundStyle(.secondary)
|
||||
|
||||
Picker(selection: $customSeconds, label: Text("Seconds")) {
|
||||
ForEach(0...59, id: \.self) { second in
|
||||
Text("\(second)").tag(second)
|
||||
}
|
||||
}
|
||||
.pickerStyle(.menu)
|
||||
.frame(width: 100)
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
Group {
|
||||
Section {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
Text("Default Custom Timer")
|
||||
.font(.headline)
|
||||
|
||||
TimerDurationStepperRow(title: "Hours", value: $customHours, range: 0...23)
|
||||
TimerDurationStepperRow(title: "Minutes", value: $customMinutes, range: 0...59)
|
||||
TimerDurationStepperRow(title: "Seconds", value: $customSeconds, range: 0...59)
|
||||
|
||||
HStack {
|
||||
Text("Current custom timer:")
|
||||
Text("Current default:")
|
||||
.foregroundStyle(.secondary)
|
||||
Text(customTimerDisplayText)
|
||||
.foregroundStyle(.primary)
|
||||
Text(customDurationDisplay)
|
||||
.font(.system(.body, design: .monospaced))
|
||||
.fontWeight(.medium)
|
||||
Spacer()
|
||||
}
|
||||
|
||||
HStack {
|
||||
Spacer()
|
||||
Button("Update Timer") {
|
||||
customTimerDuration = Double(customMinutes * 60 + customSeconds)
|
||||
}
|
||||
.disabled(customMinutes == 0 && customSeconds == 0)
|
||||
.buttonStyle(.borderedProminent)
|
||||
}
|
||||
.padding(.vertical, 4)
|
||||
.onChange(of: customHours) { _, _ in updateCustomDuration() }
|
||||
.onChange(of: customMinutes) { _, _ in updateCustomDuration() }
|
||||
.onChange(of: customSeconds) { _, _ in updateCustomDuration() }
|
||||
} header: {
|
||||
Text("Custom Timer")
|
||||
} footer: {
|
||||
Text("This duration powers the \"Custom\" option inside the timer popover for quick access.")
|
||||
}
|
||||
|
||||
Section {
|
||||
Picker("Timer tint", selection: $colorMode) {
|
||||
ForEach(TimerIconColorMode.allCases) { mode in
|
||||
Text(mode.displayName).tag(mode)
|
||||
}
|
||||
}
|
||||
.pickerStyle(.segmented)
|
||||
|
||||
if colorMode == .solid {
|
||||
ColorPicker("Solid colour", selection: $solidColor, supportsOpacity: false)
|
||||
}
|
||||
|
||||
Toggle("Show timer name", isOn: $showsLabel)
|
||||
Toggle("Show countdown", isOn: $showsCountdown)
|
||||
Toggle("Show progress", isOn: $showsProgress)
|
||||
|
||||
Toggle("Show floating pause/stop controls", isOn: $controlWindowEnabled)
|
||||
.disabled(showsLabel)
|
||||
.help("These controls sit beside the notch while a timer runs. They require the timer name to stay hidden for spacing.")
|
||||
|
||||
Picker("Progress style", selection: $progressStyle) {
|
||||
ForEach(TimerProgressStyle.allCases) { style in
|
||||
Text(style.rawValue).tag(style)
|
||||
}
|
||||
}
|
||||
.pickerStyle(.segmented)
|
||||
.disabled(!showsProgress)
|
||||
} header: {
|
||||
Text("Appearance")
|
||||
} footer: {
|
||||
Text("Configure how the timer looks inside the closed notch. Progress can render as a ring around the icon or as horizontal bars.")
|
||||
}
|
||||
.padding(.vertical, 8)
|
||||
} header: {
|
||||
Text("Custom Timer")
|
||||
} footer: {
|
||||
Text("Set a custom duration for the timer. This will be used when you press the 'Custom' button in the timer interface.")
|
||||
}
|
||||
|
||||
Section {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
|
||||
Section {
|
||||
if timerPresets.isEmpty {
|
||||
Text("No presets configured. Add a preset to make it appear in the timer popover.")
|
||||
.font(.system(size: 12))
|
||||
.foregroundStyle(.secondary)
|
||||
.padding(.vertical, 4)
|
||||
} else {
|
||||
ForEach(timerPresets.indices, id: \.self) { index in
|
||||
TimerPresetEditorRow(
|
||||
preset: $timerPresets[index],
|
||||
isFirst: index == timerPresets.startIndex,
|
||||
isLast: index == timerPresets.index(before: timerPresets.endIndex),
|
||||
moveUp: { movePresetUp(index) },
|
||||
moveDown: { movePresetDown(index) },
|
||||
remove: { removePreset(index) }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
HStack {
|
||||
Text("Timer Sound")
|
||||
.font(.system(size: 16, weight: .medium))
|
||||
Spacer()
|
||||
Button("Choose File") {
|
||||
selectCustomTimerSound()
|
||||
Button(action: addPreset) {
|
||||
Label("Add Preset", systemImage: "plus")
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
|
||||
Spacer()
|
||||
|
||||
Button(role: .destructive, action: { showingResetConfirmation = true }) {
|
||||
Label("Restore Defaults", systemImage: "arrow.counterclockwise")
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
.confirmationDialog("Restore default timer presets?", isPresented: $showingResetConfirmation, titleVisibility: .visible) {
|
||||
Button("Restore", role: .destructive, action: resetPresets)
|
||||
}
|
||||
}
|
||||
|
||||
if let customTimerSoundPath = UserDefaults.standard.string(forKey: "customTimerSoundPath") {
|
||||
Text("Custom: \(URL(fileURLWithPath: customTimerSoundPath).lastPathComponent)")
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
} else {
|
||||
Text("Default: dynamic.m4a")
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
|
||||
Button("Reset to Default") {
|
||||
UserDefaults.standard.removeObject(forKey: "customTimerSoundPath")
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
.disabled(UserDefaults.standard.string(forKey: "customTimerSoundPath") == nil)
|
||||
} header: {
|
||||
Text("Timer Presets")
|
||||
} footer: {
|
||||
Text("Presets show up inside the timer popover with the configured name, duration, and accent colour. Reorder them to change the display order.")
|
||||
}
|
||||
|
||||
Section {
|
||||
VStack(alignment: .leading, spacing: 10) {
|
||||
HStack {
|
||||
Text("Timer Sound")
|
||||
.font(.system(size: 16, weight: .medium))
|
||||
Spacer()
|
||||
Button("Choose File", action: selectCustomTimerSound)
|
||||
.buttonStyle(.bordered)
|
||||
}
|
||||
|
||||
if let customTimerSoundPath = UserDefaults.standard.string(forKey: "customTimerSoundPath") {
|
||||
Text("Custom: \(URL(fileURLWithPath: customTimerSoundPath).lastPathComponent)")
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
} else {
|
||||
Text("Default: dynamic.m4a")
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
|
||||
Button("Reset to Default") {
|
||||
UserDefaults.standard.removeObject(forKey: "customTimerSoundPath")
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
.disabled(UserDefaults.standard.string(forKey: "customTimerSoundPath") == nil)
|
||||
}
|
||||
} header: {
|
||||
Text("Timer Sound")
|
||||
} footer: {
|
||||
Text("Select a custom sound to play when a timer ends. Supported formats include MP3, M4A, WAV, and AIFF.")
|
||||
}
|
||||
}
|
||||
.onAppear {
|
||||
if showsLabel {
|
||||
controlWindowEnabled = false
|
||||
}
|
||||
}
|
||||
.onChange(of: showsLabel) { _, show in
|
||||
if show {
|
||||
controlWindowEnabled = false
|
||||
}
|
||||
} header: {
|
||||
Text("Timer Sound")
|
||||
} footer: {
|
||||
Text("Choose a custom sound file that will play when the timer completes. Supported formats: MP3, M4A, WAV, AIFF.")
|
||||
}
|
||||
}
|
||||
}
|
||||
.navigationTitle("Timer")
|
||||
.onAppear {
|
||||
let totalMinutes = Int(customTimerDuration) / 60
|
||||
customMinutes = totalMinutes
|
||||
customSeconds = Int(customTimerDuration) % 60
|
||||
.onAppear { syncCustomDuration() }
|
||||
.onChange(of: customTimerDuration) { _, newValue in syncCustomDuration(newValue) }
|
||||
}
|
||||
|
||||
private var customDurationDisplay: String {
|
||||
let formatter = DateComponentsFormatter()
|
||||
formatter.allowedUnits = customTimerDuration >= 3600 ? [.hour, .minute, .second] : [.minute, .second]
|
||||
formatter.zeroFormattingBehavior = [.pad]
|
||||
return formatter.string(from: customTimerDuration) ?? "0:00"
|
||||
}
|
||||
|
||||
private func syncCustomDuration(_ value: Double? = nil) {
|
||||
let baseValue = value ?? customTimerDuration
|
||||
let components = TimerPreset.components(for: baseValue)
|
||||
customHours = components.hours
|
||||
customMinutes = components.minutes
|
||||
customSeconds = components.seconds
|
||||
}
|
||||
|
||||
private func updateCustomDuration() {
|
||||
let duration = TimeInterval(customHours * 3600 + customMinutes * 60 + customSeconds)
|
||||
customTimerDuration = duration
|
||||
}
|
||||
|
||||
private func addPreset() {
|
||||
let nextIndex = timerPresets.count + 1
|
||||
let defaultColor = Defaults[.accentColor]
|
||||
let newPreset = TimerPreset(name: "Preset \(nextIndex)", duration: 5 * 60, color: defaultColor)
|
||||
withAnimation(.smooth) {
|
||||
timerPresets.append(newPreset)
|
||||
}
|
||||
}
|
||||
|
||||
private var customTimerDisplayText: String {
|
||||
let totalMinutes = Int(customTimerDuration) / 60
|
||||
let seconds = Int(customTimerDuration) % 60
|
||||
let hours = totalMinutes / 60
|
||||
let minutes = totalMinutes % 60
|
||||
|
||||
if hours > 0 {
|
||||
return "\(hours):\(String(format: "%02d", minutes)):\(String(format: "%02d", seconds))"
|
||||
} else if minutes > 0 {
|
||||
return "\(minutes):\(String(format: "%02d", seconds))"
|
||||
} else {
|
||||
return "\(seconds)s"
|
||||
private func movePresetUp(_ index: Int) {
|
||||
guard index > timerPresets.startIndex else { return }
|
||||
withAnimation(.smooth) {
|
||||
timerPresets.swapAt(index, index - 1)
|
||||
}
|
||||
}
|
||||
|
||||
private func movePresetDown(_ index: Int) {
|
||||
guard index < timerPresets.index(before: timerPresets.endIndex) else { return }
|
||||
withAnimation(.smooth) {
|
||||
timerPresets.swapAt(index, index + 1)
|
||||
}
|
||||
}
|
||||
|
||||
private func removePreset(_ index: Int) {
|
||||
guard timerPresets.indices.contains(index) else { return }
|
||||
withAnimation(.smooth) {
|
||||
timerPresets.remove(at: index)
|
||||
}
|
||||
}
|
||||
|
||||
private func resetPresets() {
|
||||
withAnimation(.smooth) {
|
||||
timerPresets = TimerPreset.defaultPresets
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -1869,6 +1963,146 @@ struct TimerSettings: View {
|
|||
}
|
||||
}
|
||||
|
||||
private struct TimerDurationStepperRow: View {
|
||||
let title: String
|
||||
@Binding var value: Int
|
||||
let range: ClosedRange<Int>
|
||||
|
||||
var body: some View {
|
||||
Stepper(value: $value, in: range) {
|
||||
HStack {
|
||||
Text(title)
|
||||
Spacer()
|
||||
Text("\(value)")
|
||||
.font(.system(size: 12, weight: .semibold, design: .monospaced))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct TimerPresetEditorRow: View {
|
||||
@Binding var preset: TimerPreset
|
||||
let isFirst: Bool
|
||||
let isLast: Bool
|
||||
let moveUp: () -> Void
|
||||
let moveDown: () -> Void
|
||||
let remove: () -> Void
|
||||
|
||||
private var components: TimerPreset.DurationComponents {
|
||||
TimerPreset.components(for: preset.duration)
|
||||
}
|
||||
|
||||
private var hoursBinding: Binding<Int> {
|
||||
Binding(
|
||||
get: { components.hours },
|
||||
set: { updateDuration(hours: $0) }
|
||||
)
|
||||
}
|
||||
|
||||
private var minutesBinding: Binding<Int> {
|
||||
Binding(
|
||||
get: { components.minutes },
|
||||
set: { updateDuration(minutes: $0) }
|
||||
)
|
||||
}
|
||||
|
||||
private var secondsBinding: Binding<Int> {
|
||||
Binding(
|
||||
get: { components.seconds },
|
||||
set: { updateDuration(seconds: $0) }
|
||||
)
|
||||
}
|
||||
|
||||
private var colorBinding: Binding<Color> {
|
||||
Binding(
|
||||
get: { preset.color },
|
||||
set: { preset.updateColor($0) }
|
||||
)
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
HStack(spacing: 10) {
|
||||
Circle()
|
||||
.fill(preset.color.gradient)
|
||||
.frame(width: 20, height: 20)
|
||||
.overlay(
|
||||
Circle()
|
||||
.stroke(Color.white.opacity(0.3), lineWidth: 1)
|
||||
)
|
||||
|
||||
TextField("Preset name", text: $preset.name)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
|
||||
Spacer()
|
||||
|
||||
Text(preset.formattedDuration)
|
||||
.font(.system(size: 12, weight: .medium, design: .monospaced))
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
|
||||
HStack(spacing: 16) {
|
||||
TimerPresetComponentControl(title: "Hours", value: hoursBinding, range: 0...23)
|
||||
TimerPresetComponentControl(title: "Minutes", value: minutesBinding, range: 0...59)
|
||||
TimerPresetComponentControl(title: "Seconds", value: secondsBinding, range: 0...59)
|
||||
}
|
||||
|
||||
ColorPicker("Accent colour", selection: colorBinding, supportsOpacity: false)
|
||||
.frame(maxWidth: 240, alignment: .leading)
|
||||
|
||||
HStack(spacing: 12) {
|
||||
Button(action: moveUp) {
|
||||
Label("Move Up", systemImage: "chevron.up")
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
.disabled(isFirst)
|
||||
|
||||
Button(action: moveDown) {
|
||||
Label("Move Down", systemImage: "chevron.down")
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
.disabled(isLast)
|
||||
|
||||
Spacer()
|
||||
|
||||
Button(role: .destructive, action: remove) {
|
||||
Label("Delete", systemImage: "trash")
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
}
|
||||
.font(.system(size: 12, weight: .medium))
|
||||
}
|
||||
.padding(.vertical, 6)
|
||||
}
|
||||
|
||||
private func updateDuration(hours: Int? = nil, minutes: Int? = nil, seconds: Int? = nil) {
|
||||
var values = components
|
||||
if let hours { values.hours = hours }
|
||||
if let minutes { values.minutes = minutes }
|
||||
if let seconds { values.seconds = seconds }
|
||||
preset.duration = TimerPreset.duration(from: values)
|
||||
}
|
||||
}
|
||||
|
||||
private struct TimerPresetComponentControl: View {
|
||||
let title: String
|
||||
@Binding var value: Int
|
||||
let range: ClosedRange<Int>
|
||||
|
||||
var body: some View {
|
||||
Stepper(value: $value, in: range) {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text(title)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
Text("\(value)")
|
||||
.font(.system(size: 13, weight: .semibold, design: .monospaced))
|
||||
}
|
||||
}
|
||||
.frame(width: 110, alignment: .leading)
|
||||
}
|
||||
}
|
||||
|
||||
struct StatsSettings: View {
|
||||
@ObservedObject var statsManager = StatsManager.shared
|
||||
@Default(.enableStatsFeature) var enableStatsFeature
|
||||
|
|
|
|||
|
|
@ -17,7 +17,6 @@ struct TabModel: Identifiable {
|
|||
|
||||
struct TabSelectionView: View {
|
||||
@ObservedObject var coordinator = DynamicIslandViewCoordinator.shared
|
||||
@Default(.enableTimerFeature) var enableTimerFeature
|
||||
@Default(.enableStatsFeature) var enableStatsFeature
|
||||
@Default(.enableColorPickerFeature) var enableColorPickerFeature
|
||||
@Namespace var animation
|
||||
|
|
@ -29,11 +28,6 @@ struct TabSelectionView: View {
|
|||
|
||||
tabsArray.append(TabModel(label: "Shelf", icon: "tray.fill", view: .shelf))
|
||||
|
||||
// Timer tab only shown when timer feature is enabled
|
||||
if Defaults[.enableTimerFeature] {
|
||||
tabsArray.append(TabModel(label: "Timer", icon: "timer", view: .timer))
|
||||
}
|
||||
|
||||
// Stats tab only shown when stats feature is enabled
|
||||
if Defaults[.enableStatsFeature] {
|
||||
tabsArray.append(TabModel(label: "Stats", icon: "chart.xyaxis.line", view: .stats))
|
||||
|
|
|
|||
88
DynamicIsland/components/Timer/TimerControlOverlay.swift
Normal file
88
DynamicIsland/components/Timer/TimerControlOverlay.swift
Normal file
|
|
@ -0,0 +1,88 @@
|
|||
import SwiftUI
|
||||
|
||||
struct TimerControlOverlay: View {
|
||||
let notchHeight: CGFloat
|
||||
let cornerRadius: CGFloat
|
||||
|
||||
@ObservedObject private var timerManager = TimerManager.shared
|
||||
|
||||
private var pauseIcon: String {
|
||||
timerManager.isPaused ? "play.fill" : "pause.fill"
|
||||
}
|
||||
|
||||
private var pauseForeground: Color {
|
||||
.white
|
||||
}
|
||||
|
||||
private var helpText: String {
|
||||
timerManager.isPaused ? "Resume" : "Pause"
|
||||
}
|
||||
|
||||
private var buttonSize: CGFloat {
|
||||
max(notchHeight - 20, 22)
|
||||
}
|
||||
|
||||
private var iconSize: CGFloat {
|
||||
14
|
||||
}
|
||||
|
||||
private var windowCornerRadius: CGFloat {
|
||||
max(cornerRadius - 6, 12)
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: 12) {
|
||||
Button(action: togglePause) {
|
||||
Image(systemName: pauseIcon)
|
||||
.font(.system(size: iconSize, weight: .semibold))
|
||||
.frame(width: buttonSize, height: buttonSize)
|
||||
.foregroundStyle(pauseForeground)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.contentShape(RoundedRectangle(cornerRadius: 10, style: .continuous))
|
||||
.help(helpText)
|
||||
|
||||
Button(action: stopTimer) {
|
||||
Image(systemName: "stop.fill")
|
||||
.font(.system(size: iconSize, weight: .semibold))
|
||||
.frame(width: buttonSize, height: buttonSize)
|
||||
.foregroundStyle(Color.red)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.contentShape(RoundedRectangle(cornerRadius: 10, style: .continuous))
|
||||
.help("Stop")
|
||||
}
|
||||
.padding(.horizontal, 12)
|
||||
.frame(height: notchHeight)
|
||||
.frame(minWidth: buttonSize * 2 + 32)
|
||||
.background {
|
||||
RoundedRectangle(cornerRadius: windowCornerRadius, style: .continuous)
|
||||
.fill(Color.black.opacity(0.9))
|
||||
}
|
||||
.compositingGroup()
|
||||
.animation(.smooth(duration: 0.2), value: timerManager.isPaused)
|
||||
.animation(.smooth(duration: 0.2), value: timerManager.isFinished)
|
||||
.animation(.smooth(duration: 0.2), value: timerManager.isOvertime)
|
||||
}
|
||||
|
||||
private func togglePause() {
|
||||
if timerManager.isPaused {
|
||||
timerManager.resumeTimer()
|
||||
} else {
|
||||
timerManager.pauseTimer()
|
||||
}
|
||||
}
|
||||
|
||||
private func stopTimer() {
|
||||
#if os(macOS)
|
||||
TimerControlWindowManager.shared.hide(animated: true)
|
||||
#endif
|
||||
timerManager.stopTimer()
|
||||
}
|
||||
}
|
||||
|
||||
#Preview {
|
||||
TimerControlOverlay(notchHeight: 34, cornerRadius: 14)
|
||||
.padding()
|
||||
.background(Color.gray.opacity(0.2))
|
||||
}
|
||||
|
|
@ -6,95 +6,43 @@
|
|||
//
|
||||
|
||||
import SwiftUI
|
||||
import Combine
|
||||
import Defaults
|
||||
|
||||
struct TimerIconAnimation: View {
|
||||
@ObservedObject var timerManager = TimerManager.shared
|
||||
@State private var pulseScale: CGFloat = 1.0
|
||||
@State private var rotationAngle: Double = 0
|
||||
@Default(.timerIconColorMode) private var colorMode
|
||||
@Default(.timerSolidColor) private var solidColor
|
||||
@Default(.timerPresets) private var timerPresets
|
||||
@Default(.accentColor) private var accentColor
|
||||
|
||||
var body: some View {
|
||||
ZStack {
|
||||
// Background circle with timer color
|
||||
Circle()
|
||||
.fill(timerManager.timerColor.gradient)
|
||||
.fill(resolvedColor.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()
|
||||
}
|
||||
.foregroundStyle(.white)
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
|
||||
private var resolvedColor: Color {
|
||||
guard timerManager.isTimerActive else { return accentColor }
|
||||
switch colorMode {
|
||||
case .adaptive:
|
||||
if let presetId = timerManager.activePresetId,
|
||||
let preset = timerPresets.first(where: { $0.id == presetId }) {
|
||||
return preset.color
|
||||
}
|
||||
return timerManager.timerColor
|
||||
case .solid:
|
||||
return solidColor
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct TimerIconView: NSViewRepresentable {
|
||||
@ObservedObject var timerManager = TimerManager.shared
|
||||
|
||||
func makeNSView(context: Context) -> NSView {
|
||||
let hostingView = NSHostingView(rootView: TimerIconAnimation())
|
||||
hostingView.translatesAutoresizingMaskIntoConstraints = false
|
||||
|
|
|
|||
|
|
@ -8,66 +8,470 @@
|
|||
import SwiftUI
|
||||
import Defaults
|
||||
|
||||
#if canImport(AppKit)
|
||||
import AppKit
|
||||
typealias PlatformFont = NSFont
|
||||
#elseif canImport(UIKit)
|
||||
import UIKit
|
||||
typealias PlatformFont = UIFont
|
||||
#endif
|
||||
|
||||
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
|
||||
@State private var showTransientLabel: Bool = false
|
||||
@State private var labelHideTask: DispatchWorkItem?
|
||||
@State private var isControlWindowVisible: Bool = false
|
||||
@Default(.timerShowsCountdown) private var showsCountdown
|
||||
@Default(.timerShowsProgress) private var showsProgress
|
||||
@Default(.timerShowsLabel) private var showsLabel
|
||||
@Default(.timerProgressStyle) private var progressStyle
|
||||
@Default(.timerIconColorMode) private var colorMode
|
||||
@Default(.timerSolidColor) private var solidColor
|
||||
@Default(.timerPresets) private var timerPresets
|
||||
@Default(.timerControlWindowEnabled) private var controlWindowEnabled
|
||||
|
||||
private var notchContentHeight: CGFloat {
|
||||
max(0, vm.effectiveClosedNotchHeight - (isHovering ? 0 : 12))
|
||||
}
|
||||
|
||||
private var wingPadding: CGFloat { 22 }
|
||||
private var ringStrokeWidth: CGFloat { 3 }
|
||||
private var transientLabelDuration: TimeInterval { 4 }
|
||||
|
||||
private var ringWrapsIcon: Bool {
|
||||
showsRingProgress && showsCountdown
|
||||
}
|
||||
|
||||
private var ringOnRight: Bool {
|
||||
showsRingProgress && !ringWrapsIcon
|
||||
}
|
||||
|
||||
private var iconWidth: CGFloat {
|
||||
ringWrapsIcon ? max(notchContentHeight - 6, 28) : max(0, notchContentHeight)
|
||||
}
|
||||
|
||||
private var infoContentWidth: CGFloat {
|
||||
guard showsInfoSection else { return 0 }
|
||||
if shouldDisplayLabel {
|
||||
let textWidth = min(max(titleTextWidth, 44), 220)
|
||||
return textWidth
|
||||
} else {
|
||||
return min(max(notchContentHeight * 1.4, 64), 220)
|
||||
}
|
||||
}
|
||||
|
||||
private var infoWidth: CGFloat {
|
||||
guard showsInfoSection else { return 0 }
|
||||
return infoContentWidth + 18
|
||||
}
|
||||
|
||||
private var leftWingWidth: CGFloat {
|
||||
var width = iconWidth + wingPadding
|
||||
if showsInfoSection {
|
||||
width += 8 + infoWidth
|
||||
}
|
||||
return width
|
||||
}
|
||||
|
||||
private var ringWidth: CGFloat {
|
||||
ringOnRight ? 30 : 0
|
||||
}
|
||||
|
||||
private var rightWingWidth: CGFloat {
|
||||
var width = wingPadding
|
||||
if ringOnRight {
|
||||
width += ringWidth
|
||||
}
|
||||
if ringOnRight && showsCountdown {
|
||||
width += 8
|
||||
}
|
||||
if showsCountdown {
|
||||
width += countdownWidth
|
||||
}
|
||||
return width
|
||||
}
|
||||
|
||||
private var titleTextWidth: CGFloat {
|
||||
measureTextWidth(timerManager.timerName, font: systemFont(size: 12, weight: .medium))
|
||||
}
|
||||
|
||||
private var countdownTextWidth: CGFloat {
|
||||
measureTextWidth(timerManager.formattedRemainingTime(), font: monospacedDigitFont(size: 13, weight: .semibold))
|
||||
}
|
||||
|
||||
private var countdownWidth: CGFloat {
|
||||
guard showsCountdown else { return 0 }
|
||||
return max(countdownTextWidth + 16, 72)
|
||||
}
|
||||
|
||||
private var clampedProgress: Double {
|
||||
min(max(timerManager.progress, 0), 1)
|
||||
}
|
||||
|
||||
private var glyphColor: Color {
|
||||
switch colorMode {
|
||||
case .adaptive:
|
||||
return activePresetColor ?? timerManager.timerColor
|
||||
case .solid:
|
||||
return solidColor
|
||||
}
|
||||
}
|
||||
|
||||
private var showsRingProgress: Bool {
|
||||
showsProgress && progressStyle == .ring
|
||||
}
|
||||
|
||||
private var showsBarProgress: Bool {
|
||||
showsProgress && progressStyle == .bar
|
||||
}
|
||||
|
||||
private var shouldDisplayLabel: Bool {
|
||||
showsLabel || showTransientLabel
|
||||
}
|
||||
|
||||
private var showsInfoSection: Bool {
|
||||
shouldDisplayLabel || (showsBarProgress && !showsCountdown)
|
||||
}
|
||||
|
||||
private var activePresetColor: Color? {
|
||||
guard let presetId = timerManager.activePresetId else { return nil }
|
||||
return timerPresets.first { $0.id == presetId }?.color
|
||||
}
|
||||
|
||||
private var shouldShowControlWindow: Bool {
|
||||
controlWindowEnabled && !shouldDisplayLabel && timerManager.isTimerActive
|
||||
}
|
||||
|
||||
private func measureTextWidth(_ text: String, font: PlatformFont) -> CGFloat {
|
||||
let attributes: [NSAttributedString.Key: Any] = [.font: font]
|
||||
let width = NSAttributedString(string: text, attributes: attributes).size().width
|
||||
return CGFloat(ceil(width))
|
||||
}
|
||||
|
||||
private func systemFont(size: CGFloat, weight: PlatformFont.Weight) -> PlatformFont {
|
||||
#if canImport(AppKit)
|
||||
return NSFont.systemFont(ofSize: size, weight: weight)
|
||||
#else
|
||||
return UIFont.systemFont(ofSize: size, weight: weight)
|
||||
#endif
|
||||
}
|
||||
|
||||
private func monospacedDigitFont(size: CGFloat, weight: PlatformFont.Weight) -> PlatformFont {
|
||||
#if canImport(AppKit)
|
||||
return NSFont.monospacedDigitSystemFont(ofSize: size, weight: weight)
|
||||
#else
|
||||
return UIFont.monospacedDigitSystemFont(ofSize: size, weight: weight)
|
||||
#endif
|
||||
}
|
||||
|
||||
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
|
||||
HStack(spacing: 0) {
|
||||
Color.clear
|
||||
.frame(width: leftWingWidth, height: notchContentHeight)
|
||||
.background(alignment: .leading) {
|
||||
HStack(spacing: showsInfoSection ? 8 : 0) {
|
||||
iconSection
|
||||
if showsInfoSection {
|
||||
infoSection
|
||||
}
|
||||
}
|
||||
.padding(.leading, wingPadding / 2)
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .leading)
|
||||
}
|
||||
|
||||
Rectangle()
|
||||
.fill(.black)
|
||||
.frame(width: vm.closedNotchSize.width + (isHovering ? 8 : 0), height: notchContentHeight)
|
||||
|
||||
Color.clear
|
||||
.frame(width: rightWingWidth, height: notchContentHeight)
|
||||
.background(alignment: .trailing) {
|
||||
HStack(spacing: ringOnRight && showsCountdown ? 8 : 0) {
|
||||
if ringOnRight {
|
||||
ringSection
|
||||
}
|
||||
if showsCountdown {
|
||||
countdownSection
|
||||
}
|
||||
}
|
||||
.padding(.trailing, wingPadding / 2)
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .trailing)
|
||||
}
|
||||
}
|
||||
.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)
|
||||
.contentShape(Rectangle())
|
||||
.onHover { hovering in
|
||||
withAnimation(.smooth(duration: 0.18)) {
|
||||
isHovering = hovering
|
||||
}
|
||||
syncControlWindow(forceRefresh: true)
|
||||
}
|
||||
.onAppear {
|
||||
syncControlWindow(forceRefresh: true)
|
||||
if timerManager.isTimerActive && !timerManager.isFinished && !timerManager.isOvertime {
|
||||
triggerTransientLabel()
|
||||
}
|
||||
}
|
||||
.onDisappear {
|
||||
hideControlWindow()
|
||||
}
|
||||
.onChange(of: timerManager.isTimerActive) { _, isActive in
|
||||
if isActive {
|
||||
if !timerManager.isFinished && !timerManager.isOvertime {
|
||||
syncControlWindow(forceRefresh: true)
|
||||
triggerTransientLabel()
|
||||
}
|
||||
} else {
|
||||
hideControlWindow()
|
||||
cancelTransientLabel()
|
||||
showTransientLabel = false
|
||||
isHovering = false
|
||||
}
|
||||
}
|
||||
.onChange(of: timerManager.timerName) { _, _ in
|
||||
if timerManager.isTimerActive && !timerManager.isFinished && !timerManager.isOvertime {
|
||||
syncControlWindow(forceRefresh: true)
|
||||
triggerTransientLabel()
|
||||
}
|
||||
}
|
||||
.onChange(of: timerManager.isFinished) { _, finished in
|
||||
if finished {
|
||||
cancelTransientLabel()
|
||||
withAnimation(.smooth) {
|
||||
showTransientLabel = true
|
||||
isHovering = false
|
||||
}
|
||||
syncControlWindow()
|
||||
}
|
||||
}
|
||||
.onChange(of: timerManager.isOvertime) { _, overtime in
|
||||
if overtime {
|
||||
cancelTransientLabel()
|
||||
withAnimation(.smooth) {
|
||||
showTransientLabel = true
|
||||
isHovering = false
|
||||
}
|
||||
syncControlWindow()
|
||||
}
|
||||
}
|
||||
.onChange(of: timerManager.isPaused) { _, _ in
|
||||
syncControlWindow(forceRefresh: true)
|
||||
}
|
||||
.onChange(of: vm.closedNotchSize) { _, _ in
|
||||
syncControlWindow(forceRefresh: true)
|
||||
}
|
||||
.onChange(of: vm.screen) { _, _ in
|
||||
syncControlWindow(forceRefresh: true)
|
||||
}
|
||||
.onChange(of: vm.hideOnClosed) { _, _ in
|
||||
syncControlWindow(forceRefresh: true)
|
||||
}
|
||||
.onChange(of: controlWindowEnabled) { _, _ in
|
||||
syncControlWindow(forceRefresh: true)
|
||||
}
|
||||
.onChange(of: showsLabel) { _, _ in
|
||||
syncControlWindow(forceRefresh: true)
|
||||
}
|
||||
.onChange(of: showTransientLabel) { _, _ in
|
||||
syncControlWindow(forceRefresh: true)
|
||||
}
|
||||
.onChange(of: showsCountdown) { _, _ in
|
||||
syncControlWindow(forceRefresh: true)
|
||||
}
|
||||
.onChange(of: showsProgress) { _, _ in
|
||||
syncControlWindow(forceRefresh: true)
|
||||
}
|
||||
.onChange(of: progressStyle) { _, _ in
|
||||
syncControlWindow(forceRefresh: true)
|
||||
}
|
||||
.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()
|
||||
private var iconSection: some View {
|
||||
let baseDiameter = ringWrapsIcon ? iconWidth : iconWidth
|
||||
let ringDiameter = ringWrapsIcon ? max(min(baseDiameter, notchContentHeight - 2), 22) : iconWidth
|
||||
let iconSize = ringWrapsIcon ? max(ringDiameter - 12, 16) : max(18, iconWidth - 6)
|
||||
|
||||
return ZStack {
|
||||
if ringWrapsIcon {
|
||||
Circle()
|
||||
.stroke(Color.white.opacity(0.15), lineWidth: ringStrokeWidth)
|
||||
.frame(width: ringDiameter, height: ringDiameter)
|
||||
|
||||
Circle()
|
||||
.trim(from: 0, to: clampedProgress)
|
||||
.stroke(glyphColor, style: StrokeStyle(lineWidth: ringStrokeWidth, lineCap: .round))
|
||||
.rotationEffect(.degrees(-90))
|
||||
.animation(.smooth(duration: 0.25), value: clampedProgress)
|
||||
.frame(width: ringDiameter, height: ringDiameter)
|
||||
}
|
||||
|
||||
Image(systemName: "timer")
|
||||
.font(.system(size: 16, weight: .semibold))
|
||||
.foregroundStyle(glyphColor)
|
||||
.frame(width: iconSize, height: iconSize)
|
||||
}
|
||||
.frame(width: ringWrapsIcon ? ringDiameter : iconWidth,
|
||||
height: notchContentHeight,
|
||||
alignment: .center)
|
||||
}
|
||||
|
||||
private var infoSection: some View {
|
||||
let availableWidth = max(0, infoWidth - 10)
|
||||
let safeWidth = max(44, availableWidth - 6)
|
||||
let resolvedTextWidth = min(max(titleTextWidth, 44), safeWidth)
|
||||
let marqueeLabel = shouldDisplayLabel && (timerManager.isFinished || timerManager.isOvertime || titleTextWidth > availableWidth)
|
||||
let showsBarHere = showsBarProgress && !showsCountdown
|
||||
let barWidth = shouldDisplayLabel ? resolvedTextWidth : availableWidth
|
||||
|
||||
return Rectangle()
|
||||
.fill(.black)
|
||||
.frame(width: vm.closedNotchSize.width + (isHovering ? 8 : 0))
|
||||
.frame(width: infoWidth, height: notchContentHeight)
|
||||
.overlay(alignment: .leading) {
|
||||
VStack(alignment: .leading, spacing: showsBarHere ? 4 : 0) {
|
||||
if shouldDisplayLabel {
|
||||
if marqueeLabel {
|
||||
MarqueeText(
|
||||
.constant(timerManager.timerName),
|
||||
font: .system(size: 12, weight: .medium),
|
||||
nsFont: .callout,
|
||||
textColor: .white,
|
||||
minDuration: 0.25,
|
||||
frameWidth: resolvedTextWidth
|
||||
)
|
||||
} else {
|
||||
Text(timerManager.timerName)
|
||||
.font(.system(size: 12, weight: .medium))
|
||||
.lineLimit(1)
|
||||
.foregroundStyle(.white)
|
||||
.transition(.opacity.combined(with: .move(edge: .top)))
|
||||
.frame(width: resolvedTextWidth, alignment: .leading)
|
||||
}
|
||||
}
|
||||
|
||||
if showsBarHere {
|
||||
Capsule()
|
||||
.fill(Color.white.opacity(0.12))
|
||||
.frame(width: barWidth, height: 3)
|
||||
.overlay(alignment: .leading) {
|
||||
Capsule()
|
||||
.fill(glyphColor)
|
||||
.frame(width: barWidth * max(0, CGFloat(clampedProgress)))
|
||||
.animation(.smooth(duration: 0.25), value: clampedProgress)
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(.leading, 12)
|
||||
.padding(.trailing, 6)
|
||||
}
|
||||
.animation(.smooth, value: timerManager.isFinished)
|
||||
}
|
||||
|
||||
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)
|
||||
private var ringSection: some View {
|
||||
let diameter = max(min(notchContentHeight - 4, 26), 20)
|
||||
return ZStack {
|
||||
Circle()
|
||||
.stroke(Color.white.opacity(0.15), lineWidth: ringStrokeWidth)
|
||||
Circle()
|
||||
.trim(from: 0, to: clampedProgress)
|
||||
.stroke(glyphColor, style: StrokeStyle(lineWidth: ringStrokeWidth, lineCap: .round))
|
||||
.rotationEffect(.degrees(-90))
|
||||
.animation(.smooth(duration: 0.25), value: clampedProgress)
|
||||
}
|
||||
.frame(width: diameter, height: diameter)
|
||||
.frame(width: ringWidth, height: notchContentHeight, alignment: .center)
|
||||
}
|
||||
|
||||
private var countdownSection: some View {
|
||||
let barWidth = max(countdownTextWidth, 1)
|
||||
return VStack(spacing: 4) {
|
||||
Text(timerManager.formattedRemainingTime())
|
||||
.font(.system(size: 13, weight: .semibold, design: .monospaced))
|
||||
.foregroundColor(timerManager.isOvertime ? .red : .white)
|
||||
.contentTransition(.numericText())
|
||||
.animation(.smooth(duration: 0.25), value: timerManager.remainingTime)
|
||||
.frame(maxWidth: .infinity, alignment: .trailing)
|
||||
|
||||
if showsBarProgress {
|
||||
Capsule()
|
||||
.fill(Color.white.opacity(0.12))
|
||||
.frame(width: barWidth, height: 3)
|
||||
.overlay(alignment: .leading) {
|
||||
Capsule()
|
||||
.fill(glyphColor)
|
||||
.frame(width: barWidth * max(0, CGFloat(clampedProgress)))
|
||||
.animation(.smooth(duration: 0.25), value: clampedProgress)
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .trailing)
|
||||
}
|
||||
}
|
||||
.padding(.trailing, 8)
|
||||
.frame(width: countdownWidth,
|
||||
height: notchContentHeight, alignment: .center)
|
||||
}
|
||||
|
||||
private func triggerTransientLabel() {
|
||||
guard !showsLabel else { return }
|
||||
cancelTransientLabel()
|
||||
withAnimation(.smooth) {
|
||||
showTransientLabel = true
|
||||
}
|
||||
let task = DispatchWorkItem {
|
||||
withAnimation(.smooth) {
|
||||
showTransientLabel = false
|
||||
}
|
||||
syncControlWindow(forceRefresh: true)
|
||||
}
|
||||
labelHideTask = task
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + transientLabelDuration, execute: task)
|
||||
}
|
||||
|
||||
private func cancelTransientLabel() {
|
||||
labelHideTask?.cancel()
|
||||
labelHideTask = nil
|
||||
}
|
||||
|
||||
private func syncControlWindow(forceRefresh: Bool = false) {
|
||||
#if os(macOS)
|
||||
let notchAvailable = vm.effectiveClosedNotchHeight > 0 && vm.closedNotchSize.width > 0
|
||||
let targetVisible = shouldShowControlWindow && notchAvailable
|
||||
if targetVisible {
|
||||
let metrics = currentControlWindowMetrics()
|
||||
if !isControlWindowVisible {
|
||||
let didPresent = TimerControlWindowManager.shared.present(using: vm, metrics: metrics)
|
||||
isControlWindowVisible = didPresent
|
||||
} else if forceRefresh {
|
||||
let didRefresh = TimerControlWindowManager.shared.refresh(using: vm, metrics: metrics)
|
||||
isControlWindowVisible = didRefresh
|
||||
if !didRefresh {
|
||||
TimerControlWindowManager.shared.hide()
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
} else if isControlWindowVisible {
|
||||
TimerControlWindowManager.shared.hide()
|
||||
isControlWindowVisible = false
|
||||
}
|
||||
.frame(width: max(0, vm.effectiveClosedNotchHeight - (isHovering ? 0 : 12) + gestureProgress / 2),
|
||||
height: max(0, vm.effectiveClosedNotchHeight - (isHovering ? 0 : 12)), alignment: .center)
|
||||
#endif
|
||||
}
|
||||
|
||||
private func hideControlWindow() {
|
||||
#if os(macOS)
|
||||
if isControlWindowVisible {
|
||||
TimerControlWindowManager.shared.hide()
|
||||
isControlWindowVisible = false
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
private func currentControlWindowMetrics() -> TimerControlWindowMetrics {
|
||||
TimerControlWindowMetrics(
|
||||
notchHeight: max(vm.closedNotchSize.height, vm.effectiveClosedNotchHeight),
|
||||
notchWidth: vm.closedNotchSize.width + (isHovering ? 8 : 0),
|
||||
rightWingWidth: rightWingWidth,
|
||||
cornerRadius: cornerRadiusInsets.closed.bottom,
|
||||
spacing: 6
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -77,7 +481,6 @@ struct TimerLiveActivity: View {
|
|||
.frame(width: 300, height: 32)
|
||||
.background(.black)
|
||||
.onAppear {
|
||||
// Start a demo timer for preview
|
||||
TimerManager.shared.startDemoTimer(duration: 300)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
362
DynamicIsland/components/Timer/TimerPopover.swift
Normal file
362
DynamicIsland/components/Timer/TimerPopover.swift
Normal file
|
|
@ -0,0 +1,362 @@
|
|||
//
|
||||
// TimerPopover.swift
|
||||
// DynamicIsland
|
||||
//
|
||||
// Created by GitHub Copilot on 2025-11-01.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
import Defaults
|
||||
|
||||
struct TimerPopover: View {
|
||||
@ObservedObject var timerManager = TimerManager.shared
|
||||
@Default(.timerPresets) private var timerPresets
|
||||
@Default(.timerShowsCountdown) private var showsCountdown
|
||||
@Default(.timerShowsProgress) private var showsProgress
|
||||
@Default(.timerProgressStyle) private var progressStyle
|
||||
@Default(.timerIconColorMode) private var colorMode
|
||||
@Default(.timerSolidColor) private var solidColor
|
||||
@AppStorage("customTimerDuration") private var customTimerDuration: Double = 600
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
|
||||
@State private var customHours: Int = 0
|
||||
@State private var customMinutes: Int = 10
|
||||
@State private var customSeconds: Int = 0
|
||||
|
||||
private var customDurationInSeconds: TimeInterval {
|
||||
TimeInterval(customHours * 3600 + customMinutes * 60 + customSeconds)
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 16) {
|
||||
HeaderView(statusText: statusText)
|
||||
|
||||
if timerManager.isTimerActive {
|
||||
ActiveTimerSection(timerManager: timerManager)
|
||||
} else {
|
||||
CustomTimerSection(hours: $customHours, minutes: $customMinutes, seconds: $customSeconds, startAction: startCustomTimer)
|
||||
.onChange(of: customHours) { _, _ in updateStoredCustomDuration() }
|
||||
.onChange(of: customMinutes) { _, _ in updateStoredCustomDuration() }
|
||||
.onChange(of: customSeconds) { _, _ in updateStoredCustomDuration() }
|
||||
}
|
||||
|
||||
Divider()
|
||||
.padding(.horizontal, -8)
|
||||
|
||||
PresetList(presets: timerPresets, activePresetId: timerManager.activePresetId, startAction: startPreset)
|
||||
.animation(.smooth, value: timerManager.activePresetId)
|
||||
.frame(maxHeight: 200)
|
||||
}
|
||||
.padding(16)
|
||||
.frame(width: 300)
|
||||
.background(
|
||||
VisualEffectView(material: .hudWindow, blendingMode: .behindWindow)
|
||||
.cornerRadius(12)
|
||||
)
|
||||
.onAppear {
|
||||
syncCustomDuration(with: customTimerDuration)
|
||||
}
|
||||
.onChange(of: customTimerDuration) { _, newValue in
|
||||
syncCustomDuration(with: newValue)
|
||||
}
|
||||
}
|
||||
|
||||
private var statusText: String {
|
||||
if timerManager.isOvertime {
|
||||
return "Overtime"
|
||||
} else if timerManager.isPaused {
|
||||
return "Paused"
|
||||
} else if timerManager.isTimerActive {
|
||||
return "Running"
|
||||
} else {
|
||||
return "Ready"
|
||||
}
|
||||
}
|
||||
|
||||
private func syncCustomDuration(with value: Double) {
|
||||
let components = TimerPreset.components(for: value)
|
||||
customHours = components.hours
|
||||
customMinutes = components.minutes
|
||||
customSeconds = components.seconds
|
||||
}
|
||||
|
||||
private func updateStoredCustomDuration() {
|
||||
let newValue = customDurationInSeconds
|
||||
customTimerDuration = newValue
|
||||
}
|
||||
|
||||
private func startCustomTimer() {
|
||||
let duration = customDurationInSeconds
|
||||
guard duration > 0 else { return }
|
||||
withAnimation(.smooth) {
|
||||
timerManager.startTimer(duration: duration, name: "Custom Timer")
|
||||
}
|
||||
dismiss()
|
||||
}
|
||||
|
||||
private func startPreset(_ preset: TimerPreset) {
|
||||
withAnimation(.smooth) {
|
||||
timerManager.startTimer(duration: preset.duration, name: preset.name, preset: preset)
|
||||
}
|
||||
dismiss()
|
||||
}
|
||||
}
|
||||
|
||||
private struct HeaderView: View {
|
||||
let statusText: String
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: 12) {
|
||||
TimerIconAnimation()
|
||||
.frame(width: 32, height: 32)
|
||||
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text("Timer")
|
||||
.font(.system(size: 14, weight: .semibold))
|
||||
Text(statusText)
|
||||
.font(.system(size: 11, weight: .medium))
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
|
||||
Spacer()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct ActiveTimerSection: View {
|
||||
@ObservedObject var timerManager: TimerManager
|
||||
@Default(.timerShowsProgress) private var showsProgress
|
||||
@Default(.timerProgressStyle) private var progressStyle
|
||||
@Default(.timerIconColorMode) private var colorMode
|
||||
@Default(.timerSolidColor) private var solidColor
|
||||
@Default(.timerPresets) private var timerPresets
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text(timerManager.timerName)
|
||||
.font(.system(size: 15, weight: .semibold))
|
||||
.lineLimit(1)
|
||||
.foregroundColor(.primary)
|
||||
|
||||
Text(timerManager.formattedRemainingTime())
|
||||
.font(.system(size: 28, weight: .bold, design: .monospaced))
|
||||
.foregroundStyle(timerManager.isOvertime ? Color.red : Color.primary)
|
||||
.contentTransition(.numericText())
|
||||
}
|
||||
|
||||
if showsProgress && progressStyle == .bar {
|
||||
ProgressView(value: min(timerManager.progress, 1.0))
|
||||
.progressViewStyle(LinearProgressViewStyle(tint: progressTint))
|
||||
.animation(.smooth(duration: 0.2), value: timerManager.progress)
|
||||
}
|
||||
|
||||
HStack(spacing: 8) {
|
||||
Button(action: togglePause) {
|
||||
Label(timerManager.isPaused ? "Resume" : "Pause", systemImage: timerManager.isPaused ? "play.fill" : "pause.fill")
|
||||
.font(.system(size: 12, weight: .medium))
|
||||
.labelStyle(.titleAndIcon)
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
|
||||
Button(role: .destructive, action: stopTimer) {
|
||||
Label("Stop", systemImage: "stop.fill")
|
||||
.font(.system(size: 12, weight: .medium))
|
||||
.labelStyle(.titleAndIcon)
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.padding(12)
|
||||
.background(Color.white.opacity(0.08))
|
||||
.clipShape(RoundedRectangle(cornerRadius: 10, style: .continuous))
|
||||
.animation(.smooth, value: timerManager.isPaused)
|
||||
}
|
||||
|
||||
private func togglePause() {
|
||||
withAnimation(.smooth) {
|
||||
if timerManager.isPaused {
|
||||
timerManager.resumeTimer()
|
||||
} else {
|
||||
timerManager.pauseTimer()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func stopTimer() {
|
||||
withAnimation(.smooth) {
|
||||
timerManager.stopTimer()
|
||||
}
|
||||
}
|
||||
|
||||
private var progressTint: Color {
|
||||
switch colorMode {
|
||||
case .adaptive:
|
||||
return activePresetColor ?? timerManager.timerColor
|
||||
case .solid:
|
||||
return solidColor
|
||||
}
|
||||
}
|
||||
|
||||
private var activePresetColor: Color? {
|
||||
guard let presetId = timerManager.activePresetId else { return nil }
|
||||
return timerPresets.first { $0.id == presetId }?.color
|
||||
}
|
||||
}
|
||||
|
||||
private struct CustomTimerSection: View {
|
||||
@Binding var hours: Int
|
||||
@Binding var minutes: Int
|
||||
@Binding var seconds: Int
|
||||
let startAction: () -> Void
|
||||
|
||||
private var totalSeconds: Int {
|
||||
hours * 3600 + minutes * 60 + seconds
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
Text("Custom Timer")
|
||||
.font(.system(size: 14, weight: .semibold))
|
||||
|
||||
Grid(alignment: .leading, horizontalSpacing: 12, verticalSpacing: 8) {
|
||||
GridRow {
|
||||
DurationStepper(title: "Hours", value: $hours, range: 0...23)
|
||||
DurationStepper(title: "Minutes", value: $minutes, range: 0...59)
|
||||
DurationStepper(title: "Seconds", value: $seconds, range: 0...59)
|
||||
}
|
||||
}
|
||||
|
||||
Text(formattedDuration)
|
||||
.font(.system(size: 12, weight: .medium, design: .monospaced))
|
||||
.foregroundStyle(.secondary)
|
||||
|
||||
Button(action: startAction) {
|
||||
Label("Start Custom Timer", systemImage: "play.fill")
|
||||
.font(.system(size: 13, weight: .medium))
|
||||
.labelStyle(.titleAndIcon)
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
.disabled(totalSeconds == 0)
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.padding(12)
|
||||
.background(Color.white.opacity(0.05))
|
||||
.clipShape(RoundedRectangle(cornerRadius: 10, style: .continuous))
|
||||
}
|
||||
|
||||
private var formattedDuration: String {
|
||||
let components = TimerPreset.DurationComponents(hours: hours, minutes: minutes, seconds: seconds)
|
||||
let interval = TimerPreset.duration(from: components)
|
||||
return TimerPreset(name: "", duration: interval, color: .clear).formattedDuration
|
||||
}
|
||||
}
|
||||
|
||||
private struct DurationStepper: View {
|
||||
let title: String
|
||||
@Binding var value: Int
|
||||
let range: ClosedRange<Int>
|
||||
|
||||
var body: some View {
|
||||
Stepper(value: $value, in: range) {
|
||||
HStack {
|
||||
Text(title)
|
||||
.font(.system(size: 12, weight: .medium))
|
||||
Spacer()
|
||||
Text("\(value)")
|
||||
.font(.system(size: 12, weight: .semibold, design: .monospaced))
|
||||
}
|
||||
}
|
||||
.frame(width: 88)
|
||||
}
|
||||
}
|
||||
|
||||
private struct PresetList: View {
|
||||
let presets: [TimerPreset]
|
||||
let activePresetId: UUID?
|
||||
let startAction: (TimerPreset) -> Void
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
Text("Presets")
|
||||
.font(.system(size: 13, weight: .semibold))
|
||||
.padding(.leading, 4)
|
||||
|
||||
if presets.isEmpty {
|
||||
Text("Configure presets in Settings")
|
||||
.font(.system(size: 12))
|
||||
.foregroundStyle(.secondary)
|
||||
.padding(12)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.background(Color.white.opacity(0.05))
|
||||
.clipShape(RoundedRectangle(cornerRadius: 10, style: .continuous))
|
||||
} else {
|
||||
ScrollView {
|
||||
VStack(spacing: 8) {
|
||||
ForEach(presets) { preset in
|
||||
TimerPresetRow(preset: preset, isActive: activePresetId == preset.id) {
|
||||
startAction(preset)
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(.vertical, 4)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct TimerPresetRow: View {
|
||||
let preset: TimerPreset
|
||||
let isActive: Bool
|
||||
let action: () -> Void
|
||||
|
||||
var body: some View {
|
||||
Button(action: action) {
|
||||
HStack(spacing: 12) {
|
||||
Circle()
|
||||
.fill(preset.color.gradient)
|
||||
.frame(width: 20, height: 20)
|
||||
.overlay(
|
||||
Circle()
|
||||
.stroke(Color.white.opacity(0.5), lineWidth: 1)
|
||||
)
|
||||
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(preset.name)
|
||||
.font(.system(size: 13, weight: .medium))
|
||||
.foregroundStyle(.primary)
|
||||
.lineLimit(1)
|
||||
Text(preset.formattedDuration)
|
||||
.font(.system(size: 11, weight: .medium, design: .monospaced))
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
Image(systemName: isActive ? "checkmark" : "play.fill")
|
||||
.font(.system(size: 11, weight: .semibold))
|
||||
.foregroundStyle(isActive ? preset.color : Color.secondary)
|
||||
.padding(6)
|
||||
.background(isActive ? preset.color.opacity(0.2) : Color.clear)
|
||||
.clipShape(Circle())
|
||||
}
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.vertical, 10)
|
||||
.contentShape(RoundedRectangle(cornerRadius: 10, style: .continuous))
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: 10, style: .continuous)
|
||||
.fill(isActive ? preset.color.opacity(0.18) : Color.white.opacity(0.05))
|
||||
)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
}
|
||||
|
||||
#Preview {
|
||||
TimerPopover()
|
||||
.frame(width: 300)
|
||||
.padding()
|
||||
.background(Color.black)
|
||||
}
|
||||
199
DynamicIsland/managers/TimerControlWindowManager.swift
Normal file
199
DynamicIsland/managers/TimerControlWindowManager.swift
Normal file
|
|
@ -0,0 +1,199 @@
|
|||
#if os(macOS)
|
||||
import AppKit
|
||||
import SwiftUI
|
||||
import SkyLightWindow
|
||||
import QuartzCore
|
||||
|
||||
struct TimerControlWindowMetrics {
|
||||
let notchHeight: CGFloat
|
||||
let notchWidth: CGFloat
|
||||
let rightWingWidth: CGFloat
|
||||
let cornerRadius: CGFloat
|
||||
let spacing: CGFloat
|
||||
}
|
||||
|
||||
@MainActor
|
||||
final class TimerControlWindowManager {
|
||||
static let shared = TimerControlWindowManager()
|
||||
|
||||
private var window: NSWindow?
|
||||
private var hostingView: NSHostingView<TimerControlOverlay>?
|
||||
private var hasDelegated = false
|
||||
private var lastMetrics: TimerControlWindowMetrics?
|
||||
|
||||
private init() {}
|
||||
|
||||
@discardableResult
|
||||
func present(using viewModel: DynamicIslandViewModel, metrics: TimerControlWindowMetrics) -> Bool {
|
||||
guard let screen = resolveScreen(from: viewModel) else { return false }
|
||||
guard viewModel.effectiveClosedNotchHeight > 0, viewModel.closedNotchSize.width > 0 else {
|
||||
hide()
|
||||
return false
|
||||
}
|
||||
|
||||
let overlay = TimerControlOverlay(
|
||||
notchHeight: metrics.notchHeight,
|
||||
cornerRadius: metrics.cornerRadius
|
||||
)
|
||||
let hosting = ensureHostingView(with: overlay)
|
||||
let fittingSize = measuredSize(for: hosting)
|
||||
hosting.frame = NSRect(origin: .zero, size: fittingSize)
|
||||
|
||||
let window = ensureWindow(on: screen)
|
||||
if window.contentView !== hosting {
|
||||
window.contentView = hosting
|
||||
}
|
||||
|
||||
let targetFrame = frame(for: fittingSize, viewModel: viewModel, screen: screen, metrics: metrics)
|
||||
lastMetrics = metrics
|
||||
|
||||
if !hasDelegated {
|
||||
SkyLightOperator.shared.delegateWindow(window)
|
||||
hasDelegated = true
|
||||
}
|
||||
|
||||
if window.alphaValue <= 0.01 {
|
||||
let startFrame = initialFrame(for: targetFrame, metrics: metrics)
|
||||
window.setFrame(startFrame, display: true)
|
||||
window.alphaValue = 0
|
||||
window.orderFrontRegardless()
|
||||
|
||||
NSAnimationContext.runAnimationGroup { context in
|
||||
context.duration = 0.24
|
||||
context.timingFunction = CAMediaTimingFunction(name: .easeOut)
|
||||
window.animator().setFrame(targetFrame, display: true)
|
||||
window.animator().alphaValue = 1
|
||||
} completionHandler: {
|
||||
window.setFrame(targetFrame, display: false)
|
||||
}
|
||||
} else {
|
||||
NSAnimationContext.runAnimationGroup { context in
|
||||
context.duration = 0.16
|
||||
context.timingFunction = CAMediaTimingFunction(name: .easeInEaseOut)
|
||||
window.animator().setFrame(targetFrame, display: true)
|
||||
} completionHandler: {
|
||||
window.setFrame(targetFrame, display: false)
|
||||
}
|
||||
window.orderFrontRegardless()
|
||||
window.alphaValue = 1
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
func refresh(using viewModel: DynamicIslandViewModel, metrics: TimerControlWindowMetrics) -> Bool {
|
||||
guard window != nil else {
|
||||
return present(using: viewModel, metrics: metrics)
|
||||
}
|
||||
return present(using: viewModel, metrics: metrics)
|
||||
}
|
||||
|
||||
func hide(animated: Bool = true) {
|
||||
guard let window else { return }
|
||||
|
||||
guard animated, window.alphaValue > 0.01 else {
|
||||
window.orderOut(nil)
|
||||
window.alphaValue = 0
|
||||
return
|
||||
}
|
||||
|
||||
let retreatFrame = notchRetreatFrame(from: window.frame)
|
||||
let originalFrame = window.frame
|
||||
|
||||
NSAnimationContext.runAnimationGroup { context in
|
||||
context.duration = 0.18
|
||||
context.timingFunction = CAMediaTimingFunction(name: .easeIn)
|
||||
window.animator().setFrame(retreatFrame, display: true)
|
||||
window.animator().alphaValue = 0
|
||||
} completionHandler: {
|
||||
window.setFrame(originalFrame, display: false)
|
||||
window.orderOut(nil)
|
||||
window.alphaValue = 0
|
||||
}
|
||||
}
|
||||
|
||||
private func ensureHostingView(with overlay: TimerControlOverlay) -> NSHostingView<TimerControlOverlay> {
|
||||
if let hostingView {
|
||||
hostingView.rootView = overlay
|
||||
return hostingView
|
||||
}
|
||||
let view = NSHostingView(rootView: overlay)
|
||||
hostingView = view
|
||||
return view
|
||||
}
|
||||
|
||||
private func ensureWindow(on screen: NSScreen) -> NSWindow {
|
||||
if let window {
|
||||
return window
|
||||
}
|
||||
|
||||
let window = NSWindow(
|
||||
contentRect: NSRect(x: screen.frame.midX, y: screen.frame.midY, width: 220, height: 64),
|
||||
styleMask: [.borderless, .nonactivatingPanel],
|
||||
backing: .buffered,
|
||||
defer: false
|
||||
)
|
||||
|
||||
window.isReleasedWhenClosed = false
|
||||
window.isOpaque = false
|
||||
window.backgroundColor = .clear
|
||||
window.hasShadow = false
|
||||
window.level = NSWindow.Level(rawValue: Int(CGShieldingWindowLevel()))
|
||||
window.collectionBehavior = [.canJoinAllSpaces, .stationary, .fullScreenAuxiliary]
|
||||
window.ignoresMouseEvents = false
|
||||
window.isMovable = false
|
||||
window.alphaValue = 0
|
||||
|
||||
self.window = window
|
||||
self.hasDelegated = false
|
||||
return window
|
||||
}
|
||||
|
||||
private func measuredSize(for hosting: NSHostingView<TimerControlOverlay>) -> CGSize {
|
||||
hosting.layoutSubtreeIfNeeded()
|
||||
let size = hosting.fittingSize
|
||||
return CGSize(width: ceil(size.width), height: ceil(size.height))
|
||||
}
|
||||
|
||||
private func resolveScreen(from viewModel: DynamicIslandViewModel) -> NSScreen? {
|
||||
if let screenName = viewModel.screen,
|
||||
let targetScreen = NSScreen.screens.first(where: { $0.localizedName == screenName }) {
|
||||
return targetScreen
|
||||
}
|
||||
return NSScreen.main
|
||||
}
|
||||
|
||||
private func frame(for size: CGSize, viewModel: DynamicIslandViewModel, screen: NSScreen, metrics: TimerControlWindowMetrics) -> NSRect {
|
||||
let screenFrame = screen.frame
|
||||
let notchOriginX = screenFrame.midX - (metrics.notchWidth / 2)
|
||||
let originY = screenFrame.maxY - size.height
|
||||
|
||||
let rightEdge = notchOriginX + metrics.notchWidth + metrics.rightWingWidth
|
||||
let rawOriginX = rightEdge + metrics.spacing
|
||||
|
||||
let clampedOriginX = max(screenFrame.minX + 8, min(rawOriginX, screenFrame.maxX - size.width - 8))
|
||||
|
||||
return NSRect(x: clampedOriginX, y: originY, width: size.width, height: size.height)
|
||||
}
|
||||
|
||||
private func initialFrame(for targetFrame: NSRect, metrics: TimerControlWindowMetrics) -> NSRect {
|
||||
var frame = targetFrame
|
||||
let notchRightEdge = targetFrame.minX - metrics.spacing
|
||||
frame.origin.x = notchRightEdge - targetFrame.width
|
||||
return frame
|
||||
}
|
||||
|
||||
private func notchRetreatFrame(from currentFrame: NSRect) -> NSRect {
|
||||
guard let metrics = lastMetrics else {
|
||||
return currentFrame.offsetBy(dx: -currentFrame.width * 0.75, dy: 0)
|
||||
}
|
||||
|
||||
let notchRightEdge = currentFrame.minX - metrics.spacing
|
||||
var frame = currentFrame
|
||||
frame.origin.x = notchRightEdge - currentFrame.width
|
||||
return frame
|
||||
}
|
||||
}
|
||||
|
||||
#endif
|
||||
|
|
@ -10,6 +10,7 @@ import Combine
|
|||
import SwiftUI
|
||||
import AVFoundation
|
||||
import AppKit
|
||||
import Defaults
|
||||
|
||||
class TimerManager: ObservableObject {
|
||||
// MARK: - Properties
|
||||
|
|
@ -24,6 +25,7 @@ class TimerManager: ObservableObject {
|
|||
@Published var isFinished: Bool = false
|
||||
@Published var isOvertime: Bool = false // Timer has gone past 0 and is counting negative
|
||||
@Published var lastUpdated: Date = .distantPast
|
||||
@Published var activePresetId: UUID?
|
||||
|
||||
// Timer progress (0.0 to 1.0, or >1.0 for overtime)
|
||||
var progress: Double {
|
||||
|
|
@ -124,7 +126,7 @@ class TimerManager: ObservableObject {
|
|||
}
|
||||
|
||||
// MARK: - Timer Methods
|
||||
func startTimer(duration: TimeInterval, name: String = "Timer") {
|
||||
func startTimer(duration: TimeInterval, name: String = "Timer", preset: TimerPreset? = nil) {
|
||||
// Stop any existing timer
|
||||
timerInstance?.invalidate()
|
||||
|
||||
|
|
@ -140,8 +142,8 @@ class TimerManager: ObservableObject {
|
|||
elapsedTime = 0
|
||||
isPaused = false
|
||||
lastUpdated = Date()
|
||||
|
||||
triggerTimerSneakPeek()
|
||||
|
||||
activePresetId = preset?.id
|
||||
|
||||
// Start countdown timer
|
||||
timerInstance = Timer.scheduledTimer(withTimeInterval: 1.0, repeats: true) { [weak self] _ in
|
||||
|
|
@ -250,8 +252,15 @@ class TimerManager: ObservableObject {
|
|||
isPaused = false
|
||||
isFinished = false
|
||||
isOvertime = false
|
||||
activePresetId = nil
|
||||
}
|
||||
|
||||
|
||||
// MARK: - Derived State
|
||||
var activePreset: TimerPreset? {
|
||||
guard let presetId = activePresetId else { return nil }
|
||||
return Defaults[.timerPresets].first { $0.id == presetId }
|
||||
}
|
||||
|
||||
private func scheduleSmoothClose() {
|
||||
// Wait 3 seconds then smoothly close the live activity
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 3.0) {
|
||||
|
|
@ -297,21 +306,6 @@ class TimerManager: ObservableObject {
|
|||
}
|
||||
}
|
||||
|
||||
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 {
|
||||
|
|
|
|||
|
|
@ -196,6 +196,27 @@ enum ProgressBarStyle: String, CaseIterable, Identifiable, Defaults.Serializable
|
|||
var id: String { self.rawValue }
|
||||
}
|
||||
|
||||
enum TimerIconColorMode: String, CaseIterable, Identifiable, Defaults.Serializable {
|
||||
case adaptive = "Adaptive"
|
||||
case solid = "Solid"
|
||||
|
||||
var id: String { rawValue }
|
||||
|
||||
var displayName: String {
|
||||
switch self {
|
||||
case .adaptive: return "Adaptive gradient"
|
||||
case .solid: return "Solid colour"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
enum TimerProgressStyle: String, CaseIterable, Identifiable, Defaults.Serializable {
|
||||
case bar = "Bar"
|
||||
case ring = "Ring"
|
||||
|
||||
var id: String { rawValue }
|
||||
}
|
||||
|
||||
// AI Model types for screen assistant
|
||||
enum AIModelProvider: String, CaseIterable, Identifiable, Defaults.Serializable {
|
||||
case gemini = "Gemini"
|
||||
|
|
@ -407,6 +428,14 @@ extension Defaults.Keys {
|
|||
|
||||
// MARK: Timer Feature
|
||||
static let enableTimerFeature = Key<Bool>("enableTimerFeature", default: true)
|
||||
static let timerPresets = Key<[TimerPreset]>("timerPresets", default: TimerPreset.defaultPresets)
|
||||
static let timerIconColorMode = Key<TimerIconColorMode>("timerIconColorMode", default: .adaptive)
|
||||
static let timerSolidColor = Key<Color>("timerSolidColor", default: .blue)
|
||||
static let timerShowsCountdown = Key<Bool>("timerShowsCountdown", default: true)
|
||||
static let timerShowsLabel = Key<Bool>("timerShowsLabel", default: true)
|
||||
static let timerShowsProgress = Key<Bool>("timerShowsProgress", default: true)
|
||||
static let timerProgressStyle = Key<TimerProgressStyle>("timerProgressStyle", default: .bar)
|
||||
static let timerControlWindowEnabled = Key<Bool>("timerControlWindowEnabled", default: true)
|
||||
|
||||
// MARK: ColorPicker Feature
|
||||
static let enableColorPickerFeature = Key<Bool>("enableColorPickerFeature", default: true)
|
||||
|
|
|
|||
|
|
@ -33,6 +33,7 @@ class DynamicIslandViewModel: NSObject, ObservableObject {
|
|||
@Published var isColorPickerPopoverActive: Bool = false
|
||||
@Published var isStatsPopoverActive: Bool = false
|
||||
@Published var isMediaOutputPopoverActive: Bool = false
|
||||
@Published var isTimerPopoverActive: Bool = false
|
||||
@Published var shouldRecheckHover: Bool = false
|
||||
|
||||
let webcamManager = WebcamManager.shared
|
||||
|
|
|
|||
93
DynamicIsland/models/TimerPreset.swift
Normal file
93
DynamicIsland/models/TimerPreset.swift
Normal file
|
|
@ -0,0 +1,93 @@
|
|||
//
|
||||
// TimerPreset.swift
|
||||
// DynamicIsland
|
||||
//
|
||||
// Created by GitHub Copilot on 2025-11-01.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
import AppKit
|
||||
import Defaults
|
||||
|
||||
struct TimerPreset: Identifiable, Codable, Hashable, Defaults.Serializable {
|
||||
struct ColorData: Codable, Hashable, Defaults.Serializable {
|
||||
var red: Double
|
||||
var green: Double
|
||||
var blue: Double
|
||||
var alpha: Double
|
||||
|
||||
init(red: Double, green: Double, blue: Double, alpha: Double = 1.0) {
|
||||
self.red = red
|
||||
self.green = green
|
||||
self.blue = blue
|
||||
self.alpha = alpha
|
||||
}
|
||||
|
||||
init(color: Color) {
|
||||
let nsColor = NSColor(color).usingColorSpace(.sRGB) ?? NSColor(color)
|
||||
self.red = nsColor.redComponent
|
||||
self.green = nsColor.greenComponent
|
||||
self.blue = nsColor.blueComponent
|
||||
self.alpha = nsColor.alphaComponent
|
||||
}
|
||||
|
||||
var color: Color {
|
||||
Color(red: red, green: green, blue: blue, opacity: alpha)
|
||||
}
|
||||
}
|
||||
|
||||
struct DurationComponents {
|
||||
var hours: Int
|
||||
var minutes: Int
|
||||
var seconds: Int
|
||||
}
|
||||
|
||||
var id: UUID
|
||||
var name: String
|
||||
var duration: TimeInterval
|
||||
var colorData: ColorData
|
||||
|
||||
init(id: UUID = UUID(), name: String, duration: TimeInterval, colorData: ColorData) {
|
||||
self.id = id
|
||||
self.name = name
|
||||
self.duration = duration
|
||||
self.colorData = colorData
|
||||
}
|
||||
|
||||
init(id: UUID = UUID(), name: String, duration: TimeInterval, color: Color) {
|
||||
self.init(id: id, name: name, duration: duration, colorData: ColorData(color: color))
|
||||
}
|
||||
|
||||
var color: Color {
|
||||
colorData.color
|
||||
}
|
||||
|
||||
mutating func updateColor(_ newColor: Color) {
|
||||
colorData = ColorData(color: newColor)
|
||||
}
|
||||
|
||||
var formattedDuration: String {
|
||||
let formatter = DateComponentsFormatter()
|
||||
formatter.allowedUnits = duration >= 3600 ? [.hour, .minute, .second] : [.minute, .second]
|
||||
formatter.zeroFormattingBehavior = [.pad]
|
||||
return formatter.string(from: duration) ?? "0:00"
|
||||
}
|
||||
|
||||
static func components(for duration: TimeInterval) -> DurationComponents {
|
||||
let totalSeconds = max(0, Int(duration))
|
||||
let hours = totalSeconds / 3600
|
||||
let minutes = (totalSeconds % 3600) / 60
|
||||
let seconds = totalSeconds % 60
|
||||
return DurationComponents(hours: hours, minutes: minutes, seconds: seconds)
|
||||
}
|
||||
|
||||
static func duration(from components: DurationComponents) -> TimeInterval {
|
||||
TimeInterval(components.hours * 3600 + components.minutes * 60 + components.seconds)
|
||||
}
|
||||
|
||||
static let defaultPresets: [TimerPreset] = [
|
||||
TimerPreset(name: "Focus", duration: 25 * 60, color: Color.orange),
|
||||
TimerPreset(name: "Break", duration: 5 * 60, color: Color.green),
|
||||
TimerPreset(name: "Deep Work", duration: 45 * 60, color: Color.purple)
|
||||
]
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue