977 lines
38 KiB
Swift
977 lines
38 KiB
Swift
import SwiftUI
|
|
|
|
/// Landing screen modeled after the new prototype:
|
|
/// hero gauge + stats + quick actions + suggestion cards.
|
|
/// Replaces the old SmartScanView idle/completed states with a richer
|
|
/// at-a-glance view, and delegates active-scan progress to inline state UI.
|
|
struct DashboardView: View {
|
|
@EnvironmentObject var appState: AppState
|
|
@State private var showConfirmation = false
|
|
@State private var fireCleanConfetti = false
|
|
@State private var lastCleanedScanState: Bool = false
|
|
@State private var hoveredSegment: String?
|
|
/// Confetti burst origin as a fraction of the dashboard, derived from the
|
|
/// SuccessMedal's real frame so the burst tracks it across window sizes
|
|
/// and RTL layout instead of a hand-aimed constant.
|
|
@State private var burstOrigin: UnitPoint = UnitPoint(x: 0.25, y: 0.28)
|
|
@State private var dashboardSize: CGSize = .zero
|
|
@Environment(\.accessibilityReduceMotion) private var reduceMotion
|
|
|
|
private let dashboardSpace = "dashboard"
|
|
|
|
/// Hero cards rise in with a slight settle and dissolve out; under
|
|
/// Reduce Motion both directions collapse to a plain cross-fade.
|
|
private var heroTransition: AnyTransition {
|
|
reduceMotion
|
|
? .opacity
|
|
: .asymmetric(
|
|
insertion: .opacity
|
|
.combined(with: .offset(y: 12))
|
|
.combined(with: .scale(scale: 0.98, anchor: .top)),
|
|
removal: .opacity
|
|
)
|
|
}
|
|
|
|
var body: some View {
|
|
ZStack {
|
|
// Quiet tinted wash so the glass hero states have color to
|
|
// refract. Static — no Reduce Motion concerns.
|
|
LinearGradient(
|
|
colors: [Tint.blue.opacity(0.08), Tint.purple.opacity(0.05), .clear],
|
|
startPoint: .top, endPoint: .bottom
|
|
)
|
|
.ignoresSafeArea()
|
|
|
|
ScrollView {
|
|
VStack(alignment: .leading, spacing: 20) {
|
|
switch appState.scanState {
|
|
case .idle:
|
|
hero
|
|
.transition(heroTransition)
|
|
stats
|
|
if appState.diskInfo.totalSpace > 0 {
|
|
sectionHeader("Storage composition")
|
|
storageComposition
|
|
}
|
|
if !suggestionRows.isEmpty {
|
|
sectionHeader("Suggested for you")
|
|
suggestions
|
|
}
|
|
case .scanning:
|
|
scanningHero
|
|
.transition(heroTransition)
|
|
if !appState.allResults.isEmpty {
|
|
sectionHeader("Found so far")
|
|
liveResults
|
|
}
|
|
case .completed:
|
|
completedHero
|
|
.transition(heroTransition)
|
|
if appState.totalJunkSize > 0 {
|
|
sectionHeader("By category")
|
|
categoryChartCard
|
|
resultsList
|
|
}
|
|
case .cleaning:
|
|
cleaningHero
|
|
.transition(heroTransition)
|
|
case .cleaned:
|
|
cleanedHero
|
|
.transition(heroTransition)
|
|
}
|
|
}
|
|
.padding(.horizontal, 28)
|
|
.padding(.vertical, 24)
|
|
.frame(maxWidth: 920, alignment: .leading)
|
|
.animation(reduceMotion ? nil : MotionTokens.gentle, value: appState.scanState)
|
|
}
|
|
|
|
// Celebratory burst when a clean cycle finishes with something
|
|
// freed. Origin tracks the SuccessMedal's real frame (see
|
|
// burstOrigin) so it explodes from the celebration on any window
|
|
// size / layout direction.
|
|
// allowsHitTesting=false keeps Done clickable through particles.
|
|
ConfettiView(trigger: fireCleanConfetti, mode: .burst(origin: burstOrigin))
|
|
.allowsHitTesting(false)
|
|
}
|
|
.coordinateSpace(name: dashboardSpace)
|
|
.background(
|
|
GeometryReader { geo in
|
|
Color.clear.onAppear { dashboardSize = geo.size }
|
|
.onChange(of: geo.size) { dashboardSize = $0 }
|
|
}
|
|
)
|
|
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top)
|
|
.onChange(of: appState.scanState) { newState in
|
|
// Fire only on the rising edge of .cleaned with freed > 0 so
|
|
// the burst doesn't replay when the user navigates back to the
|
|
// dashboard while .cleaned is still on screen.
|
|
let isCleaned: Bool = {
|
|
if case .cleaned = newState { return true }
|
|
return false
|
|
}()
|
|
if isCleaned && !lastCleanedScanState && appState.totalFreedSpace > 0 {
|
|
if reduceMotion {
|
|
// Confetti renders nothing under Reduce Motion; no need
|
|
// to wait for an entrance spring that isn't playing.
|
|
fireCleanConfetti.toggle()
|
|
} else {
|
|
// Let the hero settle and the counter roll before the
|
|
// burst so the celebration lands as one beat.
|
|
DispatchQueue.main.asyncAfter(deadline: .now() + 0.55) {
|
|
fireCleanConfetti.toggle()
|
|
}
|
|
}
|
|
}
|
|
lastCleanedScanState = isCleaned
|
|
}
|
|
.confirmationDialog(
|
|
cleanConfirmationTitle,
|
|
isPresented: $showConfirmation,
|
|
titleVisibility: .visible
|
|
) {
|
|
Button("Clean", role: .destructive) { appState.cleanAll() }
|
|
Button("Cancel", role: .cancel) {}
|
|
} message: {
|
|
Text("This will permanently delete the selected files. This cannot be undone.")
|
|
}
|
|
}
|
|
|
|
private var cleanConfirmationTitle: String {
|
|
String(
|
|
format: String(localized: "Clean %@?"),
|
|
ByteCountFormatter.string(fromByteCount: appState.totalSelectedSize, countStyle: .file)
|
|
)
|
|
}
|
|
|
|
// MARK: - Hero (idle)
|
|
|
|
private var hero: some View {
|
|
let total = appState.diskInfo.totalSpace
|
|
let used = appState.diskInfo.usedSpace
|
|
let free = appState.diskInfo.freeSpace
|
|
let percentUsed = total > 0 ? Double(used) / Double(total) : 0
|
|
let stress = percentUsed > 0.85
|
|
// Below this width the side-by-side ring + storage column overflows the
|
|
// card, so the hero stacks vertically and the ring shrinks.
|
|
let compact = dashboardSize.width > 0 && dashboardSize.width < 660
|
|
let ringSize: CGFloat = compact ? 132 : 180
|
|
|
|
return CardSurface(padding: 24, accent: stress ? Tint.orange : Tint.blue, elevation: .raised) {
|
|
AdaptiveStack(compact: compact, spacing: compact ? 18 : 28) {
|
|
ZStack {
|
|
// Slow atmospheric drift behind the ring — barely-there
|
|
// ambient depth, frozen under Reduce Motion.
|
|
HeroDrift(tint: stress ? Tint.orange : Tint.blue)
|
|
HealthRing(percent: percentUsed)
|
|
.frame(width: ringSize, height: ringSize)
|
|
}
|
|
|
|
VStack(alignment: .leading, spacing: 14) {
|
|
HStack(alignment: .top) {
|
|
VStack(alignment: .leading, spacing: 4) {
|
|
HStack(spacing: 8) {
|
|
Text("Storage")
|
|
.font(.system(size: 13, weight: .semibold))
|
|
.foregroundStyle(.secondary)
|
|
.textCase(.uppercase)
|
|
.tracking(0.6)
|
|
if stress {
|
|
StatusChip(label: String(localized: "Low space"),
|
|
systemImage: "exclamationmark.triangle.fill",
|
|
tint: Tint.orange)
|
|
}
|
|
}
|
|
CountUpBytes(bytes: free)
|
|
.font(.system(size: 34, weight: .semibold))
|
|
.foregroundStyle(stress ? Tint.orange : Color.primary)
|
|
Text(freeOfText(total: total))
|
|
.font(.system(size: 12))
|
|
.foregroundStyle(.secondary)
|
|
}
|
|
Spacer()
|
|
Button {
|
|
appState.startSmartScan()
|
|
} label: {
|
|
Label("Smart Scan", systemImage: "sparkles")
|
|
.padding(.horizontal, 4)
|
|
}
|
|
.buttonStyle(GlowProminentButtonStyle(breathes: true))
|
|
}
|
|
|
|
storageBreakdown(used: used, total: total)
|
|
}
|
|
.frame(maxWidth: .infinity, alignment: .leading)
|
|
}
|
|
}
|
|
}
|
|
|
|
private func freeOfText(total: Int64) -> String {
|
|
String(
|
|
format: String(localized: "free of %@"),
|
|
ByteCountFormatter.string(fromByteCount: total, countStyle: .file)
|
|
)
|
|
}
|
|
|
|
private func storageBreakdown(used: Int64, total: Int64) -> some View {
|
|
let usedPct = total > 0 ? Double(used) / Double(total) : 0
|
|
let junkPct = total > 0 ? min(0.4, Double(appState.totalJunkSize) / Double(total)) : 0
|
|
|
|
return VStack(alignment: .leading, spacing: 8) {
|
|
GeometryReader { geo in
|
|
ZStack(alignment: .leading) {
|
|
Capsule()
|
|
.fill(Color.primary.opacity(0.08))
|
|
HStack(spacing: 0) {
|
|
Capsule()
|
|
.fill(LinearGradient(colors: [Tint.blue, Tint.purple], startPoint: .leading, endPoint: .trailing))
|
|
.frame(width: geo.size.width * CGFloat(usedPct))
|
|
}
|
|
if junkPct > 0 {
|
|
Capsule()
|
|
.fill(Tint.orange)
|
|
.frame(width: max(8, geo.size.width * CGFloat(junkPct)))
|
|
.offset(x: geo.size.width * CGFloat(usedPct - junkPct))
|
|
.opacity(0.85)
|
|
}
|
|
}
|
|
}
|
|
.frame(height: 10)
|
|
|
|
HStack(spacing: 16) {
|
|
LegendDot(color: Tint.blue, label: "Used", value: ByteCountFormatter.string(fromByteCount: used, countStyle: .file))
|
|
if appState.totalJunkSize > 0 {
|
|
LegendDot(color: Tint.orange, label: "Junk",
|
|
value: ByteCountFormatter.string(fromByteCount: appState.totalJunkSize, countStyle: .file))
|
|
}
|
|
if appState.diskInfo.purgeableSpace > 0 {
|
|
LegendDot(color: Tint.green, label: "Purgeable",
|
|
value: ByteCountFormatter.string(fromByteCount: appState.diskInfo.purgeableSpace, countStyle: .file))
|
|
}
|
|
Spacer()
|
|
Text(percentUsedText(usedPct))
|
|
.font(.system(size: 11, weight: .medium))
|
|
.foregroundStyle(.secondary)
|
|
.monospacedDigit()
|
|
}
|
|
}
|
|
}
|
|
|
|
private func percentUsedText(_ usedPct: Double) -> String {
|
|
String(format: String(localized: "%lld%% used"), Int64(usedPct * 100))
|
|
}
|
|
|
|
// MARK: - Stats
|
|
|
|
private var stats: some View {
|
|
let free = appState.diskInfo.freeSpace
|
|
let total = appState.diskInfo.totalSpace
|
|
let percentUsed = total > 0 ? Double(total - free) / Double(total) : 0
|
|
|
|
// Four across when there's room, two when the dashboard is narrow so the
|
|
// cards don't crush their values.
|
|
let columnCount = dashboardSize.width > 0 && dashboardSize.width < 660 ? 2 : 4
|
|
return LazyVGrid(columns: Array(repeating: GridItem(.flexible(), spacing: 12), count: columnCount), spacing: 12) {
|
|
StatCard(
|
|
icon: "internaldrive.fill",
|
|
tint: Tint.blue,
|
|
label: "Free Space",
|
|
value: ByteCountFormatter.string(fromByteCount: free, countStyle: .file),
|
|
delta: total > 0 ? freeSpaceDelta(total: total, percentUsed: percentUsed) : nil,
|
|
byteValue: free
|
|
)
|
|
.staggered(0)
|
|
StatCard(
|
|
icon: "trash.circle.fill",
|
|
tint: Tint.orange,
|
|
label: "Junk Found",
|
|
value: appState.totalJunkSize > 0
|
|
? ByteCountFormatter.string(fromByteCount: appState.totalJunkSize, countStyle: .file)
|
|
: "—",
|
|
delta: appState.allResults.isEmpty
|
|
? String(localized: "Run a scan")
|
|
: junkFoundDelta(count: appState.allResults.count),
|
|
byteValue: appState.totalJunkSize > 0 ? appState.totalJunkSize : nil
|
|
)
|
|
.staggered(1)
|
|
StatCard(
|
|
icon: "square.grid.2x2.fill",
|
|
tint: Tint.purple,
|
|
label: "Apps",
|
|
value: "\(appState.installedApps.count)",
|
|
delta: String(localized: "installed")
|
|
)
|
|
.staggered(2)
|
|
StatCard(
|
|
icon: "memorychip.fill",
|
|
tint: Tint.green,
|
|
label: "Purgeable",
|
|
value: appState.diskInfo.purgeableSpace > 0
|
|
? ByteCountFormatter.string(fromByteCount: appState.diskInfo.purgeableSpace, countStyle: .file)
|
|
: "—",
|
|
delta: String(localized: "Managed by macOS"),
|
|
byteValue: appState.diskInfo.purgeableSpace > 0 ? appState.diskInfo.purgeableSpace : nil
|
|
)
|
|
.staggered(3)
|
|
}
|
|
}
|
|
|
|
private func freeSpaceDelta(total: Int64, percentUsed: Double) -> String {
|
|
String(
|
|
format: String(localized: "of %@ · %lld%% used"),
|
|
ByteCountFormatter.string(fromByteCount: total, countStyle: .file),
|
|
Int64(percentUsed * 100)
|
|
)
|
|
}
|
|
|
|
private func junkFoundDelta(count: Int) -> String {
|
|
String(format: String(localized: "across %lld categories"), Int64(count))
|
|
}
|
|
|
|
// MARK: - Storage composition
|
|
|
|
private var storageComposition: some View {
|
|
let total = appState.diskInfo.totalSpace
|
|
let free = appState.diskInfo.freeSpace
|
|
let purge = max(0, appState.diskInfo.purgeableSpace)
|
|
let junk = max(0, min(appState.totalJunkSize, appState.diskInfo.usedSpace))
|
|
// "Used" excludes the junk + purgeable slices so the four segments sum
|
|
// to the whole disk without double-counting.
|
|
let usedCore = max(0, appState.diskInfo.usedSpace - junk - purge)
|
|
|
|
var segments: [StorageDonut.Segment] = []
|
|
func add(_ id: String, _ value: Int64, _ color: Color, _ label: LocalizedStringKey) {
|
|
guard value > 0 else { return }
|
|
segments.append(.init(
|
|
id: id,
|
|
value: Double(value),
|
|
color: color,
|
|
label: label,
|
|
display: ByteCountFormatter.string(fromByteCount: value, countStyle: .file)
|
|
))
|
|
}
|
|
add("used", usedCore, Tint.blue, "Used")
|
|
add("junk", junk, Tint.orange, "Junk")
|
|
add("purgeable", purge, Tint.green, "Purgeable")
|
|
add("free", free, Color.primary.opacity(0.14), "Free")
|
|
|
|
return CardSurface(padding: 18, elevation: .standard) {
|
|
HStack(alignment: .center, spacing: 24) {
|
|
ZStack {
|
|
StorageDonut(segments: segments, highlightedID: hoveredSegment)
|
|
.frame(width: 132, height: 132)
|
|
VStack(spacing: 1) {
|
|
Text(ByteCountFormatter.string(fromByteCount: total, countStyle: .file))
|
|
.font(.system(size: 17, weight: .bold))
|
|
.monospacedDigit()
|
|
Text("total")
|
|
.font(.system(size: 10))
|
|
.foregroundStyle(.secondary)
|
|
}
|
|
}
|
|
|
|
VStack(alignment: .leading, spacing: 12) {
|
|
ForEach(Array(segments.enumerated()), id: \.element.id) { idx, seg in
|
|
HoverableLegendChip(
|
|
color: seg.color, label: seg.label, value: seg.display
|
|
) { hovering in
|
|
hoveredSegment = hovering ? seg.id : nil
|
|
}
|
|
.staggered(idx)
|
|
}
|
|
}
|
|
Spacer(minLength: 0)
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - Suggestions
|
|
|
|
private var suggestions: some View {
|
|
VStack(spacing: 10) {
|
|
ForEach(Array(suggestionRows.enumerated()), id: \.offset) { idx, row in
|
|
SuggestionRow(suggestion: row)
|
|
.staggered(idx)
|
|
}
|
|
}
|
|
}
|
|
|
|
private var suggestionRows: [Suggestion] {
|
|
var out: [Suggestion] = []
|
|
// Surface the largest pending category as a contextual nudge.
|
|
if let biggest = appState.allResults.max(by: { $0.totalSize < $1.totalSize }), biggest.totalSize > 0 {
|
|
let title = String(
|
|
format: String(localized: "%@ is using %@"),
|
|
String(localized: String.LocalizationValue(biggest.category.rawValue)),
|
|
biggest.formattedSize
|
|
)
|
|
out.append(Suggestion(
|
|
icon: biggest.category.icon,
|
|
tint: biggest.category.color,
|
|
title: title,
|
|
subtitle: String(localized: String.LocalizationValue(biggest.category.description)),
|
|
pill: biggest.formattedSize
|
|
))
|
|
}
|
|
if !appState.hasFullDiskAccess {
|
|
out.append(Suggestion(
|
|
icon: "lock.shield.fill",
|
|
tint: Tint.orange,
|
|
title: String(localized: "Grant Full Disk Access for full results"),
|
|
subtitle: String(localized: "Without it, most caches and uninstall flows fail."),
|
|
pill: String(localized: "Action")
|
|
))
|
|
}
|
|
return out
|
|
}
|
|
|
|
// MARK: - Scanning state
|
|
|
|
private var scanningHero: some View {
|
|
CardSurface(padding: 24, accent: Tint.blue, elevation: .raised, material: .ultraThinMaterial) {
|
|
HStack(alignment: .center, spacing: 28) {
|
|
ScanningGauge(progress: appState.scanProgress)
|
|
.frame(width: 180, height: 180)
|
|
VStack(alignment: .leading, spacing: 10) {
|
|
HStack(spacing: 8) {
|
|
sparklesIcon
|
|
Text("Scanning your Mac")
|
|
.font(.system(size: 22, weight: .bold))
|
|
}
|
|
|
|
// Category line slides up as the scan advances.
|
|
Text(currentlyInText)
|
|
.font(.system(size: 13))
|
|
.foregroundStyle(.secondary)
|
|
.id(appState.currentScanCategory)
|
|
.transition(
|
|
reduceMotion
|
|
? .opacity
|
|
: .asymmetric(
|
|
insertion: .move(edge: .bottom).combined(with: .opacity),
|
|
removal: .move(edge: .top).combined(with: .opacity)
|
|
)
|
|
)
|
|
|
|
ShimmerProgressBar(progress: appState.scanProgress)
|
|
.frame(maxWidth: 320)
|
|
.padding(.top, 2)
|
|
|
|
// Live file-path ticker — the "it's really working"
|
|
// signal. Observes the standalone ScanProgressTicker so the
|
|
// ~10Hz path churn re-renders only this label, not the whole
|
|
// dashboard (issues #119, #120).
|
|
ScanPathTicker(ticker: appState.scanTicker)
|
|
}
|
|
.animation(reduceMotion ? nil : .easeOut(duration: 0.18), value: appState.currentScanCategory)
|
|
Spacer(minLength: 0)
|
|
}
|
|
}
|
|
}
|
|
|
|
private var currentlyInText: String {
|
|
String(
|
|
format: String(localized: "Currently in: %@"),
|
|
String(localized: String.LocalizationValue(appState.currentScanCategory))
|
|
)
|
|
}
|
|
|
|
private var liveResults: some View {
|
|
CardSurface(padding: 0) {
|
|
VStack(spacing: 0) {
|
|
ForEach(appState.allResults.prefix(8)) { result in
|
|
HStack(spacing: 12) {
|
|
IconTile(systemName: result.category.icon, tint: result.category.color, size: 26)
|
|
Text(LocalizedStringKey(result.category.rawValue))
|
|
.font(.system(size: 13))
|
|
Spacer()
|
|
Text(result.formattedSize)
|
|
.font(.system(size: 13, weight: .semibold))
|
|
.monospacedDigit()
|
|
.foregroundStyle(.secondary)
|
|
}
|
|
.padding(.horizontal, 16)
|
|
.padding(.vertical, 12)
|
|
.transition(
|
|
reduceMotion
|
|
? .opacity
|
|
: .move(edge: .bottom).combined(with: .opacity)
|
|
)
|
|
if result.id != appState.allResults.prefix(8).last?.id {
|
|
Divider().padding(.leading, 54)
|
|
}
|
|
}
|
|
}
|
|
.animation(reduceMotion ? nil : .spring(response: 0.4, dampingFraction: 0.85),
|
|
value: appState.allResults.count)
|
|
}
|
|
}
|
|
|
|
// MARK: - Completed state
|
|
|
|
private var completedHero: some View {
|
|
let isClean = appState.totalJunkSize <= 0
|
|
return CardSurface(padding: 24, accent: isClean ? Tint.green : Tint.orange, elevation: .raised) {
|
|
VStack(alignment: .leading, spacing: 14) {
|
|
HStack(alignment: .firstTextBaseline) {
|
|
if !isClean {
|
|
CountUpBytes(bytes: appState.totalJunkSize)
|
|
.font(.system(size: 40, weight: .semibold))
|
|
Text("found")
|
|
.font(.system(size: 16))
|
|
.foregroundStyle(.secondary)
|
|
} else {
|
|
HStack(spacing: 10) {
|
|
cleanSealIcon
|
|
Text("Your Mac is clean")
|
|
.font(.system(size: 22, weight: .bold))
|
|
.foregroundStyle(Tint.green)
|
|
}
|
|
}
|
|
Spacer()
|
|
Button("Scan Again") { appState.startSmartScan() }
|
|
.controlSize(.large)
|
|
}
|
|
if !isClean {
|
|
HStack {
|
|
if appState.totalSelectedSize > 0 {
|
|
Button {
|
|
showConfirmation = true
|
|
} label: {
|
|
Label {
|
|
Text(cleanSelectedLabel)
|
|
} icon: {
|
|
Image(systemName: "sparkles")
|
|
}
|
|
.padding(.horizontal, 6)
|
|
}
|
|
.buttonStyle(GlowProminentButtonStyle())
|
|
}
|
|
Spacer()
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
private var cleanSelectedLabel: String {
|
|
String(
|
|
format: String(localized: "Clean %@"),
|
|
ByteCountFormatter.string(fromByteCount: appState.totalSelectedSize, countStyle: .file)
|
|
)
|
|
}
|
|
|
|
private var categoryChartCard: some View {
|
|
let bars = appState.allResults
|
|
.filter { $0.totalSize > 0 }
|
|
.sorted { $0.totalSize > $1.totalSize }
|
|
.prefix(8)
|
|
.map { CategoryBarChart.Bar(category: $0.category, size: $0.totalSize) }
|
|
|
|
return CardSurface(padding: 18, elevation: .standard) {
|
|
CategoryBarChart(bars: Array(bars))
|
|
}
|
|
}
|
|
|
|
private var resultsList: some View {
|
|
CardSurface(padding: 0) {
|
|
VStack(spacing: 0) {
|
|
ForEach(Array(appState.allResults.enumerated()), id: \.element.id) { idx, result in
|
|
CategoryToggleRow(result: result)
|
|
.staggered(idx)
|
|
if result.id != appState.allResults.last?.id {
|
|
Divider().padding(.leading, 54)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
@ViewBuilder
|
|
private var cleanSealIcon: some View {
|
|
let base = Image(systemName: "checkmark.seal.fill")
|
|
.font(.system(size: 26, weight: .bold))
|
|
.foregroundStyle(Tint.green)
|
|
if #available(macOS 14.0, *) {
|
|
base.symbolEffect(.bounce, value: appState.totalJunkSize)
|
|
} else {
|
|
base
|
|
}
|
|
}
|
|
|
|
@ViewBuilder
|
|
private var sparklesIcon: some View {
|
|
let base = Image(systemName: "sparkles")
|
|
.font(.system(size: 18, weight: .semibold))
|
|
.foregroundStyle(
|
|
LinearGradient(colors: [Tint.blue, Tint.purple],
|
|
startPoint: .topLeading, endPoint: .bottomTrailing)
|
|
)
|
|
if #available(macOS 14.0, *) {
|
|
base.symbolEffect(.variableColor.iterative, options: .repeating)
|
|
} else {
|
|
base
|
|
}
|
|
}
|
|
|
|
private var cleaningHero: some View {
|
|
CardSurface(padding: 24, accent: Tint.orange, elevation: .raised, material: .ultraThinMaterial) {
|
|
HStack(alignment: .center, spacing: 28) {
|
|
ScanningGauge(progress: appState.cleanProgress, tint: Tint.orange, label: "CLEANING")
|
|
.frame(width: 180, height: 180)
|
|
VStack(alignment: .leading, spacing: 8) {
|
|
Text("Cleaning…")
|
|
.font(.system(size: 22, weight: .bold))
|
|
Text(percentCompleteText)
|
|
.font(.system(size: 13))
|
|
.foregroundStyle(.secondary)
|
|
ShimmerProgressBar(progress: appState.cleanProgress, tint: Tint.orange)
|
|
.frame(maxWidth: 320)
|
|
.padding(.top, 2)
|
|
}
|
|
Spacer(minLength: 0)
|
|
}
|
|
}
|
|
}
|
|
|
|
private var percentCompleteText: String {
|
|
String(format: String(localized: "%lld%% complete"), Int64(appState.cleanProgress * 100))
|
|
}
|
|
|
|
/// Convert the medal's frame (in dashboard coordinates) into a UnitPoint
|
|
/// for the confetti emitter. Falls back to the existing value until the
|
|
/// dashboard size is known.
|
|
private func updateBurstOrigin(medalFrame: CGRect) {
|
|
guard dashboardSize.width > 0, dashboardSize.height > 0 else { return }
|
|
burstOrigin = UnitPoint(
|
|
x: max(0, min(1, medalFrame.midX / dashboardSize.width)),
|
|
y: max(0, min(1, medalFrame.midY / dashboardSize.height))
|
|
)
|
|
}
|
|
|
|
private var cleanedHero: some View {
|
|
CardSurface(padding: 24, accent: Tint.green, elevation: .raised, material: .ultraThinMaterial) {
|
|
HStack(alignment: .center, spacing: 28) {
|
|
SuccessMedal()
|
|
.background(
|
|
GeometryReader { geo in
|
|
Color.clear.onAppear { updateBurstOrigin(medalFrame: geo.frame(in: .named(dashboardSpace))) }
|
|
}
|
|
)
|
|
|
|
VStack(alignment: .leading, spacing: 6) {
|
|
CountUpBytes(bytes: appState.totalFreedSpace)
|
|
.font(.system(size: 40, weight: .semibold))
|
|
.foregroundStyle(Tint.green)
|
|
Text("freed")
|
|
.font(.system(size: 14))
|
|
.foregroundStyle(.secondary)
|
|
Button("Done") { appState.scanState = .idle }
|
|
.buttonStyle(GlowProminentButtonStyle(tint: Tint.green, gradient: TintGradient.of(Tint.green)))
|
|
.padding(.top, 4)
|
|
}
|
|
Spacer(minLength: 0)
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - Helpers
|
|
|
|
private func sectionHeader(_ text: LocalizedStringKey) -> some View {
|
|
Text(text)
|
|
.font(.system(size: 16, weight: .bold))
|
|
.padding(.top, 4)
|
|
}
|
|
}
|
|
|
|
// MARK: - Components
|
|
|
|
private struct StatCard: View {
|
|
let icon: String
|
|
let tint: Color
|
|
let label: LocalizedStringKey
|
|
let value: String
|
|
let delta: String?
|
|
/// When set, the headline renders as a rolling byte counter instead of
|
|
/// the static `value` string.
|
|
var byteValue: Int64? = nil
|
|
|
|
var body: some View {
|
|
CardSurface(padding: 14, accent: tint) {
|
|
VStack(alignment: .leading, spacing: 6) {
|
|
HStack(spacing: 8) {
|
|
IconTile(systemName: icon, tint: tint, size: 28, glow: true)
|
|
Text(label)
|
|
.font(.system(size: 12, weight: .medium))
|
|
.foregroundStyle(.secondary)
|
|
.textCase(.uppercase)
|
|
.tracking(0.4)
|
|
}
|
|
Group {
|
|
if let byteValue {
|
|
CountUpBytes(bytes: byteValue)
|
|
} else {
|
|
Text(value)
|
|
.monospacedDigit()
|
|
.contentTransition(.numericText())
|
|
}
|
|
}
|
|
.font(.system(size: 22, weight: .bold))
|
|
.lineLimit(1)
|
|
.minimumScaleFactor(0.6)
|
|
if let delta {
|
|
Text(delta)
|
|
.font(.system(size: 11))
|
|
.foregroundStyle(.tertiary)
|
|
}
|
|
}
|
|
}
|
|
.pressable(hoverScale: 1.02, lift: true)
|
|
}
|
|
}
|
|
|
|
private struct Suggestion: Identifiable {
|
|
let id = UUID()
|
|
let icon: String
|
|
let tint: Color
|
|
let title: String
|
|
let subtitle: String
|
|
let pill: String?
|
|
}
|
|
|
|
private struct SuggestionRow: View {
|
|
let suggestion: Suggestion
|
|
var body: some View {
|
|
CardSurface(padding: 14, accent: suggestion.tint) {
|
|
HStack(spacing: 14) {
|
|
IconTile(systemName: suggestion.icon, tint: suggestion.tint,
|
|
size: 38, corner: 10, glow: true)
|
|
VStack(alignment: .leading, spacing: 2) {
|
|
Text(suggestion.title)
|
|
.font(.system(size: 14, weight: .semibold))
|
|
Text(suggestion.subtitle)
|
|
.font(.system(size: 12))
|
|
.foregroundStyle(.secondary)
|
|
}
|
|
Spacer()
|
|
if let pill = suggestion.pill {
|
|
StatusChip(label: pill, tint: suggestion.tint)
|
|
}
|
|
Image(systemName: "chevron.right")
|
|
.font(.system(size: 11, weight: .semibold))
|
|
.foregroundStyle(.tertiary)
|
|
}
|
|
}
|
|
.pressable(hoverScale: 1.01, lift: true)
|
|
}
|
|
}
|
|
|
|
/// Live file-path ticker. Observes the standalone `ScanProgressTicker` directly
|
|
/// so the scan engine's ~10Hz path updates re-render only this one label rather
|
|
/// than the whole AppState-observing view tree (issues #119, #120).
|
|
private struct ScanPathTicker: View {
|
|
@ObservedObject var ticker: ScanProgressTicker
|
|
|
|
private var display: String {
|
|
guard !ticker.path.isEmpty else { return "" }
|
|
return (ticker.path as NSString).abbreviatingWithTildeInPath
|
|
}
|
|
|
|
var body: some View {
|
|
Text(display)
|
|
.font(.system(size: 11, design: .monospaced))
|
|
.foregroundStyle(.tertiary)
|
|
.lineLimit(1)
|
|
.truncationMode(.middle)
|
|
.frame(maxWidth: 360, alignment: .leading)
|
|
.frame(height: 14)
|
|
.opacity(display.isEmpty ? 0 : 1)
|
|
}
|
|
}
|
|
|
|
// MARK: - Gauges
|
|
|
|
private struct LegendDot: View {
|
|
let color: Color
|
|
let label: LocalizedStringKey
|
|
let value: String
|
|
|
|
var body: some View {
|
|
HStack(spacing: 6) {
|
|
Circle().fill(color).frame(width: 8, height: 8)
|
|
VStack(alignment: .leading, spacing: 0) {
|
|
Text(label)
|
|
.font(.system(size: 10.5))
|
|
.foregroundStyle(.secondary)
|
|
Text(value)
|
|
.font(.system(size: 11.5, weight: .semibold))
|
|
.monospacedDigit()
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// ScanningGauge now lives in Components/DashboardCharts.swift (radar sweep +
|
|
// halo rings + Reduce Motion compliance).
|
|
|
|
/// Legend chip wrapper that reports hover for donut cross-highlighting and
|
|
/// scales slightly while hovered.
|
|
private struct HoverableLegendChip: View {
|
|
let color: Color
|
|
let label: LocalizedStringKey
|
|
let value: String
|
|
let onHoverChange: (Bool) -> Void
|
|
|
|
@State private var hovering = false
|
|
@Environment(\.accessibilityReduceMotion) private var reduceMotion
|
|
|
|
var body: some View {
|
|
LegendChip(color: color, label: label, value: value)
|
|
.scaleEffect(hovering && !reduceMotion ? 1.05 : 1, anchor: .leading)
|
|
.animation(reduceMotion ? nil : MotionTokens.snappy, value: hovering)
|
|
.onHover { h in
|
|
hovering = h
|
|
onHoverChange(h)
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Barely-there radial wash that drifts behind the idle hero ring. Static at
|
|
/// rest size under Reduce Motion.
|
|
private struct HeroDrift: View {
|
|
let tint: Color
|
|
|
|
@State private var drift = false
|
|
@Environment(\.accessibilityReduceMotion) private var reduceMotion
|
|
|
|
var body: some View {
|
|
RadialGradient(
|
|
colors: [tint.opacity(0.16), .clear],
|
|
center: .center, startRadius: 10, endRadius: 130
|
|
)
|
|
.blur(radius: 24)
|
|
.scaleEffect(drift ? 1.12 : 0.96)
|
|
.offset(x: drift ? 8 : -8)
|
|
.onAppear {
|
|
guard !reduceMotion else { return }
|
|
withAnimation(.easeInOut(duration: 3.0).repeatForever(autoreverses: true)) {
|
|
drift = true
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Gradient progress capsule with a traveling shimmer band, replacing the
|
|
/// stock linear ProgressView during scans/cleans. Shimmer is masked to the
|
|
/// filled portion and removed entirely under Reduce Motion.
|
|
private struct ShimmerProgressBar: View {
|
|
let progress: Double
|
|
var tint: Color = Tint.blue
|
|
|
|
@Environment(\.accessibilityReduceMotion) private var reduceMotion
|
|
|
|
private var clamped: Double { max(0, min(1, progress)) }
|
|
|
|
var body: some View {
|
|
GeometryReader { geo in
|
|
let fillWidth = geo.size.width * CGFloat(clamped)
|
|
ZStack(alignment: .leading) {
|
|
Capsule()
|
|
.fill(Color.primary.opacity(0.08))
|
|
|
|
Capsule()
|
|
.fill(
|
|
LinearGradient(colors: [tint, tint.opacity(0.7)],
|
|
startPoint: .leading, endPoint: .trailing)
|
|
)
|
|
.frame(width: max(8, fillWidth))
|
|
.animation(reduceMotion ? nil : .easeOut(duration: 0.35), value: clamped)
|
|
|
|
if !reduceMotion {
|
|
TimelineView(.animation) { timeline in
|
|
let t = timeline.date.timeIntervalSinceReferenceDate
|
|
let cycle = (t.truncatingRemainder(dividingBy: 1.8)) / 1.8
|
|
let bandWidth: CGFloat = 56
|
|
LinearGradient(
|
|
colors: [.clear, .white.opacity(0.35), .clear],
|
|
startPoint: .leading, endPoint: .trailing
|
|
)
|
|
.frame(width: bandWidth)
|
|
.offset(x: CGFloat(cycle) * (geo.size.width + bandWidth) - bandWidth)
|
|
}
|
|
.mask(
|
|
Capsule()
|
|
.frame(width: max(8, fillWidth))
|
|
.frame(maxWidth: .infinity, alignment: .leading)
|
|
)
|
|
}
|
|
}
|
|
}
|
|
.frame(height: 9)
|
|
}
|
|
}
|
|
|
|
// MARK: - Toggle row
|
|
|
|
private struct CategoryToggleRow: View {
|
|
@EnvironmentObject var appState: AppState
|
|
let result: CategoryResult
|
|
|
|
private var isFullySelected: Bool {
|
|
appState.selectedCountInCategory(result.category) == result.itemCount
|
|
}
|
|
|
|
var body: some View {
|
|
Toggle(isOn: Binding(
|
|
get: { isFullySelected },
|
|
set: { newValue in
|
|
if newValue {
|
|
appState.selectAllInCategory(result.category)
|
|
} else {
|
|
appState.deselectAllInCategory(result.category)
|
|
}
|
|
}
|
|
)) {
|
|
HStack(spacing: 12) {
|
|
IconTile(systemName: result.category.icon, tint: result.category.color, size: 28)
|
|
VStack(alignment: .leading, spacing: 1) {
|
|
Text(LocalizedStringKey(result.category.rawValue))
|
|
.font(.system(size: 13.5, weight: .semibold))
|
|
Text(itemsCountText)
|
|
.font(.system(size: 11.5))
|
|
.foregroundStyle(.secondary)
|
|
}
|
|
Spacer()
|
|
Text(result.formattedSize)
|
|
.font(.system(size: 13, weight: .semibold))
|
|
.monospacedDigit()
|
|
.foregroundStyle(.secondary)
|
|
}
|
|
}
|
|
.toggleStyle(.checkbox)
|
|
.padding(.horizontal, 16)
|
|
.padding(.vertical, 10)
|
|
}
|
|
|
|
private var itemsCountText: String {
|
|
String(format: String(localized: "%lld items"), Int64(result.itemCount))
|
|
}
|
|
}
|
|
|
|
/// Lays its content out horizontally at full width and vertically when the
|
|
/// container is too narrow for the row to fit, so wide hero rows reflow into a
|
|
/// stacked layout instead of overflowing.
|
|
struct AdaptiveStack<Content: View>: View {
|
|
let compact: Bool
|
|
var spacing: CGFloat = 28
|
|
@ViewBuilder var content: Content
|
|
|
|
var body: some View {
|
|
if compact {
|
|
VStack(alignment: .leading, spacing: spacing) { content }
|
|
} else {
|
|
HStack(alignment: .center, spacing: spacing) { content }
|
|
}
|
|
}
|
|
}
|