/* * Atoll (DynamicIsland) * Copyright (C) 2024-2026 Atoll Contributors * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with this program. If not, see . */ import SwiftUI import Defaults /// A view that displays the battery status with an icon and charging indicator. struct BatteryView: View { @Default(.showPowerStatusIcons) var showPowerStatusIcons var levelBattery: Float var isPluggedIn: Bool var isCharging: Bool var isInLowPowerMode: Bool var batteryWidth: CGFloat = 26 var isForNotification: Bool var animationStyle: DynamicIslandAnimations = DynamicIslandAnimations() var icon: String = "battery.0" /// Determines the icon to display when charging. var iconStatus: String { if isCharging { return "bolt" } else if isPluggedIn { return "plug" } else { return "" } } /// Determines the color of the battery based on its status. var batteryColor: Color { if isInLowPowerMode { return .yellow } else if levelBattery <= 20 && !isCharging && !isPluggedIn { return .red } else if isCharging || isPluggedIn || levelBattery == 100 { return .green } else { return .white } } var body: some View { ZStack(alignment: .leading) { Image(systemName: icon) .resizable() .fontWeight(.thin) .aspectRatio(contentMode: .fit) .foregroundColor(.white.opacity(0.5)) .frame( width: batteryWidth + 1 ) RoundedRectangle(cornerRadius: 2.5) .fill(batteryColor) .frame( width: CGFloat(((CGFloat(CFloat(levelBattery)) / 100) * (batteryWidth - 6))), height: (batteryWidth - 2.75) - 18 ) .padding(.leading, 2) if iconStatus != "" && (isForNotification || showPowerStatusIcons) { ZStack { Image(iconStatus) .resizable() .aspectRatio(contentMode: .fit) .foregroundColor(.white) .frame( width: 17, height: 17 ) } .frame(width: batteryWidth, height: batteryWidth) } } } } /// A view that displays detailed battery information and settings. struct BatteryMenuView: View { var isPluggedIn: Bool var isCharging: Bool var levelBattery: Float var maxCapacity: Float var timeToFullCharge: Int var isInLowPowerMode: Bool var onDismiss: () -> Void @Environment(\.openURL) private var openURL var body: some View { VStack(alignment: .leading, spacing: 16) { HStack { Text("Battery Status") .font(.headline) .fontWeight(.semibold) Spacer() Text("\(Int(levelBattery))%") .font(.headline) .fontWeight(.semibold) } VStack(alignment: .leading, spacing: 8) { Text("Max Capacity: \(Int(maxCapacity))%") .font(.subheadline) .fontWeight(.regular) if isInLowPowerMode { Label("Low Power Mode", systemImage: "bolt.circle") .font(.subheadline) .fontWeight(.regular) } if isCharging { Label("Charging", systemImage: "bolt.fill") .font(.subheadline) .fontWeight(.regular) } if isPluggedIn { Label("Plugged In", systemImage: "powerplug.fill") .font(.subheadline) .fontWeight(.regular) } if timeToFullCharge > 0 { Label("Time to Full Charge: \(timeToFullCharge) min", systemImage: "clock") .font(.subheadline) .fontWeight(.regular) } if !isCharging && isPluggedIn && levelBattery >= 80 { Label("Charging on Hold: Desktop Mode", systemImage: "desktopcomputer") .font(.subheadline) .fontWeight(.regular) } } .padding(.vertical, 8) Divider().background(Color.white) Button(action: openBatteryPreferences) { Label("Battery Settings", systemImage: "gearshape") .fontWeight(.regular) } .frame(maxWidth: .infinity) .buttonStyle(.plain) .padding(.vertical, 8) } .padding() .frame(width: 280) .foregroundColor(.white) } private func openBatteryPreferences() { if let url = URL(string: "x-apple.systempreferences:com.apple.preference.battery") { openURL(url) onDismiss() } } } /// A view that displays the battery status and allows interaction to show detailed information. struct DynamicIslandBatteryView: View { @Default(.showBatteryPercentage) var showBatteryPercentage @State var batteryWidth: CGFloat = 26 var isCharging: Bool = false var isInLowPowerMode: Bool = false var isPluggedIn: Bool = false var levelBattery: Float = 0 var maxCapacity: Float = 0 var timeToFullCharge: Int = 0 @State var isForNotification: Bool = false @State private var showPopupMenu: Bool = false @State private var isPressed: Bool = false @State private var isHoveringPopover: Bool = false @EnvironmentObject var vm: DynamicIslandViewModel var body: some View { HStack { if showBatteryPercentage { ZStack(alignment: .trailing) { Text("100%") .font(.callout) .hidden() Text("\(Int32(levelBattery))%") .font(.callout) .foregroundStyle(.white) .lineLimit(1) } .fixedSize(horizontal: true, vertical: false) } BatteryView( levelBattery: levelBattery, isPluggedIn: isPluggedIn, isCharging: isCharging, isInLowPowerMode: isInLowPowerMode, batteryWidth: batteryWidth, isForNotification: isForNotification ) } .scaleEffect(isPressed ? 0.95 : 1.0) .animation(.easeInOut(duration: 0.2), value: isPressed) .gesture( DragGesture(minimumDistance: 0) .onChanged { _ in withAnimation { isPressed = true } } .onEnded { _ in withAnimation { isPressed = false showPopupMenu.toggle() } } ) .popover( isPresented: $showPopupMenu, arrowEdge: .bottom) { BatteryMenuView( isPluggedIn: isPluggedIn, isCharging: isCharging, levelBattery: levelBattery, maxCapacity: maxCapacity, timeToFullCharge: timeToFullCharge, isInLowPowerMode: isInLowPowerMode, onDismiss: { showPopupMenu = false } ) .onHover { hovering in isHoveringPopover = hovering } } .onChange(of: showPopupMenu) { _, _ in updateBatteryPopoverActiveState() } .onChange(of: isHoveringPopover) { _, _ in updateBatteryPopoverActiveState() } } private func updateBatteryPopoverActiveState() { vm.isBatteryPopoverActive = showPopupMenu && isHoveringPopover } } private struct BatteryTemporaryHUDMetrics { let width: CGFloat let height: CGFloat let topRadius: CGFloat let bottomRadius: CGFloat } private extension BatteryTemporaryHUDKind { func metrics( style: BatteryNotificationStyle, closedNotchWidth: CGFloat, baseHeight: CGFloat ) -> BatteryTemporaryHUDMetrics { let compactBaseRadius = max(baseHeight / 2, 16) let compactTopRadius = max(12, compactBaseRadius - 4) switch (self, style) { case (.charging, _), (.lowBattery, .compact), (.fullBattery, .compact): return BatteryTemporaryHUDMetrics( width: closedNotchWidth + 180, height: baseHeight, topRadius: compactTopRadius, bottomRadius: compactBaseRadius ) case (.lowBattery, .standard): return BatteryTemporaryHUDMetrics( width: closedNotchWidth + 150, height: baseHeight + 75, topRadius: 22, bottomRadius: 40 ) case (.fullBattery, .standard): return BatteryTemporaryHUDMetrics( width: closedNotchWidth + 140, height: baseHeight + 70, topRadius: 18, bottomRadius: 36 ) } } } private struct BatteryCompactStatusRow: View { let title: String let batteryLevel: Int let tint: Color var body: some View { HStack { Text(verbatim: title) .font(.system(size: 14)) .foregroundColor(.white.opacity(0.8)) Spacer() HStack(spacing: 6) { Text("\(batteryLevel)%") .font(.system(size: 14)) .foregroundColor(tint) HStack(spacing: 1.5) { ZStack(alignment: .leading) { RoundedRectangle(cornerRadius: 6, style: .continuous) .fill(tint.opacity(0.3)) GeometryReader { geo in let clamped = max(0, min(batteryLevel, 100)) let width = CGFloat(clamped) / 100 * geo.size.width Rectangle() .fill(tint.gradient) .frame(width: max(0, width)) } } .frame(width: 28, height: 16) .clipShape(RoundedRectangle(cornerRadius: 6, style: .continuous)) RoundedRectangle(cornerRadius: 1.5, style: .continuous) .fill(batteryLevel == 100 ? tint.gradient : tint.opacity(0.3).gradient) .frame(width: 2, height: 6) } } } .padding(.horizontal, 16) } } struct BatteryTemporaryActivityView: View { let kind: BatteryTemporaryHUDKind let batteryLevel: Int let isLowPowerMode: Bool let closedNotchWidth: CGFloat let baseHeight: CGFloat let isDynamicIslandMode: Bool let topCornerRadius: CGFloat @Default(.lowBatteryHUDStyle) var lowBatteryHUDStyle @Default(.fullBatteryHUDStyle) var fullBatteryHUDStyle var styleOverride: BatteryNotificationStyle? = nil @State private var pulse = false @State private var showBatteryIndicator = false @State private var changeBatteryIndicator = true private var style: BatteryNotificationStyle { if kind == .charging { return .compact } if let styleOverride { return styleOverride } switch kind { case .charging: return .compact case .lowBattery: return lowBatteryHUDStyle case .fullBattery: return fullBatteryHUDStyle } } private var metrics: BatteryTemporaryHUDMetrics { kind.metrics( style: style, closedNotchWidth: closedNotchWidth, baseHeight: baseHeight ) } private var batteryTint: Color { switch kind { case .charging: if isLowPowerMode { return .yellow } else if batteryLevel <= 20 { return .red } return .green case .lowBattery: return isLowPowerMode ? .yellow : .red case .fullBattery: return isLowPowerMode ? .yellow : .green } } private var surfaceShape: AnyShape { if isDynamicIslandMode { return AnyShape(DynamicIslandPillShape(cornerRadius: dynamicIslandPillCornerRadiusInsets.opened)) } else { return AnyShape(NotchShape(topCornerRadius: topCornerRadius, bottomCornerRadius: metrics.bottomRadius)) } } var body: some View { ZStack(alignment: .bottom) { content } .frame(width: metrics.width, height: metrics.height, alignment: .bottom) .clipShape(surfaceShape) .onAppear(perform: prepareAnimations) } @ViewBuilder private var content: some View { if style == .compact { BatteryCompactStatusRow( title: compactTitle, batteryLevel: batteryLevel, tint: batteryTint ) .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .center) } else { VStack { Spacer() HStack { VStack(alignment: .leading, spacing: kind == .lowBattery ? 2 : 3) { standardTitle standardDescription } Spacer() standardIndicator } .padding(.leading, kind == .lowBattery ? 40 : 35) .padding(.trailing, kind == .lowBattery ? 45 : 40) .padding(.bottom, 20) } } } private var compactTitle: String { switch kind { case .charging: return "Charging" case .lowBattery: return "Low Battery" case .fullBattery: return "Full Battery" } } @ViewBuilder private var standardTitle: some View { HStack(spacing: 5) { Text(verbatim: kind == .lowBattery ? "Battery Low" : "Full Battery") .font(.system(size: kind == .lowBattery ? 13 : 13, weight: .semibold)) .foregroundStyle(.white.opacity(0.82)) .lineLimit(1) Text("\(batteryLevel)%") .font(.system(size: kind == .lowBattery ? 12 : 13, weight: .semibold)) .foregroundStyle(batteryTint) } } @ViewBuilder private var standardDescription: some View { switch kind { case .charging: EmptyView() case .lowBattery: if isLowPowerMode { ( Text(verbatim: "Low Power Mode enabled") .foregroundColor(.yellow) .font(.system(size: 10, weight: .medium)) + Text(verbatim: ", it is recommended to charge it.") .foregroundColor(.gray.opacity(0.6)) .font(.system(size: 10, weight: .medium)) ) } else { Text(verbatim: "Turn on Low Power Mode or it\nis recommended to charge it.") .font(.system(size: 10, weight: .medium)) .foregroundStyle(.gray.opacity(0.6)) .lineLimit(2) } case .fullBattery: Text(verbatim: "Your Mac is fully charged.") .font(.system(size: 10)) .foregroundStyle(.gray.opacity(0.6)) .fontWeight(.medium) .lineLimit(1) } } @ViewBuilder private var standardIndicator: some View { switch kind { case .charging: EmptyView() case .lowBattery: if isLowPowerMode { yellowLowIndicator } else { redLowIndicator } case .fullBattery: if showBatteryIndicator { if isLowPowerMode { yellowFullIndicator .transition(.opacity.combined(with: .scale)) } else { greenFullIndicator .transition(.opacity.combined(with: .scale)) } } else { magSafeIndicator .transition(.opacity.combined(with: .scale)) } } } private var redLowIndicator: some View { ZStack { RoundedRectangle(cornerRadius: 30) .fill(.red.opacity(0.2)) .frame(width: 70, height: 40) HStack(spacing: 2) { RoundedRectangle(cornerRadius: 10) .fill(.red.opacity(0.4)) .frame(width: 40, height: 24) RoundedRectangle(cornerRadius: 10) .fill(.red.opacity(0.4)) .frame(width: 3, height: 8) } .padding(.trailing, 5) RoundedRectangle(cornerRadius: 8) .fill(Color.red.gradient) .frame(width: 8, height: 14) .opacity(pulse ? 1 : 0.3) .offset(x: -15) RoundedRectangle(cornerRadius: 30) .stroke(Color.red.opacity(0.9), lineWidth: 1.5) .frame(width: pulse ? 8 : 30, height: pulse ? 14 : 32) .offset(x: -15) .opacity(pulse ? 0.3 : 1) } } private var yellowLowIndicator: some View { ZStack { RoundedRectangle(cornerRadius: 30) .fill(.yellow.opacity(0.2)) .frame(width: 70, height: 40) HStack(spacing: 2) { RoundedRectangle(cornerRadius: 10) .fill(.yellow.opacity(0.4)) .frame(width: 40, height: 24) RoundedRectangle(cornerRadius: 10) .fill(.yellow.opacity(0.4)) .frame(width: 3, height: 8) } .padding(.trailing, 5) RoundedRectangle(cornerRadius: 8) .fill(.yellow.gradient) .frame(width: 8, height: 14) .offset(x: -15) } } private var greenFullIndicator: some View { ZStack { RoundedRectangle(cornerRadius: 30) .fill(.green.opacity(0.2)) .frame(width: 70, height: 40) HStack(spacing: 2) { RoundedRectangle(cornerRadius: 10) .fill(.green.opacity(0.4)) .frame(width: 44, height: 24) .overlay( RoundedRectangle(cornerRadius: 4) .fill(Color.green.gradient) .frame(width: 34, height: 14) .opacity(pulse ? 1 : 0.4) ) RoundedRectangle(cornerRadius: 10) .fill(.green.opacity(0.4)) .frame(width: 3, height: 8) } } } private var yellowFullIndicator: some View { ZStack { RoundedRectangle(cornerRadius: 30) .fill(.yellow.opacity(0.2)) .frame(width: 70, height: 40) HStack(spacing: 2) { RoundedRectangle(cornerRadius: 10) .fill(.yellow.opacity(0.4)) .frame(width: 44, height: 24) .overlay( RoundedRectangle(cornerRadius: 4) .fill(Color.yellow.gradient) .frame(width: 34, height: 14) .opacity(pulse ? 1 : 0.4) ) RoundedRectangle(cornerRadius: 10) .fill(.yellow.opacity(0.4)) .frame(width: 3, height: 8) } } } private var magSafeIndicator: some View { HStack(spacing: 0) { Rectangle() .fill(.gray.opacity(0.15)) .frame(width: 30, height: 5) ZStack { RoundedRectangle(cornerRadius: 2) .fill(.gray.opacity(0.2).gradient) .frame(width: 30, height: 40) Circle() .fill(changeBatteryIndicator ? .orange : .green) .shadow(color: changeBatteryIndicator ? .orange : .green, radius: 5) .frame(width: 5, height: 5) } Rectangle() .fill(.white.opacity(0.4)) .frame(width: 3, height: 32) } } private func prepareAnimations() { pulse = false showBatteryIndicator = kind == .fullBattery && style == .standard changeBatteryIndicator = true guard style == .standard else { return } switch kind { case .charging: break case .lowBattery: if !isLowPowerMode { withAnimation(.easeInOut(duration: 1).repeatForever(autoreverses: true)) { pulse = true } } case .fullBattery: withAnimation(.easeInOut(duration: 1).repeatForever(autoreverses: true)) { pulse = true } DispatchQueue.main.asyncAfter(deadline: .now() + 2) { withAnimation(.spring(duration: 0.4)) { showBatteryIndicator = false } } DispatchQueue.main.asyncAfter(deadline: .now() + 3) { withAnimation(.spring(duration: 0.2)) { changeBatteryIndicator = false } } } } } #Preview { DynamicIslandBatteryView( batteryWidth: 30, isCharging: false, isInLowPowerMode: false, isPluggedIn: true, levelBattery: 80, maxCapacity: 100, timeToFullCharge: 10, isForNotification: false ).frame(width: 200, height: 200) }