Merge pull request #80 from Ebullioscopic/feature/lock-screen

Feature/lock screen
This commit is contained in:
Hariharan Mudaliar 2025-11-03 20:30:23 +05:30 • committed by GitHub
commit 4656712080
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
11 changed files with 282 additions and 106 deletions

1
.gitattributes vendored
View file

@ -1 +1,2 @@
*.gif filter=lfs diff=lfs merge=lfs -text *.gif filter=lfs diff=lfs merge=lfs -text
*.mov filter=lfs diff=lfs merge=lfs -text

Binary file not shown.

After

Width:  |  Height:  |  Size: 692 KiB

3
.github/assets/timer.gif vendored Normal file
View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:9bec19433c1bddad8bc9b86b2f61f9324a109e3469753625643531d6bbb3598f
size 1189001

3
.github/assets/timer.mov vendored Normal file
View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:cdbf9a492037d5a53d3cd036f7030373a67ead87793139246290cf69c4b46777
size 2128950

View file

@ -1323,6 +1323,10 @@
} }
} }
}, },
"0" : {
"comment" : "A label displayed in a circular gauge to indicate the minimum battery level.",
"isCommentAutoGenerated" : true
},
"1x" : { "1x" : {
"localizations" : { "localizations" : {
"fr" : { "fr" : {
@ -1433,6 +1437,10 @@
} }
} }
}, },
"100" : {
"comment" : "The text displayed below a 100% battery level in the lock screen.",
"isCommentAutoGenerated" : true
},
"100%" : { "100%" : {
"localizations" : { "localizations" : {
"fr" : { "fr" : {
@ -10823,6 +10831,9 @@
} }
} }
}, },
"Mac battery at %d percent" : {
"comment" : "Mac battery gauge accessibility label"
},
"Made ❤️ by Ebullioscopic" : { "Made ❤️ by Ebullioscopic" : {
}, },
@ -18186,6 +18197,9 @@
} }
} }
} }
},
"Temperature unit" : {
}, },
"TheDynamicIsland" : { "TheDynamicIsland" : {

View file

@ -11,15 +11,10 @@ struct LockScreenWeatherWidget: View {
private var isInline: Bool { snapshot.widgetStyle == .inline } private var isInline: Bool { snapshot.widgetStyle == .inline }
private var stackAlignment: VerticalAlignment { isInline ? .firstTextBaseline : .top } private var stackAlignment: VerticalAlignment { isInline ? .firstTextBaseline : .top }
private var stackSpacing: CGFloat { isInline ? 14 : 22 } private var stackSpacing: CGFloat { isInline ? 14 : 22 }
private var gaugeLineWidth: CGFloat { 6 }
private var gaugeDiameter: CGFloat { 64 } private var gaugeDiameter: CGFloat { 64 }
private var topPadding: CGFloat { isInline ? 6 : 22 } private var topPadding: CGFloat { isInline ? 6 : 22 }
private var bottomPadding: CGFloat { isInline ? 6 : 10 } private var bottomPadding: CGFloat { isInline ? 6 : 10 }
private var gaugeBackgroundColor: Color {
snapshot.usesGaugeTint ? Color.white.opacity(0.22) : Color.white.opacity(0.32)
}
private var monochromeGaugeTint: Color { private var monochromeGaugeTint: Color {
Color.white.opacity(0.9) Color.white.opacity(0.9)
} }
@ -30,6 +25,10 @@ struct LockScreenWeatherWidget: View {
chargingSegment(for: charging) chargingSegment(for: charging)
} }
if !isInline, let battery = snapshot.battery {
batterySegment(for: battery)
}
if let bluetooth = snapshot.bluetooth { if let bluetooth = snapshot.bluetooth {
bluetoothSegment(for: bluetooth) bluetoothSegment(for: bluetooth)
} }
@ -98,6 +97,16 @@ struct LockScreenWeatherWidget: View {
} }
} }
@ViewBuilder
private func batterySegment(for info: LockScreenWeatherSnapshot.BatteryInfo) -> some View {
switch snapshot.widgetStyle {
case .inline:
EmptyView()
case .circular:
circularBatterySegment(for: info)
}
}
private func inlineChargingSegment(for info: LockScreenWeatherSnapshot.ChargingInfo) -> some View { private func inlineChargingSegment(for info: LockScreenWeatherSnapshot.ChargingInfo) -> some View {
HStack(alignment: .firstTextBaseline, spacing: 6) { HStack(alignment: .firstTextBaseline, spacing: 6) {
if let iconName = chargingIconName(for: info) { if let iconName = chargingIconName(for: info) {
@ -115,11 +124,27 @@ struct LockScreenWeatherWidget: View {
@ViewBuilder @ViewBuilder
private func circularChargingSegment(for info: LockScreenWeatherSnapshot.ChargingInfo) -> some View { private func circularChargingSegment(for info: LockScreenWeatherSnapshot.ChargingInfo) -> some View {
if let level = info.batteryLevel { if let rawLevel = info.batteryLevel {
let level = clampedBatteryLevel(rawLevel)
VStack(spacing: 6) { VStack(spacing: 6) {
circularBadge(progress: Double(level) / 100, tint: batteryTint(for: level), lineWidth: gaugeLineWidth, diameter: gaugeDiameter) { Gauge(value: Double(level), in: 0...100) {
EmptyView()
} currentValueLabel: {
chargingGlyph(for: info) chargingGlyph(for: info)
} minimumValueLabel: {
Text("0")
.font(.system(size: 11, weight: .medium, design: .rounded))
.foregroundStyle(secondaryLabelColor)
} maximumValueLabel: {
Text("100")
.font(.system(size: 11, weight: .medium, design: .rounded))
.foregroundStyle(secondaryLabelColor)
} }
.gaugeStyle(.accessoryCircularCapacity)
.tint(batteryTint(for: level))
.frame(width: gaugeDiameter, height: gaugeDiameter)
Text(chargingDetailLabel(for: info)) Text(chargingDetailLabel(for: info))
.font(inlineSecondaryFont) .font(inlineSecondaryFont)
.foregroundStyle(secondaryLabelColor) .foregroundStyle(secondaryLabelColor)
@ -131,6 +156,32 @@ struct LockScreenWeatherWidget: View {
} }
} }
private func circularBatterySegment(for info: LockScreenWeatherSnapshot.BatteryInfo) -> some View {
let level = clampedBatteryLevel(info.batteryLevel)
return VStack(spacing: 6) {
Gauge(value: Double(level), in: 0...100) {
EmptyView()
} currentValueLabel: {
Image(systemName: batteryIconName(for: level))
.font(.system(size: 20, weight: .semibold))
.foregroundStyle(Color.white)
} minimumValueLabel: {
EmptyView()
} maximumValueLabel: {
EmptyView()
}
.gaugeStyle(.accessoryCircularCapacity)
.tint(batteryTint(for: level))
.frame(width: gaugeDiameter, height: gaugeDiameter)
Text("\(level)%")
.font(inlineSecondaryFont)
.foregroundStyle(secondaryLabelColor)
}
.layoutPriority(1)
}
@ViewBuilder @ViewBuilder
private func bluetoothSegment(for info: LockScreenWeatherSnapshot.BluetoothInfo) -> some View { private func bluetoothSegment(for info: LockScreenWeatherSnapshot.BluetoothInfo) -> some View {
switch snapshot.widgetStyle { switch snapshot.widgetStyle {
@ -155,13 +206,24 @@ struct LockScreenWeatherWidget: View {
} }
private func circularBluetoothSegment(for info: LockScreenWeatherSnapshot.BluetoothInfo) -> some View { private func circularBluetoothSegment(for info: LockScreenWeatherSnapshot.BluetoothInfo) -> some View {
VStack(spacing: 6) {
let clamped = clampedBatteryLevel(info.batteryLevel) let clamped = clampedBatteryLevel(info.batteryLevel)
circularBadge(progress: Double(clamped) / 100, tint: bluetoothTint(for: clamped), lineWidth: gaugeLineWidth, diameter: gaugeDiameter) {
return VStack(spacing: 6) {
Gauge(value: Double(clamped), in: 0...100) {
EmptyView()
} currentValueLabel: {
Image(systemName: info.iconName) Image(systemName: info.iconName)
.font(.system(size: 22, weight: .semibold)) .font(.system(size: 22, weight: .semibold))
.foregroundStyle(Color.white) .foregroundStyle(Color.white)
} minimumValueLabel: {
EmptyView()
} maximumValueLabel: {
EmptyView()
} }
.gaugeStyle(.accessoryCircularCapacity)
.tint(bluetoothTint(for: clamped))
.frame(width: gaugeDiameter, height: gaugeDiameter)
Text(bluetoothPercentageText(for: info.batteryLevel)) Text(bluetoothPercentageText(for: info.batteryLevel))
.font(inlineSecondaryFont) .font(inlineSecondaryFont)
.foregroundStyle(secondaryLabelColor) .foregroundStyle(secondaryLabelColor)
@ -193,11 +255,17 @@ struct LockScreenWeatherWidget: View {
private func circularAirQualitySegment(for info: LockScreenWeatherSnapshot.AirQualityInfo) -> some View { private func circularAirQualitySegment(for info: LockScreenWeatherSnapshot.AirQualityInfo) -> some View {
VStack(spacing: 6) { VStack(spacing: 6) {
circularBadge(progress: aqiProgress(for: info.index), tint: aqiTint(for: info.category), lineWidth: gaugeLineWidth, diameter: gaugeDiameter) { Gauge(value: Double(info.index), in: 0...500) {
EmptyView()
} currentValueLabel: {
Text("\(info.index)") Text("\(info.index)")
.font(.system(size: 20, weight: .semibold, design: .rounded)) .font(.system(size: 20, weight: .semibold, design: .rounded))
.foregroundStyle(Color.white) .foregroundStyle(Color.white)
} }
.gaugeStyle(.accessoryCircular)
.tint(aqiTint(for: info.category))
.frame(width: gaugeDiameter, height: gaugeDiameter)
Text("AQI · \(info.category.displayName)") Text("AQI · \(info.category.displayName)")
.font(inlineSecondaryFont) .font(inlineSecondaryFont)
.foregroundStyle(secondaryLabelColor) .foregroundStyle(secondaryLabelColor)
@ -207,17 +275,25 @@ struct LockScreenWeatherWidget: View {
} }
private func temperatureGauge(for info: LockScreenWeatherSnapshot.TemperatureInfo) -> some View { private func temperatureGauge(for info: LockScreenWeatherSnapshot.TemperatureInfo) -> some View {
VStack(spacing: 6) { let range = temperatureRange(for: info)
circularBadge(progress: temperatureProgress(for: info), tint: temperatureTint(for: info), lineWidth: gaugeLineWidth, diameter: gaugeDiameter) {
return VStack(spacing: 6) {
Gauge(value: info.current, in: range) {
EmptyView()
} currentValueLabel: {
temperatureCenterLabel(for: info) temperatureCenterLabel(for: info)
} }
.gaugeStyle(.accessoryCircular)
.tint(temperatureTint(for: info))
.frame(width: gaugeDiameter, height: gaugeDiameter)
HStack { HStack {
Text(minimumTemperatureLabel(for: info)) Text(minimumTemperatureLabel(for: info))
.font(inlineSecondaryFont) .font(.system(size: 11, weight: .medium, design: .rounded))
.foregroundStyle(secondaryLabelColor) .foregroundStyle(secondaryLabelColor)
Spacer() Spacer()
Text(maximumTemperatureLabel(for: info)) Text(maximumTemperatureLabel(for: info))
.font(inlineSecondaryFont) .font(.system(size: 11, weight: .medium, design: .rounded))
.foregroundStyle(secondaryLabelColor) .foregroundStyle(secondaryLabelColor)
} }
.frame(width: gaugeDiameter) .frame(width: gaugeDiameter)
@ -307,21 +383,27 @@ struct LockScreenWeatherWidget: View {
} }
private func temperatureCenterLabel(for info: LockScreenWeatherSnapshot.TemperatureInfo) -> some View { private func temperatureCenterLabel(for info: LockScreenWeatherSnapshot.TemperatureInfo) -> some View {
let symbol = info.unitSymbol
let unitSuffix = symbol.replacingOccurrences(of: "°", with: "")
return HStack(alignment: .top, spacing: 2) { return HStack(alignment: .top, spacing: 2) {
Text("\(info.displayCurrent)°") Text("\(info.displayCurrent)°")
.font(.system(size: 20, weight: .semibold, design: .rounded)) .font(.system(size: 20, weight: .semibold, design: .rounded))
if !unitSuffix.isEmpty {
Text(unitSuffix)
.font(.system(size: 11, weight: .medium, design: .rounded))
.foregroundStyle(secondaryLabelColor)
.offset(y: 2)
}
} }
.foregroundStyle(Color.white) .foregroundStyle(Color.white)
} }
private func temperatureRange(for info: LockScreenWeatherSnapshot.TemperatureInfo) -> ClosedRange<Double> {
let minimumCandidate = info.minimum ?? info.current
let maximumCandidate = info.maximum ?? info.current
var lowerBound = min(minimumCandidate, info.current)
var upperBound = max(maximumCandidate, info.current)
if lowerBound == upperBound {
lowerBound -= 1
upperBound += 1
}
return lowerBound...upperBound
}
private func clampedBatteryLevel(_ level: Int) -> Int { private func clampedBatteryLevel(_ level: Int) -> Int {
min(max(level, 0), 100) min(max(level, 0), 100)
} }
@ -336,23 +418,6 @@ struct LockScreenWeatherWidget: View {
return text return text
} }
private func aqiProgress(for index: Int) -> Double {
let clamped = min(max(Double(index), 0), 500)
return clamped / 500
}
private func temperatureProgress(for info: LockScreenWeatherSnapshot.TemperatureInfo) -> Double {
let current = info.current
let minValue = info.minimum ?? current
let maxValue = info.maximum ?? current
let lowerBound = min(minValue, current)
let upperBound = max(maxValue, current)
let span = max(upperBound - lowerBound, 1)
return min(max((current - lowerBound) / span, 0), 1)
}
private func batteryTint(for level: Int) -> Color { private func batteryTint(for level: Int) -> Color {
guard snapshot.usesGaugeTint else { return monochromeGaugeTint } guard snapshot.usesGaugeTint else { return monochromeGaugeTint }
let clamped = clampedBatteryLevel(level) let clamped = clampedBatteryLevel(level)
@ -416,27 +481,6 @@ struct LockScreenWeatherWidget: View {
} }
} }
@ViewBuilder
private func circularBadge<Content: View>(
progress: Double?,
tint: Color,
background: Color? = nil,
lineWidth: CGFloat,
diameter: CGFloat,
@ViewBuilder content: @escaping () -> Content
) -> some View {
if let progress {
CircularMetricView(
progress: progress,
foreground: tint,
background: background ?? gaugeBackgroundColor,
lineWidth: lineWidth,
diameter: diameter,
content: content
)
}
}
private var accessibilityLabel: String { private var accessibilityLabel: String {
var components: [String] = [] var components: [String] = []
@ -471,6 +515,10 @@ struct LockScreenWeatherWidget: View {
components.append(accessibilityAirQualityText(for: airQuality)) components.append(accessibilityAirQualityText(for: airQuality))
} }
if let battery = snapshot.battery, !isInline {
components.append(accessibilityBatteryText(for: battery))
}
return components.joined(separator: ". ") return components.joined(separator: ". ")
} }
@ -515,46 +563,28 @@ struct LockScreenWeatherWidget: View {
airQuality.category.displayName airQuality.category.displayName
) )
} }
}
private struct CircularMetricView<Content: View>: View { private func accessibilityBatteryText(for battery: LockScreenWeatherSnapshot.BatteryInfo) -> String {
private let progress: Double String(
private let foreground: Color format: NSLocalizedString("Mac battery at %d percent", comment: "Mac battery gauge accessibility label"),
private let background: Color clampedBatteryLevel(battery.batteryLevel)
private let lineWidth: CGFloat
private let diameter: CGFloat
private let contentBuilder: () -> Content
init(
progress: Double,
foreground: Color,
background: Color,
lineWidth: CGFloat = 6,
diameter: CGFloat = 64,
@ViewBuilder content: @escaping () -> Content
) {
self.progress = min(max(progress, 0), 1)
self.foreground = foreground
self.background = background
self.lineWidth = lineWidth
self.diameter = diameter
self.contentBuilder = content
}
var body: some View {
ZStack {
Circle()
.stroke(background, lineWidth: lineWidth)
Circle()
.trim(from: 0, to: CGFloat(progress))
.stroke(
foreground,
style: StrokeStyle(lineWidth: lineWidth, lineCap: .round, lineJoin: .round)
) )
.rotationEffect(.degrees(-90))
contentBuilder()
} }
.frame(width: diameter, height: diameter)
private func batteryIconName(for level: Int) -> String {
let clamped = clampedBatteryLevel(level)
switch clamped {
case ..<10:
return "battery.0percent"
case 10..<40:
return "battery.25percent"
case 40..<70:
return "battery.50percent"
case 70..<90:
return "battery.75percent"
default:
return "battery.100percent"
}
} }
} }

View file

@ -182,7 +182,9 @@ struct GeneralSettings: View {
@Default(.lockScreenWeatherShowsCharging) var lockScreenWeatherShowsCharging @Default(.lockScreenWeatherShowsCharging) var lockScreenWeatherShowsCharging
@Default(.lockScreenWeatherShowsChargingPercentage) var lockScreenWeatherShowsChargingPercentage @Default(.lockScreenWeatherShowsChargingPercentage) var lockScreenWeatherShowsChargingPercentage
@Default(.lockScreenWeatherShowsBluetooth) var lockScreenWeatherShowsBluetooth @Default(.lockScreenWeatherShowsBluetooth) var lockScreenWeatherShowsBluetooth
@Default(.lockScreenWeatherShowsBatteryGauge) var lockScreenWeatherShowsBatteryGauge
@Default(.lockScreenWeatherWidgetStyle) var lockScreenWeatherWidgetStyle @Default(.lockScreenWeatherWidgetStyle) var lockScreenWeatherWidgetStyle
@Default(.lockScreenWeatherTemperatureUnit) var lockScreenWeatherTemperatureUnit
@Default(.lockScreenWeatherShowsAQI) var lockScreenWeatherShowsAQI @Default(.lockScreenWeatherShowsAQI) var lockScreenWeatherShowsAQI
@Default(.lockScreenWeatherUsesGaugeTint) var lockScreenWeatherUsesGaugeTint @Default(.lockScreenWeatherUsesGaugeTint) var lockScreenWeatherUsesGaugeTint
@Default(.lockScreenWeatherProviderSource) var lockScreenWeatherProviderSource @Default(.lockScreenWeatherProviderSource) var lockScreenWeatherProviderSource
@ -345,12 +347,19 @@ struct GeneralSettings: View {
} }
} }
.pickerStyle(.segmented) .pickerStyle(.segmented)
Picker("Temperature unit", selection: $lockScreenWeatherTemperatureUnit) {
ForEach(LockScreenWeatherTemperatureUnit.allCases) { unit in
Text(unit.rawValue).tag(unit)
}
}
.pickerStyle(.segmented)
Defaults.Toggle("Show location label", key: .lockScreenWeatherShowsLocation) Defaults.Toggle("Show location label", key: .lockScreenWeatherShowsLocation)
.disabled(lockScreenWeatherWidgetStyle == .circular) .disabled(lockScreenWeatherWidgetStyle == .circular)
Defaults.Toggle("Show charging status", key: .lockScreenWeatherShowsCharging) Defaults.Toggle("Show charging status", key: .lockScreenWeatherShowsCharging)
if lockScreenWeatherShowsCharging { if lockScreenWeatherShowsCharging {
Defaults.Toggle("Show charging percentage", key: .lockScreenWeatherShowsChargingPercentage) Defaults.Toggle("Show charging percentage", key: .lockScreenWeatherShowsChargingPercentage)
} }
Defaults.Toggle("Show battery indicator", key: .lockScreenWeatherShowsBatteryGauge)
Defaults.Toggle("Show Bluetooth battery", key: .lockScreenWeatherShowsBluetooth) Defaults.Toggle("Show Bluetooth battery", key: .lockScreenWeatherShowsBluetooth)
Defaults.Toggle("Show AQI widget", key: .lockScreenWeatherShowsAQI) Defaults.Toggle("Show AQI widget", key: .lockScreenWeatherShowsAQI)
.disabled(!lockScreenWeatherProviderSource.supportsAirQuality) .disabled(!lockScreenWeatherProviderSource.supportsAirQuality)

View file

@ -102,3 +102,26 @@ enum LockScreenWeatherProviderSource: String, CaseIterable, Defaults.Serializabl
} }
} }
} }
enum LockScreenWeatherTemperatureUnit: String, CaseIterable, Defaults.Serializable, Identifiable {
case celsius = "Celsius"
case fahrenheit = "Fahrenheit"
var id: String { rawValue }
var usesMetricSystem: Bool { self == .celsius }
var symbol: String {
switch self {
case .celsius: return "°C"
case .fahrenheit: return "°F"
}
}
var openMeteoTemperatureParameter: String? {
switch self {
case .celsius: return nil
case .fahrenheit: return "fahrenheit"
}
}
}

View file

@ -59,8 +59,11 @@ final class LockScreenWeatherManager: ObservableObject {
@discardableResult @discardableResult
func refresh(force: Bool = false) async -> LockScreenWeatherSnapshot? { func refresh(force: Bool = false) async -> LockScreenWeatherSnapshot? {
NSLog("LockScreenWeatherManager: refresh requested (force=%@)", force ? "true" : "false")
let interval = Defaults[.lockScreenWeatherRefreshInterval] let interval = Defaults[.lockScreenWeatherRefreshInterval]
if !force, let lastFetchDate, Date().timeIntervalSince(lastFetchDate) < interval { if !force, let lastFetchDate, Date().timeIntervalSince(lastFetchDate) < interval {
let remaining = interval - Date().timeIntervalSince(lastFetchDate)
NSLog("LockScreenWeatherManager: skipping refresh (%.0f s remaining until next fetch)", max(remaining, 0))
if let payload = latestWeatherPayload { if let payload = latestWeatherPayload {
if Defaults[.lockScreenWeatherShowsBluetooth] { if Defaults[.lockScreenWeatherShowsBluetooth] {
BluetoothAudioManager.shared.refreshConnectedDeviceBatteries() BluetoothAudioManager.shared.refreshConnectedDeviceBatteries()
@ -78,6 +81,12 @@ final class LockScreenWeatherManager: ObservableObject {
do { do {
let location = await locationProvider.currentLocation() let location = await locationProvider.currentLocation()
let provider = Defaults[.lockScreenWeatherProviderSource]
NSLog(
"LockScreenWeatherManager: fetching weather from %@ (location %@)",
provider.displayName,
location != nil ? "available" : "missing"
)
let payload = try await fetchWeatherPayload(location: location) let payload = try await fetchWeatherPayload(location: location)
latestWeatherPayload = payload latestWeatherPayload = payload
if Defaults[.lockScreenWeatherShowsBluetooth] { if Defaults[.lockScreenWeatherShowsBluetooth] {
@ -87,18 +96,24 @@ final class LockScreenWeatherManager: ObservableObject {
self.snapshot = snapshot self.snapshot = snapshot
lastFetchDate = Date() lastFetchDate = Date()
deliver(snapshot, forceShow: false) deliver(snapshot, forceShow: false)
NSLog("LockScreenWeatherManager: weather refresh succeeded")
return snapshot return snapshot
} catch { } catch {
NSLog("LockScreenWeatherManager: failed to fetch weather - \(error.localizedDescription)") NSLog("LockScreenWeatherManager: failed to fetch weather - \(error.localizedDescription)")
let providerSource = Defaults[.lockScreenWeatherProviderSource] let providerSource = Defaults[.lockScreenWeatherProviderSource]
let showsCharging = Defaults[.lockScreenWeatherShowsCharging]
let chargingInfo = showsCharging ? makeChargingInfo() : nil
let showsBatteryGauge = Defaults[.lockScreenWeatherShowsBatteryGauge]
let batteryInfo = showsBatteryGauge ? makeBatteryGaugeInfo(isCharging: chargingInfo != nil) : nil
let fallback = LockScreenWeatherSnapshot( let fallback = LockScreenWeatherSnapshot(
temperatureText: snapshot?.temperatureText ?? "--", temperatureText: snapshot?.temperatureText ?? "--",
symbolName: snapshot?.symbolName ?? "cloud.fill", symbolName: snapshot?.symbolName ?? "cloud.fill",
description: snapshot?.description ?? "", description: snapshot?.description ?? "",
locationName: snapshot?.locationName, locationName: snapshot?.locationName,
charging: Defaults[.lockScreenWeatherShowsCharging] ? makeChargingInfo() : nil, charging: chargingInfo,
bluetooth: Defaults[.lockScreenWeatherShowsBluetooth] ? makeBluetoothInfo() : nil, bluetooth: Defaults[.lockScreenWeatherShowsBluetooth] ? makeBluetoothInfo() : nil,
battery: batteryInfo,
showsLocation: snapshot?.showsLocation ?? false, showsLocation: snapshot?.showsLocation ?? false,
airQuality: (providerSource.supportsAirQuality && Defaults[.lockScreenWeatherShowsAQI]) ? snapshot?.airQuality : nil, airQuality: (providerSource.supportsAirQuality && Defaults[.lockScreenWeatherShowsAQI]) ? snapshot?.airQuality : nil,
widgetStyle: Defaults[.lockScreenWeatherWidgetStyle], widgetStyle: Defaults[.lockScreenWeatherWidgetStyle],
@ -172,8 +187,12 @@ final class LockScreenWeatherManager: ObservableObject {
.map { _ in () }.eraseToAnyPublisher(), .map { _ in () }.eraseToAnyPublisher(),
Defaults.publisher(.lockScreenWeatherShowsBluetooth, options: []) Defaults.publisher(.lockScreenWeatherShowsBluetooth, options: [])
.map { _ in () }.eraseToAnyPublisher(), .map { _ in () }.eraseToAnyPublisher(),
Defaults.publisher(.lockScreenWeatherShowsBatteryGauge, options: [])
.map { _ in () }.eraseToAnyPublisher(),
Defaults.publisher(.lockScreenWeatherWidgetStyle, options: []) Defaults.publisher(.lockScreenWeatherWidgetStyle, options: [])
.map { _ in () }.eraseToAnyPublisher(), .map { _ in () }.eraseToAnyPublisher(),
Defaults.publisher(.lockScreenWeatherTemperatureUnit, options: [])
.map { _ in () }.eraseToAnyPublisher(),
Defaults.publisher(.lockScreenWeatherShowsAQI, options: []) Defaults.publisher(.lockScreenWeatherShowsAQI, options: [])
.map { _ in () }.eraseToAnyPublisher(), .map { _ in () }.eraseToAnyPublisher(),
Defaults.publisher(.lockScreenWeatherUsesGaugeTint, options: []) Defaults.publisher(.lockScreenWeatherUsesGaugeTint, options: [])
@ -193,10 +212,37 @@ final class LockScreenWeatherManager: ObservableObject {
guard let self else { return } guard let self else { return }
self.latestWeatherPayload = nil self.latestWeatherPayload = nil
Task { @MainActor in Task { @MainActor in
NSLog("LockScreenWeatherManager: provider changed, forcing refresh")
_ = await self.refresh(force: true) _ = await self.refresh(force: true)
} }
} }
.store(in: &cancellables) .store(in: &cancellables)
Defaults.publisher(.lockScreenWeatherTemperatureUnit, options: [])
.sink { [weak self] _ in
guard let self else { return }
self.latestWeatherPayload = nil
Task { @MainActor in
NSLog("LockScreenWeatherManager: temperature unit changed, forcing refresh")
_ = await self.refresh(force: true)
}
}
.store(in: &cancellables)
Defaults.publisher(.enableLockScreenWeatherWidget, options: [])
.sink { [weak self] change in
guard let self else { return }
Task { @MainActor in
if change.newValue {
NSLog("LockScreenWeatherManager: widget enabled, triggering refresh")
self.locationProvider.prepareAuthorization()
_ = await self.refresh(force: true)
} else {
LockScreenWeatherPanelManager.shared.hide()
}
}
}
.store(in: &cancellables)
} }
private func handleAccessoryUpdate(triggerBluetoothRefresh: Bool) { private func handleAccessoryUpdate(triggerBluetoothRefresh: Bool) {
@ -230,6 +276,7 @@ final class LockScreenWeatherManager: ObservableObject {
let showsChargingPercentage = Defaults[.lockScreenWeatherShowsChargingPercentage] let showsChargingPercentage = Defaults[.lockScreenWeatherShowsChargingPercentage]
let providerSource = Defaults[.lockScreenWeatherProviderSource] let providerSource = Defaults[.lockScreenWeatherProviderSource]
let airQualityInfo = (Defaults[.lockScreenWeatherShowsAQI] && providerSource.supportsAirQuality) ? payload.airQuality : nil let airQualityInfo = (Defaults[.lockScreenWeatherShowsAQI] && providerSource.supportsAirQuality) ? payload.airQuality : nil
let batteryInfo = Defaults[.lockScreenWeatherShowsBatteryGauge] ? makeBatteryGaugeInfo(isCharging: chargingInfo != nil) : nil
let usesGaugeTint = Defaults[.lockScreenWeatherUsesGaugeTint] let usesGaugeTint = Defaults[.lockScreenWeatherUsesGaugeTint]
return LockScreenWeatherSnapshot( return LockScreenWeatherSnapshot(
@ -239,6 +286,7 @@ final class LockScreenWeatherManager: ObservableObject {
locationName: locationName, locationName: locationName,
charging: chargingInfo, charging: chargingInfo,
bluetooth: bluetoothInfo, bluetooth: bluetoothInfo,
battery: batteryInfo,
showsLocation: shouldShowLocation, showsLocation: shouldShowLocation,
airQuality: airQualityInfo, airQuality: airQualityInfo,
widgetStyle: widgetStyle, widgetStyle: widgetStyle,
@ -273,6 +321,18 @@ final class LockScreenWeatherManager: ObservableObject {
) )
} }
private func makeBatteryGaugeInfo(isCharging: Bool) -> LockScreenWeatherSnapshot.BatteryInfo? {
guard !isCharging else { return nil }
let battery = BatteryStatusViewModel.shared
let rawLevel = Int(round(Double(battery.levelBattery)))
let clampedLevel = min(max(rawLevel, 0), 100)
guard clampedLevel >= 0 else { return nil }
return LockScreenWeatherSnapshot.BatteryInfo(batteryLevel: clampedLevel)
}
private func makeBluetoothInfo() -> LockScreenWeatherSnapshot.BluetoothInfo? { private func makeBluetoothInfo() -> LockScreenWeatherSnapshot.BluetoothInfo? {
let manager = BluetoothAudioManager.shared let manager = BluetoothAudioManager.shared
@ -352,6 +412,10 @@ struct LockScreenWeatherSnapshot: Equatable {
let iconName: String let iconName: String
} }
struct BatteryInfo: Equatable {
let batteryLevel: Int
}
struct AirQualityInfo: Equatable { struct AirQualityInfo: Equatable {
enum Category: String, Equatable { enum Category: String, Equatable {
case good case good
@ -385,6 +449,7 @@ struct LockScreenWeatherSnapshot: Equatable {
let locationName: String? let locationName: String?
let charging: ChargingInfo? let charging: ChargingInfo?
let bluetooth: BluetoothInfo? let bluetooth: BluetoothInfo?
let battery: BatteryInfo?
let showsLocation: Bool let showsLocation: Bool
let airQuality: AirQualityInfo? let airQuality: AirQualityInfo?
let widgetStyle: LockScreenWeatherWidgetStyle let widgetStyle: LockScreenWeatherWidgetStyle
@ -472,11 +537,12 @@ private actor LockScreenWeatherProvider {
throw WeatherProviderError.noData throw WeatherProviderError.noData
} }
let usesMetric = Locale.current.usesMetricSystem let unit = Defaults[.lockScreenWeatherTemperatureUnit]
let usesMetric = unit.usesMetricSystem
let rawTemperature = usesMetric ? condition.tempC : condition.tempF let rawTemperature = usesMetric ? condition.tempC : condition.tempF
let temperatureValue = Double(rawTemperature) ?? 0 let temperatureValue = Double(rawTemperature) ?? 0
let temperatureText = "\(Int(round(temperatureValue)))°" let temperatureText = "\(Int(round(temperatureValue)))°"
let unitSymbol = usesMetric ? "°C" : "°F" let unitSymbol = unit.symbol
let forecast = payload.dailyWeather.first let forecast = payload.dailyWeather.first
let minTempValue = forecast.flatMap { daily in let minTempValue = forecast.flatMap { daily in
@ -519,6 +585,7 @@ private actor LockScreenWeatherProvider {
locationName: locationName, locationName: locationName,
charging: nil, charging: nil,
bluetooth: nil, bluetooth: nil,
battery: nil,
showsLocation: true, showsLocation: true,
airQuality: airQualityInfo, airQuality: airQualityInfo,
widgetStyle: .inline, widgetStyle: .inline,
@ -532,7 +599,8 @@ private actor LockScreenWeatherProvider {
let latitude = String(format: "%.4f", location.coordinate.latitude) let latitude = String(format: "%.4f", location.coordinate.latitude)
let longitude = String(format: "%.4f", location.coordinate.longitude) let longitude = String(format: "%.4f", location.coordinate.longitude)
let usesMetric = Locale.current.usesMetricSystem let unit = Defaults[.lockScreenWeatherTemperatureUnit]
let usesMetric = unit.usesMetricSystem
var weatherComponents = URLComponents(string: "https://api.open-meteo.com/v1/forecast") var weatherComponents = URLComponents(string: "https://api.open-meteo.com/v1/forecast")
var weatherQueryItems: [URLQueryItem] = [ var weatherQueryItems: [URLQueryItem] = [
URLQueryItem(name: "latitude", value: latitude), URLQueryItem(name: "latitude", value: latitude),
@ -543,8 +611,8 @@ private actor LockScreenWeatherProvider {
URLQueryItem(name: "timezone", value: "auto") URLQueryItem(name: "timezone", value: "auto")
] ]
if !usesMetric { if let temperatureUnitParameter = unit.openMeteoTemperatureParameter {
weatherQueryItems.append(URLQueryItem(name: "temperature_unit", value: "fahrenheit")) weatherQueryItems.append(URLQueryItem(name: "temperature_unit", value: temperatureUnitParameter))
} }
weatherComponents?.queryItems = weatherQueryItems weatherComponents?.queryItems = weatherQueryItems
@ -569,7 +637,7 @@ private actor LockScreenWeatherProvider {
let temperatureText = "\(Int(round(temperatureValue)))°" let temperatureText = "\(Int(round(temperatureValue)))°"
let code = current.weatherCode ?? 0 let code = current.weatherCode ?? 0
let mapping = OpenMeteoSymbolMapper.mapping(for: code) let mapping = OpenMeteoSymbolMapper.mapping(for: code)
let unitSymbol = usesMetric ? "°C" : "°F" let unitSymbol = unit.symbol
let minTempValue = weatherPayload.daily?.temperature2MMin?.first let minTempValue = weatherPayload.daily?.temperature2MMin?.first
let maxTempValue = weatherPayload.daily?.temperature2MMax?.first let maxTempValue = weatherPayload.daily?.temperature2MMax?.first
@ -592,6 +660,7 @@ private actor LockScreenWeatherProvider {
locationName: nil, locationName: nil,
charging: nil, charging: nil,
bluetooth: nil, bluetooth: nil,
battery: nil,
showsLocation: true, showsLocation: true,
airQuality: airQualityInfo, airQuality: airQualityInfo,
widgetStyle: .inline, widgetStyle: .inline,

View file

@ -364,7 +364,9 @@ extension Defaults.Keys {
static let lockScreenWeatherShowsCharging = Key<Bool>("lockScreenWeatherShowsCharging", default: true) static let lockScreenWeatherShowsCharging = Key<Bool>("lockScreenWeatherShowsCharging", default: true)
static let lockScreenWeatherShowsChargingPercentage = Key<Bool>("lockScreenWeatherShowsChargingPercentage", default: true) static let lockScreenWeatherShowsChargingPercentage = Key<Bool>("lockScreenWeatherShowsChargingPercentage", default: true)
static let lockScreenWeatherShowsBluetooth = Key<Bool>("lockScreenWeatherShowsBluetooth", default: true) static let lockScreenWeatherShowsBluetooth = Key<Bool>("lockScreenWeatherShowsBluetooth", default: true)
static let lockScreenWeatherShowsBatteryGauge = Key<Bool>("lockScreenWeatherShowsBatteryGauge", default: true)
static let lockScreenWeatherWidgetStyle = Key<LockScreenWeatherWidgetStyle>("lockScreenWeatherWidgetStyle", default: .circular) static let lockScreenWeatherWidgetStyle = Key<LockScreenWeatherWidgetStyle>("lockScreenWeatherWidgetStyle", default: .circular)
static let lockScreenWeatherTemperatureUnit = Key<LockScreenWeatherTemperatureUnit>("lockScreenWeatherTemperatureUnit", default: .celsius)
static let lockScreenWeatherShowsAQI = Key<Bool>("lockScreenWeatherShowsAQI", default: false) static let lockScreenWeatherShowsAQI = Key<Bool>("lockScreenWeatherShowsAQI", default: false)
static let lockScreenWeatherUsesGaugeTint = Key<Bool>("lockScreenWeatherUsesGaugeTint", default: false) static let lockScreenWeatherUsesGaugeTint = Key<Bool>("lockScreenWeatherUsesGaugeTint", default: false)
static let lockScreenWeatherProviderSource = Key<LockScreenWeatherProviderSource>("lockScreenWeatherProviderSource", default: .openMeteo) static let lockScreenWeatherProviderSource = Key<LockScreenWeatherProviderSource>("lockScreenWeatherProviderSource", default: .openMeteo)

View file

@ -96,6 +96,16 @@ The application provides a comprehensive interface for monitoring system perform
- **Profile-driven onboarding** that preconfigures the entire experience based on user selection from Developer, Designer, Light Use, or Student profiles, with multi-select support for combined feature sets. - **Profile-driven onboarding** that preconfigures the entire experience based on user selection from Developer, Designer, Light Use, or Student profiles, with multi-select support for combined feature sets.
- **Keyboard shortcut remapping** allowing users to customise hotkeys for toggling the notch, opening panels, activating the colour picker, starting timers, and launching utility tools. - **Keyboard shortcut remapping** allowing users to customise hotkeys for toggling the notch, opening panels, activating the colour picker, starting timers, and launching utility tools.
### Circular widgets (lock screen)
- **Circular / Gauge styles**: Lock screen widgets now include a dedicated circular mode that uses full circular gauges for charging, Bluetooth battery, AQI, and temperature. Gauges rely on the system accessory styles to match platform appearance and respect the monochrome tint preference.
- **Inline vs circular**: Inline mode keeps icon + text on a single baseline (no gauges). Circular mode increases widget sizing to match iOS-like reference visuals and adds top padding so the widget sits visually aligned with the lock screen clock.
- **AQI & provider handling**: AQI is shown only when the Open Meteo provider is active. A provider toggle in Settings selects between Open Meteo and wttr; AQI is disabled for wttr.
- **Battery & charging behaviour**: Charging state hides the standalone battery gauge and shows charging-specific accessory circular capacity. Bluetooth circular badge displays only the icon with a surrounding progress ring; battery percentages display actual values (e.g., 100%) and adopt system colouring when appropriate.
<p align="center">
<img src=".github/assets/CircularLockScreenWidgets.png" alt="Circular lock screen widgets" width="640">
</p>
## Requirements ## Requirements
- **macOS Sonoma 14.0** or later, optimised for macOS Sequoia 15.0 and subsequent releases. - **macOS Sonoma 14.0** or later, optimised for macOS Sequoia 15.0 and subsequent releases.
- **MacBook with notch** including 14-inch and 16-inch MacBook Pro models across M1 Pro, M1 Max, M2 Pro, M2 Max, M3 Pro, M3 Max, M4 Pro, and M4 Max configurations. - **MacBook with notch** including 14-inch and 16-inch MacBook Pro models across M1 Pro, M1 Max, M2 Pro, M2 Max, M3 Pro, M3 Max, M4 Pro, and M4 Max configurations.
@ -159,6 +169,16 @@ The application provides a comprehensive interface for monitoring system perform
- Timers continue running as live activities even when the notch is closed, with notifications at completion. - Timers continue running as live activities even when the notch is closed, with notifications at completion.
- Manage multiple concurrent timers and view remaining time for all active timers at a glance. - Manage multiple concurrent timers and view remaining time for all active timers at a glance.
- Timer controls live in a right-side popover alongside other notch icons and are also available from the menu bar. Timers run as live activities when the notch is closed: the timer icon remains neutral when idle and adopts the configured tint while running.
- Progress and display options: choose circular ring or linear bar progress, toggle countdown text on/off, and allow the ring size to adapt to title width to avoid overlap with the notch.
- Settings: timer tint (gradient vs solid), progress style (circular or bar), and countdown visibility.
<p align="center">
<img src=".github/assets/timer.gif" alt="Timer controls and live activity" width="640">
</p>
### Customisation ### Customisation
- Open Settings from the menu bar icon to adjust features, appearance, behaviour, keyboard shortcuts, and privacy permissions. - Open Settings from the menu bar icon to adjust features, appearance, behaviour, keyboard shortcuts, and privacy permissions.
- Switch between user profiles to quickly reconfigure the entire interface for different workflows. - Switch between user profiles to quickly reconfigure the entire interface for different workflows.
@ -323,3 +343,5 @@ If you previously cloned DynamicIsland, update the remote to track the Atoll rep
```bash ```bash
git remote set-url origin https://github.com/Ebullioscopic/Atoll.git git remote set-url origin https://github.com/Ebullioscopic/Atoll.git
``` ```
A heartfelt thanks to [TheBoredTeam](https://github.com/TheBoredTeam) for being supportive and being totally awesome, Atoll would not have been possible without Boring.Notch