Merge pull request #191 from Ebullioscopic/feature/hud

Update the Circular View with the Number interpolation
This commit is contained in:
Hariharan Mudaliar 2025-12-23 01:56:29 +05:30 • committed by GitHub
commit f7242e85e8
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 124 additions and 33 deletions

View file

@ -56,6 +56,13 @@ struct SystemEventIndicatorModifier: View {
.contentTransition(.interpolate) .contentTransition(.interpolate)
.frame(width: 20, height: 15) .frame(width: 20, height: 15)
.foregroundStyle(.white) .foregroundStyle(.white)
case .bluetoothAudio:
if !icon.isEmpty {
Image(systemName: icon)
.contentTransition(.interpolate)
.frame(width: 20, height: 15)
.foregroundStyle(.white)
}
default: default:
EmptyView() EmptyView()
} }
@ -72,6 +79,8 @@ struct SystemEventIndicatorModifier: View {
ProgressSection(value: $value, showPercentages: showProgressPercentages) ProgressSection(value: $value, showPercentages: showProgressPercentages)
case .backlight: case .backlight:
ProgressSection(value: $value, showPercentages: showProgressPercentages) ProgressSection(value: $value, showPercentages: showProgressPercentages)
case .bluetoothAudio:
ProgressSection(value: $value, showPercentages: showProgressPercentages, colorMode: .battery)
default: default:
ProgressSection(value: $value, showPercentages: showProgressPercentages) ProgressSection(value: $value, showPercentages: showProgressPercentages)
} }

View file

