diff --git a/.gitattributes b/.gitattributes index d41a940..958f79d 100644 --- a/.gitattributes +++ b/.gitattributes @@ -1 +1,2 @@ *.gif filter=lfs diff=lfs merge=lfs -text +*.mov filter=lfs diff=lfs merge=lfs -text diff --git a/.github/assets/CircularLockScreenWidgets.png b/.github/assets/CircularLockScreenWidgets.png new file mode 100644 index 0000000..c46613d Binary files /dev/null and b/.github/assets/CircularLockScreenWidgets.png differ diff --git a/.github/assets/timer.gif b/.github/assets/timer.gif new file mode 100644 index 0000000..529f31a --- /dev/null +++ b/.github/assets/timer.gif @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:9bec19433c1bddad8bc9b86b2f61f9324a109e3469753625643531d6bbb3598f +size 1189001 diff --git a/.github/assets/timer.mov b/.github/assets/timer.mov new file mode 100644 index 0000000..bb361ff --- /dev/null +++ b/.github/assets/timer.mov @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:cdbf9a492037d5a53d3cd036f7030373a67ead87793139246290cf69c4b46777 +size 2128950 diff --git a/DynamicIsland/Localizable.xcstrings b/DynamicIsland/Localizable.xcstrings index b4b6c0a..2210001 100644 --- a/DynamicIsland/Localizable.xcstrings +++ b/DynamicIsland/Localizable.xcstrings @@ -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" : { diff --git a/DynamicIsland/components/LockScreen/LockScreenWeatherWidget.swift b/DynamicIsland/components/LockScreen/LockScreenWeatherWidget.swift index 8afc0b5..8d0380d 100644 --- a/DynamicIsland/components/LockScreen/LockScreenWeatherWidget.swift +++ b/DynamicIsland/components/LockScreen/LockScreenWeatherWidget.swift @@ -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 { + 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( - 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: 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) } } diff --git a/DynamicIsland/components/Settings/SettingsView.swift b/DynamicIsland/components/Settings/SettingsView.swift index 20d76bb..9e9135f 100644 --- a/DynamicIsland/components/Settings/SettingsView.swift +++ b/DynamicIsland/components/Settings/SettingsView.swift @@ -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) diff --git a/DynamicIsland/enums/generic.swift b/DynamicIsland/enums/generic.swift index ba3ac68..1ee0b07 100644 --- a/DynamicIsland/enums/generic.swift +++ b/DynamicIsland/enums/generic.swift @@ -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" + } + } +} diff --git a/DynamicIsland/managers/LockScreenWeatherManager.swift b/DynamicIsland/managers/LockScreenWeatherManager.swift index c938671..4eb74b6 100644 --- a/DynamicIsland/managers/LockScreenWeatherManager.swift +++ b/DynamicIsland/managers/LockScreenWeatherManager.swift @@ -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, diff --git a/DynamicIsland/models/Constants.swift b/DynamicIsland/models/Constants.swift index 4c30f34..a02af29 100644 --- a/DynamicIsland/models/Constants.swift +++ b/DynamicIsland/models/Constants.swift @@ -364,7 +364,9 @@ extension Defaults.Keys { static let lockScreenWeatherShowsCharging = Key("lockScreenWeatherShowsCharging", default: true) static let lockScreenWeatherShowsChargingPercentage = Key("lockScreenWeatherShowsChargingPercentage", default: true) static let lockScreenWeatherShowsBluetooth = Key("lockScreenWeatherShowsBluetooth", default: true) + static let lockScreenWeatherShowsBatteryGauge = Key("lockScreenWeatherShowsBatteryGauge", default: true) static let lockScreenWeatherWidgetStyle = Key("lockScreenWeatherWidgetStyle", default: .circular) + static let lockScreenWeatherTemperatureUnit = Key("lockScreenWeatherTemperatureUnit", default: .celsius) static let lockScreenWeatherShowsAQI = Key("lockScreenWeatherShowsAQI", default: false) static let lockScreenWeatherUsesGaugeTint = Key("lockScreenWeatherUsesGaugeTint", default: false) static let lockScreenWeatherProviderSource = Key("lockScreenWeatherProviderSource", default: .openMeteo) diff --git a/ReadMe.md b/ReadMe.md index c0dce5b..d69bdc9 100644 --- a/ReadMe.md +++ b/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. + +

+ Circular lock screen widgets +

+ ## 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. + +

+ Timer controls and live activity +

+ ### 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 \ No newline at end of file