mirror of
https://github.com/FBukovina/Atoll.git
synced 2026-10-27 23:24:32 +00:00
443 lines
18 KiB
Swift
443 lines
18 KiB
Swift
//
|
|
// InlineHUDs.swift
|
|
// DynamicIsland
|
|
//
|
|
// Created by Richard Kunkli on 14/09/2024.
|
|
//
|
|
|
|
import SwiftUI
|
|
import Defaults
|
|
|
|
struct InlineHUD: View {
|
|
@EnvironmentObject var vm: DynamicIslandViewModel
|
|
@Binding var type: SneakContentType
|
|
@Binding var value: CGFloat
|
|
@Binding var icon: String
|
|
@Binding var hoverAnimation: Bool
|
|
@Binding var gestureProgress: CGFloat
|
|
|
|
@Default(.useColorCodedBatteryDisplay) var useColorCodedBatteryDisplay
|
|
@Default(.useColorCodedVolumeDisplay) var useColorCodedVolumeDisplay
|
|
@Default(.useSmoothColorGradient) var useSmoothColorGradient
|
|
@Default(.progressBarStyle) var progressBarStyle
|
|
@Default(.showProgressPercentages) var showProgressPercentages
|
|
@Default(.useCircularBluetoothBatteryIndicator) var useCircularBluetoothBatteryIndicator
|
|
@Default(.showBluetoothBatteryPercentageText) var showBluetoothBatteryPercentageText
|
|
@Default(.showBluetoothDeviceNameMarquee) var showBluetoothDeviceNameMarquee
|
|
@Default(.enableMinimalisticUI) var enableMinimalisticUI
|
|
@Default(.showCapsLockLabel) var showCapsLockLabel
|
|
@Default(.capsLockIndicatorTintMode) var capsLockTintMode
|
|
@ObservedObject var bluetoothManager = BluetoothAudioManager.shared
|
|
|
|
@State private var displayName: String = ""
|
|
|
|
var body: some View {
|
|
let useCircularIndicator = useCircularBluetoothBatteryIndicator
|
|
let hasBatteryLevel = value > 0
|
|
let capsLockAccentColor = capsLockTintMode.color
|
|
|
|
let baseInfoWidth: CGFloat = {
|
|
if type == .bluetoothAudio {
|
|
if showBluetoothDeviceNameMarquee {
|
|
return enableMinimalisticUI ? 128 : 140
|
|
}
|
|
return enableMinimalisticUI ? 64 : 72
|
|
}
|
|
|
|
if type == .capsLock && !showCapsLockLabel {
|
|
return enableMinimalisticUI ? 56 : 64
|
|
}
|
|
|
|
return 100
|
|
}()
|
|
|
|
let infoWidth: CGFloat = {
|
|
var width = baseInfoWidth + gestureProgress / 2
|
|
if !hoverAnimation { width -= 8 }
|
|
let minimum: CGFloat = {
|
|
if type == .bluetoothAudio {
|
|
if showBluetoothDeviceNameMarquee {
|
|
return enableMinimalisticUI ? 112 : 120
|
|
}
|
|
return enableMinimalisticUI ? 56 : 68
|
|
}
|
|
|
|
if type == .capsLock && !showCapsLockLabel {
|
|
return enableMinimalisticUI ? 44 : 52
|
|
}
|
|
|
|
return 88
|
|
}()
|
|
return max(width, minimum)
|
|
}()
|
|
|
|
let baseTrailingWidth: CGFloat = {
|
|
if type == .bluetoothAudio {
|
|
if !hasBatteryLevel {
|
|
return showBluetoothDeviceNameMarquee ? (enableMinimalisticUI ? 104 : 118) : (enableMinimalisticUI ? 74 : 88)
|
|
}
|
|
|
|
if useCircularIndicator {
|
|
return showBluetoothBatteryPercentageText ? (enableMinimalisticUI ? 108 : 120) : (enableMinimalisticUI ? 72 : 84)
|
|
}
|
|
|
|
return showBluetoothBatteryPercentageText ? (enableMinimalisticUI ? 118 : 136) : (enableMinimalisticUI ? 92 : 108)
|
|
}
|
|
|
|
if type == .capsLock {
|
|
if showCapsLockLabel {
|
|
return enableMinimalisticUI ? 84 : 96
|
|
}
|
|
return 0
|
|
}
|
|
|
|
return 100
|
|
}()
|
|
|
|
let trailingWidth: CGFloat = {
|
|
var width = baseTrailingWidth + gestureProgress / 2
|
|
if !hoverAnimation { width -= 8 }
|
|
let minimum: CGFloat = {
|
|
if type == .bluetoothAudio {
|
|
if !hasBatteryLevel {
|
|
return showBluetoothDeviceNameMarquee ? (enableMinimalisticUI ? 96 : 110) : (enableMinimalisticUI ? 62 : 88)
|
|
}
|
|
|
|
if useCircularIndicator {
|
|
return showBluetoothBatteryPercentageText ? (enableMinimalisticUI ? 92 : 110) : (enableMinimalisticUI ? 56 : 72)
|
|
}
|
|
|
|
return showBluetoothBatteryPercentageText ? (enableMinimalisticUI ? 104 : 120) : (enableMinimalisticUI ? 72 : 90)
|
|
}
|
|
|
|
if type == .capsLock {
|
|
return showCapsLockLabel ? (enableMinimalisticUI ? 68 : 80) : 0
|
|
}
|
|
|
|
return 90
|
|
}()
|
|
return max(width, minimum)
|
|
}()
|
|
|
|
return HStack {
|
|
HStack(spacing: 5) {
|
|
Group {
|
|
switch (type) {
|
|
case .volume:
|
|
if icon.isEmpty {
|
|
// Show headphone icon if Bluetooth audio is connected, otherwise speaker
|
|
let baseIcon = bluetoothManager.isBluetoothAudioConnected ? "headphones" : SpeakerSymbol(value)
|
|
Image(systemName: baseIcon)
|
|
.contentTransition(.interpolate)
|
|
.symbolVariant(value > 0 ? .none : .slash)
|
|
.frame(width: 20, height: 15, alignment: .leading)
|
|
} else {
|
|
Image(systemName: icon)
|
|
.contentTransition(.interpolate)
|
|
.opacity(value.isZero ? 0.6 : 1)
|
|
.scaleEffect(value.isZero ? 0.85 : 1)
|
|
.frame(width: 20, height: 15, alignment: .leading)
|
|
}
|
|
case .brightness:
|
|
Image(systemName: BrightnessSymbol(value))
|
|
.contentTransition(.interpolate)
|
|
.frame(width: 20, height: 15, alignment: .center)
|
|
case .backlight:
|
|
Image(systemName: BacklightSymbol(value))
|
|
.contentTransition(.interpolate)
|
|
.frame(width: 20, height: 15, alignment: .center)
|
|
case .mic:
|
|
Image(systemName: "mic")
|
|
.symbolRenderingMode(.hierarchical)
|
|
.symbolVariant(value > 0 ? .none : .slash)
|
|
.contentTransition(.interpolate)
|
|
.frame(width: 20, height: 15, alignment: .center)
|
|
case .timer:
|
|
Image(systemName: "timer")
|
|
.symbolRenderingMode(.hierarchical)
|
|
.contentTransition(.interpolate)
|
|
.frame(width: 20, height: 15, alignment: .center)
|
|
case .bluetoothAudio:
|
|
Image(systemName: icon.isEmpty ? "bluetooth" : icon)
|
|
.symbolRenderingMode(.hierarchical)
|
|
.contentTransition(.interpolate)
|
|
.frame(width: 20, height: 15, alignment: .center)
|
|
case .capsLock:
|
|
Image(systemName: "capslock.fill")
|
|
.symbolRenderingMode(.hierarchical)
|
|
.contentTransition(.interpolate)
|
|
.frame(width: 20, height: 15, alignment: .center)
|
|
.foregroundStyle(capsLockAccentColor)
|
|
default:
|
|
EmptyView()
|
|
}
|
|
}
|
|
.foregroundStyle(.white)
|
|
.symbolVariant(.fill)
|
|
|
|
// Use marquee text for device names to handle long names
|
|
if type == .bluetoothAudio {
|
|
if showBluetoothDeviceNameMarquee {
|
|
MarqueeText(
|
|
$displayName,
|
|
font: .system(size: 13, weight: .medium),
|
|
nsFont: .body,
|
|
textColor: .white,
|
|
minDuration: 0.2,
|
|
frameWidth: infoWidth
|
|
)
|
|
}
|
|
} else if type != .capsLock {
|
|
Text(Type2Name(type))
|
|
.font(.subheadline)
|
|
.fontWeight(.medium)
|
|
.lineLimit(1)
|
|
.allowsTightening(true)
|
|
.contentTransition(.numericText())
|
|
.foregroundStyle(.white)
|
|
}
|
|
}
|
|
.frame(width: infoWidth, height: vm.notchSize.height - (hoverAnimation ? 0 : 12), alignment: .leading)
|
|
|
|
Rectangle()
|
|
.fill(.black)
|
|
.frame(width: vm.closedNotchSize.width - 20)
|
|
|
|
HStack {
|
|
if (type == .mic) {
|
|
Text(value.isZero ? "muted" : "unmuted")
|
|
.foregroundStyle(.gray)
|
|
.lineLimit(1)
|
|
.allowsTightening(true)
|
|
.multilineTextAlignment(.trailing)
|
|
.frame(maxWidth: .infinity, alignment: .trailing)
|
|
.contentTransition(.interpolate)
|
|
} else if (type == .timer) {
|
|
Text(TimerManager.shared.formattedRemainingTime())
|
|
.foregroundStyle(TimerManager.shared.timerColor)
|
|
.lineLimit(1)
|
|
.allowsTightening(true)
|
|
.multilineTextAlignment(.trailing)
|
|
.frame(maxWidth: .infinity, alignment: .trailing)
|
|
.contentTransition(.interpolate)
|
|
} else if (type == .capsLock) {
|
|
if showCapsLockLabel {
|
|
Text("Caps Lock")
|
|
.foregroundStyle(capsLockAccentColor)
|
|
.font(.subheadline)
|
|
.fontWeight(.medium)
|
|
.lineLimit(1)
|
|
.allowsTightening(true)
|
|
.multilineTextAlignment(.trailing)
|
|
.frame(maxWidth: .infinity, alignment: .trailing)
|
|
.contentTransition(.interpolate)
|
|
}
|
|
} else if (type == .bluetoothAudio) {
|
|
if hasBatteryLevel {
|
|
let indicatorSpacing: CGFloat = {
|
|
if useCircularIndicator {
|
|
return showBluetoothBatteryPercentageText ? 8 : 2
|
|
}
|
|
return showBluetoothBatteryPercentageText ? 6 : 4
|
|
}()
|
|
|
|
HStack(spacing: indicatorSpacing) {
|
|
if useCircularIndicator {
|
|
CircularBatteryIndicator(
|
|
value: value,
|
|
useColorCoding: useColorCodedBatteryDisplay && progressBarStyle != .segmented,
|
|
smoothGradient: useSmoothColorGradient
|
|
)
|
|
.allowsHitTesting(false)
|
|
} else {
|
|
LinearBatteryIndicator(
|
|
value: value,
|
|
useColorCoding: useColorCodedBatteryDisplay && progressBarStyle != .segmented,
|
|
smoothGradient: useSmoothColorGradient
|
|
)
|
|
.allowsHitTesting(false)
|
|
}
|
|
|
|
if showBluetoothBatteryPercentageText {
|
|
Text("\(Int(value * 100))%")
|
|
.font(.caption)
|
|
.fontWeight(.medium)
|
|
.foregroundStyle(.white)
|
|
.lineLimit(1)
|
|
}
|
|
}
|
|
.frame(maxWidth: .infinity, alignment: .trailing)
|
|
}
|
|
} else {
|
|
// Volume and brightness displays
|
|
Group {
|
|
if type == .volume {
|
|
Group {
|
|
if value.isZero {
|
|
Text("muted")
|
|
.font(.caption)
|
|
.fontWeight(.medium)
|
|
.foregroundStyle(.gray)
|
|
.lineLimit(1)
|
|
.allowsTightening(true)
|
|
.multilineTextAlignment(.trailing)
|
|
.contentTransition(.numericText())
|
|
} else {
|
|
HStack(spacing: 6) {
|
|
DraggableProgressBar(value: $value, colorMode: .volume)
|
|
PercentageLabel(value: value, isVisible: showProgressPercentages)
|
|
}
|
|
.transition(.opacity.combined(with: .scale))
|
|
}
|
|
}
|
|
.animation(.smooth(duration: 0.2), value: value.isZero)
|
|
} else {
|
|
HStack(spacing: 6) {
|
|
DraggableProgressBar(value: $value)
|
|
PercentageLabel(value: value, isVisible: showProgressPercentages)
|
|
}
|
|
}
|
|
}
|
|
.frame(maxWidth: .infinity, alignment: .trailing)
|
|
}
|
|
}
|
|
.padding(.trailing, trailingWidth > 0 ? 4 : 0)
|
|
.frame(width: trailingWidth, height: vm.closedNotchSize.height - (hoverAnimation ? 0 : 12), alignment: .center)
|
|
}
|
|
.frame(height: vm.closedNotchSize.height + (hoverAnimation ? 8 : 0), alignment: .center)
|
|
.onAppear {
|
|
displayName = Type2Name(type)
|
|
}
|
|
.onChange(of: type) { _, _ in
|
|
displayName = Type2Name(type)
|
|
}
|
|
.onChange(of: bluetoothManager.lastConnectedDevice?.name) { _, _ in
|
|
displayName = Type2Name(type)
|
|
}
|
|
}
|
|
|
|
private struct CircularBatteryIndicator: View {
|
|
let value: CGFloat
|
|
let useColorCoding: Bool
|
|
let smoothGradient: Bool
|
|
|
|
private var clampedValue: CGFloat {
|
|
min(max(value, 0), 1)
|
|
}
|
|
|
|
private var indicatorColor: Color {
|
|
if useColorCoding {
|
|
return ColorCodedProgressBar.paletteColor(for: clampedValue, mode: .battery, smoothGradient: smoothGradient)
|
|
}
|
|
return .white
|
|
}
|
|
|
|
var body: some View {
|
|
ZStack {
|
|
Circle()
|
|
.stroke(Color.white.opacity(0.18), lineWidth: 2.6)
|
|
|
|
Circle()
|
|
.trim(from: 0, to: max(clampedValue, 0.015))
|
|
.rotation(.degrees(-90))
|
|
.stroke(indicatorColor, style: StrokeStyle(lineWidth: 2.8, lineCap: .round))
|
|
}
|
|
.frame(width: 22, height: 22)
|
|
.animation(.smooth(duration: 0.18), value: clampedValue)
|
|
}
|
|
}
|
|
|
|
private struct LinearBatteryIndicator: View {
|
|
let value: CGFloat
|
|
let useColorCoding: Bool
|
|
let smoothGradient: Bool
|
|
|
|
private let trackWidth: CGFloat = 54
|
|
private let trackHeight: CGFloat = 6
|
|
|
|
private var clampedValue: CGFloat {
|
|
min(max(value, 0), 1)
|
|
}
|
|
|
|
private var fillColor: Color {
|
|
if useColorCoding {
|
|
return ColorCodedProgressBar.paletteColor(for: clampedValue, mode: .battery, smoothGradient: smoothGradient)
|
|
}
|
|
return .white
|
|
}
|
|
|
|
var body: some View {
|
|
ZStack(alignment: .leading) {
|
|
Capsule()
|
|
.fill(Color.white.opacity(0.18))
|
|
.frame(width: trackWidth, height: trackHeight)
|
|
|
|
Capsule()
|
|
.fill(fillColor)
|
|
.frame(width: trackWidth * clampedValue, height: trackHeight)
|
|
}
|
|
.frame(width: trackWidth, height: trackHeight)
|
|
.animation(.smooth(duration: 0.18), value: clampedValue)
|
|
}
|
|
}
|
|
|
|
func SpeakerSymbol(_ value: CGFloat) -> String {
|
|
switch(value) {
|
|
case 0:
|
|
return "speaker"
|
|
case 0...0.3:
|
|
return "speaker.wave.1"
|
|
case 0.3...0.8:
|
|
return "speaker.wave.2"
|
|
case 0.8...1:
|
|
return "speaker.wave.3"
|
|
default:
|
|
return "speaker.wave.2"
|
|
}
|
|
}
|
|
|
|
func BrightnessSymbol(_ value: CGFloat) -> String {
|
|
switch(value) {
|
|
case 0...0.6:
|
|
return "sun.min"
|
|
case 0.6...1:
|
|
return "sun.max"
|
|
default:
|
|
return "sun.min"
|
|
}
|
|
}
|
|
|
|
func BacklightSymbol(_ value: CGFloat) -> String {
|
|
if value >= 0.5 {
|
|
return "light.max"
|
|
}
|
|
return "light.min"
|
|
}
|
|
|
|
func Type2Name(_ type: SneakContentType) -> String {
|
|
switch(type) {
|
|
case .volume:
|
|
return "Volume"
|
|
case .brightness:
|
|
return "Brightness"
|
|
case .backlight:
|
|
return "Backlight"
|
|
case .mic:
|
|
return "Mic"
|
|
case .bluetoothAudio:
|
|
return BluetoothAudioManager.shared.lastConnectedDevice?.name ?? "Bluetooth"
|
|
case .capsLock:
|
|
return "Caps Lock"
|
|
default:
|
|
return ""
|
|
}
|
|
}
|
|
}
|
|
|
|
#Preview {
|
|
InlineHUD(type: .constant(.brightness), value: .constant(0.4), icon: .constant(""), hoverAnimation: .constant(false), gestureProgress: .constant(0))
|
|
.padding(.horizontal, 8)
|
|
.background(Color.black)
|
|
.padding()
|
|
.environmentObject(DynamicIslandViewModel())
|
|
}
|