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

492 lines
18 KiB
Swift

import SwiftUI
/// First-launch flow. Four scenes, large typography, plenty of breathing
/// room. Sequence is welcome → mission → permission (with live demo) →
/// ready. Skip is always available — we don't want to gate adoption on a
/// reluctant user, but we do want to make the FDA step concrete enough that
/// the people who *want* to grant it understand exactly what they're doing.
struct OnboardingView: View {
@Binding var isComplete: Bool
@State private var page: Page = .welcome
@State private var hasFda = false
@State private var hasOpenedSettings = false
@State private var autoAdvanceScheduled = false
/// +1 when navigating forward, -1 going back — drives the slide direction
/// so Back doesn't slide the wrong way.
@State private var direction: Int = 1
@Environment(\.accessibilityReduceMotion) private var reduceMotion
private let pollTimer = Timer.publish(every: 1.0, on: .main, in: .common).autoconnect()
enum Page: Int, CaseIterable {
case welcome, mission, permission, ready
var index: Int { rawValue }
static var count: Int { allCases.count }
}
private var pageTransition: AnyTransition {
guard !reduceMotion else { return .opacity }
return .asymmetric(
insertion: .offset(x: direction >= 0 ? 48 : -48)
.combined(with: .opacity)
.combined(with: .scale(scale: 0.98)),
removal: .offset(x: direction >= 0 ? -48 : 48)
.combined(with: .opacity)
)
}
var body: some View {
ZStack {
backdrop
.ignoresSafeArea()
VStack(spacing: 0) {
content
.frame(maxWidth: .infinity, maxHeight: .infinity)
.padding(.horizontal, 36)
.padding(.top, 44)
.padding(.bottom, 12)
.id(page)
.transition(pageTransition)
bottomBar
}
}
.frame(width: 680, height: 560)
.onAppear {
FullDiskAccessManager.shared.triggerRegistration()
refreshFda()
}
.onReceive(pollTimer) { _ in
if page == .permission { refreshFda() }
}
}
@ViewBuilder
private var content: some View {
switch page {
case .welcome: WelcomeScene()
case .mission: MissionScene()
case .permission: PermissionScene(
hasFda: hasFda,
hasOpenedSettings: hasOpenedSettings,
openSettings: openSettings,
revealAppInFinder: { FullDiskAccessManager.shared.revealAppInFinder() }
)
case .ready: ReadyScene(hasFda: hasFda)
}
}
// MARK: - Background
private var backdrop: some View {
ZStack {
Color(nsColor: .windowBackgroundColor)
// Radial wash that shifts hue per page and drifts opposite the
// page slide — a slow parallax layer behind the faster foreground
// spring. Static under Reduce Motion.
RadialGradient(
colors: [pageTint.opacity(0.18), .clear],
center: .topTrailing,
startRadius: 60,
endRadius: 520
)
.offset(x: reduceMotion ? 0 : CGFloat(page.index) * -30)
.animation(reduceMotion ? nil : .easeInOut(duration: 0.8), value: page)
}
}
private var pageTint: Color {
switch page {
case .welcome: return Tint.blue
case .mission: return Tint.purple
case .permission: return Tint.orange
case .ready: return Tint.green
}
}
// MARK: - Bottom bar
private var bottomBar: some View {
HStack {
if page != .welcome {
Button("Back") { advance(by: -1) }
.buttonStyle(.plain)
.foregroundStyle(.secondary)
} else {
Button("Skip") { isComplete = true }
.buttonStyle(.plain)
.foregroundStyle(.secondary)
}
Spacer()
HStack(spacing: 6) {
ForEach(Page.allCases, id: \.self) { p in
Capsule()
.fill(p == page ? Color.primary.opacity(0.75) : Color.primary.opacity(0.15))
.frame(width: p == page ? 18 : 6, height: 6)
.animation(.spring(response: 0.35, dampingFraction: 0.85), value: page)
}
}
Spacer()
if page == .ready {
Button("Start") { isComplete = true }
.buttonStyle(GlowProminentButtonStyle(breathes: true))
} else {
Button(page == .permission ? "Continue" : "Next") { advance(by: 1) }
.buttonStyle(.borderedProminent)
.controlSize(.large)
}
}
.padding(.horizontal, 28)
.padding(.vertical, 16)
.background(
VStack(spacing: 0) {
Divider().opacity(0.5)
Color.clear
}
)
}
// MARK: - Actions
private func advance(by delta: Int) {
let target = max(0, min(Page.count - 1, page.index + delta))
guard target != page.index else { return }
Haptics.tap()
// Direction must be set BEFORE the transaction so the transition
// resolves with the correct slide edge.
direction = delta >= 0 ? 1 : -1
withAnimation(reduceMotion ? nil : .spring(response: 0.45, dampingFraction: 0.85)) {
page = Page(rawValue: target) ?? page
}
}
private func openSettings() {
FullDiskAccessManager.shared.openFullDiskAccessSettings()
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
FullDiskAccessManager.shared.revealAppInFinder()
}
withAnimation(.easeInOut(duration: 0.25)) {
hasOpenedSettings = true
}
}
private func refreshFda() {
let granted = FullDiskAccessManager.shared.hasFullDiskAccess
if granted != hasFda {
withAnimation(.easeInOut(duration: 0.3)) {
hasFda = granted
}
if granted { Haptics.success() }
}
// Auto-advance once they grant access while on the permission page.
// The autoAdvanceScheduled latch prevents the 1-second poll from
// queueing multiple .8s delays if grants are detected on back-to-back
// ticks before the page actually flips.
guard granted, page == .permission, !autoAdvanceScheduled else { return }
autoAdvanceScheduled = true
DispatchQueue.main.asyncAfter(deadline: .now() + 0.8) {
// Re-check page in case the user manually advanced or backed up
// during the delay window.
guard page == .permission else {
autoAdvanceScheduled = false
return
}
advance(by: 1)
autoAdvanceScheduled = false
}
}
}
// MARK: - Scenes
private struct WelcomeScene: View {
@State private var bob = false
@Environment(\.accessibilityReduceMotion) private var reduceMotion
var body: some View {
VStack(spacing: 26) {
Spacer(minLength: 0)
ZStack {
if let icon = NSImage(named: "AppIcon") {
Image(nsImage: icon)
.resizable()
.interpolation(.high)
.frame(width: 120, height: 120)
.shadow(color: .black.opacity(0.15), radius: 18, y: 8)
.offset(y: reduceMotion ? 0 : (bob ? -4 : 4))
.onAppear {
guard !reduceMotion else { return }
withAnimation(.easeInOut(duration: 2.4).repeatForever(autoreverses: true)) {
bob = true
}
}
} else {
Image(systemName: "sparkles")
.font(.system(size: 88, weight: .semibold))
.foregroundStyle(Tint.blue)
}
}
.staggered(0, baseDelay: 0.07)
VStack(spacing: 12) {
Text("Reclaim your Mac")
.font(.system(size: 38, weight: .semibold))
.multilineTextAlignment(.center)
Text("Apple sells you small disks. We help you keep them clean.")
.font(.system(size: 15))
.foregroundStyle(.secondary)
.multilineTextAlignment(.center)
.frame(maxWidth: 420)
}
.staggered(1, baseDelay: 0.07)
Text("Free. Open source. MIT licensed.")
.font(.system(size: 11.5, weight: .medium))
.foregroundStyle(.tertiary)
.tracking(0.3)
.padding(.top, 4)
.staggered(2, baseDelay: 0.07)
Spacer(minLength: 0)
}
}
}
private struct MissionScene: View {
var body: some View {
VStack(spacing: 28) {
Spacer(minLength: 0)
VStack(spacing: 12) {
Text("What's inside")
.font(.system(size: 30, weight: .semibold))
.multilineTextAlignment(.center)
Text("Three things, done well.")
.font(.system(size: 14))
.foregroundStyle(.secondary)
}
.staggered(0, baseDelay: 0.07)
VStack(spacing: 12) {
FeatureRow(
systemImage: "sparkles",
tint: Tint.blue,
title: "Smart Scan",
body: "Find caches, logs, broken installs, and the AI-app history hiding in your library."
)
.staggered(1, baseDelay: 0.07)
FeatureRow(
systemImage: "square.grid.2x2.fill",
tint: Tint.purple,
title: "App Uninstaller",
body: "Drag an app, see every file it dropped, remove all of it. No leftovers."
)
.staggered(2, baseDelay: 0.07)
FeatureRow(
systemImage: "doc.questionmark.fill",
tint: Tint.pink,
title: "Orphan Finder",
body: "Surfaces files that outlived the apps that created them."
)
.staggered(3, baseDelay: 0.07)
}
.frame(maxWidth: 460)
Spacer(minLength: 0)
}
}
}
private struct FeatureRow: View {
let systemImage: String
let tint: Color
let title: String
let body_: String
init(systemImage: String, tint: Color, title: String, body: String) {
self.systemImage = systemImage
self.tint = tint
self.title = title
self.body_ = body
}
var body: some View {
HStack(alignment: .top, spacing: 14) {
IconTile(systemName: systemImage, tint: tint, size: 36, corner: 9)
VStack(alignment: .leading, spacing: 2) {
Text(title)
.font(.system(size: 14, weight: .semibold))
Text(body_)
.font(.system(size: 12.5))
.foregroundStyle(.secondary)
.fixedSize(horizontal: false, vertical: true)
}
Spacer()
}
.padding(14)
.background(
RoundedRectangle(cornerRadius: 10, style: .continuous)
.fill(Color.primary.opacity(0.04))
)
}
}
private struct PermissionScene: View {
let hasFda: Bool
let hasOpenedSettings: Bool
let openSettings: () -> Void
let revealAppInFinder: () -> Void
var body: some View {
VStack(spacing: 18) {
VStack(spacing: 8) {
Text(hasFda ? "Permission granted" : "One permission, then we're done")
.font(.system(size: 26, weight: .semibold))
.multilineTextAlignment(.center)
Text(hasFda
? "PureMac can now reach the locations macOS protects by default."
: "macOS hides certain folders from every app until you say otherwise. We need them to find caches and uninstall cleanly.")
.font(.system(size: 13))
.foregroundStyle(.secondary)
.multilineTextAlignment(.center)
.frame(maxWidth: 460)
}
.staggered(0, baseDelay: 0.07)
FDADemoView()
.frame(maxWidth: 420)
.staggered(1, baseDelay: 0.07)
if hasFda {
Label("All set — moving you to the next step.", systemImage: "checkmark.circle.fill")
.font(.system(size: 12.5, weight: .medium))
.foregroundStyle(Tint.green)
.transition(.opacity.combined(with: .scale))
} else {
// Two equally legitimate paths: open Settings and have it
// reveal PureMac in Finder, OR grab the draggable icon on the
// left and drop it directly into the FDA list. Showing both
// side by side lets users pick the path their mental model
// prefers without a 3-step decision tree.
HStack(alignment: .top, spacing: 18) {
AppBundleDragHandle()
Divider().frame(maxHeight: 90)
VStack(spacing: 8) {
Button {
openSettings()
} label: {
Label(hasOpenedSettings ? "Reopen Settings" : "Open Settings & reveal PureMac",
systemImage: "gear")
.font(.system(size: 13, weight: .semibold))
.frame(minWidth: 240)
.padding(.vertical, 2)
}
.buttonStyle(.borderedProminent)
.controlSize(.large)
.keyboardShortcut(.defaultAction)
if hasOpenedSettings {
HStack(spacing: 12) {
Button("Reveal app again") { revealAppInFinder() }
.buttonStyle(.link)
.font(.system(size: 11.5))
Button("Reset permissions") {
_ = FullDiskAccessManager.shared.resetFullDiskAccess()
FullDiskAccessManager.shared.triggerRegistration()
}
.buttonStyle(.link)
.font(.system(size: 11.5))
}
.transition(.opacity)
} else {
Text("Tip: drag the icon on the left straight into the list.")
.font(.system(size: 11))
.foregroundStyle(.tertiary)
.multilineTextAlignment(.center)
}
}
.frame(maxWidth: 260)
}
.padding(.top, 4)
}
}
}
}
private struct ReadyScene: View {
let hasFda: Bool
@State private var bounce = false
@State private var fireConfetti = false
@State private var confettiWork: DispatchWorkItem?
@AppStorage("PureMac.HasSeenWelcomeConfetti") private var hasSeenConfetti = false
var body: some View {
ZStack {
// Confetti sits above the content but behind any touch targets;
// disabling hit-testing keeps the Start button clickable through
// falling particles.
if !hasSeenConfetti {
ConfettiView(trigger: fireConfetti)
.allowsHitTesting(false)
}
VStack(spacing: 22) {
Spacer(minLength: 0)
ZStack {
Circle()
.fill((hasFda ? Tint.green : Tint.orange).opacity(0.12))
.frame(width: 110, height: 110)
Image(systemName: hasFda ? "checkmark" : "hand.wave.fill")
.font(.system(size: 50, weight: .semibold))
.foregroundStyle(hasFda ? Tint.green : Tint.orange)
.scaleEffect(bounce ? 1.05 : 1.0)
}
.onAppear {
withAnimation(.spring(response: 0.5, dampingFraction: 0.55)) {
bounce = true
}
// Fire the welcome confetti exactly once per install.
// The work item is cancelled in onDisappear so a fast
// user who clicks Start within the 0.35s delay doesn't
// burn the once-per-install flag without ever seeing the
// celebration.
guard !hasSeenConfetti else { return }
let work = DispatchWorkItem {
fireConfetti = true
hasSeenConfetti = true
Haptics.success()
}
confettiWork = work
DispatchQueue.main.asyncAfter(deadline: .now() + 0.35, execute: work)
}
.onDisappear {
confettiWork?.cancel()
confettiWork = nil
}
VStack(spacing: 10) {
Text(hasFda ? "You're ready" : "Ready when you are")
.font(.system(size: 30, weight: .semibold))
Text(hasFda
? "Hit Start to run your first Smart Scan."
: "Some features will be limited without Full Disk Access. You can grant it later in Settings.")
.font(.system(size: 13.5))
.foregroundStyle(.secondary)
.multilineTextAlignment(.center)
.frame(maxWidth: 420)
}
Spacer(minLength: 0)
}
}
}
}