@ -20,44 +20,71 @@ struct CircularHUDView: View {
var body: some View { var body: some View {
ZStack { ZStack {
// Background circle // Background circle (SOLO OPAQUE - No transparency)
Circle() Circle()
.fill(.ultraThinMaterial) .fill(Color(white: 0.1))
.overlay { .overlay {
Circle() Circle()
.stroke(.white.opacity(0.1), lineWidth: 1) .stroke(Color(white: 0.2), lineWidth: 1)
} }
// Progress ring background // 1. Static Background Track (Empty binario)
Circle() Circle()
.stroke(Color.white.opacity(0.2), lineWidth: strokeWidth) .trim(from: 0.15, to: 0.85)
.padding(strokeWidth / 2) .stroke(Color(white: 0.15), style: StrokeStyle(lineWidth: strokeWidth * 1.5, lineCap: .round))
.rotationEffect(.degrees(90))
.frame(width: size, height: size)
// Progress ring // 2. Animated Progress Arc (Filled part)
Circle() Circle()
.trim(from: 0, to: value) .trim(from: 0.15, to: 0.15 + (0.7 * value))
.stroke( .stroke(strokeStyle, style: StrokeStyle(lineWidth: strokeWidth * 1.5, lineCap: .round))
strokeStyle, .rotationEffect(.degrees(90))
style: StrokeStyle(lineWidth: strokeWidth, lineCap: .round) .frame(width: size, height: size)
) .animation(.interactiveSpring(response: 0.35, dampingFraction: 0.85, blendDuration: 0), value: value)
.rotationEffect(.degrees(-90))
.padding(strokeWidth / 2)
.animation(.interactiveSpring(response: 0.35, dampingFraction: 0.8, blendDuration: 0), value: value)
// Content // 3. The "Ball" Indicator (Oversized White Pallino)
VStack(spacing: 2) { GeometryReader { geometry in
Image(systemName: symbolName) let radius = size / 2
.font(.system(size: size * 0.35, weight: .semibold)) let trackWidth = strokeWidth * 1.5
.foregroundStyle(useAccentColor ? (value > 0.9 ? .white : .primary) : .white) let startAngle = 144.0
.symbolRenderingMode(.hierarchical) let currentAngle = startAngle + (252.0 * value)
if showValue { let x = radius + radius * cos(currentAngle * .pi / 180)
Text("\(Int(value * 100))") let y = radius + radius * sin(currentAngle * .pi / 180)
.font(.system(size: size * 0.18, weight: .semibold, design: .rounded))
.foregroundStyle(.white.opacity(0.8)) Circle()
.contentTransition(.numericText()) .fill(.white)
.frame(width: trackWidth * 1.45, height: trackWidth * 1.45)
.shadow(color: .black.opacity(0.45), radius: 2, x: 0, y: 1.2)
.position(x: x, y: y)
.animation(.interactiveSpring(response: 0.35, dampingFraction: 0.85, blendDuration: 0), value: value)
}
.frame(width: size, height: size)
// Central Icon
Image(systemName: symbolName)
.font(.system(size: size * 0.32, weight: .bold))
.symbolRenderingMode(.hierarchical)
.foregroundStyle(.white)
.contentTransition(.symbolEffect(.replace))
.animation(.spring(response: 0.3, dampingFraction: 0.6), value: symbolName)
// Bottom Value Label
if showValue {
VStack {
Spacer()
HUDNumericLabel(
value: value,
font: .system(size: size * 0.15, weight: .bold, design: .rounded),
color: .white.opacity(0.65)
)
.padding(.bottom, size * 0.03)
} }
} }
} }
.frame(width: size, height: size) .frame(width: size, height: size)
.shadow(color: .black.opacity(0.2), radius: 15, x: 0, y: 5) .shadow(color: .black.opacity(0.2), radius: 15, x: 0, y: 5)
@ -69,7 +96,12 @@ struct CircularHUDView: View {
return ColorCodedProgressBar.shapeStyle(for: value, mode: .volume, smoothGradient: useSmoothGradient) return ColorCodedProgressBar.shapeStyle(for: value, mode: .volume, smoothGradient: useSmoothGradient)
} }
return AnyShapeStyle(useAccentColor ? Color.accentColor : Color.white) if useAccentColor {
return AnyShapeStyle(Color.accentColor)
}
// Default to the approved solid dark gray to match reference when not colored
return AnyShapeStyle(Color(white: 0.25))
} }
private var symbolName: String { private var symbolName: String {
@ -82,7 +114,7 @@ struct CircularHUDView: View {
if deviceInfo.isAirPods { if deviceInfo.isAirPods {
// Use AirPods icon when AirPods are connected // Use AirPods icon when AirPods are connected
if value < 0.01 { return "airpods" } if value < 0.01 { return "headphones.slash" }
else { return "airpods" } else { return "airpods" }
} else if deviceInfo.isHeadphones { } else if deviceInfo.isHeadphones {
// Use headphone icons when other headphones are connected // Use headphone icons when other headphones are connected

View file

@ -0,0 +1,40 @@
#if os(macOS)
import SwiftUI
/// Shared numeric label for HUD/OSD styles with rolling value animation.
struct HUDNumericLabel: View {
let value: CGFloat
var font: Font
var color: Color
var alignment: Alignment = .center
var width: CGFloat? = nil
var minimumDigits: Int = 3
var animation: Animation = .interactiveSpring(response: 0.35, dampingFraction: 0.75, blendDuration: 0.15)
@State private var animatedValue: CGFloat = 0
var body: some View {
Text(formattedValue)
.font(font)
.monospacedDigit()
.foregroundStyle(color)
.contentTransition(.numericText())
.frame(width: width, alignment: alignment)
.onAppear {
animatedValue = value
}
.onChange(of: value) { newValue in
withAnimation(animation) {
animatedValue = newValue
}
}
}
private var formattedValue: String {
let clamped = max(0, min(100, Int(round(animatedValue * 100))))
let digits = max(1, minimumDigits)
let format = "%\(digits)d"
return String(format: format, clamped)
}
}
#endif

View file

@ -73,11 +73,14 @@ struct VerticalHUDView: View {
// Icon & Text overlay // Icon & Text overlay
VStack { VStack {
if showValue { if showValue {
Text("\(Int(state.value * 100))%") HUDNumericLabel(
.font(.system(size: 10, weight: .bold)) value: state.value,
.foregroundStyle(state.value > 0.85 ? (useAccentColor ? .white : .black) : .secondary) font: .system(size: 10, weight: .bold),
.padding(.top, 8 + (stretchOffset < 0 ? abs(stretchOffset/4) : 0)) color: valueLabelColor,
.contentTransition(.numericText()) alignment: .center,
width: hudWidth * 0.8
)
.padding(.top, 8 + (stretchOffset < 0 ? abs(stretchOffset/4) : 0))
} }
Spacer() Spacer()
@ -225,5 +228,12 @@ struct VerticalHUDView: View {
return AnyShapeStyle(useAccentColor ? Color.accentColor : Color.white) return AnyShapeStyle(useAccentColor ? Color.accentColor : Color.white)
} }
private var valueLabelColor: Color {
if state.value > 0.85 {
return useAccentColor ? .white : .black
}
return .secondary
}
} }
#endif #endif