// // SystemEventIndicatorModifier.swift // DynamicIsland // // Created by Richard Kunkli on 12/08/2024. // import SwiftUI import Defaults struct SystemEventIndicatorModifier: View { @EnvironmentObject var vm: DynamicIslandViewModel @Binding var eventType: SneakContentType @Binding var value: CGFloat { didSet { DispatchQueue.main.async { self.sendEventBack(value) self.vm.objectWillChange.send() } } } @Binding var icon: String let showSlider: Bool = false var sendEventBack: (CGFloat) -> Void @Default(.showProgressPercentages) private var showProgressPercentages var body: some View { HStack(spacing: 14) { switch (eventType) { case .volume: if icon.isEmpty { Image(systemName: SpeakerSymbol(value)) .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: "sun.max.fill") .contentTransition(.symbolEffect) .frame(width: 20, height: 15) .foregroundStyle(.white) case .backlight: Image(systemName: BacklightSymbol(value)) .contentTransition(.symbolEffect) .frame(width: 20, height: 15) .foregroundStyle(.white) case .mic: Image(systemName: "mic") .symbolVariant(value > 0 ? .none : .slash) .contentTransition(.interpolate) .frame(width: 20, height: 15) .foregroundStyle(.white) case .bluetoothAudio: if !icon.isEmpty { Image(systemName: icon) .contentTransition(.interpolate) .frame(width: 20, height: 15) .foregroundStyle(.white) } default: EmptyView() } switch eventType { case .mic: Text(value > 0 ? "Mic unmuted" : "Mic muted") .foregroundStyle(.gray) .lineLimit(1) .allowsTightening(true) .contentTransition(.numericText()) case .volume: VolumeProgressSection(value: $value, showPercentages: showProgressPercentages) case .brightness: ProgressSection(value: $value, showPercentages: showProgressPercentages) case .backlight: ProgressSection(value: $value, showPercentages: showProgressPercentages) case .bluetoothAudio: ProgressSection(value: $value, showPercentages: showProgressPercentages, colorMode: .battery) default: ProgressSection(value: $value, showPercentages: showProgressPercentages) } } .frame(maxWidth: .infinity, alignment: .leading) .symbolVariant(.fill) .imageScale(.large) } func SpeakerSymbol(_ value: CGFloat) -> String { switch(value) { case 0: return "speaker.slash" 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 BacklightSymbol(_ value: CGFloat) -> String { value >= 0.5 ? "light.max" : "light.min" } } struct ProgressSection: View { @Binding var value: CGFloat var showPercentages: Bool var colorMode: ProgressColorMode? = nil var body: some View { HStack(spacing: 8) { DraggableProgressBar(value: $value, colorMode: colorMode) PercentageLabel(value: value, isVisible: showPercentages) } .frame(maxWidth: .infinity, alignment: .leading) } } struct VolumeProgressSection: View { @Binding var value: CGFloat var showPercentages: Bool var body: some View { Group { if value.isZero { Text("muted") .font(.caption) .fontWeight(.medium) .foregroundStyle(.gray) .contentTransition(.numericText()) } else { ProgressSection(value: $value, showPercentages: showPercentages, colorMode: .volume) .transition(.opacity.combined(with: .scale)) } } .frame(maxWidth: .infinity, alignment: .leading) .animation(.smooth(duration: 0.2), value: value.isZero) } } struct PercentageLabel: View { let value: CGFloat let isVisible: Bool @Default(.progressBarStyle) private var progressBarStyle var body: some View { Group { if shouldShow { Text(formattedValue) .font(.caption) .fontWeight(.semibold) .monospacedDigit() .foregroundStyle(.white.opacity(0.9)) .contentTransition(.numericText()) .frame(width: 28, alignment: .trailing) } } } private var shouldShow: Bool { isVisible && progressBarStyle != .segmented } private var formattedValue: String { let raw = max(0, min(100, Int(round(value * 100)))) return String(format: "%3d", raw) } } enum ProgressColorMode { case volume case battery } private extension ProgressColorMode { var colorCodingMode: ColorCodingMode { switch self { case .volume: return .volume case .battery: return .battery } } } struct DraggableProgressBar: View { @EnvironmentObject var vm: DynamicIslandViewModel @Binding var value: CGFloat var colorMode: ProgressColorMode? = nil @State private var isDragging = false @Default(.progressBarStyle) private var progressBarStyle @Default(.useColorCodedVolumeDisplay) private var useColorCodedVolumeDisplay @Default(.useColorCodedBatteryDisplay) private var useColorCodedBatteryDisplay @Default(.useSmoothColorGradient) private var useSmoothColorGradient @Default(.systemEventIndicatorUseAccent) private var useAccentColor @Default(.systemEventIndicatorShadow) private var useShadow @Default(.inlineHUD) private var inlineHUD var body: some View { VStack { GeometryReader { geo in Group { if progressBarStyle == .segmented { // Segmented progress bar - completely different layout SegmentedProgressContent(value: value, geometry: geo) } else { // Traditional capsule-based progress bar ZStack(alignment: .leading) { Capsule() .fill(.tertiary) progressFill(width: geo.size.width) .opacity(value.isZero ? 0 : 1) } } } .gesture( DragGesture(minimumDistance: 0) .onChanged { gesture in withAnimation(.smooth(duration: 0.3)) { isDragging = true updateValue(gesture: gesture, in: geo) } } .onEnded { _ in withAnimation(.smooth(duration: 0.3)) { isDragging = false } } ) } .frame(height: inlineHUD ? (isDragging ? 8 : 5) : (isDragging ? 9 : 6)) } } private func updateValue(gesture: DragGesture.Value, in geometry: GeometryProxy) { let dragPosition = gesture.location.x let newValue = dragPosition / geometry.size.width value = max(0, min(newValue, 1)) } private func progressFill(width: CGFloat) -> some View { let clampedWidth = max(0, min(width * value, width)) let accentColor = Defaults[.accentColor] let shadowColor: Color = { guard useShadow else { return .clear } if useAccentColor { return accentColor.ensureMinimumBrightness(factor: 0.7) } return .white }() return Group { if shouldUseColorCoding, let mode = colorMode?.colorCodingMode { Capsule() .fill(ColorCodedProgressBar.shapeStyle(for: value, mode: mode, smoothGradient: useSmoothColorGradient)) .frame(width: clampedWidth) .shadow(color: shadowColor, radius: useShadow ? 8 : 0, x: 3) } else { switch progressBarStyle { case .gradient: Capsule() .fill(LinearGradient( colors: useAccentColor ? [accentColor, accentColor.ensureMinimumBrightness(factor: 0.2)] : [.white, .white.opacity(0.2)], startPoint: .trailing, endPoint: .leading )) .frame(width: clampedWidth) .shadow(color: shadowColor, radius: useShadow ? 8 : 0, x: 3) case .hierarchical: Capsule() .fill(useAccentColor ? accentColor : .white) .frame(width: clampedWidth) .shadow(color: shadowColor, radius: useShadow ? 8 : 0, x: 3) case .segmented: EmptyView() } } } } private var shouldUseColorCoding: Bool { guard let colorMode else { return false } guard progressBarStyle != .segmented else { return false } switch colorMode { case .volume: return useColorCodedVolumeDisplay case .battery: return useColorCodedBatteryDisplay } } } struct SegmentedProgressContent: View { let value: CGFloat let geometry: GeometryProxy private let segmentCount = 16 @State private var glowIndex: Int? = nil @State private var lastValue: CGFloat = 0 var body: some View { let spacing: CGFloat = 1.5 let computed = (geometry.size.width - CGFloat(segmentCount - 1) * spacing) / CGFloat(segmentCount) let barWidth = max(3.0, computed) let activeCount = Int(round(value * CGFloat(segmentCount))) HStack(spacing: spacing) { ForEach(0.. Color { if isActive { if Defaults[.systemEventIndicatorUseAccent] { return Defaults[.accentColor] } return .white } else { return .white.opacity(0.15) } } private func glowShadowColor(for isActive: Bool, index: Int) -> Color { if isActive { if let glowIndex = glowIndex, index == glowIndex { return Defaults[.systemEventIndicatorUseAccent] ? Defaults[.accentColor].ensureMinimumBrightness(factor: 0.8) : .white } else { return Defaults[.systemEventIndicatorUseAccent] ? Defaults[.accentColor].ensureMinimumBrightness(factor: 0.7) : .white } } return .clear } private func handleGlow(oldValue: CGFloat, newValue: CGFloat) { defer { lastValue = newValue } let oldIndex = Int(round(oldValue * CGFloat(segmentCount))) let newIndex = Int(round(newValue * CGFloat(segmentCount))) guard oldIndex != newIndex else { return } if newIndex > oldIndex { animateWave(from: oldIndex, to: newIndex, step: 1) } else { animateWave(from: oldIndex - 1, to: newIndex - 1, step: -1) } } private func animateWave(from start: Int, to end: Int, step: Int) { let clampedStart = max(0, min(segmentCount - 1, start)) let clampedEnd = max(-1, min(segmentCount - 1, end)) let range = stride(from: clampedStart, through: clampedEnd, by: step) var delay: Double = 0 for i in range { DispatchQueue.main.asyncAfter(deadline: .now() + delay) { withAnimation(.spring(response: 0.2, dampingFraction: 0.7)) { glowIndex = i } } DispatchQueue.main.asyncAfter(deadline: .now() + delay + 0.2) { if glowIndex == i { withAnimation(.spring(response: 0.3, dampingFraction: 0.8)) { glowIndex = nil } } } delay += 0.015 } } }