Worked on some stuff, made it look better

This commit is contained in:
Hariharan Mudaliar 2025-11-02 15:41:09 +05:30
commit 4f963dbb68
6 changed files with 606 additions and 136 deletions

View file

@ -1716,6 +1716,7 @@ struct TimerSettings: View {
@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
@ -1738,134 +1739,150 @@ struct TimerSettings: View {
}
if enableTimerFeature {
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 default:")
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 default:")
.foregroundStyle(.secondary)
Text(customDurationDisplay)
.font(.system(.body, design: .monospaced))
.fontWeight(.medium)
Spacer()
}
}
.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.")
}
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)
Text(customDurationDisplay)
.font(.system(.body, design: .monospaced))
.fontWeight(.medium)
Spacer()
}
}
.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)
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.")
}
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 {
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)
}
}
} 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)
.padding(.vertical, 4)
} else {
Text("Default: dynamic.m4a")
.font(.caption)
.foregroundColor(.secondary)
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) }
)
}
}
Button("Reset to Default") {
UserDefaults.standard.removeObject(forKey: "customTimerSoundPath")
HStack {
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)
}
}
.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("Select a custom sound to play when a timer ends. Supported formats include MP3, M4A, WAV, and AIFF.")
}
}
}

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

View file

@ -20,6 +20,9 @@ struct TimerLiveActivity: View {
@EnvironmentObject var vm: DynamicIslandViewModel
@ObservedObject var timerManager = TimerManager.shared
@State private var isHovering: Bool = false
@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
@ -27,12 +30,15 @@ struct TimerLiveActivity: View {
@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 { 18 }
private var wingPadding: CGFloat { 22 }
private var ringStrokeWidth: CGFloat { 3 }
private var transientLabelDuration: TimeInterval { 4 }
private var ringWrapsIcon: Bool {
showsRingProgress && showsCountdown
@ -43,13 +49,13 @@ struct TimerLiveActivity: View {
}
private var iconWidth: CGFloat {
ringWrapsIcon ? max(notchContentHeight, 32) : max(0, notchContentHeight)
ringWrapsIcon ? max(notchContentHeight - 6, 28) : max(0, notchContentHeight)
}
private var infoContentWidth: CGFloat {
guard showsInfoSection else { return 0 }
let textWidth = min(max(titleTextWidth, 44), 220)
if showsLabel {
if shouldDisplayLabel {
let textWidth = min(max(titleTextWidth, 44), 220)
return textWidth
} else {
return min(max(notchContentHeight * 1.4, 64), 220)
@ -70,7 +76,7 @@ struct TimerLiveActivity: View {
}
private var ringWidth: CGFloat {
ringOnRight ? 32 : 0
ringOnRight ? 30 : 0
}
private var rightWingWidth: CGFloat {
@ -97,7 +103,7 @@ struct TimerLiveActivity: View {
private var countdownWidth: CGFloat {
guard showsCountdown else { return 0 }
return max(countdownTextWidth + 20, 80)
return max(countdownTextWidth + 16, 72)
}
private var clampedProgress: Double {
@ -121,14 +127,22 @@ struct TimerLiveActivity: View {
showsProgress && progressStyle == .bar
}
private var shouldDisplayLabel: Bool {
showsLabel || showTransientLabel
}
private var showsInfoSection: Bool {
showsLabel || (showsBarProgress && !showsCountdown)
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]
@ -192,22 +206,102 @@ struct TimerLiveActivity: View {
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)
}
}
private var iconSection: some View {
let ringDiameter = ringWrapsIcon ? max(iconWidth, 32) : iconWidth
let iconSize = ringWrapsIcon ? max(ringDiameter - 10, 18) : max(20, iconWidth - 4)
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: 3)
.stroke(Color.white.opacity(0.15), lineWidth: ringStrokeWidth)
.frame(width: ringDiameter, height: ringDiameter)
Circle()
.trim(from: 0, to: clampedProgress)
.stroke(glyphColor, style: StrokeStyle(lineWidth: 3, lineCap: .round))
.stroke(glyphColor, style: StrokeStyle(lineWidth: ringStrokeWidth, lineCap: .round))
.rotationEffect(.degrees(-90))
.animation(.smooth(duration: 0.25), value: clampedProgress)
.frame(width: ringDiameter, height: ringDiameter)
@ -224,19 +318,20 @@ struct TimerLiveActivity: View {
}
private var infoSection: some View {
let availableWidth = max(0, infoWidth - 18)
let resolvedTextWidth = min(max(titleTextWidth, 44), availableWidth)
let shouldMarquee = showsLabel && (timerManager.isFinished || timerManager.isOvertime || titleTextWidth > availableWidth)
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 = showsLabel ? resolvedTextWidth : availableWidth
let barWidth = shouldDisplayLabel ? resolvedTextWidth : availableWidth
return Rectangle()
.fill(.black)
.frame(width: infoWidth, height: notchContentHeight)
.overlay(alignment: .leading) {
VStack(alignment: .leading, spacing: showsBarHere ? 4 : 0) {
if showsLabel {
if shouldMarquee {
if shouldDisplayLabel {
if marqueeLabel {
MarqueeText(
.constant(timerManager.timerName),
font: .system(size: 12, weight: .medium),
@ -274,16 +369,17 @@ struct TimerLiveActivity: View {
}
private var ringSection: some View {
ZStack {
let diameter = max(min(notchContentHeight - 4, 26), 20)
return ZStack {
Circle()
.stroke(Color.white.opacity(0.15), lineWidth: 3)
.stroke(Color.white.opacity(0.15), lineWidth: ringStrokeWidth)
Circle()
.trim(from: 0, to: clampedProgress)
.stroke(glyphColor, style: StrokeStyle(lineWidth: 3, lineCap: .round))
.stroke(glyphColor, style: StrokeStyle(lineWidth: ringStrokeWidth, lineCap: .round))
.rotationEffect(.degrees(-90))
.animation(.smooth(duration: 0.25), value: clampedProgress)
}
.frame(width: 26, height: 26)
.frame(width: diameter, height: diameter)
.frame(width: ringWidth, height: notchContentHeight, alignment: .center)
}
@ -310,10 +406,73 @@ struct TimerLiveActivity: View {
.frame(maxWidth: .infinity, alignment: .trailing)
}
}
.padding(.trailing, 8)
.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()
}
}
} else if isControlWindowVisible {
TimerControlWindowManager.shared.hide()
isControlWindowVisible = false
}
#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
)
}
}
#Preview {