grammargem/Sources/GrammaGem/App/MainWindow/LiveCheckView.swift
BigMarc 143620e44e Add Grammarly-style value loop: score, buckets, dismiss, streaks, demo
Implements the highest-leverage items from docs/grammarly-value-plan.md:
- Live Check: on-device 0-100 writing score, Correctness/Clarity/Polish
  count chips, and per-suggestion Dismiss / Add-to-dictionary (a cheap,
  always-available 'no' is what makes 'Apply' feel safe).
- Value loop: day-streak, correction milestones and a 'subscription you
  didn't pay' figure on the dashboard + a streak line in the menu bar
  (all local, self-vs-self, no telemetry).
- First-run demo: a planted sample the real engine cleans in one click,
  before any permission ask.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-18 06:43:31 +02:00

243 lines
9.7 KiB
Swift
Raw 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
import AppKit
/// A floating panel that lists every on-screen text area with grammar/spelling
/// issues, shows the offending text with the errors highlighted, and offers
/// one-click fixes (per issue, per field, or everything).
struct LiveCheckView: View {
@EnvironmentObject private var app: AppState
@ObservedObject private var monitor: LiveMonitor
init() {
_monitor = ObservedObject(wrappedValue: AppState.shared.liveMonitor)
}
var body: some View {
VStack(spacing: 0) {
header
Divider()
if monitor.fields.isEmpty {
emptyState
} else {
ScrollView {
VStack(spacing: 14) {
ForEach(monitor.fields) { field in
FieldCard(field: field)
}
}
.padding(16)
}
}
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
.tint(GG.emerald)
}
private var header: some View {
VStack(alignment: .leading, spacing: 10) {
HStack(spacing: 12) {
ScoreRing(score: monitor.writingScore)
VStack(alignment: .leading, spacing: 2) {
Text(summary).font(.headline)
Text(contextLine).font(.caption).foregroundStyle(.secondary)
}
Spacer()
Button { monitor.refreshSoon() } label: { Image(systemName: "arrow.clockwise") }
.help("Re-scan now")
if monitor.issueCount > 0 {
Button { app.fixAllDetected() } label: {
Label("Fix everything", systemImage: "checkmark.circle.fill")
}
.buttonStyle(.borderedProminent)
}
}
if monitor.issueCount > 0 {
HStack(spacing: 8) {
ForEach(IssueBucket.allCases) { bucket in
if let n = monitor.bucketCounts[bucket], n > 0 {
BucketChip(bucket: bucket, count: n)
}
}
Spacer()
}
}
}
.padding(16)
}
private var summary: String {
let issues = monitor.issueCount
if issues == 0 { return "Looks clean" }
return "\(issues) issue\(issues == 1 ? "" : "s") across \(monitor.fields.count) text area\(monitor.fields.count == 1 ? "" : "s")"
}
private var contextLine: String {
monitor.activeAppName.isEmpty
? "Checking text areas on screen"
: "In \(monitor.activeAppName) · \(monitor.scannedCount) text area\(monitor.scannedCount == 1 ? "" : "s") checked"
}
private var emptyState: some View {
VStack(spacing: 10) {
Image(systemName: "checkmark.seal.fill").font(.system(size: 42)).foregroundStyle(GG.emerald)
Text("Looks clean").font(.title3.weight(.semibold))
Text("No grammar or spelling issues in the text on screen right now.")
.font(.callout).foregroundStyle(.secondary).multilineTextAlignment(.center)
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
.padding(40)
}
}
/// One detected text area: highlighted text + its individual issues.
private struct FieldCard: View {
@EnvironmentObject private var app: AppState
let field: DetectedField
var body: some View {
VStack(alignment: .leading, spacing: 10) {
HStack(spacing: 8) {
Image(systemName: field.isFocused ? "cursorarrow.rays" : "text.cursor")
.foregroundStyle(field.isFocused ? GG.emerald : .secondary)
Text(field.label).font(.subheadline.weight(.semibold)).lineLimit(1)
Text(field.roleName).font(.caption).foregroundStyle(.secondary)
Spacer()
Text("\(field.suggestions.count)")
.font(.caption.weight(.semibold))
.padding(.horizontal, 7).padding(.vertical, 2)
.background(GG.gold.opacity(0.2), in: Capsule())
.foregroundStyle(GG.gold)
}
Text(highlighted(field.text, field.suggestions))
.font(.callout)
.textSelection(.enabled)
.frame(maxWidth: .infinity, alignment: .leading)
.padding(10)
.background(Color(nsColor: .textBackgroundColor), in: RoundedRectangle(cornerRadius: 8))
VStack(spacing: 6) {
ForEach(field.suggestions) { s in
HStack(spacing: 8) {
Circle().fill(color(for: s.kind)).frame(width: 7, height: 7)
VStack(alignment: .leading, spacing: 1) {
Text(s.message).font(.caption)
if s.replacement != s.original && !s.replacement.isEmpty {
Text("“\(s.original)” → “\(s.replacement)”")
.font(.caption2.monospaced()).foregroundStyle(.secondary).lineLimit(1)
}
}
Spacer()
if s.kind == .spelling {
Button { app.addWordToDictionary(s.original) } label: {
Image(systemName: "book.closed")
}
.controlSize(.small).buttonStyle(.borderless)
.help("Add “\(s.original)” to your dictionary")
}
Button { app.dismissSuggestion(s, in: field) } label: {
Image(systemName: "xmark")
}
.controlSize(.small).buttonStyle(.borderless)
.help("Dismiss this suggestion")
if s.replacement != s.original && !s.replacement.isEmpty {
Button("Apply") { app.applySuggestion(s, in: field) }
.controlSize(.small).buttonStyle(.borderedProminent)
}
}
}
}
HStack {
Spacer()
Button { app.fixDetectedField(field) } label: {
Label("Fix this field", systemImage: "checkmark.circle")
}
.controlSize(.small)
}
}
.padding(14)
.background(Color(nsColor: .controlBackgroundColor), in: RoundedRectangle(cornerRadius: 12))
.overlay(RoundedRectangle(cornerRadius: 12).stroke(Color.primary.opacity(0.07)))
}
private func color(for kind: GrammarKind) -> Color {
switch kind {
case .spelling: return .red
case .grammar, .punctuation: return .orange
default: return GG.gold
}
}
fileprivate static func color(for bucket: IssueBucket) -> Color {
switch bucket {
case .correctness: return .red
case .clarity: return .blue
case .polish: return GG.gold
}
}
/// Build an attributed string with each issue span tinted.
private func highlighted(_ text: String, _ suggestions: [Suggestion]) -> AttributedString {
let ns = text as NSString
let sorted = suggestions.sorted { $0.location < $1.location }
var result = AttributedString()
var cursor = 0
for s in sorted {
let loc = s.location, len = s.length
guard loc >= cursor, len > 0, loc + len <= ns.length else { continue }
if loc > cursor {
result += AttributedString(ns.substring(with: NSRange(location: cursor, length: loc - cursor)))
}
var run = AttributedString(ns.substring(with: NSRange(location: loc, length: len)))
let tint: Color = s.kind == .spelling ? .red : .orange
run.backgroundColor = tint.opacity(0.18)
run.foregroundColor = tint
run.underlineStyle = .single
result += run
cursor = loc + len
}
if cursor < ns.length {
result += AttributedString(ns.substring(with: NSRange(location: cursor, length: ns.length - cursor)))
}
return result
}
}
/// A compact 0–100 cleanliness gauge. Green when clean, amber/orange as issues
/// pile up — a meter the user can watch climb as they accept fixes.
private struct ScoreRing: View {
let score: Int
var body: some View {
ZStack {
Circle().stroke(Color.primary.opacity(0.1), lineWidth: 5)
Circle()
.trim(from: 0, to: CGFloat(max(0, min(100, score))) / 100)
.stroke(color, style: StrokeStyle(lineWidth: 5, lineCap: .round))
.rotationEffect(.degrees(-90))
.animation(.easeOut(duration: 0.35), value: score)
Text("\(score)").font(.system(size: 15, weight: .bold, design: .rounded))
}
.frame(width: 44, height: 44)
.help("Writing score for the text on screen")
}
private var color: Color {
score >= 90 ? GG.emerald : (score >= 70 ? GG.gold : .orange)
}
}
/// A tappable-looking count chip per issue bucket (Correctness / Clarity / Polish).
private struct BucketChip: View {
let bucket: IssueBucket
let count: Int
var body: some View {
HStack(spacing: 5) {
Circle().fill(FieldCard.color(for: bucket)).frame(width: 7, height: 7)
Text("\(bucket.title) \(count)").font(.caption.weight(.medium))
}
.padding(.horizontal, 9).padding(.vertical, 4)
.background(FieldCard.color(for: bucket).opacity(0.12), in: Capsule())
}
}