Files
wehub-resource-sync 869b84f27c
Build / build (push) Waiting to run
chore: import upstream snapshot with attribution
2026-07-13 12:29:30 +08:00

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
}
}
}