diff --git a/DynamicIsland/ContentView.swift b/DynamicIsland/ContentView.swift index 4ab32a2..1a73dbe 100644 --- a/DynamicIsland/ContentView.swift +++ b/DynamicIsland/ContentView.swift @@ -85,6 +85,15 @@ struct ContentView: View { @Default(.hideNonNotchUntilHover) var hideNonNotchUntilHover @Default(.terminalStickyMode) var terminalStickyMode + // Battery settings reactivity + @Default(.showPowerStatusNotifications) var showPowerStatusNotifications + @Default(.showChargingBatteryHUD) var showChargingBatteryHUD + @Default(.showLowBatteryHUD) var showLowBatteryHUD + @Default(.showFullBatteryHUD) var showFullBatteryHUD + @Default(.showOnAllDisplays) var showOnAllDisplays + @Default(.lowBatteryHUDStyle) var lowBatteryHUDStyle + @Default(.fullBatteryHUDStyle) var fullBatteryHUDStyle + // Dynamic sizing based on view type and graph count with smooth transitions var dynamicNotchSize: CGSize { let baseSize = Defaults[.enableMinimalisticUI] ? minimalisticOpenNotchSize : openNotchSize @@ -101,6 +110,39 @@ struct ContentView: View { return CGSize(width: max(baseSize.width, inlineWidth), height: baseSize.height) } + // Handle battery HUD expansion sizing + if vm.notchState == .closed && + coordinator.expandingView.show && + coordinator.expandingView.type == .battery && + isBatteryHUDVisibleOnCurrentScreen { + + if let kind = batteryModel.activeTemporaryHUDKind { + let style: BatteryNotificationStyle = { + switch kind { + case .charging: return .compact + case .lowBattery: return Defaults[.lowBatteryHUDStyle] + case .fullBattery: return Defaults[.fullBatteryHUDStyle] + } + }() + + var width = vm.closedNotchSize.width + var height = vm.effectiveClosedNotchHeight + + switch (kind, style) { + case (.charging, _), (.lowBattery, .compact), (.fullBattery, .compact): + width += 180 + case (.lowBattery, .standard): + width += 100 + height += 75 + case (.fullBattery, .standard): + width += 80 + height += 70 + } + + return CGSize(width: width, height: height) + } + } + if coordinator.currentView == .timer { return CGSize(width: baseSize.width, height: 250) // Extra height for timer presets } @@ -383,9 +425,78 @@ struct ContentView: View { return DynamicIslandPillShape(cornerRadius: radius) } + private var isBatteryHUDVisibleOnCurrentScreen: Bool { + guard coordinator.expandingView.show, coordinator.expandingView.type == .battery else { return false } + guard showPowerStatusNotifications else { return false } + guard batteryModel.activeTemporaryHUDKind != nil else { return false } + if showOnAllDisplays { return true } + guard let targetScreenName = batteryModel.activeTemporaryHUDTargetScreenName else { return true } + return currentScreenName == targetScreenName + } + + private var isCurrentScreenExpansionVisible: Bool { + guard coordinator.expandingView.show else { return false } + if coordinator.expandingView.type == .battery { + return isBatteryHUDVisibleOnCurrentScreen + } + return true + } + + private var currentScreenExpansionType: SneakContentType? { + isCurrentScreenExpansionVisible ? coordinator.expandingView.type : nil + } + + private var displayedBatteryHUDLevel: Int { + let resolvedLevel = batteryModel.activeTemporaryHUDLevelOverride + ?? Int(batteryModel.levelBattery.rounded()) + return min(max(resolvedLevel, 0), 100) + } + + private var displayedBatteryHUDUsesLowPowerMode: Bool { + batteryModel.activeTemporaryHUDLowPowerModeOverride ?? batteryModel.isInLowPowerMode + } + + + private var activeClosedBatterySurfaceShape: AnyShape? { + guard vm.notchState == .closed else { return nil } + guard isBatteryHUDVisibleOnCurrentScreen else { return nil } + guard let kind = batteryModel.activeTemporaryHUDKind else { return nil } + + if isDynamicIslandMode { + let radius = dynamicIslandPillCornerRadiusInsets.opened + return AnyShape(DynamicIslandPillShape(cornerRadius: radius)) + } else { + let topRadius = activeCornerRadiusInsets.closed.top + let bottomRadius: CGFloat = { + switch resolvedBatteryNotificationStyle(for: kind) { + case .compact: + return activeCornerRadiusInsets.closed.bottom + case .standard: + return kind == .fullBattery ? 36 : 40 + } + }() + return AnyShape(NotchShape(topCornerRadius: topRadius, bottomCornerRadius: bottomRadius)) + } + } + + private func resolvedBatteryNotificationStyle(for kind: BatteryTemporaryHUDKind) -> BatteryNotificationStyle { + switch kind { + case .charging: + return .compact + case .lowBattery: + return lowBatteryHUDStyle + case .fullBattery: + return fullBatteryHUDStyle + } + } + + /// Resolves the clip/content shape per-screen: pill on non-notch screens /// when dynamic island mode is active, standard notch shape otherwise. private var resolvedClipShape: AnyShape { + if let activeClosedBatterySurfaceShape { + return activeClosedBatterySurfaceShape + } if isDynamicIslandMode { return AnyShape(currentPillShape) } @@ -764,13 +875,13 @@ struct ContentView: View { guard let musicSecondary else { return false } switch musicSecondary { case .timer: - return coordinator.expandingView.type == .timer + return currentScreenExpansionType == .timer case .reminder: - return coordinator.expandingView.type == .reminder + return currentScreenExpansionType == .reminder case .recording: - return coordinator.expandingView.type == .recording + return currentScreenExpansionType == .recording case .focus: - return coordinator.expandingView.type == .doNotDisturb + return currentScreenExpansionType == .doNotDisturb case .capsLock: return false case .extensionPayload: @@ -779,34 +890,26 @@ struct ContentView: View { return false } }() - let canShowMusicDuringExpansion = !coordinator.expandingView.show - || coordinator.expandingView.type == .music + let canShowMusicDuringExpansion = !isCurrentScreenExpansionVisible + || currentScreenExpansionType == .music || expansionMatchesSecondary - if coordinator.expandingView.type == .battery && coordinator.expandingView.show && vm.notchState == .closed && Defaults[.showPowerStatusNotifications] { - HStack(spacing: 0) { - HStack { - Text(batteryModel.statusText) - .font(.subheadline) - } - - Rectangle() - .fill(.black) - .frame(width: vm.closedNotchSize.width + 10) - - HStack { - DynamicIslandBatteryView( - batteryWidth: 30, - isCharging: batteryModel.isCharging, - isInLowPowerMode: batteryModel.isInLowPowerMode, - isPluggedIn: batteryModel.isPluggedIn, - levelBattery: batteryModel.levelBattery, - isForNotification: true - ) - } - .frame(width: 76, alignment: .trailing) - } - .frame(height: vm.effectiveClosedNotchHeight + (isHovering ? 8 : 0), alignment: .center) + if currentScreenExpansionType == .battery + && isBatteryHUDVisibleOnCurrentScreen + && vm.notchState == .closed + && Defaults[.showPowerStatusNotifications] + && batteryModel.activeTemporaryHUDKind != nil { + BatteryTemporaryActivityView( + kind: batteryModel.activeTemporaryHUDKind ?? .charging, + batteryLevel: displayedBatteryHUDLevel, + isLowPowerMode: displayedBatteryHUDUsesLowPowerMode, + closedNotchWidth: vm.closedNotchSize.width + (isHovering ? 8 : 0), + baseHeight: vm.effectiveClosedNotchHeight + (isHovering ? 8 : 0), + isDynamicIslandMode: isDynamicIslandMode, + topCornerRadius: activeCornerRadiusInsets.closed.top, + styleOverride: batteryModel.activeTemporaryHUDKind.map { resolvedBatteryNotificationStyle(for: $0) } + ) + .id(batteryModel.activeTemporaryHUDToken) } else if isSneakPeekVisibleOnCurrentScreen && Defaults[.inlineHUD] && (coordinator.sneakPeek.type != .music) && (coordinator.sneakPeek.type != .battery) && (coordinator.sneakPeek.type != .timer) && (coordinator.sneakPeek.type != .reminder) && !coordinator.sneakPeek.type.isExtensionPayload && ((coordinator.sneakPeek.type != .volume && coordinator.sneakPeek.type != .brightness && coordinator.sneakPeek.type != .backlight) || vm.notchState == .closed) { InlineHUD(type: $coordinator.sneakPeek.type, value: $coordinator.sneakPeek.value, icon: $coordinator.sneakPeek.icon, hoverAnimation: $isHovering, gestureProgress: $gestureProgress) .transition( @@ -821,25 +924,25 @@ struct ContentView: View { MusicLiveActivity(secondary: musicSecondary) .id("closed-music-live-activity") .transition(closedLiveActivitySwapTransition) - } else if (!coordinator.expandingView.show || coordinator.expandingView.type == .timer) && vm.notchState == .closed && timerManager.isTimerActive && coordinator.timerLiveActivityEnabled && !vm.hideOnClosed { + } else if (!isCurrentScreenExpansionVisible || currentScreenExpansionType == .timer) && vm.notchState == .closed && timerManager.isTimerActive && coordinator.timerLiveActivityEnabled && !vm.hideOnClosed { TimerLiveActivity() - } else if (!coordinator.expandingView.show || coordinator.expandingView.type == .reminder) && vm.notchState == .closed && reminderManager.isActive && enableReminderLiveActivity && !vm.hideOnClosed { + } else if (!isCurrentScreenExpansionVisible || currentScreenExpansionType == .reminder) && vm.notchState == .closed && reminderManager.isActive && enableReminderLiveActivity && !vm.hideOnClosed { ReminderLiveActivity() - } else if (!coordinator.expandingView.show || coordinator.expandingView.type == .recording) && vm.notchState == .closed && (recordingManager.isRecording || !recordingManager.isRecorderIdle) && Defaults[.enableScreenRecordingDetection] && !vm.hideOnClosed && !musicPairingEligible { + } else if (!isCurrentScreenExpansionVisible || currentScreenExpansionType == .recording) && vm.notchState == .closed && (recordingManager.isRecording || !recordingManager.isRecorderIdle) && Defaults[.enableScreenRecordingDetection] && !vm.hideOnClosed && !musicPairingEligible { RecordingLiveActivity() - } else if (!coordinator.expandingView.show || coordinator.expandingView.type == .download) && vm.notchState == .closed && downloadManager.isDownloading && Defaults[.enableDownloadListener] && !vm.hideOnClosed { + } else if (!isCurrentScreenExpansionVisible || currentScreenExpansionType == .download) && vm.notchState == .closed && downloadManager.isDownloading && Defaults[.enableDownloadListener] && !vm.hideOnClosed { DownloadLiveActivity() .transition(.blurReplace.animation(.interactiveSpring(dampingFraction: 1.2))) - } else if !coordinator.expandingView.show && vm.notchState == .closed && localSendLiveActivityActive && !vm.hideOnClosed { + } else if !isCurrentScreenExpansionVisible && vm.notchState == .closed && localSendLiveActivityActive && !vm.hideOnClosed { LocalSendLiveActivity() .transition(.blurReplace.animation(.interactiveSpring(dampingFraction: 1.2))) - } else if (!coordinator.expandingView.show || coordinator.expandingView.type == .doNotDisturb) && vm.notchState == .closed && Defaults[.enableDoNotDisturbDetection] && Defaults[.showDoNotDisturbIndicator] && (doNotDisturbManager.isDoNotDisturbActive || doNotDisturbManager.isFocusToastDismissing) && !vm.hideOnClosed && !lockScreenManager.isLocked { + } else if (!isCurrentScreenExpansionVisible || currentScreenExpansionType == .doNotDisturb) && vm.notchState == .closed && Defaults[.enableDoNotDisturbDetection] && Defaults[.showDoNotDisturbIndicator] && (doNotDisturbManager.isDoNotDisturbActive || doNotDisturbManager.isFocusToastDismissing) && !vm.hideOnClosed && !lockScreenManager.isLocked { DoNotDisturbLiveActivity() - } else if (!coordinator.expandingView.show || coordinator.expandingView.type == .lockScreen) && vm.notchState == .closed && (lockScreenManager.isLocked || !lockScreenManager.isLockIdle) && Defaults[.enableLockScreenLiveActivity] && !vm.hideOnClosed { + } else if (!isCurrentScreenExpansionVisible || currentScreenExpansionType == .lockScreen) && vm.notchState == .closed && (lockScreenManager.isLocked || !lockScreenManager.isLockIdle) && Defaults[.enableLockScreenLiveActivity] && !vm.hideOnClosed { LockScreenLiveActivity() .id("lock-screen-live-activity") .transition(closedLiveActivitySwapTransition) - } else if (!coordinator.expandingView.show || coordinator.expandingView.type == .privacy) && vm.notchState == .closed && privacyManager.hasAnyIndicator && (Defaults[.enableCameraDetection] || Defaults[.enableMicrophoneDetection]) && !vm.hideOnClosed { + } else if (!isCurrentScreenExpansionVisible || currentScreenExpansionType == .privacy) && vm.notchState == .closed && privacyManager.hasAnyIndicator && (Defaults[.enableCameraDetection] || Defaults[.enableMicrophoneDetection]) && !vm.hideOnClosed { PrivacyLiveActivity() } else if let extensionPayload = extensionStandalonePayload { let layout = extensionStandaloneLayout( @@ -856,6 +959,7 @@ struct ContentView: View { ShelfInlineLiveActivity() .transition(.opacity.animation(.smooth(duration: 0.25))) } else if !coordinator.expandingView.show && vm.notchState == .closed && (!musicManager.isPlaying && musicManager.isPlayerIdle) && Defaults[.showNotHumanFace] && !vm.hideOnClosed { + } else if !isCurrentScreenExpansionVisible && vm.notchState == .closed && (!musicManager.isPlaying && musicManager.isPlayerIdle) && Defaults[.showNotHumanFace] && !vm.hideOnClosed { DynamicIslandFaceAnimation().animation(.interactiveSpring, value: musicManager.isPlayerIdle) } else if vm.notchState == .open { DynamicIslandHeader() @@ -1631,10 +1735,10 @@ struct ContentView: View { return nil } - guard !coordinator.expandingView.show else { + guard !isCurrentScreenExpansionVisible else { ExtensionRoutingDiagnostics.shared.logSuppression( .standalone, - reason: "expanding view \(coordinator.expandingView.type) visible", + reason: "expanding view \(String(describing: currentScreenExpansionType ?? coordinator.expandingView.type)) visible", pendingCount: candidates.count ) return nil @@ -1754,7 +1858,7 @@ struct ContentView: View { && !vm.hideOnClosed && !lockScreenManager.isLocked && !isMusicHUDDeferredAfterUnlock - && !coordinator.expandingView.show + && !isCurrentScreenExpansionVisible && (!musicManager.isPlayerIdle || musicManager.bundleIdentifier != nil) && !coordinator.firstLaunch } diff --git a/DynamicIsland/DynamicIslandApp.swift b/DynamicIsland/DynamicIslandApp.swift index 8650f79..25a5e95 100644 --- a/DynamicIsland/DynamicIslandApp.swift +++ b/DynamicIsland/DynamicIslandApp.swift @@ -395,6 +395,43 @@ class AppDelegate: NSObject, NSApplicationDelegate { let inlineSneakPeekWidth: CGFloat = 460 return CGSize(width: inlineSneakPeekWidth, height: vm.effectiveClosedNotchHeight) } + + // Check for battery HUD expansion + if vm.notchState == .closed && + coordinator.expandingView.show && + coordinator.expandingView.type == .battery && + Defaults[.showPowerStatusNotifications] { + + let batteryModel = BatteryStatusViewModel.shared + if let kind = batteryModel.activeTemporaryHUDKind { + let closedNotchHeight = vm.effectiveClosedNotchHeight + let closedNotchWidth = vm.closedNotchSize.width + + let style: BatteryNotificationStyle = { + switch kind { + case .charging: return .compact + case .lowBattery: return Defaults[.lowBatteryHUDStyle] + case .fullBattery: return Defaults[.fullBatteryHUDStyle] + } + }() + + var width = closedNotchWidth + var height = closedNotchHeight + + switch (kind, style) { + case (.charging, _), (.lowBattery, .compact), (.fullBattery, .compact): + width += 180 + case (.lowBattery, .standard): + width += 100 + height += 75 + case (.fullBattery, .standard): + width += 80 + height += 70 + } + + return addShadowPadding(to: CGSize(width: width, height: height), isMinimalistic: Defaults[.enableMinimalisticUI]) + } + } // Use minimalistic or normal size based on settings var baseSize = Defaults[.enableMinimalisticUI] ? minimalisticOpenNotchSize : openNotchSize diff --git a/DynamicIsland/DynamicIslandViewCoordinator.swift b/DynamicIsland/DynamicIslandViewCoordinator.swift index be70f58..c6ebb3a 100644 --- a/DynamicIsland/DynamicIslandViewCoordinator.swift +++ b/DynamicIsland/DynamicIslandViewCoordinator.swift @@ -97,6 +97,7 @@ struct ExpandedItem { var type: SneakContentType = .battery var value: CGFloat = 0 var browser: BrowserType = .chromium + var autoHideDuration: TimeInterval? = nil } class DynamicIslandViewCoordinator: ObservableObject { @@ -413,7 +414,8 @@ class DynamicIslandViewCoordinator: ObservableObject { status: Bool, type: SneakContentType, value: CGFloat = 0, - browser: BrowserType = .chromium + browser: BrowserType = .chromium, + autoHideDuration: TimeInterval? = nil ) { Task { @MainActor in withAnimation(.smooth) { @@ -421,6 +423,7 @@ class DynamicIslandViewCoordinator: ObservableObject { self.expandingView.type = type self.expandingView.value = value self.expandingView.browser = browser + self.expandingView.autoHideDuration = autoHideDuration } } } @@ -433,7 +436,7 @@ class DynamicIslandViewCoordinator: ObservableObject { expandingViewTask?.cancel() // Only auto-hide for battery, not for downloads (DownloadManager handles that) if expandingView.type != .download { - let duration: TimeInterval = 3 + let duration = expandingView.autoHideDuration ?? 3 expandingViewTask = Task { [weak self] in try? await Task.sleep(for: .seconds(duration)) guard let self = self, !Task.isCancelled else { return } diff --git a/DynamicIsland/Localizable.xcstrings b/DynamicIsland/Localizable.xcstrings index 3c9788b..67ed55a 100644 --- a/DynamicIsland/Localizable.xcstrings +++ b/DynamicIsland/Localizable.xcstrings @@ -1739,6 +1739,9 @@ } } } + }, + "%llds" : { + }, "%lldx" : { "localizations" : { @@ -2314,7 +2317,6 @@ }, "±10 Seconds" : { "comment" : "Display name for the \"±10 Seconds\" music skip behavior option.", - "extractionState" : "stale", "isCommentAutoGenerated" : true, "localizations" : { "ko" : { @@ -3138,7 +3140,6 @@ }, "Accent" : { "comment" : "Display name for the caps lock indicator tint mode.", - "extractionState" : "stale", "isCommentAutoGenerated" : true, "localizations" : { "ko" : { @@ -3696,7 +3697,6 @@ }, "Adaptive gradient" : { "comment" : "Display name for the timer icon color mode: adaptive gradient.", - "extractionState" : "stale", "isCommentAutoGenerated" : true, "localizations" : { "ko" : { @@ -5623,6 +5623,10 @@ } } }, + "Amazon Music" : { + "comment" : "Spotify music service.", + "isCommentAutoGenerated" : true + }, "Animation" : { "extractionState" : "stale", "localizations" : { @@ -6016,7 +6020,6 @@ }, "Apple Music" : { "comment" : "Media controller type for Apple Music", - "extractionState" : "stale", "isCommentAutoGenerated" : true, "localizations" : { "ko" : { @@ -6920,7 +6923,6 @@ }, "Bar" : { "comment" : "Name of the timer progress style that shows a horizontal bar.", - "extractionState" : "stale", "isCommentAutoGenerated" : true, "localizations" : { "ko" : { @@ -7158,6 +7160,9 @@ } } } + }, + "Battery HUDs" : { + }, "Battery Indicator Styling" : { "comment" : "A section header in the Settings app that lists options for customizing the appearance of the battery indicator.", @@ -9032,6 +9037,12 @@ } } } + }, + "Charging duration" : { + + }, + "Charging HUD" : { + }, "Charging on Hold: Desktop Mode" : { "localizations" : { @@ -11426,6 +11437,16 @@ } } } + }, + "Compact" : { + "comment" : "Title for the compact battery notification style.", + "isCommentAutoGenerated" : true + }, + "Compact keeps the alert inline. Standard uses the taller full-charge HUD with the charging animation." : { + + }, + "Compact matches the charging HUD. Standard uses the expanded DynamicNotch-style card." : { + }, "Compressed" : { "localizations" : { @@ -14521,7 +14542,6 @@ }, "Digital" : { "comment" : "The display name for the \"Digital\" reminder presentation style.", - "extractionState" : "stale", "isCommentAutoGenerated" : true, "localizations" : { "ko" : { @@ -19732,6 +19752,15 @@ } } } + }, + "Full Battery" : { + + }, + "Full battery duration" : { + + }, + "Full battery style" : { + }, "Full charge" : { "comment" : "Text displayed in the status text when the battery is fully charged.", @@ -19756,6 +19785,9 @@ } } } + }, + "Full charge threshold" : { + }, "Full Disk Access required" : { "extractionState" : "stale", @@ -19830,6 +19862,9 @@ } } } + }, + "Fully charged HUD" : { + }, "Gemini API Key" : { "localizations" : { @@ -20829,7 +20864,6 @@ }, "Green" : { "comment" : "Display name for the green caps lock indicator tint mode.", - "extractionState" : "stale", "isCommentAutoGenerated" : true, "localizations" : { "ko" : { @@ -21699,6 +21733,9 @@ } }, "shouldTranslate" : false + }, + "HUD Duration" : { + }, "HUD icon style" : { "comment" : "A label describing the style of the 3D or symbol-based Bluetooth HUD icon.", @@ -21851,6 +21888,9 @@ } } } + }, + "HUD Tests" : { + }, "HUDs" : { "extractionState" : "stale", @@ -24396,6 +24436,21 @@ } } } + }, + "Low Battery" : { + + }, + "Low battery duration" : { + + }, + "Low battery HUD" : { + + }, + "Low battery style" : { + + }, + "Low battery threshold" : { + }, "Low Power Mode" : { "localizations" : { @@ -29944,7 +29999,6 @@ }, "Now Playing" : { "comment" : "Name of the media controller type for the \"Now Playing\" option.", - "extractionState" : "stale", "isCommentAutoGenerated" : true, "localizations" : { "ko" : { @@ -30690,7 +30744,6 @@ }, "Panel" : { "comment" : "Display name for the clipboard display mode \"Panel\".", - "extractionState" : "stale", "isCommentAutoGenerated" : true, "localizations" : { "ko" : { @@ -34233,7 +34286,6 @@ }, "Ring" : { "comment" : "The name of the timer progress style that shows a ring shape.", - "extractionState" : "stale", "isCommentAutoGenerated" : true, "localizations" : { "ko" : { @@ -34385,6 +34437,9 @@ } } } + }, + "Runs the real notch animation on the current target display. If an external screen is using Dynamic Island mode, the battery HUD is sent there first." : { + }, "Sampling can continue while the notch is closed when the timeout is disabled." : { "localizations" : { @@ -35755,7 +35810,6 @@ }, "Separate Tab" : { "comment" : "Display name for the clipboard display mode option to open the clipboard in a separate tab.", - "extractionState" : "stale", "isCommentAutoGenerated" : true, "localizations" : { "ko" : { @@ -38188,7 +38242,6 @@ }, "Shows screen assistant as a dropdown attached to the AI button" : { "comment" : "Description of the screen assistant display mode option that shows the screen assistant as a dropdown attached to the AI button.", - "extractionState" : "stale", "isCommentAutoGenerated" : true, "localizations" : { "ko" : { @@ -38213,7 +38266,6 @@ }, "Shows screen assistant in a floating panel near the notch" : { "comment" : "Description of the screen assistant display mode option that shows the screen assistant in a floating panel near the notch.", - "extractionState" : "stale", "isCommentAutoGenerated" : true, "localizations" : { "ko" : { @@ -38441,7 +38493,6 @@ }, "Skip forward or backward by ten seconds" : { "comment" : "Description of a music skip behavior option.", - "extractionState" : "stale", "isCommentAutoGenerated" : true, "localizations" : { "ko" : { @@ -39165,7 +39216,6 @@ }, "Spotify" : { "comment" : "Spotify media controller type.", - "extractionState" : "stale", "isCommentAutoGenerated" : true, "localizations" : { "ko" : { @@ -39402,7 +39452,6 @@ }, "Standard previous/next track controls" : { "comment" : "Description of the music skip behavior option to use standard previous/next track controls.", - "extractionState" : "stale", "isCommentAutoGenerated" : true, "localizations" : { "ko" : { @@ -41029,6 +41078,15 @@ }, "Terminal opacity only affects the terminal backdrop; text stays fully opaque. Blur uses the system material behind the window." : { + }, + "Test charging HUD" : { + + }, + "Test full battery HUD" : { + + }, + "Test low battery HUD" : { + }, "Text" : { "comment" : "Name of a clipboard item type that contains plain text.", @@ -41148,6 +41206,9 @@ } } } + }, + "These temporary HUDs recreate the charging, low-battery, and full-battery notch alerts." : { + }, "Third-party apps using AtollExtensionKit can display live activities, lock screen widgets, and dedicated notch experiences. Toggle features above or manage individual app permissions below." : { "comment" : "A footer text that explains the purpose of the \"Global Settings\" section in the Extensions settings view. | A footer text that explains the purpose of the \"Global Settings\" section in the Extensions settings view. | merge:ko:alt='AtollExtensionKit을 사용하는 타사 앱은 실시간 현황, 잠금 화면 위젯 및 노치 전용 기능을 표시할 수 있습니다. 위에서 기능을 켜고 끄거나 아래에서 개별 앱 권한을 관리할 수 있습니다.'", @@ -42233,7 +42294,6 @@ }, "Track Skip" : { "comment" : "Display name for the \"Track Skip\" music skip behavior.", - "extractionState" : "stale", "isCommentAutoGenerated" : true, "localizations" : { "ko" : { @@ -45598,7 +45658,6 @@ }, "Youtube Music" : { "comment" : "Spotify music service.", - "extractionState" : "stale", "isCommentAutoGenerated" : true, "localizations" : { "ko" : { @@ -45724,4 +45783,4 @@ } }, "version" : "1.2" -} \ No newline at end of file +} diff --git a/DynamicIsland/components/Live activities/DynamicIslandBattery.swift b/DynamicIsland/components/Live activities/DynamicIslandBattery.swift index 10eef4c..af509b6 100644 --- a/DynamicIsland/components/Live activities/DynamicIslandBattery.swift +++ b/DynamicIsland/components/Live activities/DynamicIslandBattery.swift @@ -21,7 +21,7 @@ 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 @@ -79,7 +79,7 @@ struct BatteryView: View { ) .padding(.leading, 2) - if iconStatus != "" && (isForNotification || Defaults[.showPowerStatusIcons]) { + if iconStatus != "" && (isForNotification || showPowerStatusIcons) { ZStack { Image(iconStatus) .resizable() @@ -182,6 +182,7 @@ struct BatteryMenuView: View { /// 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 @@ -199,7 +200,7 @@ struct DynamicIslandBatteryView: View { var body: some View { HStack { - if Defaults[.showBatteryPercentage] { + if showBatteryPercentage { ZStack(alignment: .trailing) { Text("100%") .font(.callout) @@ -268,6 +269,443 @@ struct DynamicIslandBatteryView: View { } } + +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, diff --git a/DynamicIsland/components/Notch/DynamicIslandHeader.swift b/DynamicIsland/components/Notch/DynamicIslandHeader.swift index c9eda7b..6e0618f 100644 --- a/DynamicIsland/components/Notch/DynamicIslandHeader.swift +++ b/DynamicIsland/components/Notch/DynamicIslandHeader.swift @@ -36,10 +36,12 @@ struct DynamicIslandHeader: View { @Default(.showClipboardIcon) var showClipboardIcon @Default(.showColorPickerIcon) var showColorPickerIcon @Default(.clipboardDisplayMode) var clipboardDisplayMode + @Default(.showBatteryIndicator) var showBatteryIndicator + @Default(.enableMinimalisticUI) var enableMinimalisticUI var body: some View { HStack(spacing: 0) { - if !Defaults[.enableMinimalisticUI] { + if !enableMinimalisticUI { HStack { let shouldShowTabs = coordinator.alwaysShowTabs || vm.notchState == .open || !shelfState.items.isEmpty if shouldShowTabs { @@ -55,7 +57,7 @@ struct DynamicIslandHeader: View { } - if vm.notchState == .open && !Defaults[.enableMinimalisticUI] { + if vm.notchState == .open && !enableMinimalisticUI { let spacerWidth = min(vm.closedNotchSize.width, 300) Rectangle() .fill(NSScreen.screens @@ -67,7 +69,7 @@ struct DynamicIslandHeader: View { } HStack(spacing: 4) { - if vm.notchState == .open && !Defaults[.enableMinimalisticUI] { + if vm.notchState == .open && !enableMinimalisticUI { if Defaults[.showMirror] { Button(action: { vm.toggleCameraPreview() @@ -230,7 +232,7 @@ struct DynamicIslandHeader: View { - if Defaults[.showBatteryIndicator] { + if showBatteryIndicator { DynamicIslandBatteryView( batteryWidth: 30, isCharging: batteryModel.isCharging, diff --git a/DynamicIsland/components/Settings/SettingsView.swift b/DynamicIsland/components/Settings/SettingsView.swift index 2a4464b..9e4def1 100644 --- a/DynamicIsland/components/Settings/SettingsView.swift +++ b/DynamicIsland/components/Settings/SettingsView.swift @@ -731,6 +731,19 @@ struct SettingsView: View { SettingsSearchEntry(tab: .battery, title: "Show power status notifications", keywords: ["notifications", "power"], highlightID: SettingsTab.battery.highlightID(for: "Show power status notifications")), SettingsSearchEntry(tab: .battery, title: "Show power status icons", keywords: ["power icons", "charging icon"], highlightID: SettingsTab.battery.highlightID(for: "Show power status icons")), SettingsSearchEntry(tab: .battery, title: "Play low battery alert sound", keywords: ["low battery", "alert", "sound"], highlightID: SettingsTab.battery.highlightID(for: "Play low battery alert sound")), + SettingsSearchEntry(tab: .battery, title: "Charging HUD", keywords: ["battery", "charging", "temporary activity"], highlightID: SettingsTab.battery.highlightID(for: "Charging HUD")), + SettingsSearchEntry(tab: .battery, title: "Low battery HUD", keywords: ["battery", "low", "temporary activity"], highlightID: SettingsTab.battery.highlightID(for: "Low battery HUD")), + SettingsSearchEntry(tab: .battery, title: "Fully charged HUD", keywords: ["battery", "full", "temporary activity"], highlightID: SettingsTab.battery.highlightID(for: "Fully charged HUD")), + SettingsSearchEntry(tab: .battery, title: "Charging duration", keywords: ["charging", "duration", "seconds"], highlightID: SettingsTab.battery.highlightID(for: "Charging duration")), + SettingsSearchEntry(tab: .battery, title: "Low battery duration", keywords: ["low battery", "duration", "seconds"], highlightID: SettingsTab.battery.highlightID(for: "Low battery duration")), + SettingsSearchEntry(tab: .battery, title: "Full battery duration", keywords: ["full battery", "duration", "seconds"], highlightID: SettingsTab.battery.highlightID(for: "Full battery duration")), + SettingsSearchEntry(tab: .battery, title: "Test charging HUD", keywords: ["battery", "test", "charging", "preview"], highlightID: nil), + SettingsSearchEntry(tab: .battery, title: "Test low battery HUD", keywords: ["battery", "test", "low", "preview"], highlightID: nil), + SettingsSearchEntry(tab: .battery, title: "Test full battery HUD", keywords: ["battery", "test", "full", "preview"], highlightID: nil), + SettingsSearchEntry(tab: .battery, title: "Low battery style", keywords: ["battery", "style", "compact", "standard"], highlightID: SettingsTab.battery.highlightID(for: "Low battery style")), + SettingsSearchEntry(tab: .battery, title: "Low battery threshold", keywords: ["battery", "threshold", "percent"], highlightID: SettingsTab.battery.highlightID(for: "Low battery threshold")), + SettingsSearchEntry(tab: .battery, title: "Full battery style", keywords: ["battery", "style", "compact", "standard"], highlightID: SettingsTab.battery.highlightID(for: "Full battery style")), + SettingsSearchEntry(tab: .battery, title: "Full charge threshold", keywords: ["battery", "threshold", "full"], highlightID: SettingsTab.battery.highlightID(for: "Full charge threshold")), // HUDs SettingsSearchEntry(tab: .devices, title: "Show Bluetooth device connections", keywords: ["bluetooth", "hud"], highlightID: SettingsTab.devices.highlightID(for: "Show Bluetooth device connections")), @@ -1305,10 +1318,62 @@ struct GeneralSettings: View { } struct Charge: View { + @ObservedObject private var batteryStatusViewModel = BatteryStatusViewModel.shared + @Default(.showPowerStatusNotifications) private var showPowerStatusNotifications + @Default(.showChargingBatteryHUD) private var showChargingBatteryHUD + @Default(.showLowBatteryHUD) private var showLowBatteryHUD + @Default(.showFullBatteryHUD) private var showFullBatteryHUD + @Default(.chargingBatteryHUDDuration) private var chargingBatteryHUDDuration + @Default(.lowBatteryHUDDuration) private var lowBatteryHUDDuration + @Default(.fullBatteryHUDDuration) private var fullBatteryHUDDuration + @Default(.lowBatteryHUDThreshold) private var lowBatteryHUDThreshold + @Default(.fullBatteryHUDThreshold) private var fullBatteryHUDThreshold + @Default(.lowBatteryHUDStyle) private var lowBatteryHUDStyle + @Default(.fullBatteryHUDStyle) private var fullBatteryHUDStyle + private func highlightID(_ title: String) -> String { SettingsTab.battery.highlightID(for: title) } + private var chargingDurationBinding: Binding { + Binding( + get: { Double(chargingBatteryHUDDuration) }, + set: { chargingBatteryHUDDuration = Int($0.rounded()) } + ) + } + + private var lowBatteryDurationBinding: Binding { + Binding( + get: { Double(lowBatteryHUDDuration) }, + set: { lowBatteryHUDDuration = Int($0.rounded()) } + ) + } + + private var fullBatteryDurationBinding: Binding { + Binding( + get: { Double(fullBatteryHUDDuration) }, + set: { fullBatteryHUDDuration = Int($0.rounded()) } + ) + } + + private var lowBatteryThresholdBinding: Binding { + Binding( + get: { Double(lowBatteryHUDThreshold) }, + set: { lowBatteryHUDThreshold = Int($0.rounded()) } + ) + } + + private var fullBatteryThresholdBinding: Binding { + Binding( + get: { Double(fullBatteryHUDThreshold) }, + set: { fullBatteryHUDThreshold = Int($0.rounded()) } + ) + } + + private func sectionOpacity(_ isEnabled: Bool) -> Double { + isEnabled ? 1 : 0.5 + } + var body: some View { Form { if BatteryActivityManager.shared.hasBattery() { @@ -1340,6 +1405,157 @@ struct Charge: View { } header: { Text("Battery Information") } + Section { + Defaults.Toggle(key: .showChargingBatteryHUD) { + Text("Charging HUD") + } + .settingsHighlight(id: highlightID("Charging HUD")) + + Defaults.Toggle(key: .showLowBatteryHUD) { + Text("Low battery HUD") + } + .settingsHighlight(id: highlightID("Low battery HUD")) + + Defaults.Toggle(key: .showFullBatteryHUD) { + Text("Fully charged HUD") + } + .settingsHighlight(id: highlightID("Fully charged HUD")) + } header: { + Text("Battery HUDs") + } footer: { + Text("These temporary HUDs recreate the charging, low-battery, and full-battery notch alerts.") + } + Section { + VStack(alignment: .leading, spacing: 8) { + HStack { + Text("Charging duration") + Spacer() + Text("\(chargingBatteryHUDDuration)s") + .foregroundStyle(.secondary) + } + Slider(value: chargingDurationBinding, in: 1...10, step: 1) + } + .settingsHighlight(id: highlightID("Charging duration")) + .disabled(!showPowerStatusNotifications || !showChargingBatteryHUD) + .opacity(sectionOpacity(showPowerStatusNotifications && showChargingBatteryHUD)) + + VStack(alignment: .leading, spacing: 8) { + HStack { + Text("Low battery duration") + Spacer() + Text("\(lowBatteryHUDDuration)s") + .foregroundStyle(.secondary) + } + Slider(value: lowBatteryDurationBinding, in: 1...10, step: 1) + } + .settingsHighlight(id: highlightID("Low battery duration")) + .disabled(!showPowerStatusNotifications || !showLowBatteryHUD) + .opacity(sectionOpacity(showPowerStatusNotifications && showLowBatteryHUD)) + + VStack(alignment: .leading, spacing: 8) { + HStack { + Text("Full battery duration") + Spacer() + Text("\(fullBatteryHUDDuration)s") + .foregroundStyle(.secondary) + } + Slider(value: fullBatteryDurationBinding, in: 1...10, step: 1) + } + .settingsHighlight(id: highlightID("Full battery duration")) + .disabled(!showPowerStatusNotifications || !showFullBatteryHUD) + .opacity(sectionOpacity(showPowerStatusNotifications && showFullBatteryHUD)) + } header: { + Text("HUD Duration") + } + Section { + Button { + batteryStatusViewModel.triggerTestHUD(kind: .charging) + } label: { + Label("Test charging HUD", systemImage: "bolt.fill") + } + .disabled(!showPowerStatusNotifications || !showChargingBatteryHUD) + + Button { + batteryStatusViewModel.triggerTestHUD(kind: .lowBattery) + } label: { + Label("Test low battery HUD", systemImage: "battery.25") + } + .disabled(!showPowerStatusNotifications || !showLowBatteryHUD) + + Button { + batteryStatusViewModel.triggerTestHUD(kind: .fullBattery) + } label: { + Label("Test full battery HUD", systemImage: "battery.100") + } + .disabled(!showPowerStatusNotifications || !showFullBatteryHUD) + } header: { + Text("HUD Tests") + } footer: { + Text("Runs the real notch animation on the current target display. If an external screen is using Dynamic Island mode, the battery HUD is sent there first.") + } + Section { + VStack(alignment: .leading, spacing: 10) { + Picker("Low battery style", selection: $lowBatteryHUDStyle) { + ForEach(BatteryNotificationStyle.allCases) { style in + Text(style.title) + .tag(style) + } + } + .pickerStyle(.segmented) + Text("Compact matches the charging HUD. Standard uses the expanded DynamicNotch-style card.") + .font(.caption) + .foregroundStyle(.secondary) + } + .settingsHighlight(id: highlightID("Low battery style")) + + + VStack(alignment: .leading, spacing: 8) { + HStack { + Text("Low battery threshold") + Spacer() + Text("\(lowBatteryHUDThreshold)%") + .foregroundStyle(.secondary) + } + Slider(value: lowBatteryThresholdBinding, in: 5...30, step: 1) + } + .settingsHighlight(id: highlightID("Low battery threshold")) + } header: { + Text("Low Battery") + } + .disabled(!showPowerStatusNotifications || !showLowBatteryHUD) + .opacity(sectionOpacity(showPowerStatusNotifications && showLowBatteryHUD)) + + Section { + VStack(alignment: .leading, spacing: 10) { + Picker("Full battery style", selection: $fullBatteryHUDStyle) { + ForEach(BatteryNotificationStyle.allCases) { style in + Text(style.title) + .tag(style) + } + } + .pickerStyle(.segmented) + Text("Compact keeps the alert inline. Standard uses the taller full-charge HUD with the charging animation.") + .font(.caption) + .foregroundStyle(.secondary) + } + .settingsHighlight(id: highlightID("Full battery style")) + + + VStack(alignment: .leading, spacing: 8) { + HStack { + Text("Full charge threshold") + Spacer() + Text("\(fullBatteryHUDThreshold)%") + .foregroundStyle(.secondary) + } + Slider(value: fullBatteryThresholdBinding, in: 80...100, step: 1) + } + .settingsHighlight(id: highlightID("Full charge threshold")) + } header: { + Text("Full Battery") + } + .disabled(!showPowerStatusNotifications || !showFullBatteryHUD) + .opacity(sectionOpacity(showPowerStatusNotifications && showFullBatteryHUD)) } else { ContentUnavailableView { VStack(spacing: 16) { diff --git a/DynamicIsland/models/BatteryStatusViewModel.swift b/DynamicIsland/models/BatteryStatusViewModel.swift index 0b9e792..b83fb42 100644 --- a/DynamicIsland/models/BatteryStatusViewModel.swift +++ b/DynamicIsland/models/BatteryStatusViewModel.swift @@ -26,6 +26,12 @@ import Foundation import IOKit.ps import SwiftUI +enum BatteryTemporaryHUDKind: Equatable { + case charging + case lowBattery + case fullBattery +} + /// A view model that manages and monitors the battery status of the device class BatteryStatusViewModel: ObservableObject { @@ -34,7 +40,6 @@ class BatteryStatusViewModel: ObservableObject { private var runLoopSource: Unmanaged? var animations: DynamicIslandAnimations = DynamicIslandAnimations() private let lowBatteryAlertSoundPlayer = AudioPlayer() - private let lowBatteryAlertThresholds: [Float] = [20, 15, 10, 5] @ObservedObject var coordinator = DynamicIslandViewCoordinator.shared @@ -46,6 +51,11 @@ class BatteryStatusViewModel: ObservableObject { @Published private(set) var isInitial: Bool = false @Published private(set) var timeToFullCharge: Int = 0 @Published private(set) var statusText: String = "" + @Published private(set) var activeTemporaryHUDKind: BatteryTemporaryHUDKind? + @Published private(set) var activeTemporaryHUDToken: UUID = UUID() + @Published private(set) var activeTemporaryHUDTargetScreenName: String? + @Published private(set) var activeTemporaryHUDLevelOverride: Int? + @Published private(set) var activeTemporaryHUDLowPowerModeOverride: Bool? private let managerBattery = BatteryActivityManager.shared private var managerBatteryId: Int? @@ -79,33 +89,39 @@ class BatteryStatusViewModel: ObservableObject { switch event { case .powerSourceChanged(let isPluggedIn): print("🔌 Power source: \(isPluggedIn ? "Connected" : "Disconnected")") + let wasPluggedIn = self.isPluggedIn withAnimation { self.isPluggedIn = isPluggedIn self.statusText = isPluggedIn ? String(localized: "Plugged In") : String(localized: "Unplugged") - self.notifyImportanChangeStatus() + } + if !wasPluggedIn && isPluggedIn { + presentTemporaryBatteryHUDIfNeeded(kind: .charging) } case .batteryLevelChanged(let level): print("🔋 Battery level: \(Int(level))%") let previousLevel = self.levelBattery - self.handleLowBatteryAlertIfNeeded(previousLevel: previousLevel, newLevel: level) withAnimation { self.levelBattery = level } + self.handleLowBatteryAlertIfNeeded(previousLevel: previousLevel, newLevel: level) + self.handleFullBatteryAlertIfNeeded(previousLevel: previousLevel, newLevel: level) case .lowPowerModeChanged(let isEnabled): print("⚡ Low power mode: \(isEnabled ? "Enabled" : "Disabled")") - self.notifyImportanChangeStatus() + let wasEnabled = self.isInLowPowerMode withAnimation { self.isInLowPowerMode = isEnabled self.statusText = String(localized: "Low Power: \(self.isInLowPowerMode ? String(localized: "On") : String(localized: "Off"))") } + if !wasEnabled && isEnabled { + presentTemporaryBatteryHUDIfNeeded(kind: .lowBattery) + } case .isChargingChanged(let isCharging): print("🔌 Charging: \(isCharging ? "Yes" : "No")") print("maxCapacity: \(self.maxCapacity)") print("levelBattery: \(self.levelBattery)") - self.notifyImportanChangeStatus() withAnimation { self.isCharging = isCharging self.statusText = @@ -145,30 +161,131 @@ class BatteryStatusViewModel: ObservableObject { } } - /// Notifies important changes in the battery status with an optional delay - /// - Parameter delay: The delay before notifying the change, default is 0.0 - private func notifyImportanChangeStatus(delay: Double = 0.0) { - Task { - try? await Task.sleep(for: .seconds(delay)) - self.coordinator.toggleExpandingView(status: true, type: .battery) + private func presentTemporaryBatteryHUDIfNeeded(kind: BatteryTemporaryHUDKind) { + presentTemporaryBatteryHUDIfNeeded(kind: kind, force: false) + } + + func triggerTestHUD(kind: BatteryTemporaryHUDKind) { + let previewLevel: Int + + switch kind { + case .charging: + previewLevel = max(12, min(95, Int(levelBattery.rounded()))) + case .lowBattery: + previewLevel = max(5, min(20, Defaults[.lowBatteryHUDThreshold])) + case .fullBattery: + previewLevel = 100 } + + presentTemporaryBatteryHUDIfNeeded( + kind: kind, + force: true, + levelOverride: previewLevel, + lowPowerModeOverride: kind == .lowBattery ? isInLowPowerMode : nil + ) + } + + private func presentTemporaryBatteryHUDIfNeeded( + kind: BatteryTemporaryHUDKind, + force: Bool, + levelOverride: Int? = nil, + lowPowerModeOverride: Bool? = nil + ) { + guard force || Defaults[.showPowerStatusNotifications] else { return } + + let duration: Int + let isEnabled: Bool + + switch kind { + case .charging: + duration = Defaults[.chargingBatteryHUDDuration] + isEnabled = Defaults[.showChargingBatteryHUD] + case .lowBattery: + duration = Defaults[.lowBatteryHUDDuration] + isEnabled = Defaults[.showLowBatteryHUD] + case .fullBattery: + duration = Defaults[.fullBatteryHUDDuration] + isEnabled = Defaults[.showFullBatteryHUD] + } + + guard force || isEnabled else { return } + + activeTemporaryHUDKind = kind + activeTemporaryHUDToken = UUID() + activeTemporaryHUDTargetScreenName = resolvedTemporaryHUDTargetScreenName() + activeTemporaryHUDLevelOverride = levelOverride + activeTemporaryHUDLowPowerModeOverride = lowPowerModeOverride + coordinator.toggleExpandingView( + status: true, + type: .battery, + autoHideDuration: TimeInterval(max(1, duration)) + ) + } + + private func resolvedTemporaryHUDTargetScreenName() -> String? { + if Defaults[.showOnAllDisplays] { + return nil + } + + let preferredNames = [ + coordinator.selectedScreen, + coordinator.preferredScreen, + NSScreen.main?.localizedName + ] + .compactMap { $0 } + + for candidate in preferredNames where NSScreen.screens.contains(where: { $0.localizedName == candidate }) { + return candidate + } + + return NSScreen.screens.first?.localizedName + } + + private func preferredDynamicIslandTargetScreenName() -> String? { + let mainScreenName = NSScreen.main?.localizedName + let preferredNames = [coordinator.selectedScreen, coordinator.preferredScreen] + + for candidate in preferredNames where shouldUseDynamicIslandMode(for: candidate) { + return candidate + } + + if let externalDynamicIslandScreen = NSScreen.screens.first(where: { + $0.localizedName != mainScreenName && shouldUseDynamicIslandMode(for: $0.localizedName) + }) { + return externalDynamicIslandScreen.localizedName + } + + if let anyDynamicIslandScreen = NSScreen.screens.first(where: { + shouldUseDynamicIslandMode(for: $0.localizedName) + }) { + return anyDynamicIslandScreen.localizedName + } + + return nil } private func handleLowBatteryAlertIfNeeded(previousLevel: Float, newLevel: Float) { - guard Defaults[.playLowBatteryAlertSound] else { return } guard !isPluggedIn, !isCharging else { return } guard newLevel < previousLevel else { return } + let threshold = Float(Defaults[.lowBatteryHUDThreshold]) + guard previousLevel > threshold && newLevel <= threshold else { return } - for threshold in lowBatteryAlertThresholds { - if previousLevel >= threshold && newLevel < threshold { - self.statusText = String(localized: "Low battery") - notifyImportanChangeStatus() - playLowBatteryAlertSound() - break - } + self.statusText = String(localized: "Low battery") + presentTemporaryBatteryHUDIfNeeded(kind: .lowBattery) + if Defaults[.playLowBatteryAlertSound] { + playLowBatteryAlertSound() } } + private func handleFullBatteryAlertIfNeeded(previousLevel: Float, newLevel: Float) { + guard newLevel > previousLevel else { return } + let threshold = Float(Defaults[.fullBatteryHUDThreshold]) + guard previousLevel < threshold && newLevel >= threshold else { return } + + self.statusText = String(localized: "Full charge") + presentTemporaryBatteryHUDIfNeeded(kind: .fullBattery) + } + private func playLowBatteryAlertSound() { lowBatteryAlertSoundPlayer.play(fileName: "lowbattery", fileExtension: "mp3") } diff --git a/DynamicIsland/models/Constants.swift b/DynamicIsland/models/Constants.swift index 198cd85..d29ff4e 100644 --- a/DynamicIsland/models/Constants.swift +++ b/DynamicIsland/models/Constants.swift @@ -473,6 +473,22 @@ enum ProgressBarStyle: String, CaseIterable, Identifiable, Defaults.Serializable var id: String { self.rawValue } } +enum BatteryNotificationStyle: String, CaseIterable, Identifiable, Defaults.Serializable { + case standard + case compact + + var id: String { rawValue } + + var title: String { + switch self { + case .standard: + return String(localized: "Standard") + case .compact: + return String(localized: "Compact") + } + } +} + enum MusicAuxiliaryControl: String, CaseIterable, Identifiable, Defaults.Serializable { case shuffle case repeatMode @@ -937,7 +953,17 @@ extension Defaults.Keys { static let showBatteryPercentage = Key("showBatteryPercentage", default: true) static let showPowerStatusIcons = Key("showPowerStatusIcons", default: true) static let playLowBatteryAlertSound = Key("playLowBatteryAlertSound", default: true) - + static let showChargingBatteryHUD = Key("showChargingBatteryHUD", default: true) + static let showLowBatteryHUD = Key("showLowBatteryHUD", default: true) + static let showFullBatteryHUD = Key("showFullBatteryHUD", default: true) + static let chargingBatteryHUDDuration = Key("chargingBatteryHUDDuration", default: 3) + static let lowBatteryHUDDuration = Key("lowBatteryHUDDuration", default: 3) + static let fullBatteryHUDDuration = Key("fullBatteryHUDDuration", default: 3) + static let lowBatteryHUDThreshold = Key("lowBatteryHUDThreshold", default: 20) + static let fullBatteryHUDThreshold = Key("fullBatteryHUDThreshold", default: 100) + static let lowBatteryHUDStyle = Key("lowBatteryHUDStyle", default: .standard) + static let fullBatteryHUDStyle = Key("fullBatteryHUDStyle", default: .standard) + static let lockScreenBatteryShowsBatteryGauge = Key( "lockScreenWeatherShowsBatteryGauge", default: BatteryActivityManager.shared.hasBattery()