grammargem/Sources/GrammarGem/App/OnboardingView.swift
BigMarc e5b0977abf f
2026-06-18 16:49:59 +02:00

221 lines
9.6 KiB
Swift
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import SwiftUI
/// First-run onboarding: explains why GrammarGem needs Accessibility, deep-links
/// to System Settings, and advances automatically the moment permission is
/// granted (even if granted directly in System Settings).
struct OnboardingView: View {
@EnvironmentObject private var app: AppState
@ObservedObject private var permissions: Permissions
@ObservedObject private var model: ModelManager
init() {
_permissions = ObservedObject(wrappedValue: AppState.shared.permissions)
_model = ObservedObject(wrappedValue: AppState.shared.model)
}
var body: some View {
VStack(spacing: 0) {
ScrollView {
VStack(alignment: .leading, spacing: 18) {
VStack(alignment: .leading, spacing: 6) {
Text("Welcome to GrammarGem")
.font(.largeTitle.bold())
Text("A private, on-device writing assistant that works in every app.")
.foregroundStyle(.secondary)
}
// The rigged win: prove the value in one click, before any ask.
VStack(alignment: .leading, spacing: 10) {
Label("See it work", systemImage: "wand.and.stars").font(.headline)
DemoStep()
}
.padding(16)
.background(GG.emerald.opacity(0.06), in: RoundedRectangle(cornerRadius: 12))
.overlay(RoundedRectangle(cornerRadius: 12).stroke(GG.emerald.opacity(0.25)))
stepCard(
number: 1,
title: "Turn it on everywhere",
body: "GrammarGem reads the text you select and writes the correction back. macOS requires Accessibility permission for this. Your text is processed on-device and never leaves your Mac.",
granted: permissions.accessibilityTrusted
) {
if permissions.accessibilityTrusted {
Label("Permission granted", systemImage: "checkmark.circle.fill")
.foregroundStyle(.green)
} else {
HStack {
Button("Open System Settings") { permissions.openAccessibilitySettings() }
Button("Request permission") { permissions.requestAccessibility() }
}
Text("This updates automatically once you flip GrammarGem on in Accessibility.")
.font(.caption).foregroundStyle(.secondary)
}
}
stepCard(
number: 2,
title: "Download the on-device model (optional)",
body: "Grammar & spelling work instantly with no download. The AI rewrite/tone/Ask features use a small model downloaded once from Hugging Face — then everything runs offline.",
granted: model.state == .ready
) {
ModelDownloadControls()
}
}
.padding(28)
}
Divider()
HStack {
Label("Yours once. No subscription, nothing leaves your Mac.", systemImage: "lock.shield")
.font(.caption).foregroundStyle(.secondary)
Spacer()
Button(permissions.accessibilityTrusted ? "Done" : "Continue without permission") {
app.showOnboardingDismiss()
}
.keyboardShortcut(.defaultAction)
}
.padding(.horizontal, 28).padding(.vertical, 14)
}
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
.onAppear {
permissions.startPollingUntilGranted()
}
.onDisappear {
permissions.stopPolling()
}
}
private func stepCard<Controls: View>(
number: Int, title: String, body: String, granted: Bool,
@ViewBuilder controls: () -> Controls
) -> some View {
HStack(alignment: .top, spacing: 14) {
ZStack {
Circle().fill(granted ? Color.green : Color.accentColor.opacity(0.15))
.frame(width: 30, height: 30)
if granted {
Image(systemName: "checkmark").foregroundStyle(.white).font(.caption.bold())
} else {
Text("\(number)").font(.callout.bold()).foregroundStyle(.tint)
}
}
VStack(alignment: .leading, spacing: 8) {
Text(title).font(.headline)
Text(body).font(.callout).foregroundStyle(.secondary)
controls()
}
}
.padding(16)
.background(Color.primary.opacity(0.04), in: RoundedRectangle(cornerRadius: 12))
}
}
/// A safe, on-rails first-run demo: a local sample with planted mistakes that
/// the REAL grammar engine fixes in one click. It operates on a plain string
/// (never a live Accessibility element), so it's completely risk-free — the
/// "guaranteed visible win" before GrammarGem asks for anything.
private struct DemoStep: View {
@State private var text = "i has wrote alot of mistakes here, and it dont look profesional"
@State private var issues: [Suggestion] = []
@State private var cleaned = false
var body: some View {
VStack(alignment: .leading, spacing: 10) {
Text(headline)
.font(.subheadline.weight(.semibold))
.foregroundStyle(cleaned ? GG.emerald : .primary)
Text(highlighted)
.font(.callout)
.frame(maxWidth: .infinity, alignment: .leading)
.padding(10)
.background(Color(nsColor: .textBackgroundColor), in: RoundedRectangle(cornerRadius: 8))
.animation(.easeInOut(duration: 0.35), value: text)
Button {
let fixed = AppState.shared.grammar.correct(text)
withAnimation(.easeOut(duration: 0.4)) {
text = fixed
issues = []
cleaned = true
}
} label: {
Label(cleaned ? "All clean" : "Clean it up",
systemImage: cleaned ? "checkmark.circle.fill" : "wand.and.stars")
}
.buttonStyle(.borderedProminent)
.disabled(cleaned || issues.isEmpty)
}
.onAppear {
if issues.isEmpty && !cleaned { issues = AppState.shared.grammar.check(text) }
}
}
private var headline: String {
if cleaned { return "Clean ✨ — GrammarGem does this in every app you type." }
if issues.isEmpty { return "Checking…" }
return "We spotted \(issues.count) thing\(issues.count == 1 ? "" : "s") to tidy up — one click fixes them."
}
private var highlighted: AttributedString {
let ns = text as NSString
var result = AttributedString()
var cursor = 0
for s in issues.sorted(by: { $0.location < $1.location }) {
guard s.location >= cursor, s.length > 0, s.location + s.length <= ns.length else { continue }
if s.location > cursor {
result += AttributedString(ns.substring(with: NSRange(location: cursor, length: s.location - cursor)))
}
var run = AttributedString(ns.substring(with: s.range))
let tint: Color = s.kind == .spelling ? .red : .orange
run.backgroundColor = tint.opacity(0.18)
run.foregroundColor = tint
run.underlineStyle = .single
result += run
cursor = s.location + s.length
}
if cursor < ns.length {
result += AttributedString(ns.substring(with: NSRange(location: cursor, length: ns.length - cursor)))
}
return result
}
}
/// Real model download progress + controls, shared by onboarding and settings.
struct ModelDownloadControls: View {
@ObservedObject private var model: ModelManager
init() {
_model = ObservedObject(wrappedValue: AppState.shared.model)
}
var body: some View {
switch model.state {
case .notDownloaded:
VStack(alignment: .leading, spacing: 4) {
Button("Download model now") { model.startDownload() }
Text("Installs automatically in the background on first launch (about 1–2 GB from Hugging Face). One time, then fully offline.")
.font(.caption).foregroundStyle(.secondary)
}
case .downloading(let progress):
VStack(alignment: .leading, spacing: 6) {
ProgressView(value: progress) {
Text("Downloading… \(Int(progress * 100))%")
}
.frame(maxWidth: 320)
if !model.statusText.isEmpty {
Text(model.statusText).font(.caption).foregroundStyle(.secondary).lineLimit(1)
}
Button("Cancel") { model.cancel() }.controlSize(.small)
}
case .ready:
Label("Model ready", systemImage: "checkmark.seal.fill").foregroundStyle(.green)
case .failed(let message):
VStack(alignment: .leading, spacing: 4) {
Label("Download failed", systemImage: "exclamationmark.triangle").foregroundStyle(.orange)
Text(message).font(.caption).foregroundStyle(.secondary).lineLimit(2)
Button("Retry") { model.startDownload() }
}
}
}
}