/*
* Atoll (DynamicIsland)
* Copyright (C) 2024-2026 Atoll Contributors
*
* Originally from boring.notch project
* Modified and adapted for Atoll (DynamicIsland)
* See NOTICE for details.
*
* 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
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
}
}
}