diff --git a/DynamicIsland/Localizable.xcstrings b/DynamicIsland/Localizable.xcstrings index 03808d2..357cb91 100644 --- a/DynamicIsland/Localizable.xcstrings +++ b/DynamicIsland/Localizable.xcstrings @@ -16957,6 +16957,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." : { diff --git a/DynamicIsland/components/Notch/NotchTimerView.swift b/DynamicIsland/components/Notch/NotchTimerView.swift index 27db6f5..2650198 100644 --- a/DynamicIsland/components/Notch/NotchTimerView.swift +++ b/DynamicIsland/components/Notch/NotchTimerView.swift @@ -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()) diff --git a/DynamicIsland/components/Settings/SettingsView.swift b/DynamicIsland/components/Settings/SettingsView.swift index e52d58b..4a8cd95 100644 --- a/DynamicIsland/components/Settings/SettingsView.swift +++ b/DynamicIsland/components/Settings/SettingsView.swift @@ -1713,6 +1713,7 @@ struct TimerSettings: View { @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 @AppStorage("customTimerDuration") private var customTimerDuration: Double = 600 @@ -1777,6 +1778,7 @@ struct TimerSettings: View { ColorPicker("Solid colour", selection: $solidColor, supportsOpacity: false) } + Toggle("Show timer name", isOn: $showsLabel) Toggle("Show countdown", isOn: $showsCountdown) Toggle("Show progress", isOn: $showsProgress) diff --git a/DynamicIsland/components/Timer/TimerIconAnimation.swift b/DynamicIsland/components/Timer/TimerIconAnimation.swift index e5c4303..9eb6bd8 100644 --- a/DynamicIsland/components/Timer/TimerIconAnimation.swift +++ b/DynamicIsland/components/Timer/TimerIconAnimation.swift @@ -9,12 +9,16 @@ import SwiftUI import Defaults struct TimerIconAnimation: View { + @ObservedObject var timerManager = TimerManager.shared + @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 { Circle() - .fill(accentColor.gradient) + .fill(resolvedColor.gradient) .frame(width: 24, height: 24) Image(systemName: "timer") @@ -22,6 +26,20 @@ struct TimerIconAnimation: View { .foregroundStyle(.white) } } + + 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 { diff --git a/DynamicIsland/components/Timer/TimerLiveActivity.swift b/DynamicIsland/components/Timer/TimerLiveActivity.swift index 666cae9..59d9401 100644 --- a/DynamicIsland/components/Timer/TimerLiveActivity.swift +++ b/DynamicIsland/components/Timer/TimerLiveActivity.swift @@ -8,26 +8,96 @@ 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 timerManager = TimerManager.shared @State private var isHovering: 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 - private var iconAreaWidth: CGFloat { + private var notchContentHeight: CGFloat { max(0, vm.effectiveClosedNotchHeight - (isHovering ? 0 : 12)) } + private var wingPadding: CGFloat { 18 } + + private var ringWrapsIcon: Bool { + showsRingProgress && showsCountdown + } + + private var ringOnRight: Bool { + showsRingProgress && !ringWrapsIcon + } + + private var iconWidth: CGFloat { + ringWrapsIcon ? max(notchContentHeight, 32) : max(0, notchContentHeight) + } + + private var infoContentWidth: CGFloat { + guard showsInfoSection else { return 0 } + let textWidth = min(max(titleTextWidth, 44), 220) + if showsLabel { + 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 ? 32 : 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 } - let characters = max(timerManager.formattedRemainingTime().count, 4) - let base = CGFloat(characters) * 9.5 + 24 - return max(base, 72) + return max(countdownTextWidth + 20, 80) } private var clampedProgress: Double { @@ -51,21 +121,70 @@ struct TimerLiveActivity: View { showsProgress && progressStyle == .bar } + private var showsInfoSection: Bool { + showsLabel || (showsBarProgress && !showsCountdown) + } + private var activePresetColor: Color? { guard let presetId = timerManager.activePresetId else { return nil } return timerPresets.first { $0.id == presetId }?.color } + 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(spacing: 10) { - iconSection - infoSection - if showsRingProgress { - ringSection - } - if showsCountdown { - countdownSection - } + 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) .contentShape(Rectangle()) @@ -77,42 +196,81 @@ struct TimerLiveActivity: View { } private var iconSection: some View { - let iconSize = max(20, iconAreaWidth - 4) - return Image(systemName: "timer") - .font(.system(size: 16, weight: .semibold)) - .foregroundStyle(glyphColor) - .frame(width: iconSize, height: iconSize) - .frame(width: iconAreaWidth, height: max(0, vm.effectiveClosedNotchHeight - (isHovering ? 0 : 12)), alignment: .center) + let ringDiameter = ringWrapsIcon ? max(iconWidth, 32) : iconWidth + let iconSize = ringWrapsIcon ? max(ringDiameter - 10, 18) : max(20, iconWidth - 4) + + return ZStack { + if ringWrapsIcon { + Circle() + .stroke(Color.white.opacity(0.15), lineWidth: 3) + .frame(width: ringDiameter, height: ringDiameter) + + Circle() + .trim(from: 0, to: clampedProgress) + .stroke(glyphColor, style: StrokeStyle(lineWidth: 3, 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 { - Rectangle() + let availableWidth = max(0, infoWidth - 18) + let resolvedTextWidth = min(max(titleTextWidth, 44), availableWidth) + let shouldMarquee = showsLabel && (timerManager.isFinished || timerManager.isOvertime || titleTextWidth > availableWidth) + let showsBarHere = showsBarProgress && !showsCountdown + let barWidth = showsLabel ? resolvedTextWidth : availableWidth + + return Rectangle() .fill(.black) - .overlay( - VStack(alignment: .leading, spacing: 4) { - if timerManager.isFinished || timerManager.isOvertime { - GeometryReader { geo in + .frame(width: infoWidth, height: notchContentHeight) + .overlay(alignment: .leading) { + VStack(alignment: .leading, spacing: showsBarHere ? 4 : 0) { + if showsLabel { + if shouldMarquee { MarqueeText( .constant(timerManager.timerName), + font: .system(size: 12, weight: .medium), + nsFont: .callout, textColor: .white, minDuration: 0.25, - frameWidth: geo.size.width - 12 + 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) } - .frame(height: 16) - } else { - Text(timerManager.timerName) - .font(.system(size: 12, weight: .medium)) - .lineLimit(1) - .foregroundStyle(.white) - .transition(.opacity.combined(with: .move(edge: .top))) + } + + 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(.horizontal, 12) - .padding(.vertical, 6) - .animation(.smooth, value: timerManager.isFinished) - ) - .frame(width: vm.closedNotchSize.width + (isHovering ? 8 : 0)) + .padding(.leading, 12) + .padding(.trailing, 6) + } + .animation(.smooth, value: timerManager.isFinished) } private var ringSection: some View { @@ -126,10 +284,12 @@ struct TimerLiveActivity: View { .animation(.smooth(duration: 0.25), value: clampedProgress) } .frame(width: 26, height: 26) + .frame(width: ringWidth, height: notchContentHeight, alignment: .center) } private var countdownSection: some View { - VStack(spacing: 4) { + 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) @@ -140,18 +300,19 @@ struct TimerLiveActivity: View { if showsBarProgress { Capsule() .fill(Color.white.opacity(0.12)) - .frame(height: 3) + .frame(width: barWidth, height: 3) .overlay(alignment: .leading) { Capsule() .fill(glyphColor) - .frame(width: max(0, countdownWidth - 20) * max(0, CGFloat(clampedProgress))) + .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: max(0, vm.effectiveClosedNotchHeight - (isHovering ? 0 : 12)), alignment: .center) + .frame(width: countdownWidth, + height: notchContentHeight, alignment: .center) } } diff --git a/DynamicIsland/models/Constants.swift b/DynamicIsland/models/Constants.swift index 6e27980..bba7d00 100644 --- a/DynamicIsland/models/Constants.swift +++ b/DynamicIsland/models/Constants.swift @@ -432,6 +432,7 @@ extension Defaults.Keys { static let timerIconColorMode = Key("timerIconColorMode", default: .adaptive) static let timerSolidColor = Key("timerSolidColor", default: .blue) static let timerShowsCountdown = Key("timerShowsCountdown", default: true) + static let timerShowsLabel = Key("timerShowsLabel", default: true) static let timerShowsProgress = Key("timerShowsProgress", default: true) static let timerProgressStyle = Key("timerProgressStyle", default: .bar)