From 5a8c592157612ffc137a58f2ddca45530e38d773 Mon Sep 17 00:00:00 2001 From: Hariharan Mudaliar Date: Tue, 9 Dec 2025 17:03:17 +0530 Subject: [PATCH 1/3] Working stuff with revamped UI --- DynamicIsland/ContentView.swift | 13 +- .../DynamicIslandViewCoordinator.swift | 31 + DynamicIsland/Localizable.xcstrings | 49 +- .../Notch/DynamicIslandHeader.swift | 9 +- .../components/Notch/NotchTimerView.swift | 733 ++++++++++++------ .../components/Settings/SettingsView.swift | 10 + .../components/Tabs/TabSelectionView.swift | 6 + DynamicIsland/enums/TimerDisplayMode.swift | 26 + .../managers/BluetoothAudioManager.swift | 16 +- .../managers/SystemChangesObserver.swift | 45 +- DynamicIsland/models/Constants.swift | 1 + 11 files changed, 653 insertions(+), 286 deletions(-) create mode 100644 DynamicIsland/enums/TimerDisplayMode.swift diff --git a/DynamicIsland/ContentView.swift b/DynamicIsland/ContentView.swift index e6bcedc..c8347ed 100644 --- a/DynamicIsland/ContentView.swift +++ b/DynamicIsland/ContentView.swift @@ -37,6 +37,8 @@ struct ContentView: View { @Default(.showNetworkGraph) var showNetworkGraph @Default(.showDiskGraph) var showDiskGraph @Default(.enableReminderLiveActivity) var enableReminderLiveActivity + @Default(.enableTimerFeature) var enableTimerFeature + @Default(.timerDisplayMode) var timerDisplayMode // Dynamic sizing based on view type and graph count with smooth transitions var dynamicNotchSize: CGSize { @@ -765,9 +767,11 @@ struct ContentView: View { triggerHapticIfAllowed() } + let shouldFocusTimerTab = enableTimerFeature && timerDisplayMode == .tab && timerManager.isTimerActive + guard vm.notchState == .closed, - !coordinator.sneakPeek.show, - Defaults[.openNotchOnHover] else { return } + !coordinator.sneakPeek.show, + (Defaults[.openNotchOnHover] || shouldFocusTimerTab) else { return } hoverTask = Task { try? await Task.sleep(for: .seconds(Defaults[.minimumHoverDuration])) @@ -778,6 +782,11 @@ struct ContentView: View { self.isHovering, !self.coordinator.sneakPeek.show else { return } + if shouldFocusTimerTab { + withAnimation(.smooth) { + self.coordinator.currentView = .timer + } + } self.openNotch() } } diff --git a/DynamicIsland/DynamicIslandViewCoordinator.swift b/DynamicIsland/DynamicIslandViewCoordinator.swift index e4028c8..16813d3 100644 --- a/DynamicIsland/DynamicIslandViewCoordinator.swift +++ b/DynamicIsland/DynamicIslandViewCoordinator.swift @@ -47,6 +47,7 @@ struct ExpandedItem { class DynamicIslandViewCoordinator: ObservableObject { static let shared = DynamicIslandViewCoordinator() + private var cancellables = Set() @Published var currentView: NotchViews = .home { didSet { @@ -64,6 +65,9 @@ class DynamicIslandViewCoordinator: ObservableObject { @AppStorage("showWhatsNew") var showWhatsNew: Bool = true @AppStorage("musicLiveActivityEnabled") var musicLiveActivityEnabled: Bool = true @AppStorage("timerLiveActivityEnabled") var timerLiveActivityEnabled: Bool = true + + @Default(.enableTimerFeature) private var enableTimerFeature + @Default(.timerDisplayMode) private var timerDisplayMode @AppStorage("alwaysShowTabs") var alwaysShowTabs: Bool = true { didSet { @@ -99,6 +103,19 @@ class DynamicIslandViewCoordinator: ObservableObject { private init() { selectedScreen = preferredScreen + Defaults.publisher(.timerDisplayMode) + .receive(on: DispatchQueue.main) + .sink { [weak self] change in + self?.handleTimerDisplayModeChange(change.newValue) + } + .store(in: &cancellables) + + Defaults.publisher(.enableTimerFeature) + .receive(on: DispatchQueue.main) + .sink { [weak self] change in + self?.handleTimerFeatureToggle(change.newValue) + } + .store(in: &cancellables) } private func handleStatsTabTransition(from oldValue: NotchViews, to newValue: NotchViews) { @@ -121,6 +138,20 @@ class DynamicIslandViewCoordinator: ObservableObject { } } } + + private func handleTimerDisplayModeChange(_ mode: TimerDisplayMode) { + guard mode == .popover, currentView == .timer else { return } + withAnimation(.smooth) { + currentView = .home + } + } + + private func handleTimerFeatureToggle(_ isEnabled: Bool) { + guard !isEnabled, currentView == .timer else { return } + withAnimation(.smooth) { + currentView = .home + } + } func toggleSneakPeek(status: Bool, type: SneakContentType, duration: TimeInterval = 1.5, value: CGFloat = 0, icon: String = "") { let resolvedDuration: TimeInterval diff --git a/DynamicIsland/Localizable.xcstrings b/DynamicIsland/Localizable.xcstrings index 2aeef68..6de831a 100644 --- a/DynamicIsland/Localizable.xcstrings +++ b/DynamicIsland/Localizable.xcstrings @@ -230,6 +230,10 @@ } } }, + ":" : { + "comment" : "A colon used in the timer interface.", + "isCommentAutoGenerated" : true + }, "'Now Playing' was the only option on previous versions and works with all media apps." : { "localizations" : { "ar" : { @@ -1374,6 +1378,10 @@ "comment" : "A label displayed in a circular gauge to indicate the minimum battery level.", "isCommentAutoGenerated" : true }, + "00" : { + "comment" : "A placeholder text for the minutes in the timer.", + "isCommentAutoGenerated" : true + }, "00:05:00" : { "comment" : "A time displayed in the timer panel on a lock screen.", "isCommentAutoGenerated" : true @@ -5713,6 +5721,10 @@ "comment" : "A message displayed when there are no presets configured. It encourages the user to configure presets in the app settings.", "isCommentAutoGenerated" : true }, + "Configure presets in Settings to see them here." : { + "comment" : "A message displayed when there are no timer presets configured. It instructs the user to configure presets in the app settings to see them here.", + "isCommentAutoGenerated" : true + }, "Connected" : { "extractionState" : "stale", "localizations" : { @@ -5963,6 +5975,7 @@ "isCommentAutoGenerated" : true }, "Custom" : { + "extractionState" : "stale", "localizations" : { "fr" : { "stringUnit" : { @@ -8143,6 +8156,10 @@ }, "Enable stats monitoring in Settings to view system performance data." : { + }, + "Enable the timer feature in Settings to access this tab." : { + "comment" : "A message explaining that the timer feature must be enabled in settings to access this tab.", + "isCommentAutoGenerated" : true }, "Enable the weather capsule and configure its layout, provider, units, and optional battery/AQI indicators." : { "comment" : "A footer for the \"Weather Widget\" section in the Lock Screen Settings view, describing its purpose.", @@ -8150,9 +8167,6 @@ }, "Enable Thinking Mode" : { - }, - "Enable timer feature in Settings to use this tab" : { - }, "Enable timer live activity" : { @@ -9636,10 +9650,6 @@ } } }, - "Hide from Notch" : { - "comment" : "A button label that hides the Clock timer from the Notch app.", - "isCommentAutoGenerated" : true - }, "Hide only when NowPlaying app is in fullscreen" : { "localizations" : { "ar" : { @@ -11268,8 +11278,9 @@ "comment" : "A description of how to manage the microphone privacy indicator preferences.", "isCommentAutoGenerated" : true }, - "Manage the Clock timer from the Clock app." : { - + "Managed in Clock app" : { + "comment" : "A notice displayed below the custom timer section, explaining that the timer is managed by the Clock app.", + "isCommentAutoGenerated" : true }, "Mark as complete" : { "comment" : "An accessibility label for a button that marks an item as complete.", @@ -12559,6 +12570,7 @@ } }, "min" : { + "extractionState" : "stale", "localizations" : { "fr" : { "stringUnit" : { @@ -14859,6 +14871,10 @@ "comment" : "Label describing the system volume control inside the media output selector.", "isCommentAutoGenerated" : true }, + "Overtime" : { + "comment" : "A label indicating that the timer is running overtime.", + "isCommentAutoGenerated" : true + }, "Panel mode shows clipboard in a floating window near the notch. Popover mode shows clipboard as a dropdown attached to the clipboard button." : { }, @@ -14870,9 +14886,6 @@ }, "Pause" : { - }, - "Paused" : { - }, "Percentage" : { "extractionState" : "stale", @@ -15214,10 +15227,6 @@ "comment" : "A picker that lets the user select a quick share service to use when sharing files from the shelf.", "isCommentAutoGenerated" : true }, - "Quick Start" : { - "comment" : "A label displayed above the quick start options in the timer tab.", - "isCommentAutoGenerated" : true - }, "Quit" : { "localizations" : { "ar" : { @@ -18164,6 +18173,10 @@ } } }, + "Start Timer" : { + "comment" : "A button label that starts a custom timer.", + "isCommentAutoGenerated" : true + }, "Starts a 5-minute demo timer to test the timer live activity feature. Only works when timer feature is enabled." : { }, @@ -18782,6 +18795,10 @@ } } }, + "Timer controls appear as" : { + "comment" : "A picker that lets the user choose how the timer controls are displayed.", + "isCommentAutoGenerated" : true + }, "Timer Disabled" : { "localizations" : { "fr" : { diff --git a/DynamicIsland/components/Notch/DynamicIslandHeader.swift b/DynamicIsland/components/Notch/DynamicIslandHeader.swift index e1169c7..88c1536 100644 --- a/DynamicIsland/components/Notch/DynamicIslandHeader.swift +++ b/DynamicIsland/components/Notch/DynamicIslandHeader.swift @@ -21,6 +21,7 @@ struct DynamicIslandHeader: View { @State private var showColorPickerPopover = false @State private var showTimerPopover = false @Default(.enableTimerFeature) var enableTimerFeature + @Default(.timerDisplayMode) var timerDisplayMode var body: some View { HStack(spacing: 0) { @@ -144,7 +145,7 @@ struct DynamicIslandHeader: View { } } - if Defaults[.enableTimerFeature] { + if Defaults[.enableTimerFeature] && timerDisplayMode == .popover { Button(action: { withAnimation(.smooth) { showTimerPopover.toggle() @@ -252,6 +253,12 @@ struct DynamicIslandHeader: View { vm.isTimerPopoverActive = false } } + .onChange(of: timerDisplayMode) { _, mode in + if mode == .tab { + showTimerPopover = false + vm.isTimerPopoverActive = false + } + } } } diff --git a/DynamicIsland/components/Notch/NotchTimerView.swift b/DynamicIsland/components/Notch/NotchTimerView.swift index 3602b40..decaefd 100644 --- a/DynamicIsland/components/Notch/NotchTimerView.swift +++ b/DynamicIsland/components/Notch/NotchTimerView.swift @@ -7,298 +7,547 @@ import SwiftUI import Defaults +#if canImport(AppKit) +import AppKit +#endif struct NotchTimerView: View { @EnvironmentObject var vm: DynamicIslandViewModel @ObservedObject var timerManager = TimerManager.shared @ObservedObject var coordinator = DynamicIslandViewCoordinator.shared @Default(.enableTimerFeature) var enableTimerFeature - - @AppStorage("customTimerDuration") private var customTimerDuration: Double = 600 // 10 minutes default - + @Default(.timerPresets) private var timerPresets + @Default(.timerIconColorMode) private var colorMode + @Default(.timerSolidColor) private var solidColor + @Default(.timerShowsProgress) private var showsProgress + @Default(.timerProgressStyle) private var progressStyle + + @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 isSyncingCustomDuration = false + @State private var lockedAccentColor: Color? + var body: some View { - if enableTimerFeature { - HStack(alignment: .center, spacing: 32) { - // Timer Progress and Controls - timerProgressSection - - // Timer Control Panel - controlsSection - } - .padding(.horizontal, 20) - .transition(.opacity.combined(with: .blurReplace)) - } else { - VStack(spacing: 16) { - Image(systemName: "timer.slash") - .font(.system(size: 48, weight: .light)) - .foregroundStyle(.secondary) - - Text("Timer Disabled") - .font(.title2) - .fontWeight(.medium) - .foregroundStyle(.primary) - - Text("Enable timer feature in Settings to use this tab") - .font(.body) - .foregroundStyle(.secondary) - .multilineTextAlignment(.center) - } - .frame(maxWidth: .infinity, maxHeight: .infinity) - } - } - - 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(openIconColor) - .scaleEffect(timerManager.isRunning && timerManager.isTimerActive ? 1.1 : 1.0) - .animation( - timerManager.isRunning && timerManager.isTimerActive ? - .easeInOut(duration: 0.3).repeatForever(autoreverses: true) : - .easeInOut(duration: 0.3), - value: timerManager.isRunning && timerManager.isTimerActive - ) - } - - // Time display - 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) + Group { + if enableTimerFeature { + HStack(alignment: .top, spacing: timerManager.isTimerActive ? 0 : 20) { + leftColumn + if shouldShowPresetColumn { + Divider() + .frame(height: max(0, maxTabContentHeight - 8)) + .opacity(0.2) + presetColumn + } } + .frame(maxHeight: maxTabContentHeight, alignment: .top) + .padding(.horizontal, 16) + .padding(.vertical, 6) + .transition(.opacity.combined(with: .blurReplace)) + .onAppear { syncCustomDuration(with: customTimerDuration) } + .onChange(of: customTimerDuration) { _, newValue in syncCustomDuration(with: newValue) } + .onChange(of: customHours) { _, _ in updateStoredCustomDuration() } + .onChange(of: customMinutes) { _, _ in updateStoredCustomDuration() } + .onChange(of: customSeconds) { _, _ in updateStoredCustomDuration() } + } else { + disabledState + } + } + .onAppear { + lockAccentColorIfNeeded() + } + .onChange(of: timerManager.isTimerActive) { _, isActive in + if isActive { + lockAccentColorIfNeeded() + } else { + lockedAccentColor = nil + } + } + .onChange(of: timerManager.activePresetId) { _, _ in + if timerManager.isTimerActive && lockedAccentColor == nil { + lockAccentColorIfNeeded() } } } - - private var controlsSection: some View { - VStack(alignment: .leading, spacing: 20) { - if timerManager.allowsManualInteraction { - manualControlButtons - quickTimerControls + + private var leftColumn: some View { + VStack(alignment: .leading, spacing: 16) { + if timerManager.isTimerActive { + activeTimerCard } else { - externalTimerNotice + customTimerComposer } + Spacer(minLength: 0) } .frame(maxWidth: .infinity, alignment: .leading) + .frame(maxHeight: maxTabContentHeight, alignment: .top) + .padding(.bottom, 2) } - private var manualControlButtons: some View { - VStack(alignment: .leading, spacing: 16) { - if timerManager.isOvertime { - 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(perform: cursorHover) + private var presetColumn: some View { + VStack(spacing: 6) { + if timerPresets.isEmpty { + Text("Configure presets in Settings to see them here.") + .font(.system(size: 12)) + .foregroundStyle(.secondary) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(14) + .background(Color.white.opacity(0.05)) + .clipShape(RoundedRectangle(cornerRadius: 14, style: .continuous)) } else { - HStack(spacing: 16) { - Button(action: { - if timerManager.isPaused { - timerManager.resumeTimer() - } else { - timerManager.pauseTimer() + let computedHeight = CGFloat(timerPresets.count) * 60 + 4 + let listHeight = min(max(0, maxTabContentHeight - 16), computedHeight) + List { + ForEach(timerPresets) { preset in + TimerPresetCard(preset: preset, isActive: timerManager.activePresetId == preset.id) { + timerManager.startTimer(duration: preset.duration, name: preset.name, preset: preset) + coordinator.currentView = .timer } - }) { - 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)) + .listRowInsets(EdgeInsets(top: 2, leading: 0, bottom: 2, trailing: 0)) + .listRowBackground(Color.clear) + .listRowSeparator(.hidden) } - .buttonStyle(PlainButtonStyle()) - .onHover(perform: cursorHover) - - 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.1)) - .clipShape(RoundedRectangle(cornerRadius: 12)) - } - .buttonStyle(PlainButtonStyle()) - .onHover(perform: cursorHover) } + .listStyle(.plain) + .scrollContentBackground(.hidden) + .scrollIndicators(.never) + .frame(height: listHeight) + } + } + .frame(width: 210, alignment: .leading) + .frame(maxHeight: maxTabContentHeight, alignment: .top) + .padding(.bottom, 2) + } + + private var activeTimerCard: some View { + VStack(alignment: .leading, spacing: 12) { + HStack(alignment: .center, spacing: 14) { + leadingControlSection + .frame(width: 128, alignment: .leading) + + timerTitleSection + + countdownSection + } + + if showsProgress { + progressSection + } + } + .padding(16) + .background(Color.white.opacity(0.06)) + .clipShape(RoundedRectangle(cornerRadius: 18, style: .continuous)) + } + + @ViewBuilder + private var leadingControlSection: some View { + if timerManager.allowsManualInteraction { + HStack(spacing: 10) { + TimerControlButton( + icon: pauseIconName, + foreground: .white.opacity(0.95), + background: timerAccentColor.opacity(0.32), + accessibilityLabel: pauseAccessibilityLabel, + action: togglePauseAction + ) + + TimerControlButton( + icon: "xmark", + foreground: .white.opacity(0.95), + background: Color.white.opacity(0.16), + accessibilityLabel: "Stop", + action: stopTimerAction + ) + } + } else { + externalTimerNotice + } + } + + private var timerTitleSection: some View { + GeometryReader { geometry in + let status = timerStatusText + let spacing: CGFloat = status == nil ? 0 : 8 + let badgeWidth: CGFloat = status.map(statusBadgeWidth) ?? 0 + let marqueeWidth = max(48, geometry.size.width - badgeWidth - spacing) + + HStack(alignment: .center, spacing: spacing) { + MarqueeText( + .constant(timerDisplayName), + font: .system(size: 20, weight: .semibold), + nsFont: .title3, + textColor: .white, + minDuration: 0.2, + frameWidth: marqueeWidth + ) + .frame(width: marqueeWidth, height: 24, alignment: .leading) + + if let status { + statusBadge(status) + .frame(width: badgeWidth) + } + } + .frame(maxWidth: .infinity, alignment: .leading) + } + .frame(height: 32) + } + + private var countdownSection: some View { + VStack(alignment: .trailing, spacing: 4) { + Text(timerManager.formattedRemainingTime()) + .font(.system(size: 36, weight: .black, design: .monospaced)) + .foregroundStyle(timerManager.isOvertime ? Color.red : .white) + .contentTransition(.numericText()) + .animation(.smooth(duration: 0.25), value: timerManager.remainingTime) + + if timerManager.isOvertime { + Text("Overtime") + .font(.caption) + .foregroundStyle(.red) + } + } + .frame(width: 150, alignment: .trailing) + } + + @ViewBuilder + private var progressSection: some View { + if progressStyle == .bar { + GeometryReader { geometry in + Capsule() + .fill(Color.white.opacity(0.12)) + .frame(height: 4) + .overlay(alignment: .leading) { + Capsule() + .fill(timerAccentColor) + .frame(width: geometry.size.width * max(0, CGFloat(min(timerManager.progress, 1.0)))) + .animation(.smooth(duration: 0.25), value: timerManager.progress) + } + } + .frame(height: 4) + .frame(maxWidth: .infinity) + } else { + HStack(spacing: 12) { + TimerProgressRing(progress: timerManager.progress, tint: timerAccentColor) + .frame(height: 56) + Spacer() } } } - private var quickTimerControls: some View { - VStack(alignment: .leading, spacing: 16) { - Text("Quick Start") - .font(.system(size: 14, weight: .medium)) - .foregroundStyle(.secondary) + private func togglePauseAction() { + guard timerManager.allowsManualInteraction else { return } + timerManager.isPaused ? timerManager.resumeTimer() : timerManager.pauseTimer() + } - VStack(spacing: 12) { - HStack(spacing: 12) { - quickTimerButton(minutes: 1) - quickTimerButton(minutes: 5) - quickTimerButton(minutes: 15) - } - - HStack(spacing: 12) { - Button(action: { - timerManager.startTimer(duration: customTimerDuration, name: "Custom Timer") - }) { - VStack(spacing: 4) { - Text(customTimerDisplayText) - .font(.system(size: 16, weight: .medium)) - Text("Custom") - .font(.system(size: 12, weight: .regular)) - .opacity(0.7) - } - .foregroundStyle(.white) - .frame(height: 60) - .frame(minWidth: 80) - .background(.white.opacity(0.08)) - .clipShape(RoundedRectangle(cornerRadius: 12)) - .overlay( - RoundedRectangle(cornerRadius: 12) - .stroke(.white.opacity(0.1), lineWidth: 1) - ) - } - .buttonStyle(PlainButtonStyle()) - .onHover(perform: cursorHover) - } - } + private func stopTimerAction() { + if timerManager.allowsManualInteraction { + timerManager.stopTimer() + } else { + timerManager.endExternalTimer(triggerSmoothClose: false) } } + private var customTimerComposer: some View { + VStack(alignment: .leading, spacing: 10) { + DurationInputRow(hours: $customHours, minutes: $customMinutes, seconds: $customSeconds) + + Button { + withAnimation(.smooth) { + timerManager.startTimer(duration: customDurationInSeconds, name: "Custom Timer") + coordinator.currentView = .timer + } + } label: { + Label("Start Timer", systemImage: "play.fill") + .frame(maxWidth: .infinity) + } + .buttonStyle(.borderedProminent) + .disabled(customDurationInSeconds == 0) + } + .padding(12) + .background(Color.white.opacity(0.04)) + .clipShape(RoundedRectangle(cornerRadius: 14, style: .continuous)) + } + private var externalTimerNotice: some View { - VStack(alignment: .leading, spacing: 12) { - Text("Manage the Clock timer from the Clock app.") - .font(.system(size: 14, weight: .medium)) + VStack(alignment: .leading, spacing: 6) { + Label("Managed in Clock app", systemImage: "arrow.triangle.2.circlepath") + .font(.caption) .foregroundStyle(.secondary) - Button(action: { + Button("Hide", role: .destructive) { timerManager.endExternalTimer(triggerSmoothClose: false) - }) { - HStack(spacing: 8) { - Image(systemName: "eye.slash") - .font(.system(size: 16, weight: .medium)) - Text("Hide from Notch") - .font(.system(size: 16, weight: .medium)) - } - .foregroundStyle(.white) - .frame(height: 40) - .frame(minWidth: 140) - .background(.white.opacity(0.12)) - .clipShape(RoundedRectangle(cornerRadius: 12)) } - .buttonStyle(PlainButtonStyle()) - .onHover(perform: cursorHover) + .buttonStyle(.borderless) + .font(.caption) } } - - private var customTimerDisplayText: String { - let totalMinutes = Int(customTimerDuration) / 60 - let seconds = Int(customTimerDuration) % 60 - let hours = totalMinutes / 60 - let minutes = totalMinutes % 60 - - if hours > 0 { - return "\(hours):\(String(format: "%02d", minutes))" - } else if minutes > 0 { - return "\(minutes) min" - } else { - return "\(seconds)s" + + private var disabledState: some View { + VStack(spacing: 16) { + Image(systemName: "timer.slash") + .font(.system(size: 48, weight: .light)) + .foregroundStyle(.secondary) + + Text("Timer Disabled") + .font(.title2) + .fontWeight(.medium) + + Text("Enable the timer feature in Settings to access this tab.") + .font(.body) + .foregroundStyle(.secondary) + .multilineTextAlignment(.center) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + } + + private var shouldShowPresetColumn: Bool { + !timerManager.isTimerActive + } + + private var resolvedNotchHeight: CGFloat { + let height = vm.notchSize.height + return height > 0 ? height : openNotchSize.height + } + + private var headerHeight: CGFloat { + max(24, vm.effectiveClosedNotchHeight) + } + + private var maxTabContentHeight: CGFloat { + let available = resolvedNotchHeight - headerHeight - 36 + return max(130, available) + } + + private func lockAccentColorIfNeeded() { + if timerManager.isTimerActive { + lockedAccentColor = resolvedAccentColor } } - - private func quickTimerButton(minutes: Int) -> some View { - Button(action: { - timerManager.startTimer(duration: TimeInterval(minutes * 60), name: "\(minutes) Min Timer") - }) { - 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) - ) + + private var timerAccentColor: Color { + lockedAccentColor ?? resolvedAccentColor + } + + private var resolvedAccentColor: Color { + switch colorMode { + case .adaptive: + return timerManager.activePreset?.color ?? timerManager.timerColor + case .solid: + return solidColor } - .buttonStyle(PlainButtonStyle()) - .onHover(perform: cursorHover) + } + + private var timerDisplayName: String { + timerManager.timerName.isEmpty ? "Timer" : timerManager.timerName + } + + private var timerStatusText: String? { + if timerManager.isOvertime { + return "Overtime" + } else if timerManager.isPaused { + return "Paused" + } else if timerManager.isFinished { + return "Completed" + } + return nil + } + + private var timerStatusColor: Color { + timerManager.isOvertime ? .red : timerAccentColor + } + + private func statusBadge(_ text: String) -> some View { + Text(text) + .font(.caption.weight(.semibold)) + .foregroundStyle(timerStatusColor) + .padding(.horizontal, 12) + .padding(.vertical, 4) + .background(timerStatusColor.opacity(0.18)) + .clipShape(Capsule()) + } + + private func statusBadgeWidth(for text: String) -> CGFloat { +#if canImport(AppKit) + let font = NSFont.systemFont(ofSize: 12, weight: .semibold) +#elseif canImport(UIKit) + let font = UIFont.systemFont(ofSize: 12, weight: .semibold) +#else + return 80 +#endif + let width = (text as NSString).size(withAttributes: [.font: font]).width + return width + 24 + } + + private var pauseIconName: String { + timerManager.isPaused ? "play.fill" : "pause.fill" + } + + private var pauseAccessibilityLabel: String { + timerManager.isPaused ? "Resume" : "Pause" + } + + private var customDurationInSeconds: TimeInterval { + TimeInterval(customHours * 3600 + customMinutes * 60 + customSeconds) + } + + private func syncCustomDuration(with value: Double) { + isSyncingCustomDuration = true + let components = TimerPreset.components(for: value) + customHours = components.hours + customMinutes = components.minutes + customSeconds = components.seconds + isSyncingCustomDuration = false + } + + private func updateStoredCustomDuration() { + guard !isSyncingCustomDuration else { return } + customTimerDuration = customDurationInSeconds } } -private extension NotchTimerView { - var openIconColor: Color { - guard timerManager.isTimerActive && timerManager.isRunning else { return .white.opacity(0.9) } - return timerManager.currentColor +private struct TimerControlButton: View { + let icon: String + let foreground: Color + let background: Color + let accessibilityLabel: String + let action: () -> Void + + var body: some View { + Button(action: action) { + Image(systemName: icon) + .font(.system(size: 18, weight: .semibold)) + .foregroundStyle(foreground) + .frame(width: 46, height: 46) + .background(background) + .clipShape(Circle()) + } + .buttonStyle(.plain) + .help(accessibilityLabel) + } +} + +private struct TimerProgressRing: View { + let progress: Double + let tint: Color + + var body: some View { + ZStack { + Circle() + .stroke(Color.white.opacity(0.12), lineWidth: 6) + Circle() + .trim(from: 0, to: min(progress, 1.0)) + .stroke(tint, style: StrokeStyle(lineWidth: 6, lineCap: .round)) + .rotationEffect(.degrees(-90)) + .animation(.smooth(duration: 0.3), value: progress) + } + .frame(width: 60, height: 60) + } +} + +private struct DurationInputRow: View { + @Binding var hours: Int + @Binding var minutes: Int + @Binding var seconds: Int + + var body: some View { + HStack(alignment: .center, spacing: 8) { + DurationField(label: "HH", value: $hours, range: 0...23) + colon + DurationField(label: "MM", value: $minutes, range: 0...59) + colon + DurationField(label: "SS", value: $seconds, range: 0...59) + } } - private func cursorHover(_ isHovering: Bool) { - if isHovering { - NSCursor.pointingHand.push() - } else { - NSCursor.pop() + private var colon: some View { + Text(":") + .font(.system(size: 26, weight: .black, design: .monospaced)) + .foregroundStyle(.secondary) + } +} + +private struct DurationField: View { + let label: String + @Binding var value: Int + let range: ClosedRange + + var body: some View { + VStack(spacing: 6) { + TextField("00", text: binding) + .font(.system(size: 28, weight: .semibold, design: .monospaced)) + .multilineTextAlignment(.center) + .textFieldStyle(.plain) + .frame(width: 64, height: 46) + .background(Color.white.opacity(0.08)) + .clipShape(RoundedRectangle(cornerRadius: 12, style: .continuous)) + + Text(label) + .font(.caption) + .foregroundStyle(.secondary) } } + + private var binding: Binding { + Binding( + get: { String(format: "%02d", value) }, + set: { newValue in + let digits = newValue.filter { $0.isNumber } + let number = min(max(range.lowerBound, Int(digits) ?? 0), range.upperBound) + value = number + } + ) + } +} + +private struct TimerPresetCard: 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: 30, height: 30) + .overlay( + Circle() + .stroke(Color.white.opacity(0.3), lineWidth: 1) + ) + + VStack(alignment: .leading, spacing: 2) { + Text(preset.name) + .font(.system(size: 14, weight: .semibold)) + .lineLimit(1) + Text(preset.formattedDuration) + .font(.caption.monospaced()) + .foregroundStyle(.secondary) + } + + Spacer() + + Image(systemName: isActive ? "checkmark" : "play.fill") + .font(.system(size: 12, weight: .bold)) + .foregroundStyle(isActive ? preset.color : Color.secondary) + .padding(6) + .background(isActive ? preset.color.opacity(0.2) : Color.white.opacity(0.08)) + .clipShape(Circle()) + } + .padding(.horizontal, 12) + .padding(.vertical, 6) + .background( + RoundedRectangle(cornerRadius: 16, style: .continuous) + .fill(isActive ? preset.color.opacity(0.12) : Color.white.opacity(0.04)) + ) + } + .buttonStyle(.plain) + } } #Preview { NotchTimerView() .environmentObject(DynamicIslandViewModel()) - .frame(width: 500, height: 200) + .frame(width: 600, height: 320) .background(.black) } diff --git a/DynamicIsland/components/Settings/SettingsView.swift b/DynamicIsland/components/Settings/SettingsView.swift index c193d4c..850b7d4 100644 --- a/DynamicIsland/components/Settings/SettingsView.swift +++ b/DynamicIsland/components/Settings/SettingsView.swift @@ -3354,6 +3354,7 @@ struct TimerSettings: View { @Default(.timerProgressStyle) private var progressStyle @Default(.timerControlWindowEnabled) private var controlWindowEnabled @Default(.mirrorSystemTimer) private var mirrorSystemTimer + @Default(.timerDisplayMode) private var timerDisplayMode @Default(.enableLockScreenTimerWidget) private var enableLockScreenTimerWidget @AppStorage("customTimerDuration") private var customTimerDuration: Double = 600 @State private var customHours: Int = 0 @@ -3390,6 +3391,15 @@ struct TimerSettings: View { Defaults.Toggle("Mirror macOS Clock timers", key: .mirrorSystemTimer) .help("Shows the system Clock timer in the notch when available. Requires Accessibility permission to read the status item.") .settingsHighlight(id: highlightID("Mirror macOS Clock timers")) + + Picker("Timer controls appear as", selection: $timerDisplayMode) { + ForEach(TimerDisplayMode.allCases) { mode in + Text(mode.displayName).tag(mode) + } + } + .pickerStyle(.segmented) + .help(timerDisplayMode.description) + .settingsHighlight(id: highlightID("Timer controls appear as")) } } header: { Text("Timer Feature") diff --git a/DynamicIsland/components/Tabs/TabSelectionView.swift b/DynamicIsland/components/Tabs/TabSelectionView.swift index 6e0e9b4..9d552a9 100644 --- a/DynamicIsland/components/Tabs/TabSelectionView.swift +++ b/DynamicIsland/components/Tabs/TabSelectionView.swift @@ -17,8 +17,10 @@ struct TabModel: Identifiable { struct TabSelectionView: View { @ObservedObject var coordinator = DynamicIslandViewCoordinator.shared + @Default(.enableTimerFeature) var enableTimerFeature @Default(.enableStatsFeature) var enableStatsFeature @Default(.enableColorPickerFeature) var enableColorPickerFeature + @Default(.timerDisplayMode) var timerDisplayMode @Namespace var animation private var tabs: [TabModel] { @@ -30,6 +32,10 @@ struct TabSelectionView: View { tabsArray.append(TabModel(label: "Shelf", icon: "tray.fill", view: .shelf)) } + if enableTimerFeature && timerDisplayMode == .tab { + 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)) diff --git a/DynamicIsland/enums/TimerDisplayMode.swift b/DynamicIsland/enums/TimerDisplayMode.swift new file mode 100644 index 0000000..3f75951 --- /dev/null +++ b/DynamicIsland/enums/TimerDisplayMode.swift @@ -0,0 +1,26 @@ +import Defaults + +public enum TimerDisplayMode: String, CaseIterable, Defaults.Serializable, Identifiable { + case tab + case popover + + public var id: String { rawValue } + + var displayName: String { + switch self { + case .tab: + return "Tab" + case .popover: + return "Popover" + } + } + + var description: String { + switch self { + case .tab: + return "Shows timer controls as a dedicated tab inside the open notch." + case .popover: + return "Keeps the current popover button beside the notch instead of adding a tab." + } + } +} diff --git a/DynamicIsland/managers/BluetoothAudioManager.swift b/DynamicIsland/managers/BluetoothAudioManager.swift index 718ab29..ff8da1a 100644 --- a/DynamicIsland/managers/BluetoothAudioManager.swift +++ b/DynamicIsland/managers/BluetoothAudioManager.swift @@ -1425,13 +1425,15 @@ class BluetoothAudioManager: ObservableObject { HUDSuppressionCoordinator.shared.suppressVolumeHUD(for: 1.5) - coordinator.toggleSneakPeek( - status: true, - type: .bluetoothAudio, - duration: 2.5, - value: batteryValue, - icon: device.deviceType.sfSymbol - ) + Task { @MainActor in + coordinator.toggleSneakPeek( + status: true, + type: .bluetoothAudio, + duration: 2.5, + value: batteryValue, + icon: device.deviceType.sfSymbol + ) + } } // MARK: - Cleanup diff --git a/DynamicIsland/managers/SystemChangesObserver.swift b/DynamicIsland/managers/SystemChangesObserver.swift index 7c2fafa..dd709f5 100644 --- a/DynamicIsland/managers/SystemChangesObserver.swift +++ b/DynamicIsland/managers/SystemChangesObserver.swift @@ -155,12 +155,15 @@ final class SystemChangesObserver: MediaKeyInterceptorDelegate { // Send to notch HUD if enabled and OSD is not enabled if Defaults[.enableSystemHUD] && !Defaults[.enableCustomOSD] && Defaults[.enableVolumeHUD] { - coordinator?.toggleSneakPeek( - status: true, - type: .volume, - value: CGFloat(value), - icon: "" - ) + Task { @MainActor in + guard let coordinator else { return } + coordinator.toggleSneakPeek( + status: true, + type: .volume, + value: CGFloat(value), + icon: "" + ) + } } } @@ -174,12 +177,15 @@ final class SystemChangesObserver: MediaKeyInterceptorDelegate { // Send to notch HUD if enabled and OSD is not enabled if Defaults[.enableSystemHUD] && !Defaults[.enableCustomOSD] && Defaults[.enableBrightnessHUD] { - coordinator?.toggleSneakPeek( - status: true, - type: .brightness, - value: CGFloat(value), - icon: "" - ) + Task { @MainActor in + guard let coordinator else { return } + coordinator.toggleSneakPeek( + status: true, + type: .brightness, + value: CGFloat(value), + icon: "" + ) + } } } @@ -193,12 +199,15 @@ final class SystemChangesObserver: MediaKeyInterceptorDelegate { // Send to notch HUD if enabled and OSD is not enabled if Defaults[.enableSystemHUD] && !Defaults[.enableCustomOSD] && Defaults[.enableKeyboardBacklightHUD] { - coordinator?.toggleSneakPeek( - status: true, - type: .backlight, - value: CGFloat(value), - icon: "" - ) + Task { @MainActor in + guard let coordinator else { return } + coordinator.toggleSneakPeek( + status: true, + type: .backlight, + value: CGFloat(value), + icon: "" + ) + } } } diff --git a/DynamicIsland/models/Constants.swift b/DynamicIsland/models/Constants.swift index 8af0b38..c9e7051 100644 --- a/DynamicIsland/models/Constants.swift +++ b/DynamicIsland/models/Constants.swift @@ -545,6 +545,7 @@ extension Defaults.Keys { // MARK: Timer Feature static let enableTimerFeature = Key("enableTimerFeature", default: true) + static let timerDisplayMode = Key("timerDisplayMode", default: .tab) static let timerPresets = Key<[TimerPreset]>("timerPresets", default: TimerPreset.defaultPresets) static let timerIconColorMode = Key("timerIconColorMode", default: .adaptive) static let timerSolidColor = Key("timerSolidColor", default: .blue) From 547ed4d323e5c4dbed5d7f259c98c92693441e86 Mon Sep 17 00:00:00 2001 From: Hariharan Mudaliar Date: Tue, 9 Dec 2025 17:15:33 +0530 Subject: [PATCH 2/3] Update Timer UI --- DynamicIsland/Localizable.xcstrings | 5 -- .../components/Notch/NotchTimerView.swift | 67 ++++++++++++++++--- 2 files changed, 57 insertions(+), 15 deletions(-) diff --git a/DynamicIsland/Localizable.xcstrings b/DynamicIsland/Localizable.xcstrings index 6de831a..4e1cff8 100644 --- a/DynamicIsland/Localizable.xcstrings +++ b/DynamicIsland/Localizable.xcstrings @@ -18106,7 +18106,6 @@ } }, "Start" : { - "extractionState" : "stale", "localizations" : { "fr" : { "stringUnit" : { @@ -18173,10 +18172,6 @@ } } }, - "Start Timer" : { - "comment" : "A button label that starts a custom timer.", - "isCommentAutoGenerated" : true - }, "Starts a 5-minute demo timer to test the timer live activity feature. Only works when timer feature is enabled." : { }, diff --git a/DynamicIsland/components/Notch/NotchTimerView.swift b/DynamicIsland/components/Notch/NotchTimerView.swift index decaefd..7a88df3 100644 --- a/DynamicIsland/components/Notch/NotchTimerView.swift +++ b/DynamicIsland/components/Notch/NotchTimerView.swift @@ -249,20 +249,49 @@ struct NotchTimerView: View { } private var customTimerComposer: some View { - VStack(alignment: .leading, spacing: 10) { + VStack(alignment: .leading, spacing: 12) { DurationInputRow(hours: $customHours, minutes: $customMinutes, seconds: $customSeconds) - Button { - withAnimation(.smooth) { - timerManager.startTimer(duration: customDurationInSeconds, name: "Custom Timer") - coordinator.currentView = .timer + HStack(spacing: 10) { + Button { + withAnimation(.smooth) { + timerManager.startTimer(duration: customDurationInSeconds, name: "Custom Timer") + coordinator.currentView = .timer + } + } label: { + Label("Start", systemImage: "play.fill") + .font(.system(size: 15, weight: .semibold)) + .frame(maxWidth: .infinity) + .padding(.vertical, 10) + .foregroundStyle(Color.white) } - } label: { - Label("Start Timer", systemImage: "play.fill") - .frame(maxWidth: .infinity) + .buttonStyle(.plain) + .background(startButtonColor.opacity(isStartDisabled ? 0.5 : 1)) + .clipShape(RoundedRectangle(cornerRadius: 12, style: .continuous)) + .overlay( + RoundedRectangle(cornerRadius: 12, style: .continuous) + .stroke(Color.white.opacity(0.15), lineWidth: 1) + ) + .opacity(isStartDisabled ? 0.7 : 1) + .disabled(isStartDisabled) + + Button { + resetCustomTimerInputs() + } label: { + Label("Reset", systemImage: "arrow.counterclockwise") + .font(.system(size: 15, weight: .semibold)) + .frame(maxWidth: .infinity) + .padding(.vertical, 10) + .foregroundStyle(.white.opacity(0.9)) + } + .buttonStyle(.plain) + .background(Color.white.opacity(0.16)) + .clipShape(RoundedRectangle(cornerRadius: 12, style: .continuous)) + .overlay( + RoundedRectangle(cornerRadius: 12, style: .continuous) + .stroke(Color.white.opacity(0.08), lineWidth: 1) + ) } - .buttonStyle(.borderedProminent) - .disabled(customDurationInSeconds == 0) } .padding(12) .background(Color.white.opacity(0.04)) @@ -387,10 +416,27 @@ struct NotchTimerView: View { timerManager.isPaused ? "Resume" : "Pause" } + private var startButtonColor: Color { + Color(red: 0.142, green: 0.633, blue: 0.265) + } + + private var isStartDisabled: Bool { + customDurationInSeconds == 0 + } + private var customDurationInSeconds: TimeInterval { TimeInterval(customHours * 3600 + customMinutes * 60 + customSeconds) } + private func resetCustomTimerInputs() { + withAnimation(.smooth(duration: 0.2)) { + customHours = 0 + customMinutes = 0 + customSeconds = 0 + } + customTimerDuration = 0 + } + private func syncCustomDuration(with value: Double) { isSyncingCustomDuration = true let components = TimerPreset.components(for: value) @@ -421,6 +467,7 @@ private struct TimerControlButton: View { .frame(width: 46, height: 46) .background(background) .clipShape(Circle()) + .contentTransition(.symbolEffect(.replace)) } .buttonStyle(.plain) .help(accessibilityLabel) From 57fc9b9bf928141bccb91b9d57d4973dd8b91f35 Mon Sep 17 00:00:00 2001 From: Hariharan Mudaliar Date: Wed, 10 Dec 2025 14:28:40 +0530 Subject: [PATCH 3/3] Update Notch Timer View: Give option to disable/enable presets --- DynamicIsland/Localizable.xcstrings | 4 + .../components/Notch/NotchTimerView.swift | 147 +++++++++++++----- .../components/Settings/SettingsView.swift | 3 + DynamicIsland/models/Constants.swift | 1 + 4 files changed, 113 insertions(+), 42 deletions(-) diff --git a/DynamicIsland/Localizable.xcstrings b/DynamicIsland/Localizable.xcstrings index 4e1cff8..8a3fe61 100644 --- a/DynamicIsland/Localizable.xcstrings +++ b/DynamicIsland/Localizable.xcstrings @@ -17340,6 +17340,10 @@ } } }, + "Show preset list in timer tab" : { + "comment" : "A toggle that lets users decide whether to show a list of presets in the timer tab.", + "isCommentAutoGenerated" : true + }, "Show progress" : { }, diff --git a/DynamicIsland/components/Notch/NotchTimerView.swift b/DynamicIsland/components/Notch/NotchTimerView.swift index 7a88df3..92f006e 100644 --- a/DynamicIsland/components/Notch/NotchTimerView.swift +++ b/DynamicIsland/components/Notch/NotchTimerView.swift @@ -21,6 +21,7 @@ struct NotchTimerView: View { @Default(.timerSolidColor) private var solidColor @Default(.timerShowsProgress) private var showsProgress @Default(.timerProgressStyle) private var progressStyle + @Default(.showTimerPresetsInNotchTab) private var showTimerPresetsInNotchTab @AppStorage("customTimerDuration") private var customTimerDuration: Double = 600 @State private var customHours: Int = 0 @@ -249,48 +250,37 @@ struct NotchTimerView: View { } private var customTimerComposer: some View { - VStack(alignment: .leading, spacing: 12) { - DurationInputRow(hours: $customHours, minutes: $customMinutes, seconds: $customSeconds) + Group { + if showTimerPresetsInNotchTab { + VStack(alignment: .leading, spacing: 12) { + DurationInputRow( + hours: $customHours, + minutes: $customMinutes, + seconds: $customSeconds, + fieldWidth: durationFieldWidth + ) - HStack(spacing: 10) { - Button { - withAnimation(.smooth) { - timerManager.startTimer(duration: customDurationInSeconds, name: "Custom Timer") - coordinator.currentView = .timer + HStack(spacing: 10) { + startButton + resetButton } - } label: { - Label("Start", systemImage: "play.fill") - .font(.system(size: 15, weight: .semibold)) - .frame(maxWidth: .infinity) - .padding(.vertical, 10) - .foregroundStyle(Color.white) } - .buttonStyle(.plain) - .background(startButtonColor.opacity(isStartDisabled ? 0.5 : 1)) - .clipShape(RoundedRectangle(cornerRadius: 12, style: .continuous)) - .overlay( - RoundedRectangle(cornerRadius: 12, style: .continuous) - .stroke(Color.white.opacity(0.15), lineWidth: 1) - ) - .opacity(isStartDisabled ? 0.7 : 1) - .disabled(isStartDisabled) + } else { + HStack(alignment: .center, spacing: 16) { + DurationInputRow( + hours: $customHours, + minutes: $customMinutes, + seconds: $customSeconds, + fieldWidth: durationFieldWidth + ) + .frame(maxWidth: .infinity, alignment: .leading) - Button { - resetCustomTimerInputs() - } label: { - Label("Reset", systemImage: "arrow.counterclockwise") - .font(.system(size: 15, weight: .semibold)) - .frame(maxWidth: .infinity) - .padding(.vertical, 10) - .foregroundStyle(.white.opacity(0.9)) + VStack(spacing: 10) { + startButton + resetButton + } + .frame(width: buttonColumnWidth, alignment: .top) } - .buttonStyle(.plain) - .background(Color.white.opacity(0.16)) - .clipShape(RoundedRectangle(cornerRadius: 12, style: .continuous)) - .overlay( - RoundedRectangle(cornerRadius: 12, style: .continuous) - .stroke(Color.white.opacity(0.08), lineWidth: 1) - ) } } .padding(12) @@ -331,7 +321,7 @@ struct NotchTimerView: View { } private var shouldShowPresetColumn: Bool { - !timerManager.isTimerActive + !timerManager.isTimerActive && showTimerPresetsInNotchTab } private var resolvedNotchHeight: CGFloat { @@ -424,6 +414,53 @@ struct NotchTimerView: View { customDurationInSeconds == 0 } + private var durationFieldWidth: CGFloat { + showTimerPresetsInNotchTab ? 64 : 78 + } + + private var buttonColumnWidth: CGFloat { 210 } + + private var startButton: some View { + Button { + withAnimation(.smooth) { + timerManager.startTimer(duration: customDurationInSeconds, name: "Custom Timer") + coordinator.currentView = .timer + } + } label: { + Label("Start", systemImage: "play.fill") + .font(.system(size: 15, weight: .semibold)) + .frame(maxWidth: .infinity) + .padding(.vertical, 10) + .foregroundStyle(Color.white) + } + .buttonStyle(.plain) + .background(startButtonColor.opacity(isStartDisabled ? 0.5 : 1)) + .clipShape(RoundedRectangle(cornerRadius: 12, style: .continuous)) + .overlay( + RoundedRectangle(cornerRadius: 12, style: .continuous) + .stroke(Color.white.opacity(0.15), lineWidth: 1) + ) + .opacity(isStartDisabled ? 0.7 : 1) + .disabled(isStartDisabled) + } + + private var resetButton: some View { + Button(action: resetCustomTimerInputs) { + Label("Reset", systemImage: "arrow.counterclockwise") + .font(.system(size: 15, weight: .semibold)) + .frame(maxWidth: .infinity) + .padding(.vertical, 10) + .foregroundStyle(.white.opacity(0.9)) + } + .buttonStyle(.plain) + .background(Color.white.opacity(0.16)) + .clipShape(RoundedRectangle(cornerRadius: 12, style: .continuous)) + .overlay( + RoundedRectangle(cornerRadius: 12, style: .continuous) + .stroke(Color.white.opacity(0.08), lineWidth: 1) + ) + } + private var customDurationInSeconds: TimeInterval { TimeInterval(customHours * 3600 + customMinutes * 60 + customSeconds) } @@ -496,14 +533,27 @@ private struct DurationInputRow: View { @Binding var hours: Int @Binding var minutes: Int @Binding var seconds: Int + let fieldWidth: CGFloat + + init( + hours: Binding, + minutes: Binding, + seconds: Binding, + fieldWidth: CGFloat = 64 + ) { + _hours = hours + _minutes = minutes + _seconds = seconds + self.fieldWidth = fieldWidth + } var body: some View { HStack(alignment: .center, spacing: 8) { - DurationField(label: "HH", value: $hours, range: 0...23) + DurationField(label: "HH", value: $hours, range: 0...23, width: fieldWidth) colon - DurationField(label: "MM", value: $minutes, range: 0...59) + DurationField(label: "MM", value: $minutes, range: 0...59, width: fieldWidth) colon - DurationField(label: "SS", value: $seconds, range: 0...59) + DurationField(label: "SS", value: $seconds, range: 0...59, width: fieldWidth) } } @@ -518,6 +568,19 @@ private struct DurationField: View { let label: String @Binding var value: Int let range: ClosedRange + let width: CGFloat + + init( + label: String, + value: Binding, + range: ClosedRange, + width: CGFloat = 64 + ) { + self.label = label + _value = value + self.range = range + self.width = width + } var body: some View { VStack(spacing: 6) { @@ -525,7 +588,7 @@ private struct DurationField: View { .font(.system(size: 28, weight: .semibold, design: .monospaced)) .multilineTextAlignment(.center) .textFieldStyle(.plain) - .frame(width: 64, height: 46) + .frame(width: width, height: 46) .background(Color.white.opacity(0.08)) .clipShape(RoundedRectangle(cornerRadius: 12, style: .continuous)) diff --git a/DynamicIsland/components/Settings/SettingsView.swift b/DynamicIsland/components/Settings/SettingsView.swift index 850b7d4..3cbb986 100644 --- a/DynamicIsland/components/Settings/SettingsView.swift +++ b/DynamicIsland/components/Settings/SettingsView.swift @@ -3352,6 +3352,7 @@ struct TimerSettings: View { @Default(.timerShowsLabel) private var showsLabel @Default(.timerShowsProgress) private var showsProgress @Default(.timerProgressStyle) private var progressStyle + @Default(.showTimerPresetsInNotchTab) private var showTimerPresetsInNotchTab @Default(.timerControlWindowEnabled) private var controlWindowEnabled @Default(.mirrorSystemTimer) private var mirrorSystemTimer @Default(.timerDisplayMode) private var timerDisplayMode @@ -3494,6 +3495,8 @@ struct TimerSettings: View { Toggle("Show timer name", isOn: $showsLabel) Toggle("Show countdown", isOn: $showsCountdown) Toggle("Show progress", isOn: $showsProgress) + Toggle("Show preset list in timer tab", isOn: $showTimerPresetsInNotchTab) + .settingsHighlight(id: highlightID("Show preset list in timer tab")) Toggle("Show floating pause/stop controls", isOn: $controlWindowEnabled) .disabled(showsLabel) diff --git a/DynamicIsland/models/Constants.swift b/DynamicIsland/models/Constants.swift index c9e7051..37b9ae1 100644 --- a/DynamicIsland/models/Constants.swift +++ b/DynamicIsland/models/Constants.swift @@ -547,6 +547,7 @@ extension Defaults.Keys { static let enableTimerFeature = Key("enableTimerFeature", default: true) static let timerDisplayMode = Key("timerDisplayMode", default: .tab) static let timerPresets = Key<[TimerPreset]>("timerPresets", default: TimerPreset.defaultPresets) + static let showTimerPresetsInNotchTab = Key("showTimerPresetsInNotchTab", default: true) static let timerIconColorMode = Key("timerIconColorMode", default: .adaptive) static let timerSolidColor = Key("timerSolidColor", default: .blue) static let timerShowsCountdown = Key("timerShowsCountdown", default: true)