mirror of
https://github.com/FBukovina/Atoll.git
synced 2026-10-27 15:14:33 +00:00
Merge pull request #80 from Ebullioscopic/feature/lock-screen
Feature/lock screen
This commit is contained in:
commit
4656712080
11 changed files with 282 additions and 106 deletions
1
.gitattributes
vendored
1
.gitattributes
vendored
|
|
@ -1 +1,2 @@
|
|||
*.gif filter=lfs diff=lfs merge=lfs -text
|
||||
*.mov filter=lfs diff=lfs merge=lfs -text
|
||||
|
|
|
|||
BIN
.github/assets/CircularLockScreenWidgets.png
vendored
Normal file
BIN
.github/assets/CircularLockScreenWidgets.png
vendored
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 692 KiB |
3
.github/assets/timer.gif
vendored
Normal file
3
.github/assets/timer.gif
vendored
Normal 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
3
.github/assets/timer.mov
vendored
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:cdbf9a492037d5a53d3cd036f7030373a67ead87793139246290cf69c4b46777
|
||||
size 2128950
|
||||
|
|
@ -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" : {
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
22
ReadMe.md
22
ReadMe.md
|
|
@ -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
|
||||
Loading…
Add table
Add a link
Reference in a new issue