mirror of
https://github.com/BigMarc/grammargem.git
synced 2026-10-13 17:01:55 +02:00
- 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>
133 lines
5.4 KiB
Swift
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)
|
|
}
|
|
}
|