492 lines
18 KiB
Swift
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)
|
|
}
|
|
}
|
|
}
|
|
}
|