From 6c15417b3b71deb25235418b85ff4db6367f3399 Mon Sep 17 00:00:00 2001 From: productdevbook Date: Mon, 9 Mar 2026 10:06:04 +0300 Subject: [PATCH] feat: add onboarding wizard for first-time users Show a 4-step welcome wizard on first launch: Welcome, Features, Setup (launch at login, notifications, keyboard shortcut), and Ready. Persists completion state via Defaults. Settings includes a button to replay the onboarding. Closes #43 Co-Authored-By: Claude Opus 4.6 --- platforms/macos/Sources/AppState.swift | 3 + platforms/macos/Sources/PortKillerApp.swift | 4 + .../Onboarding/OnboardingFeaturesStep.swift | 39 ++++++ .../Onboarding/OnboardingReadyStep.swift | 45 +++++++ .../Onboarding/OnboardingSetupStep.swift | 115 ++++++++++++++++++ .../Views/Onboarding/OnboardingView.swift | 81 ++++++++++++ .../Onboarding/OnboardingWelcomeStep.swift | 26 ++++ .../Sources/Views/Settings/SettingsView.swift | 9 ++ 8 files changed, 322 insertions(+) create mode 100644 platforms/macos/Sources/Views/Onboarding/OnboardingFeaturesStep.swift create mode 100644 platforms/macos/Sources/Views/Onboarding/OnboardingReadyStep.swift create mode 100644 platforms/macos/Sources/Views/Onboarding/OnboardingSetupStep.swift create mode 100644 platforms/macos/Sources/Views/Onboarding/OnboardingView.swift create mode 100644 platforms/macos/Sources/Views/Onboarding/OnboardingWelcomeStep.swift diff --git a/platforms/macos/Sources/AppState.swift b/platforms/macos/Sources/AppState.swift index 589cb72..37dcfea 100644 --- a/platforms/macos/Sources/AppState.swift +++ b/platforms/macos/Sources/AppState.swift @@ -26,6 +26,9 @@ extension Defaults.Keys { // Kubernetes-related keys static let customNamespaces = Key<[String]>("customNamespaces", default: []) + // Onboarding + static let hasCompletedOnboarding = Key("hasCompletedOnboarding", default: false) + // Sponsor-related keys static let sponsorCache = Key("sponsorCache", default: nil) static let lastSponsorWindowShown = Key("lastSponsorWindowShown", default: nil) diff --git a/platforms/macos/Sources/PortKillerApp.swift b/platforms/macos/Sources/PortKillerApp.swift index 003f0df..28538ca 100644 --- a/platforms/macos/Sources/PortKillerApp.swift +++ b/platforms/macos/Sources/PortKillerApp.swift @@ -64,6 +64,7 @@ struct PortKillerApp: App { @NSApplicationDelegateAdaptor(AppDelegate.self) var appDelegate @State private var state = AppState() @State private var sponsorManager = SponsorManager() + @State private var showOnboarding = !Defaults[.hasCompletedOnboarding] @Environment(\.openWindow) private var openWindow init() { @@ -77,6 +78,9 @@ struct PortKillerApp: App { MainWindowView() .environment(state) .environment(sponsorManager) + .sheet(isPresented: $showOnboarding) { + OnboardingView() + } .task { // Pass state to AppDelegate for termination handling appDelegate.appState = state diff --git a/platforms/macos/Sources/Views/Onboarding/OnboardingFeaturesStep.swift b/platforms/macos/Sources/Views/Onboarding/OnboardingFeaturesStep.swift new file mode 100644 index 0000000..52599d8 --- /dev/null +++ b/platforms/macos/Sources/Views/Onboarding/OnboardingFeaturesStep.swift @@ -0,0 +1,39 @@ +import SwiftUI + +struct OnboardingFeaturesStep: View { + private let features: [(icon: String, title: String, description: String, color: Color)] = [ + ("magnifyingglass", "Port Scanning", "See all listening ports in one place", .blue), + ("xmark.circle.fill", "Quick Kill", "Terminate processes with one click", .red), + ("star.fill", "Favorites", "Pin frequently used ports for quick access", .yellow), + ("eye.fill", "Watched Ports", "Get notifications when ports become active", .purple), + ("globe", "Cloudflare Tunnels", "Share local ports publicly", .orange), + ] + + var body: some View { + VStack(alignment: .leading, spacing: 16) { + Text("What you can do") + .font(.title2) + .fontWeight(.bold) + .padding(.bottom, 4) + + ForEach(features, id: \.title) { feature in + HStack(spacing: 14) { + Image(systemName: feature.icon) + .font(.title3) + .foregroundStyle(feature.color) + .frame(width: 28, alignment: .center) + + VStack(alignment: .leading, spacing: 2) { + Text(feature.title) + .fontWeight(.medium) + Text(feature.description) + .font(.callout) + .foregroundStyle(.secondary) + } + } + } + } + .padding(32) + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) + } +} diff --git a/platforms/macos/Sources/Views/Onboarding/OnboardingReadyStep.swift b/platforms/macos/Sources/Views/Onboarding/OnboardingReadyStep.swift new file mode 100644 index 0000000..41c2601 --- /dev/null +++ b/platforms/macos/Sources/Views/Onboarding/OnboardingReadyStep.swift @@ -0,0 +1,45 @@ +import SwiftUI + +struct OnboardingReadyStep: View { + var body: some View { + VStack(spacing: 20) { + Spacer() + + Image(systemName: "checkmark.circle.fill") + .font(.system(size: 56)) + .foregroundColor(.green) + + Text("You're All Set!") + .font(.largeTitle) + .fontWeight(.bold) + + Text("PortKiller is ready to use.\nLook for the icon in your menu bar.") + .font(.title3) + .foregroundStyle(.secondary) + .multilineTextAlignment(.center) + .lineSpacing(4) + + HStack(spacing: 24) { + tipView(icon: "menubar.arrow.up.rectangle", text: "Click the menu bar icon\nfor quick access") + tipView(icon: "gearshape.fill", text: "Visit Settings to\ncustomize further") + } + .padding(.top, 8) + + Spacer() + } + .padding(32) + } + + private func tipView(icon: String, text: String) -> some View { + VStack(spacing: 8) { + Image(systemName: icon) + .font(.title2) + .foregroundStyle(.secondary) + Text(text) + .font(.caption) + .foregroundStyle(.secondary) + .multilineTextAlignment(.center) + } + .frame(width: 140) + } +} diff --git a/platforms/macos/Sources/Views/Onboarding/OnboardingSetupStep.swift b/platforms/macos/Sources/Views/Onboarding/OnboardingSetupStep.swift new file mode 100644 index 0000000..80aaaff --- /dev/null +++ b/platforms/macos/Sources/Views/Onboarding/OnboardingSetupStep.swift @@ -0,0 +1,115 @@ +import SwiftUI +import LaunchAtLogin +import KeyboardShortcuts +@preconcurrency import UserNotifications + +struct OnboardingSetupStep: View { + @State private var notificationStatus: UNAuthorizationStatus = .notDetermined + + var body: some View { + VStack(alignment: .leading, spacing: 20) { + Text("Quick Setup") + .font(.title2) + .fontWeight(.bold) + .padding(.bottom, 4) + + // Launch at Login + VStack(alignment: .leading, spacing: 8) { + LaunchAtLogin.Toggle { + VStack(alignment: .leading, spacing: 2) { + Text("Launch at Login") + .fontWeight(.medium) + Text("Start PortKiller when you log in") + .font(.callout) + .foregroundStyle(.secondary) + } + } + .toggleStyle(.switch) + } + .padding(12) + .background(Color(nsColor: .controlBackgroundColor)) + .clipShape(RoundedRectangle(cornerRadius: 8)) + + // Notifications + VStack(alignment: .leading, spacing: 8) { + HStack { + VStack(alignment: .leading, spacing: 2) { + Text("Notifications") + .fontWeight(.medium) + Text("Get notified when watched ports change state") + .font(.callout) + .foregroundStyle(.secondary) + } + + Spacer() + + if notificationStatus == .authorized { + HStack(spacing: 4) { + Image(systemName: "checkmark.circle.fill") + .foregroundStyle(.green) + Text("Enabled") + .font(.callout) + .foregroundStyle(.green) + } + } else if notificationStatus == .denied { + Button("Open Settings") { + if let bundleId = Bundle.main.bundleIdentifier { + let url = URL(string: "x-apple.systempreferences:com.apple.Notifications-Settings.extension?id=\(bundleId)")! + NSWorkspace.shared.open(url) + } + } + .controlSize(.small) + } else { + Button("Enable") { + requestPermission() + } + .controlSize(.small) + } + } + } + .padding(12) + .background(Color(nsColor: .controlBackgroundColor)) + .clipShape(RoundedRectangle(cornerRadius: 8)) + + // Keyboard Shortcut + VStack(alignment: .leading, spacing: 8) { + HStack { + VStack(alignment: .leading, spacing: 2) { + Text("Global Shortcut") + .fontWeight(.medium) + Text("Open PortKiller from anywhere") + .font(.callout) + .foregroundStyle(.secondary) + } + + Spacer() + + KeyboardShortcuts.Recorder(for: .toggleMainWindow) + .frame(width: 150) + } + } + .padding(12) + .background(Color(nsColor: .controlBackgroundColor)) + .clipShape(RoundedRectangle(cornerRadius: 8)) + + Spacer() + } + .padding(32) + .task { + await checkNotificationStatus() + } + } + + private func requestPermission() { + Task { + _ = await NotificationService.shared.requestPermission() + await checkNotificationStatus() + } + } + + private func checkNotificationStatus() async { + guard Bundle.main.bundlePath.hasSuffix(".app") else { return } + let settings = await UNUserNotificationCenter.current().notificationSettings() + notificationStatus = settings.authorizationStatus + } +} diff --git a/platforms/macos/Sources/Views/Onboarding/OnboardingView.swift b/platforms/macos/Sources/Views/Onboarding/OnboardingView.swift new file mode 100644 index 0000000..8ebc151 --- /dev/null +++ b/platforms/macos/Sources/Views/Onboarding/OnboardingView.swift @@ -0,0 +1,81 @@ +import SwiftUI +import Defaults + +struct OnboardingView: View { + @Environment(\.dismiss) private var dismiss + @State private var currentStep = 0 + + private let totalSteps = 4 + + var body: some View { + VStack(spacing: 0) { + // Content + Group { + switch currentStep { + case 0: OnboardingWelcomeStep() + case 1: OnboardingFeaturesStep() + case 2: OnboardingSetupStep() + case 3: OnboardingReadyStep() + default: EmptyView() + } + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + + Divider() + + // Navigation + HStack { + // Step indicators + HStack(spacing: 6) { + ForEach(0.. 0 { + Button("Back") { + withAnimation(.easeInOut(duration: 0.2)) { + currentStep -= 1 + } + } + .controlSize(.large) + } + + if currentStep < totalSteps - 1 { + Button("Skip") { + completeOnboarding() + } + .controlSize(.large) + .foregroundStyle(.secondary) + + Button("Next") { + withAnimation(.easeInOut(duration: 0.2)) { + currentStep += 1 + } + } + .controlSize(.large) + .buttonStyle(.borderedProminent) + } else { + Button("Get Started") { + completeOnboarding() + } + .controlSize(.large) + .buttonStyle(.borderedProminent) + } + } + } + .padding(20) + } + .frame(width: 520, height: 420) + } + + private func completeOnboarding() { + Defaults[.hasCompletedOnboarding] = true + dismiss() + } +} diff --git a/platforms/macos/Sources/Views/Onboarding/OnboardingWelcomeStep.swift b/platforms/macos/Sources/Views/Onboarding/OnboardingWelcomeStep.swift new file mode 100644 index 0000000..5747c9b --- /dev/null +++ b/platforms/macos/Sources/Views/Onboarding/OnboardingWelcomeStep.swift @@ -0,0 +1,26 @@ +import SwiftUI + +struct OnboardingWelcomeStep: View { + var body: some View { + VStack(spacing: 20) { + Spacer() + + Image(systemName: "network") + .font(.system(size: 56)) + .foregroundColor(.accentColor) + + Text("Welcome to PortKiller") + .font(.largeTitle) + .fontWeight(.bold) + + Text("Find and kill processes on any port.\nManage your development servers with ease.") + .font(.title3) + .foregroundStyle(.secondary) + .multilineTextAlignment(.center) + .lineSpacing(4) + + Spacer() + } + .padding(32) + } +} diff --git a/platforms/macos/Sources/Views/Settings/SettingsView.swift b/platforms/macos/Sources/Views/Settings/SettingsView.swift index a615156..3efc7aa 100644 --- a/platforms/macos/Sources/Views/Settings/SettingsView.swift +++ b/platforms/macos/Sources/Views/Settings/SettingsView.swift @@ -180,6 +180,15 @@ struct SettingsView: View { SettingsLinkRow(title: "Report Issue", subtitle: "Found a bug?", icon: "ladybug.fill", url: AppInfo.githubIssues) SettingsDivider() SettingsLinkRow(title: "Twitter/X", subtitle: "@productdevbook", icon: "at", url: AppInfo.twitterURL) + SettingsDivider() + SettingsButtonRow( + title: "Show Welcome Screen", + subtitle: "Replay the onboarding wizard", + icon: "hand.wave.fill", + action: { + Defaults[.hasCompletedOnboarding] = false + } + ) } } }