447 lines
16 KiB
Swift
447 lines
16 KiB
Swift
import SwiftUI
|
|
import Charts
|
|
|
|
// MARK: - Health Ring
|
|
//
|
|
// Premium animated radial gauge that replaces the flat StorageGauge. An
|
|
// angular-gradient sweep with a soft tinted glow, a rounded cap, and a
|
|
// spring-driven fill. The center number rolls via numericText. Honors
|
|
// Reduce Motion (no spring, no idle shimmer).
|
|
|
|
struct HealthRing: View {
|
|
/// Primary fill, 0...1 (e.g. fraction of disk used).
|
|
let percent: Double
|
|
var tint: Color = Tint.blue
|
|
var warnTint: Color = Tint.orange
|
|
var stressThreshold: Double = 0.85
|
|
var lineWidth: CGFloat = 14
|
|
var subtitle: LocalizedStringKey = "USED"
|
|
|
|
@State private var sweep: Double = 0
|
|
@State private var displayPercent: Int = 0
|
|
@Environment(\.accessibilityReduceMotion) private var reduceMotion
|
|
|
|
private var clamped: Double { max(0, min(1, percent)) }
|
|
private var stress: Bool { clamped >= stressThreshold }
|
|
private var arcColor: Color { stress ? warnTint : tint }
|
|
private var trailColor: Color { stress ? Tint.red : Tint.purple }
|
|
|
|
var body: some View {
|
|
ZStack {
|
|
Circle()
|
|
.stroke(Color.primary.opacity(0.06), lineWidth: lineWidth)
|
|
|
|
// Soft glow layer underneath the crisp arc.
|
|
Circle()
|
|
.trim(from: 0, to: sweep)
|
|
.stroke(arcColor.opacity(0.5),
|
|
style: StrokeStyle(lineWidth: lineWidth, lineCap: .round))
|
|
.rotationEffect(.degrees(-90))
|
|
.blur(radius: 9)
|
|
|
|
Circle()
|
|
.trim(from: 0, to: sweep)
|
|
.stroke(
|
|
AngularGradient(
|
|
gradient: Gradient(colors: [arcColor.opacity(0.85), trailColor, arcColor]),
|
|
center: .center,
|
|
startAngle: .degrees(-90),
|
|
endAngle: .degrees(-90 + 360 * max(sweep, 0.0001))
|
|
),
|
|
style: StrokeStyle(lineWidth: lineWidth, lineCap: .round)
|
|
)
|
|
.rotationEffect(.degrees(-90))
|
|
|
|
// Endpoint highlight dot for a polished finish. The dot sits at the
|
|
// top of the ring and the whole layer rotates about the ring
|
|
// center so it orbits to the arc's end. Radius is derived from the
|
|
// real frame so it tracks the stroke at any size.
|
|
if sweep > 0.02 {
|
|
GeometryReader { geo in
|
|
let radius = (min(geo.size.width, geo.size.height) - lineWidth) / 2
|
|
Circle()
|
|
.fill(.white)
|
|
.frame(width: lineWidth * 0.42, height: lineWidth * 0.42)
|
|
.position(x: geo.size.width / 2, y: geo.size.height / 2 - radius)
|
|
.shadow(color: arcColor.opacity(0.7), radius: 4)
|
|
}
|
|
.rotationEffect(.degrees(360 * sweep))
|
|
}
|
|
|
|
VStack(spacing: 1) {
|
|
Text("\(displayPercent)%")
|
|
.font(.system(size: 44, weight: .semibold))
|
|
.monospacedDigit()
|
|
.contentTransition(.numericText())
|
|
.foregroundStyle(stress ? warnTint : Color.primary)
|
|
Text(subtitle)
|
|
.font(.system(size: 10, weight: .semibold))
|
|
.tracking(0.8)
|
|
.foregroundStyle(.secondary)
|
|
}
|
|
}
|
|
.onAppear { animate(to: clamped) }
|
|
.onChange(of: clamped) { animate(to: $0) }
|
|
}
|
|
|
|
private func animate(to value: Double) {
|
|
if reduceMotion {
|
|
sweep = value
|
|
displayPercent = Int(round(value * 100))
|
|
return
|
|
}
|
|
withAnimation(.spring(response: 1.0, dampingFraction: 0.82)) {
|
|
sweep = value
|
|
}
|
|
withAnimation(.easeOut(duration: 0.9)) {
|
|
displayPercent = Int(round(value * 100))
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - Storage Donut
|
|
//
|
|
// Custom multi-segment donut (Used / Junk / Purgeable / Free) drawn from
|
|
// trimmed arcs so it works on macOS 13 (SectorMark is 14+). Segments grow in
|
|
// with a spring on appear and animate when values change.
|
|
|
|
struct StorageDonut: View {
|
|
struct Segment: Identifiable {
|
|
/// Stable id (e.g. "used"/"junk") so legend rows keep identity across
|
|
/// the many @Published refreshes that rebuild this array — otherwise a
|
|
/// fresh UUID per render replays the staggered entrance animation.
|
|
let id: String
|
|
let value: Double // raw byte fraction; normalized internally
|
|
let color: Color
|
|
let label: LocalizedStringKey
|
|
let display: String
|
|
}
|
|
|
|
let segments: [Segment]
|
|
var lineWidth: CGFloat = 16
|
|
var gap: Double = 0.006 // angular gap between segments (fraction of circle)
|
|
/// When set (legend hover), every other segment dims for cross-highlight.
|
|
var highlightedID: String? = nil
|
|
|
|
@State private var reveal: CGFloat = 0
|
|
@Environment(\.accessibilityReduceMotion) private var reduceMotion
|
|
|
|
private var total: Double { max(segments.reduce(0) { $0 + $1.value }, 0.0001) }
|
|
|
|
var body: some View {
|
|
// A single segment is a full ring — no inter-segment hairline, so the
|
|
// gap would otherwise carve a false missing slice.
|
|
let effectiveGap = segments.count <= 1 ? 0 : gap
|
|
let fractions = segments.map { $0.value / total }
|
|
var cursor = 0.0
|
|
var ranges: [(start: Double, end: Double)] = []
|
|
for frac in fractions {
|
|
let start = cursor
|
|
cursor += frac
|
|
// Trim back the trailing edge by `gap` for a hairline between
|
|
// segments, clamped so a tiny slice never inverts.
|
|
ranges.append((start, max(start, cursor - effectiveGap)))
|
|
}
|
|
|
|
return ZStack {
|
|
Circle().stroke(Color.primary.opacity(0.05), lineWidth: lineWidth)
|
|
ForEach(Array(segments.enumerated()), id: \.element.id) { idx, seg in
|
|
Circle()
|
|
.trim(from: ranges[idx].start * reveal, to: ranges[idx].end * reveal)
|
|
.stroke(seg.color, style: StrokeStyle(lineWidth: lineWidth, lineCap: .butt))
|
|
.rotationEffect(.degrees(-90))
|
|
.opacity(highlightedID == nil || highlightedID == seg.id ? 1 : 0.25)
|
|
}
|
|
}
|
|
.animation(reduceMotion ? nil : .easeOut(duration: 0.2), value: highlightedID)
|
|
.onAppear {
|
|
if reduceMotion { reveal = 1; return }
|
|
withAnimation(.spring(response: 1.0, dampingFraction: 0.85)) { reveal = 1 }
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - Category Bar Chart
|
|
//
|
|
// Horizontal Swift Charts bar chart of junk size per category. BarMark is
|
|
// available on macOS 13. Used in the completed-scan state to give an
|
|
// at-a-glance "where the space is" graphic above the toggle list.
|
|
|
|
struct CategoryBarChart: View {
|
|
struct Bar: Identifiable {
|
|
// Category is unique per bar, so derive a stable id from it.
|
|
var id: String { category.rawValue }
|
|
let category: CleaningCategory
|
|
let size: Int64
|
|
var name: String { String(localized: String.LocalizationValue(category.rawValue)) }
|
|
}
|
|
|
|
let bars: [Bar]
|
|
|
|
@State private var reveal: Double = 0
|
|
@Environment(\.accessibilityReduceMotion) private var reduceMotion
|
|
|
|
var body: some View {
|
|
// Reserve ~28% trailing headroom so the byte-size annotation on the
|
|
// longest bar renders fully instead of clipping at the chart edge
|
|
// (the macOS 14+ annotation overflowResolution isn't available here).
|
|
let maxSize = bars.map(\.size).max() ?? 0
|
|
let upper = max(Int64(1), Int64(Double(maxSize) * 1.28))
|
|
|
|
return Chart(bars) { bar in
|
|
BarMark(
|
|
x: .value("Size", Double(bar.size) * reveal),
|
|
y: .value("Category", bar.name)
|
|
)
|
|
.foregroundStyle(bar.category.color.gradient)
|
|
.cornerRadius(5)
|
|
.annotation(position: .trailing, alignment: .leading) {
|
|
Text(ByteCountFormatter.string(fromByteCount: bar.size, countStyle: .file))
|
|
.font(.system(size: 10, weight: .semibold))
|
|
.monospacedDigit()
|
|
.foregroundStyle(.secondary)
|
|
.opacity(reveal)
|
|
}
|
|
}
|
|
.onAppear {
|
|
if reduceMotion { reveal = 1; return }
|
|
withAnimation(.spring(response: 0.8, dampingFraction: 0.85)) { reveal = 1 }
|
|
}
|
|
.chartXScale(domain: 0...upper)
|
|
.chartXAxis(.hidden)
|
|
.chartYAxis {
|
|
AxisMarks(preset: .aligned, position: .leading) { _ in
|
|
AxisValueLabel()
|
|
}
|
|
}
|
|
.chartLegend(.hidden)
|
|
.frame(height: max(120, CGFloat(bars.count) * 34))
|
|
}
|
|
}
|
|
|
|
// MARK: - Legend chip
|
|
|
|
struct LegendChip: View {
|
|
let color: Color
|
|
let label: LocalizedStringKey
|
|
let value: String
|
|
|
|
var body: some View {
|
|
HStack(spacing: 7) {
|
|
RoundedRectangle(cornerRadius: 3, style: .continuous)
|
|
.fill(color)
|
|
.frame(width: 10, height: 10)
|
|
VStack(alignment: .leading, spacing: 0) {
|
|
Text(label)
|
|
.font(.system(size: 10.5))
|
|
.foregroundStyle(.secondary)
|
|
Text(value)
|
|
.font(.system(size: 12, weight: .semibold))
|
|
.monospacedDigit()
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - Success medal
|
|
//
|
|
// Center checkmark with two concentric rings that expand and fade once on
|
|
// appear — a calm, premium "done" beat to pair with the freed-space number.
|
|
|
|
struct SuccessMedal: View {
|
|
var tint: Color = Tint.green
|
|
var size: CGFloat = 120
|
|
|
|
@State private var pop = false
|
|
@State private var ripple = false
|
|
@Environment(\.accessibilityReduceMotion) private var reduceMotion
|
|
|
|
var body: some View {
|
|
ZStack {
|
|
ForEach(0..<2) { i in
|
|
Circle()
|
|
.stroke(tint.opacity(ripple ? 0 : 0.45), lineWidth: 2)
|
|
.scaleEffect(ripple ? 1.5 + CGFloat(i) * 0.25 : 0.7)
|
|
}
|
|
Circle()
|
|
.fill(tint.opacity(0.14))
|
|
.scaleEffect(pop ? 1 : 0.6)
|
|
|
|
Image(systemName: "checkmark")
|
|
.font(.system(size: size * 0.42, weight: .bold))
|
|
.foregroundStyle(tint)
|
|
.scaleEffect(pop ? 1 : 0.3)
|
|
.opacity(pop ? 1 : 0)
|
|
}
|
|
.frame(width: size, height: size)
|
|
.onAppear {
|
|
if reduceMotion { pop = true; return }
|
|
withAnimation(.spring(response: 0.5, dampingFraction: 0.6)) { pop = true }
|
|
withAnimation(.easeOut(duration: 1.1)) { ripple = true }
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - Staggered reveal
|
|
//
|
|
// Drop-in entrance animation: fades + lifts content with a per-index delay so
|
|
// grids and lists cascade in. Honors Reduce Motion.
|
|
|
|
struct StaggeredReveal: ViewModifier {
|
|
let index: Int
|
|
var baseDelay: Double = 0.04
|
|
@State private var shown = false
|
|
@Environment(\.accessibilityReduceMotion) private var reduceMotion
|
|
|
|
func body(content: Content) -> some View {
|
|
content
|
|
.opacity(shown ? 1 : 0)
|
|
.offset(y: shown ? 0 : 10)
|
|
.onAppear {
|
|
if reduceMotion { shown = true; return }
|
|
withAnimation(.spring(response: 0.5, dampingFraction: 0.85)
|
|
.delay(Double(index) * baseDelay)) {
|
|
shown = true
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
extension View {
|
|
func staggered(_ index: Int, baseDelay: Double = 0.04) -> some View {
|
|
modifier(StaggeredReveal(index: index, baseDelay: baseDelay))
|
|
}
|
|
}
|
|
|
|
// MARK: - Count-up bytes
|
|
//
|
|
// Animatable byte counter. `.contentTransition(.numericText())` only rolls
|
|
// when the change happens inside an animation transaction — values assigned
|
|
// during a plain data refresh snap instead. Driving the formatter through
|
|
// `animatableData` makes the roll-up play every time the target changes.
|
|
// Styling (font/color) flows in from the environment of the call site.
|
|
|
|
struct CountUpBytes: View {
|
|
let bytes: Int64
|
|
|
|
@State private var shown: Double = 0
|
|
@Environment(\.accessibilityReduceMotion) private var reduceMotion
|
|
|
|
var body: some View {
|
|
Color.clear
|
|
.frame(width: 0, height: 0)
|
|
.modifier(ByteRollEffect(value: shown))
|
|
.onAppear { animate(to: bytes) }
|
|
.onChange(of: bytes) { animate(to: $0) }
|
|
}
|
|
|
|
private func animate(to target: Int64) {
|
|
if reduceMotion {
|
|
shown = Double(target)
|
|
return
|
|
}
|
|
withAnimation(.easeOut(duration: 0.8)) {
|
|
shown = Double(target)
|
|
}
|
|
}
|
|
}
|
|
|
|
private struct ByteRollEffect: AnimatableModifier {
|
|
var value: Double
|
|
|
|
var animatableData: Double {
|
|
get { value }
|
|
set { value = newValue }
|
|
}
|
|
|
|
func body(content: Content) -> some View {
|
|
Text(ByteCountFormatter.string(fromByteCount: Int64(max(0, value)), countStyle: .file))
|
|
.monospacedDigit()
|
|
}
|
|
}
|
|
|
|
// MARK: - Scanning gauge
|
|
//
|
|
// Active-scan focal element: a rotating trimmed arc over a radar wedge sweep
|
|
// with two expanding halo rings. Reduce Motion freezes every loop and falls
|
|
// back to the static arc + rolling percent.
|
|
|
|
struct ScanningGauge: View {
|
|
let progress: Double
|
|
var tint: Color = Tint.blue
|
|
var label: LocalizedStringKey = "SCANNING"
|
|
|
|
@State private var rotate = false
|
|
@State private var pulse = false
|
|
@Environment(\.accessibilityReduceMotion) private var reduceMotion
|
|
|
|
var body: some View {
|
|
ZStack {
|
|
// Expanding halo rings, SuccessMedal-style but repeating.
|
|
if !reduceMotion {
|
|
ForEach(0..<2, id: \.self) { i in
|
|
Circle()
|
|
.stroke(tint.opacity(0.35), lineWidth: 1.5)
|
|
.scaleEffect(pulse ? 1.22 : 0.9)
|
|
.opacity(pulse ? 0 : 0.6)
|
|
.animation(
|
|
.easeOut(duration: 1.6)
|
|
.repeatForever(autoreverses: false)
|
|
.delay(Double(i) * 0.8),
|
|
value: pulse
|
|
)
|
|
}
|
|
}
|
|
|
|
// Radar wedge sweeping the ring interior. TimelineView keeps the
|
|
// angle deterministic; the branch above removes it entirely under
|
|
// Reduce Motion.
|
|
if !reduceMotion {
|
|
TimelineView(.animation) { timeline in
|
|
let t = timeline.date.timeIntervalSinceReferenceDate
|
|
let angle = (t.truncatingRemainder(dividingBy: 3.0) / 3.0) * 360.0
|
|
Circle()
|
|
.fill(
|
|
AngularGradient(
|
|
colors: [tint.opacity(0), tint.opacity(0), tint.opacity(0.22)],
|
|
center: .center
|
|
)
|
|
)
|
|
.rotationEffect(.degrees(angle))
|
|
}
|
|
.padding(14)
|
|
.clipShape(Circle())
|
|
}
|
|
|
|
Circle()
|
|
.stroke(Color.primary.opacity(0.07), lineWidth: 10)
|
|
|
|
Circle()
|
|
.trim(from: 0, to: CGFloat(max(0.05, min(0.95, progress))))
|
|
.stroke(tint, style: StrokeStyle(lineWidth: 10, lineCap: .round))
|
|
.rotationEffect(.degrees(rotate ? 360 : 0))
|
|
.animation(
|
|
reduceMotion ? nil : .linear(duration: 4).repeatForever(autoreverses: false),
|
|
value: rotate
|
|
)
|
|
|
|
VStack(spacing: 2) {
|
|
Text("\(Int(progress * 100))%")
|
|
.font(.system(size: 36, weight: .semibold))
|
|
.monospacedDigit()
|
|
.contentTransition(reduceMotion ? .identity : .numericText())
|
|
.animation(reduceMotion ? nil : .easeOut(duration: 0.3), value: Int(progress * 100))
|
|
Text(label)
|
|
.font(.system(size: 10, weight: .medium))
|
|
.tracking(0.6)
|
|
.foregroundStyle(.secondary)
|
|
}
|
|
}
|
|
.onAppear {
|
|
guard !reduceMotion else { return }
|
|
rotate = true
|
|
pulse = true
|
|
}
|
|
}
|
|
}
|