grammargem/Sources/GrammaGem/App/MainWindow/DashboardView.swift
BigMarc a7bf45b466 Add management window; sales-focused README; FounderGem LLC
- Management app: sidebar window with Dashboard (usage insights), Devices
  (slot usage, deactivate this Mac), Writing Modes (custom + app-aware map),
  Dictionary, Snippets, Shortcuts, AI Model, Privacy, General, License, About.
- New local stores: Preferences (launch-at-login, appearance), UsageStats
  (privacy-friendly local insights), CustomModesStore, SnippetStore.
- License model now tracks device name + activation usage/limit for the
  Devices screen.
- README rewritten as a conversion-focused product page (no internal cost/setup).
- Legal entity set to FounderGem LLC across README, LICENSE, Info.plist, About.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 14:28:23 +02:00

133 lines
5.4 KiB
Swift

import SwiftUI
struct DashboardView: View {
@EnvironmentObject private var app: AppState
@EnvironmentObject private var license: LicenseManager
@EnvironmentObject private var gate: FeatureGate
@EnvironmentObject private var usage: UsageStats
@EnvironmentObject private var model: ModelManager
@EnvironmentObject private var permissions: Permissions
var body: some View {
DetailScaffold(
title: "Welcome back",
subtitle: "Your private writing assistant, at a glance."
) {
// Key metrics
LazyVGrid(columns: [GridItem(.adaptive(minimum: 158), spacing: 14)], spacing: 14) {
StatTile(
value: license.tier.displayName,
label: license.isLicensed ? "Lifetime plan" : "Free plan",
systemImage: "key.fill", tint: license.isLicensed ? GG.emerald : .secondary)
if gate.entitlements.unlimitedAIActions {
StatTile(value: "∞", label: "AI actions today", systemImage: "infinity", tint: GG.emerald)
} else {
StatTile(
value: "\(gate.remainingAIActionsToday)/\(gate.entitlements.dailyAIActionCap)",
label: "AI actions left today", systemImage: "bolt.fill", tint: GG.gold)
}
StatTile(value: "\(usage.data.totalCorrections)", label: "Corrections made", systemImage: "checkmark.seal.fill", tint: GG.emerald)
StatTile(value: timeSaved, label: "Time saved (est.)", systemImage: "clock.fill", tint: .primary)
}
// Setup health
Card {
Text("Setup").font(.headline)
healthRow(
ok: permissions.accessibilityTrusted,
okText: "Accessibility granted",
badText: "Accessibility needed to fix text in apps",
action: permissions.accessibilityTrusted ? nil : ("Fix", { app.showOnboarding() }))
Divider()
healthRow(
ok: model.state == .ready,
okText: "On-device AI model ready",
badText: "AI model not downloaded (grammar still works)",
action: model.state == .ready ? nil : ("Set up", { app.showMainWindow(select: .model) }))
}
// Activity
Card {
HStack {
Text("Activity").font(.headline)
Spacer()
Text("Last 7 days").font(.caption).foregroundStyle(.secondary)
}
WeeklyBars(points: usage.last7Days())
.frame(height: 110)
Text("\(usage.data.totalWords) words polished overall — all on this Mac.")
.font(.caption).foregroundStyle(.secondary)
}
// Quick actions
Card {
Text("Quick actions").font(.headline)
HStack {
Button { Task { await app.runFix() } } label: {
Label("Fix selection", systemImage: "checkmark.circle")
}
Button { app.showAsk() } label: {
Label("Ask GrammaGem", systemImage: "sparkles")
}
if !license.isLicensed {
Spacer()
Button { app.showMainWindow(select: .license) } label: {
Label("Upgrade", systemImage: "arrow.up.circle.fill")
}
.buttonStyle(.borderedProminent)
}
}
}
}
}
private var timeSaved: String {
let m = usage.estimatedMinutesSaved
if m >= 60 { return "\(m / 60)h \(m % 60)m" }
return "\(m)m"
}
@ViewBuilder
private func healthRow(ok: Bool, okText: String, badText: String, action: (String, () -> Void)?) -> some View {
HStack(spacing: 10) {
Image(systemName: ok ? "checkmark.circle.fill" : "exclamationmark.triangle.fill")
.foregroundStyle(ok ? GG.emerald : .orange)
Text(ok ? okText : badText)
Spacer()
if let action {
Button(action.0, action: action.1)
}
}
}
}
/// A tiny dependency-free weekly bar chart.
struct WeeklyBars: View {
let points: [(label: String, count: Int)]
var body: some View {
let maxCount = max(points.map(\.count).max() ?? 0, 1)
HStack(alignment: .bottom, spacing: 10) {
ForEach(Array(points.enumerated()), id: \.offset) { _, point in
VStack(spacing: 6) {
Spacer(minLength: 0)
Text(point.count > 0 ? "\(point.count)" : "")
.font(.caption2).foregroundStyle(.secondary)
RoundedRectangle(cornerRadius: 5)
.fill(point.count > 0 ? GG.emerald : Color.secondary.opacity(0.18))
.frame(height: barHeight(point.count, maxCount: maxCount))
Text(point.label).font(.caption2).foregroundStyle(.secondary)
}
.frame(maxWidth: .infinity)
}
}
}
private func barHeight(_ count: Int, maxCount: Int) -> CGFloat {
let ratio = CGFloat(count) / CGFloat(maxCount)
return max(4, ratio * 78)
}
}