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
*.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" : {
"localizations" : {
"fr" : {
@ -1433,6 +1437,10 @@
}
}
},
"100" : {
"comment" : "The text displayed below a 100% battery level in the lock screen.",
"isCommentAutoGenerated" : true
},
"100%" : {
"localizations" : {
"fr" : {
@ -10823,6 +10831,9 @@
}
}
},
"Mac battery at %d percent" : {
"comment" : "Mac battery gauge accessibility label"
},
"Made ❤️ by Ebullioscopic" : {
},
@ -18186,6 +18197,9 @@
}
}
}
},
"Temperature unit" : {
},
"TheDynamicIsland" : {

View file

@ -11,15 +11,10 @@ struct LockScreenWeatherWidget: View {
private var isInline: Bool { snapshot.widgetStyle == .inline }
private var stackAlignment: VerticalAlignment { isInline ? .firstTextBaseline : .top }
private var stackSpacing: CGFloat { isInline ? 14 : 22 }
private var gaugeLineWidth: CGFloat { 6 }
private var gaugeDiameter: CGFloat { 64 }
private var topPadding: CGFloat { isInline ? 6 : 22 }
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 {
Color.white.opacity(0.9)
}
@ -30,6 +25,10 @@ struct LockScreenWeatherWidget: View {
chargingSegment(for: charging)
}
if !isInline, let battery = snapshot.battery {
batterySegment(for: battery)
}
if let bluetooth = snapshot.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 {
HStack(alignment: .firstTextBaseline, spacing: 6) {
if let iconName = chargingIconName(for: info) {
@ -115,11 +124,27 @@ struct LockScreenWeatherWidget: View {
@ViewBuilder
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) {
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)
} 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))
.font(inlineSecondaryFont)
.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
private func bluetoothSegment(for info: LockScreenWeatherSnapshot.BluetoothInfo) -> some View {
switch snapshot.widgetStyle {
@ -155,13 +206,24 @@ struct LockScreenWeatherWidget: View {
}
private func circularBluetoothSegment(for info: LockScreenWeatherSnapshot.BluetoothInfo) -> some View {
VStack(spacing: 6) {
let clamped = clampedBatteryLevel(info.batteryLevel)
circularBadge(progress: Double(clamped) / 100, tint: bluetoothTint(for: clamped), lineWidth: gaugeLineWidth, diameter: gaugeDiameter) {
let clamped = clampedBatteryLevel(info.batteryLevel)
return VStack(spacing: 6) {
Gauge(value: Double(clamped), in: 0...100) {
EmptyView()
} currentValueLabel: {
Image(systemName: info.iconName)
.font(.system(size: 22, weight: .semibold))
.foregroundStyle(Color.white)
} minimumValueLabel: {
EmptyView()
} maximumValueLabel: {
EmptyView()
}
.gaugeStyle(.accessoryCircularCapacity)
.tint(bluetoothTint(for: clamped))
.frame(width: gaugeDiameter, height: gaugeDiameter)
Text(bluetoothPercentageText(for: info.batteryLevel))
.font(inlineSecondaryFont)
.foregroundStyle(secondaryLabelColor)
@ -193,11 +255,17 @@ struct LockScreenWeatherWidget: View {
private func circularAirQualitySegment(for info: LockScreenWeatherSnapshot.AirQualityInfo) -> some View {
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)")
.font(.system(size: 20, weight: .semibold, design: .rounded))
.foregroundStyle(Color.white)
}
.gaugeStyle(.accessoryCircular)
.tint(aqiTint(for: info.category))
.frame(width: gaugeDiameter, height: gaugeDiameter)
Text("AQI · \(info.category.displayName)")
.font(inlineSecondaryFont)
.foregroundStyle(secondaryLabelColor)
@ -207,17 +275,25 @@ struct LockScreenWeatherWidget: View {
}
private func temperatureGauge(for info: LockScreenWeatherSnapshot.TemperatureInfo) -> some View {
VStack(spacing: 6) {
circularBadge(progress: temperatureProgress(for: info), tint: temperatureTint(for: info), lineWidth: gaugeLineWidth, diameter: gaugeDiameter) {
let range = temperatureRange(for: info)
return VStack(spacing: 6) {
Gauge(value: info.current, in: range) {
EmptyView()
} currentValueLabel: {
temperatureCenterLabel(for: info)
}
.gaugeStyle(.accessoryCircular)
.tint(temperatureTint(for: info))
.frame(width: gaugeDiameter, height: gaugeDiameter)
HStack {
Text(minimumTemperatureLabel(for: info))
.font(inlineSecondaryFont)
.font(.system(size: 11, weight: .medium, design: .rounded))
.foregroundStyle(secondaryLabelColor)
Spacer()
Text(maximumTemperatureLabel(for: info))
.font(inlineSecondaryFont)
.font(.system(size: 11, weight: .medium, design: .rounded))
.foregroundStyle(secondaryLabelColor)
}
.frame(width: gaugeDiameter)
@ -307,21 +383,27 @@ struct LockScreenWeatherWidget: 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) {
Text("\(info.displayCurrent)°")
.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)
}
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 {
min(max(level, 0), 100)
}
@ -336,23 +418,6 @@ struct LockScreenWeatherWidget: View {
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 {
guard snapshot.usesGaugeTint else { return monochromeGaugeTint }
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 {
var components: [String] = []
@ -471,6 +515,10 @@ struct LockScreenWeatherWidget: View {
components.append(accessibilityAirQualityText(for: airQuality))
}
if let battery = snapshot.battery, !isInline {
components.append(accessibilityBatteryText(for: battery))
}
return components.joined(separator: ". ")
}
@ -515,46 +563,28 @@ struct LockScreenWeatherWidget: View {
airQuality.category.displayName
)
}
}
private struct CircularMetricView<Content: View>: View {
private let progress: Double
private let foreground: Color
private let background: Color
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
private func accessibilityBatteryText(for battery: LockScreenWeatherSnapshot.BatteryInfo) -> String {
String(
format: NSLocalizedString("Mac battery at %d percent", comment: "Mac battery gauge accessibility label"),
clampedBatteryLevel(battery.batteryLevel)
)
}
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()
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"
}
.frame(width: diameter, height: diameter)
}
}

View file

@ -182,7 +182,9 @@ struct GeneralSettings: View {
@Default(.lockScreenWeatherShowsCharging) var lockScreenWeatherShowsCharging
@Default(.lockScreenWeatherShowsChargingPercentage) var lockScreenWeatherShowsChargingPercentage
@Default(.lockScreenWeatherShowsBluetooth) var lockScreenWeatherShowsBluetooth
@Default(.lockScreenWeatherShowsBatteryGauge) var lockScreenWeatherShowsBatteryGauge
@Default(.lockScreenWeatherWidgetStyle) var lockScreenWeatherWidgetStyle
@Default(.lockScreenWeatherTemperatureUnit) var lockScreenWeatherTemperatureUnit
@Default(.lockScreenWeatherShowsAQI) var lockScreenWeatherShowsAQI
@Default(.lockScreenWeatherUsesGaugeTint) var lockScreenWeatherUsesGaugeTint
@Default(.lockScreenWeatherProviderSource) var lockScreenWeatherProviderSource
@ -345,12 +347,19 @@ struct GeneralSettings: View {
}
}
.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)
.disabled(lockScreenWeatherWidgetStyle == .circular)
Defaults.Toggle("Show charging status", key: .lockScreenWeatherShowsCharging)
if lockScreenWeatherShowsCharging {
Defaults.Toggle("Show charging percentage", key: .lockScreenWeatherShowsChargingPercentage)
}
Defaults.Toggle("Show battery indicator", key: .lockScreenWeatherShowsBatteryGauge)
Defaults.Toggle("Show Bluetooth battery", key: .lockScreenWeatherShowsBluetooth)
Defaults.Toggle("Show AQI widget", key: .lockScreenWeatherShowsAQI)
.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
func refresh(force: Bool = false) async -> LockScreenWeatherSnapshot? {
NSLog("LockScreenWeatherManager: refresh requested (force=%@)", force ? "true" : "false")
let interval = Defaults[.lockScreenWeatherRefreshInterval]
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 Defaults[.lockScreenWeatherShowsBluetooth] {
BluetoothAudioManager.shared.refreshConnectedDeviceBatteries()
@ -78,6 +81,12 @@ final class LockScreenWeatherManager: ObservableObject {
do {
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)
latestWeatherPayload = payload
if Defaults[.lockScreenWeatherShowsBluetooth] {
@ -87,18 +96,24 @@ final class LockScreenWeatherManager: ObservableObject {
self.snapshot = snapshot
lastFetchDate = Date()
deliver(snapshot, forceShow: false)
NSLog("LockScreenWeatherManager: weather refresh succeeded")
return snapshot
} catch {
NSLog("LockScreenWeatherManager: failed to fetch weather - \(error.localizedDescription)")
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(
temperatureText: snapshot?.temperatureText ?? "--",
symbolName: snapshot?.symbolName ?? "cloud.fill",
description: snapshot?.description ?? "",
locationName: snapshot?.locationName,
charging: Defaults[.lockScreenWeatherShowsCharging] ? makeChargingInfo() : nil,
charging: chargingInfo,
bluetooth: Defaults[.lockScreenWeatherShowsBluetooth] ? makeBluetoothInfo() : nil,
battery: batteryInfo,
showsLocation: snapshot?.showsLocation ?? false,
airQuality: (providerSource.supportsAirQuality && Defaults[.lockScreenWeatherShowsAQI]) ? snapshot?.airQuality : nil,
widgetStyle: Defaults[.lockScreenWeatherWidgetStyle],
@ -172,8 +187,12 @@ final class LockScreenWeatherManager: ObservableObject {
.map { _ in () }.eraseToAnyPublisher(),
Defaults.publisher(.lockScreenWeatherShowsBluetooth, options: [])
.map { _ in () }.eraseToAnyPublisher(),
Defaults.publisher(.lockScreenWeatherShowsBatteryGauge, options: [])
.map { _ in () }.eraseToAnyPublisher(),
Defaults.publisher(.lockScreenWeatherWidgetStyle, options: [])
.map { _ in () }.eraseToAnyPublisher(),
Defaults.publisher(.lockScreenWeatherTemperatureUnit, options: [])
.map { _ in () }.eraseToAnyPublisher(),
Defaults.publisher(.lockScreenWeatherShowsAQI, options: [])
.map { _ in () }.eraseToAnyPublisher(),
Defaults.publisher(.lockScreenWeatherUsesGaugeTint, options: [])
@ -193,10 +212,37 @@ final class LockScreenWeatherManager: ObservableObject {
guard let self else { return }
self.latestWeatherPayload = nil
Task { @MainActor in
NSLog("LockScreenWeatherManager: provider changed, forcing refresh")
_ = await self.refresh(force: true)
}
}
.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) {
@ -222,14 +268,15 @@ final class LockScreenWeatherManager: ObservableObject {
}
private func makeSnapshot(from payload: LockScreenWeatherSnapshot) -> LockScreenWeatherSnapshot {
let locationName = payload.locationName
let locationName = payload.locationName
let chargingInfo = Defaults[.lockScreenWeatherShowsCharging] ? makeChargingInfo() : nil
let bluetoothInfo = Defaults[.lockScreenWeatherShowsBluetooth] ? makeBluetoothInfo() : nil
let widgetStyle = Defaults[.lockScreenWeatherWidgetStyle]
let shouldShowLocation = widgetStyle == .inline && Defaults[.lockScreenWeatherShowsLocation] && !(locationName?.isEmpty ?? true)
let shouldShowLocation = widgetStyle == .inline && Defaults[.lockScreenWeatherShowsLocation] && !(locationName?.isEmpty ?? true)
let showsChargingPercentage = Defaults[.lockScreenWeatherShowsChargingPercentage]
let providerSource = Defaults[.lockScreenWeatherProviderSource]
let airQualityInfo = (Defaults[.lockScreenWeatherShowsAQI] && providerSource.supportsAirQuality) ? payload.airQuality : nil
let batteryInfo = Defaults[.lockScreenWeatherShowsBatteryGauge] ? makeBatteryGaugeInfo(isCharging: chargingInfo != nil) : nil
let usesGaugeTint = Defaults[.lockScreenWeatherUsesGaugeTint]
return LockScreenWeatherSnapshot(
@ -239,6 +286,7 @@ final class LockScreenWeatherManager: ObservableObject {
locationName: locationName,
charging: chargingInfo,
bluetooth: bluetoothInfo,
battery: batteryInfo,
showsLocation: shouldShowLocation,
airQuality: airQualityInfo,
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? {
let manager = BluetoothAudioManager.shared
@ -352,6 +412,10 @@ struct LockScreenWeatherSnapshot: Equatable {
let iconName: String
}
struct BatteryInfo: Equatable {
let batteryLevel: Int
}
struct AirQualityInfo: Equatable {
enum Category: String, Equatable {
case good
@ -385,6 +449,7 @@ struct LockScreenWeatherSnapshot: Equatable {
let locationName: String?
let charging: ChargingInfo?
let bluetooth: BluetoothInfo?
let battery: BatteryInfo?
let showsLocation: Bool
let airQuality: AirQualityInfo?
let widgetStyle: LockScreenWeatherWidgetStyle
@ -472,11 +537,12 @@ private actor LockScreenWeatherProvider {
throw WeatherProviderError.noData
}
let usesMetric = Locale.current.usesMetricSystem
let unit = Defaults[.lockScreenWeatherTemperatureUnit]
let usesMetric = unit.usesMetricSystem
let rawTemperature = usesMetric ? condition.tempC : condition.tempF
let temperatureValue = Double(rawTemperature) ?? 0
let temperatureText = "\(Int(round(temperatureValue)))°"
let unitSymbol = usesMetric ? "°C" : "°F"
let unitSymbol = unit.symbol
let forecast = payload.dailyWeather.first
let minTempValue = forecast.flatMap { daily in
@ -519,6 +585,7 @@ private actor LockScreenWeatherProvider {
locationName: locationName,
charging: nil,
bluetooth: nil,
battery: nil,
showsLocation: true,
airQuality: airQualityInfo,
widgetStyle: .inline,
@ -532,7 +599,8 @@ private actor LockScreenWeatherProvider {
let latitude = String(format: "%.4f", location.coordinate.latitude)
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 weatherQueryItems: [URLQueryItem] = [
URLQueryItem(name: "latitude", value: latitude),
@ -543,8 +611,8 @@ private actor LockScreenWeatherProvider {
URLQueryItem(name: "timezone", value: "auto")
]
if !usesMetric {
weatherQueryItems.append(URLQueryItem(name: "temperature_unit", value: "fahrenheit"))
if let temperatureUnitParameter = unit.openMeteoTemperatureParameter {
weatherQueryItems.append(URLQueryItem(name: "temperature_unit", value: temperatureUnitParameter))
}
weatherComponents?.queryItems = weatherQueryItems
@ -569,7 +637,7 @@ private actor LockScreenWeatherProvider {
let temperatureText = "\(Int(round(temperatureValue)))°"
let code = current.weatherCode ?? 0
let mapping = OpenMeteoSymbolMapper.mapping(for: code)
let unitSymbol = usesMetric ? "°C" : "°F"
let unitSymbol = unit.symbol
let minTempValue = weatherPayload.daily?.temperature2MMin?.first
let maxTempValue = weatherPayload.daily?.temperature2MMax?.first
@ -592,6 +660,7 @@ private actor LockScreenWeatherProvider {
locationName: nil,
charging: nil,
bluetooth: nil,
battery: nil,
showsLocation: true,
airQuality: airQualityInfo,
widgetStyle: .inline,

View file

@ -364,7 +364,9 @@ extension Defaults.Keys {
static let lockScreenWeatherShowsCharging = Key<Bool>("lockScreenWeatherShowsCharging", default: true)
static let lockScreenWeatherShowsChargingPercentage = Key<Bool>("lockScreenWeatherShowsChargingPercentage", 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 lockScreenWeatherTemperatureUnit = Key<LockScreenWeatherTemperatureUnit>("lockScreenWeatherTemperatureUnit", default: .celsius)
static let lockScreenWeatherShowsAQI = Key<Bool>("lockScreenWeatherShowsAQI", default: false)
static let lockScreenWeatherUsesGaugeTint = Key<Bool>("lockScreenWeatherUsesGaugeTint", default: false)
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.
- **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
- **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.
@ -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.
- 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
- 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.
@ -323,3 +343,5 @@ If you previously cloned DynamicIsland, update the remote to track the Atoll rep
```bash
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