588 lines
26 KiB
Swift
588 lines
26 KiB
Swift
import SwiftUI
|
|
import Foundation
|
|
import NucleicCore
|
|
|
|
/// The home dashboard shown when no chat is selected: a greeting, rollup stats,
|
|
/// and a GitHub-style activity grid of usage over the last several weeks.
|
|
struct HomeView: View {
|
|
@Environment(AppStore.self) private var store
|
|
@Environment(\.appPalette) private var palette
|
|
/// Persisted so the home chat bar reopens on the last project you picked.
|
|
@AppStorage("nucleic.home.lastProjectID") private var lastProjectID = ""
|
|
@AppStorage(StreakBadge.showKey) private var showStreak = true
|
|
@AppStorage(SubmitKeyMode.storageKey) private var submitKeyRaw = SubmitKeyMode.returnSends.rawValue
|
|
@State private var composerHeight = ChatInputField.restingHeight
|
|
@State private var showingAddProject = false
|
|
@State private var modelOverride: String?
|
|
@State private var effortOverride: String?
|
|
@State private var branches: [String] = []
|
|
@State private var selectedBranch: String?
|
|
@State private var useWorktree = true
|
|
/// nil until the user explicitly toggles, so the button reflects the app default.
|
|
@State private var autoOverride: Bool?
|
|
@State private var shipOverride: Bool?
|
|
/// Measured height of the activity column, mirrored onto the usage card so the two
|
|
/// sit at the same height (the card's content is shorter, so it stretches to match).
|
|
@State private var activityHeight: CGFloat = 0
|
|
/// Measured width of the activity/usage row, so it can reflow from side-by-side to
|
|
/// a vertical stack once there isn't room to hold both columns.
|
|
@State private var dashboardWidth: CGFloat = 0
|
|
/// Below this content width the usage card drops beneath the activity card instead
|
|
/// of being squeezed up over it.
|
|
private let dashboardStackThreshold: CGFloat = 600
|
|
|
|
/// Width reserved for the send button, so the bottom controls row can be
|
|
/// inset to line up with the text field's trailing edge.
|
|
private let sendButtonWidth: CGFloat = 28
|
|
|
|
private var submitMode: SubmitKeyMode { SubmitKeyMode(rawValue: submitKeyRaw) ?? .returnSends }
|
|
|
|
/// Consecutive days (ending today, or yesterday if today is still idle) that
|
|
/// have at least one message. Derived from the same activity data as the grid.
|
|
private var streak: Int {
|
|
let calendar = Calendar.current
|
|
var day = calendar.startOfDay(for: Date())
|
|
// Today not yet active doesn't break a streak that ran through yesterday.
|
|
if (store.activityByDay[day] ?? 0) == 0 {
|
|
day = calendar.date(byAdding: .day, value: -1, to: day)!
|
|
}
|
|
var count = 0
|
|
while (store.activityByDay[day] ?? 0) > 0 {
|
|
count += 1
|
|
day = calendar.date(byAdding: .day, value: -1, to: day)!
|
|
}
|
|
return count
|
|
}
|
|
|
|
private var firstName: String {
|
|
if let first = NSFullUserName().split(separator: " ").first, !first.isEmpty {
|
|
return String(first)
|
|
}
|
|
let login = NSUserName()
|
|
return login.isEmpty ? "there" : login
|
|
}
|
|
|
|
var body: some View {
|
|
VStack(spacing: 0) {
|
|
ScrollView {
|
|
VStack(alignment: .leading, spacing: 28) {
|
|
HStack(alignment: .center, spacing: 16) {
|
|
VStack(alignment: .leading, spacing: 4) {
|
|
Text("Hi, \(firstName)")
|
|
.font(.system(size: 36, weight: .bold))
|
|
Text(subtitle).foregroundStyle(.secondary)
|
|
}
|
|
if showStreak {
|
|
Spacer(minLength: 16)
|
|
StreakBadge(days: streak)
|
|
}
|
|
}
|
|
|
|
dashboardRow
|
|
|
|
HStack(spacing: 16) {
|
|
StatCard(value: store.dashboard.projects, label: "Projects", icon: "folder")
|
|
StatCard(value: store.dashboard.chats, label: "Chats", icon: "bubble.left.and.bubble.right")
|
|
StatCard(value: store.dashboard.activeChats, label: "Active", icon: "bolt")
|
|
StatCard(value: store.dashboard.messages, label: "Messages", icon: "paperplane")
|
|
}
|
|
|
|
TodoSection()
|
|
}
|
|
.padding(32)
|
|
.frame(maxWidth: .infinity, alignment: .leading)
|
|
}
|
|
.scrollContentBackground(.hidden)
|
|
Divider()
|
|
chatBar
|
|
}
|
|
.task { await store.loadDashboard() }
|
|
.task { await store.loadTodos() }
|
|
.task(id: selectedProject?.id) { await loadBranches() }
|
|
.sheet(isPresented: $showingAddProject) { AddProjectSheet() }
|
|
}
|
|
|
|
/// Activity and usage cards: side by side when there's room, but once the row
|
|
/// narrows past `dashboardStackThreshold` the usage card drops below the activity
|
|
/// card rather than being squeezed up over it.
|
|
private var dashboardRow: some View {
|
|
let stacked = dashboardWidth > 0 && dashboardWidth < dashboardStackThreshold
|
|
let layout = stacked
|
|
? AnyLayout(VStackLayout(alignment: .leading, spacing: 28))
|
|
: AnyLayout(HStackLayout(alignment: .top, spacing: 28))
|
|
return layout {
|
|
activityColumn
|
|
// Only mirror the activity height side-by-side; stacked, the card sizes
|
|
// to its own content at full width.
|
|
QuotaCard(matchHeight: stacked ? 0 : activityHeight)
|
|
.frame(maxWidth: .infinity, alignment: .top)
|
|
}
|
|
.frame(maxWidth: .infinity, alignment: .leading)
|
|
.background(GeometryReader { proxy in
|
|
Color.clear
|
|
.onAppear { dashboardWidth = proxy.size.width }
|
|
.onChange(of: proxy.size.width) { _, width in dashboardWidth = width }
|
|
})
|
|
}
|
|
|
|
private var activityColumn: some View {
|
|
VStack(alignment: .leading, spacing: 12) {
|
|
Text("Activity").font(.headline)
|
|
ActivityGrid(activityByDay: store.activityByDay)
|
|
HStack(spacing: 5) {
|
|
Text("Less").font(.caption2).foregroundStyle(.secondary)
|
|
ForEach(0..<4, id: \.self) { level in
|
|
RoundedRectangle(cornerRadius: 2)
|
|
.fill(palette.activity(level: level))
|
|
.frame(width: 11, height: 11)
|
|
}
|
|
Text("More").font(.caption2).foregroundStyle(.secondary)
|
|
}
|
|
}
|
|
.frame(maxWidth: .infinity, alignment: .leading)
|
|
.background(GeometryReader { proxy in
|
|
Color.clear
|
|
.onAppear { activityHeight = proxy.size.height }
|
|
.onChange(of: proxy.size.height) { _, height in activityHeight = height }
|
|
})
|
|
}
|
|
|
|
private func loadBranches() async {
|
|
guard let project = selectedProject else { branches = []; return }
|
|
branches = await store.branches(in: project)
|
|
if let current = selectedBranch, !branches.contains(current) { selectedBranch = nil }
|
|
}
|
|
|
|
/// Base branch a new chat would start from: the explicit pick, else the
|
|
/// project's default branch.
|
|
private var effectiveBranch: String {
|
|
selectedBranch ?? selectedProject?.defaultBranch.value ?? "main"
|
|
}
|
|
|
|
/// The project a new chat would start in: the last-picked project (persisted),
|
|
/// else the store's current/first project.
|
|
private var selectedProject: Project? {
|
|
if !lastProjectID.isEmpty, let project = store.project(ProjectID(rawValue: lastProjectID)) {
|
|
return project
|
|
}
|
|
return store.currentProject
|
|
}
|
|
|
|
/// A composer pinned to the bottom of the dashboard: pick a project (or add a
|
|
/// new one) and start a chat in it directly from home.
|
|
private var chatBar: some View {
|
|
@Bindable var store = store
|
|
return VStack(spacing: 8) {
|
|
HStack(spacing: 8) {
|
|
Menu {
|
|
ForEach(store.projects) { project in
|
|
Button(project.name) { lastProjectID = project.id.rawValue }
|
|
}
|
|
if !store.projects.isEmpty { Divider() }
|
|
Button("New Project…", systemImage: "folder.badge.plus") {
|
|
showingAddProject = true
|
|
}
|
|
} label: {
|
|
HStack(spacing: 4) {
|
|
Image(systemName: "folder")
|
|
Text(selectedProject?.name ?? "Select a project")
|
|
Image(systemName: "chevron.down").font(.caption2)
|
|
}
|
|
}
|
|
.menuStyle(.button)
|
|
.fixedSize()
|
|
branchMenu
|
|
Toggle(isOn: $useWorktree) {
|
|
Label("Worktree", systemImage: "arrow.triangle.branch")
|
|
}
|
|
.toggleStyle(.checkbox)
|
|
.help(useWorktree
|
|
? "Run on an isolated worktree branched from \(effectiveBranch)."
|
|
: "Run directly in the main checkout on \(effectiveBranch) — no isolation.")
|
|
Spacer()
|
|
}
|
|
.font(.callout)
|
|
HStack(alignment: .bottom, spacing: 8) {
|
|
ChatInputField(
|
|
text: $store.homeDraft, placeholder: "Start a new chat…",
|
|
submitMode: submitMode, isEnabled: selectedProject != nil,
|
|
height: $composerHeight, onSend: start)
|
|
.frame(height: composerHeight)
|
|
.padding(8)
|
|
.background(.quaternary.opacity(0.5), in: .rect(cornerRadius: 8))
|
|
.ultracodeGlow(active: isUltracode)
|
|
Button(action: start) {
|
|
SubmitKeyIcon(mode: submitMode)
|
|
}
|
|
.buttonStyle(.plain)
|
|
.frame(width: sendButtonWidth)
|
|
.help(submitMode.detail)
|
|
.disabled(selectedProject == nil || store.homeDraft.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty)
|
|
}
|
|
HStack(spacing: 8) {
|
|
autoToggle
|
|
// Shipping implies Auto, so the Ship toggle is only meaningful when Auto
|
|
// is on. It slides out from behind the Auto button when Auto is enabled
|
|
// and tucks back under it when Auto is turned off.
|
|
if effectiveAuto {
|
|
shipToggle
|
|
.transition(.move(edge: .leading).combined(with: .opacity))
|
|
}
|
|
Spacer()
|
|
modelMenu
|
|
effortMenu
|
|
}
|
|
.animation(.easeInOut(duration: 0.25), value: effectiveAuto)
|
|
.font(.callout)
|
|
.padding(.trailing, sendButtonWidth + 8)
|
|
}
|
|
.padding(12)
|
|
}
|
|
|
|
// Effective concrete values for the chat about to be started: the user's
|
|
// override, or the app default (never shows "Default").
|
|
private var effectiveModel: String { modelOverride ?? store.defaultModel ?? ModelCatalog.fallbackModel }
|
|
private var effectiveEffort: String { effortOverride ?? store.defaultEffort ?? ModelCatalog.fallbackEffort }
|
|
private var defaultModel: String { store.defaultModel ?? ModelCatalog.fallbackModel }
|
|
private var defaultEffort: String { store.defaultEffort ?? ModelCatalog.fallbackEffort }
|
|
|
|
/// Menu item: checkmark on the selected value, a "house" on the app default.
|
|
@ViewBuilder
|
|
private func choiceLabel(_ display: String, badge: String? = nil, isSelected: Bool, isDefault: Bool) -> some View {
|
|
let title = Text(display)
|
|
+ (badge.map { Text(" \($0)").foregroundColor(.secondary) } ?? Text(""))
|
|
+ (isDefault ? Text(" (default)") : Text(""))
|
|
if isSelected {
|
|
Label { title } icon: { Image(systemName: "checkmark") }
|
|
} else if isDefault {
|
|
Label { title } icon: { Image(systemName: "house") }
|
|
} else {
|
|
title
|
|
}
|
|
}
|
|
|
|
private var modelMenu: some View {
|
|
Menu {
|
|
ForEach(ModelCatalog.models, id: \.self) { sku in
|
|
Button { modelOverride = sku } label: {
|
|
choiceLabel(ModelCatalog.displayName(sku),
|
|
badge: ModelCatalog.contextBadge(for: sku),
|
|
isSelected: sku == effectiveModel, isDefault: sku == defaultModel)
|
|
}
|
|
}
|
|
} label: {
|
|
Text("Model: \(ModelCatalog.displayName(effectiveModel))")
|
|
+ (ModelCatalog.contextBadge(for: effectiveModel).map { Text(" \($0)").foregroundColor(.secondary) } ?? Text(""))
|
|
}
|
|
.menuStyle(.button)
|
|
.fixedSize()
|
|
.help("Model for the new chat")
|
|
}
|
|
|
|
/// Whether the chat about to start runs in ultracode (orchestration) mode.
|
|
private var isUltracode: Bool { ModelCatalog.isUltracode(effectiveEffort) }
|
|
|
|
private var effortMenu: some View {
|
|
Menu {
|
|
ForEach(ModelCatalog.efforts, id: \.self) { level in
|
|
Button { effortOverride = level } label: {
|
|
choiceLabel(level, isSelected: level == effectiveEffort, isDefault: level == defaultEffort)
|
|
}
|
|
}
|
|
// Ultracode sits below the API levels, set apart: it isn't an effort level but an
|
|
// orchestration mode (xhigh + standing consent to fan out to subagents).
|
|
Divider()
|
|
Button { effortOverride = ModelCatalog.ultracodeEffort } label: {
|
|
ultracodeMenuItem
|
|
}
|
|
} label: {
|
|
if isUltracode {
|
|
UltracodeEffortLabel()
|
|
} else {
|
|
Text("Effort: \(effectiveEffort)")
|
|
}
|
|
}
|
|
.menuStyle(.button)
|
|
.fixedSize()
|
|
.help(isUltracode ? ModelCatalog.ultracodeBlurb : "Reasoning effort for the new chat")
|
|
}
|
|
|
|
/// The Ultracode row in an effort menu: a sparkles glyph (a checkmark when it's the
|
|
/// selected mode), "Ultracode", and the "(default)" suffix when it's the app default.
|
|
@ViewBuilder
|
|
private var ultracodeMenuItem: some View {
|
|
let selected = isUltracode
|
|
let isDefault = ModelCatalog.isUltracode(defaultEffort)
|
|
Label {
|
|
Text("Ultracode") + (isDefault ? Text(" (default)") : Text(""))
|
|
} icon: {
|
|
Image(systemName: selected ? "checkmark" : UltracodeStyle.symbol)
|
|
}
|
|
}
|
|
|
|
/// Auto-approve mode for the chat about to be started; defaults to the app-wide
|
|
/// setting until the user explicitly toggles it. Mirrors the in-session button.
|
|
private var effectiveAuto: Bool { autoOverride ?? store.defaultAuto }
|
|
|
|
private var autoToggle: some View {
|
|
Button {
|
|
autoOverride = !effectiveAuto
|
|
} label: {
|
|
Label("Auto", systemImage: effectiveAuto ? "bolt.fill" : "bolt.slash")
|
|
.foregroundStyle(effectiveAuto ? palette.accent : Color.secondary)
|
|
}
|
|
.buttonStyle(.bordered)
|
|
.fixedSize()
|
|
.help(effectiveAuto
|
|
? "Auto-approve mode on: the new chat auto-approves safe actions; destructive ones still ask."
|
|
: "Manual approvals: every gated tool in the new chat asks first.")
|
|
}
|
|
|
|
/// Whether autoship can be enabled for the new chat: only Nucleic Control projects may
|
|
/// autoship (the hardened, sandboxed path). Mirrors the in-session gate.
|
|
private var shipAvailable: Bool { selectedProject?.isNucleicControlled == true }
|
|
|
|
/// Autoship for the chat about to be started; defaults to the app-wide setting, but is
|
|
/// clamped off for a non-Control project so the UI never promises a merge Core won't run.
|
|
private var effectiveShip: Bool { (shipOverride ?? store.defaultAutoShip) && shipAvailable }
|
|
|
|
private var shipToggle: some View {
|
|
Button {
|
|
guard shipAvailable else { return }
|
|
shipOverride = !effectiveShip
|
|
} label: {
|
|
Label("Merge", systemImage: effectiveShip ? "shippingbox.fill" : "shippingbox")
|
|
.foregroundStyle(effectiveShip ? palette.accent : Color.secondary)
|
|
}
|
|
.buttonStyle(.bordered)
|
|
.fixedSize()
|
|
.disabled(!shipAvailable)
|
|
.help(shipAvailable
|
|
? (effectiveShip
|
|
? "Autoship on: when the new chat's agent finishes, squash-merge its branch into the destination branch via the merge queue (keeps Auto on)."
|
|
: "Autoship off: finished work waits for a manual merge.")
|
|
: "Autoship requires Nucleic Control — clone or move this project under Nucleic Control to enable it.")
|
|
// macOS suppresses .help tooltips on disabled controls, so overlay an enabled,
|
|
// transparent hit area to carry the explanation when Merge is grayed out off-control.
|
|
.overlay {
|
|
if !shipAvailable {
|
|
Color.clear
|
|
.contentShape(Rectangle())
|
|
.help("Autoship/automerge is only available for Nucleic Control projects — clone or move this project under Nucleic Control to enable it.")
|
|
}
|
|
}
|
|
}
|
|
|
|
private var branchMenu: some View {
|
|
Menu {
|
|
ForEach(branches, id: \.self) { name in
|
|
Button { selectedBranch = name } label: {
|
|
choiceLabel(name, isSelected: name == effectiveBranch,
|
|
isDefault: name == selectedProject?.defaultBranch.value)
|
|
}
|
|
}
|
|
} label: {
|
|
// Mirror the project selector to its left (icon + name + chevron) so the
|
|
// two read as the same control; a plain Text label rendered as a near-
|
|
// invisible white field against the chat bar.
|
|
HStack(spacing: 4) {
|
|
Image(systemName: "arrow.triangle.branch")
|
|
Text(effectiveBranch)
|
|
Image(systemName: "chevron.down").font(.caption2)
|
|
}
|
|
}
|
|
.menuStyle(.button)
|
|
.fixedSize()
|
|
.disabled(branches.isEmpty)
|
|
.help("Base branch the new chat starts from")
|
|
}
|
|
|
|
private func start() {
|
|
let trimmed = store.homeDraft.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
guard let project = selectedProject, !trimmed.isEmpty else { return }
|
|
store.homeDraft = ""
|
|
let text = SingularityPreparation.prepare(trimmed)
|
|
let base = GitRef(effectiveBranch)
|
|
let worktree = useWorktree
|
|
let auto = effectiveAuto
|
|
let ship = effectiveShip
|
|
Task {
|
|
await store.startChat(
|
|
in: project, message: text, model: effectiveModel, effort: effectiveEffort,
|
|
base: base, useWorktree: worktree, auto: auto, autoShip: ship)
|
|
}
|
|
}
|
|
|
|
private var subtitle: String {
|
|
let stats = store.dashboard
|
|
if stats.chats == 0 {
|
|
return "No chats yet — pick a project below and start one from the chat bar."
|
|
}
|
|
let projectWord = stats.projects == 1 ? "project" : "projects"
|
|
return "\(stats.activeChats) active of \(stats.chats) chats across \(stats.projects) \(projectWord)."
|
|
}
|
|
}
|
|
|
|
/// A day-streak indicator aligned to the greeting: a lightning bolt and the run length.
|
|
/// The bolt charges hotter — bigger, brighter, with a stronger electric glow — as
|
|
/// the streak lengthens. Dims to grey when the streak is broken (0 days).
|
|
struct StreakBadge: View {
|
|
static let showKey = "nucleic.home.showStreak"
|
|
let days: Int
|
|
|
|
/// Drives the repeating charge pulse; toggled on appear so the animation runs forever.
|
|
@State private var charged = false
|
|
|
|
/// 0 for a one-day streak, ramping to 1 around three weeks — how charged to glow.
|
|
private var intensity: Double { min(1, Double(max(0, days - 1)) / 20) }
|
|
|
|
/// Electric gradient from a hot white tip down to a deep electric blue base. Short
|
|
/// streaks read as a muted steel-blue spark; long ones as a vivid cyan→blue arc.
|
|
private var boltGradient: LinearGradient {
|
|
func lerp(_ lo: Double, _ hi: Double) -> Double { lo + (hi - lo) * intensity }
|
|
let tip = Color(hue: lerp(0.55, 0.50), saturation: lerp(0.40, 0.70), brightness: 1.0)
|
|
let mid = Color(hue: lerp(0.58, 0.54), saturation: lerp(0.70, 0.95), brightness: 1.0)
|
|
let base = Color(hue: lerp(0.62, 0.60), saturation: lerp(0.85, 1.0), brightness: lerp(0.85, 1.0))
|
|
return LinearGradient(colors: [tip, mid, base], startPoint: .top, endPoint: .bottom)
|
|
}
|
|
|
|
/// One pulse cycle: a lazy ~1.5s throb for a fresh streak, accelerating to a frantic
|
|
/// ~0.35s flicker once fully charged — the higher the streak, the more electric.
|
|
private var pulsePeriod: Double { 1.5 - 1.15 * intensity }
|
|
|
|
var body: some View {
|
|
let active = days > 0
|
|
// Pulse amplitudes scale with the streak: a barely-there flutter early, a hard
|
|
// strobe of scale, brightness, rotation and glow by the time it's fully charged.
|
|
let pulsing = active && charged
|
|
let scaleAmp = 0.04 + 0.16 * intensity
|
|
let tiltAmp = 1.5 + 6.0 * intensity // degrees of jitter
|
|
// The bolt never fades back to base: it rests brighter than base and pulses
|
|
// brighter still, so the animation only ever lights it up further.
|
|
let restBright = active ? 0.08 + 0.10 * intensity : 0
|
|
let peakBright = active ? 0.22 + 0.45 * intensity : 0
|
|
// Glow likewise rests boosted and crests stronger — never collapsing to base.
|
|
let glowFactor = active ? (pulsing ? 1.4 + 1.5 * intensity : 1.2 + 0.4 * intensity) : 1
|
|
return HStack(spacing: 8) {
|
|
Image(systemName: "bolt.fill")
|
|
// Grows from ~22pt to ~32pt as the charge intensifies.
|
|
.font(.system(size: 22 + 10 * intensity))
|
|
.foregroundStyle(active ? AnyShapeStyle(boltGradient) : AnyShapeStyle(Color.secondary))
|
|
.brightness(pulsing ? peakBright : restBright)
|
|
.scaleEffect(pulsing ? 1 + scaleAmp : 1)
|
|
.rotationEffect(.degrees(pulsing ? tiltAmp : 0))
|
|
.shadow(color: active ? Color(hue: 0.56, saturation: 0.85, brightness: 1.0).opacity((0.30 + 0.50 * intensity) * glowFactor) : .clear,
|
|
radius: active ? (1 + 7 * intensity) * glowFactor : 0)
|
|
.animation(active ? .easeInOut(duration: pulsePeriod).repeatForever(autoreverses: true) : .default,
|
|
value: pulsing)
|
|
.onAppear { charged = true }
|
|
HStack(alignment: .firstTextBaseline, spacing: 6) {
|
|
Text("\(days)")
|
|
.font(.system(size: 26, weight: .semibold).monospacedDigit())
|
|
Text("day streak")
|
|
.font(.system(size: 26, weight: .semibold).monospacedDigit())
|
|
.foregroundStyle(.secondary)
|
|
}
|
|
}
|
|
.opacity(active ? 1 : 0.55)
|
|
.padding(.horizontal, 16)
|
|
.padding(.vertical, 10)
|
|
.background(AppTheme.surface, in: .rect(cornerRadius: 12))
|
|
.overlay(RoundedRectangle(cornerRadius: 12).strokeBorder(AppTheme.hairline, lineWidth: 1))
|
|
.help(active
|
|
? "\(days)-day activity streak — consecutive days with at least one message."
|
|
: "No active streak — send a message today to start one.")
|
|
}
|
|
}
|
|
|
|
private struct StatCard: View {
|
|
let value: Int
|
|
let label: String
|
|
let icon: String
|
|
|
|
var body: some View {
|
|
VStack(alignment: .leading, spacing: 4) {
|
|
Image(systemName: icon).foregroundStyle(.secondary)
|
|
Text("\(value)").font(.system(size: 26, weight: .semibold).monospacedDigit())
|
|
Text(label).font(.caption).foregroundStyle(.secondary)
|
|
}
|
|
.frame(maxWidth: .infinity, alignment: .leading)
|
|
.padding(.horizontal, 16)
|
|
.padding(.vertical, 10)
|
|
.background(AppTheme.surface, in: .rect(cornerRadius: 12))
|
|
.overlay(RoundedRectangle(cornerRadius: 12).strokeBorder(AppTheme.hairline, lineWidth: 1))
|
|
}
|
|
}
|
|
|
|
/// Squares for the last `weeks` weeks, columns = weeks (old → new), rows = Sun…Sat,
|
|
/// shaded by that day's message count.
|
|
struct ActivityGrid: View {
|
|
@Environment(\.appPalette) private var palette
|
|
let activityByDay: [Date: Int]
|
|
private let cell: CGFloat = 16
|
|
private let gap: CGFloat = 3
|
|
private let minWeeks = 16
|
|
|
|
var body: some View {
|
|
GeometryReader { geo in
|
|
let weeks = max(minWeeks, Int((geo.size.width + gap) / (cell + gap)))
|
|
grid(weeks: weeks)
|
|
}
|
|
.frame(height: cell * 7 + gap * 6)
|
|
}
|
|
|
|
private func grid(weeks: Int) -> some View {
|
|
let calendar = Calendar.current
|
|
let today = calendar.startOfDay(for: Date())
|
|
let weekday = calendar.component(.weekday, from: today) // 1 = Sun … 7 = Sat
|
|
let startOfThisWeek = calendar.date(byAdding: .day, value: -(weekday - 1), to: today)!
|
|
let gridStart = calendar.date(byAdding: .day, value: -7 * (weeks - 1), to: startOfThisWeek)!
|
|
|
|
return HStack(alignment: .top, spacing: gap) {
|
|
ForEach(0..<weeks, id: \.self) { column in
|
|
VStack(spacing: gap) {
|
|
ForEach(0..<7, id: \.self) { row in
|
|
let date = calendar.date(byAdding: .day, value: column * 7 + row, to: gridStart)!
|
|
cellView(date: date, today: today)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
.frame(maxWidth: .infinity, alignment: .leading)
|
|
}
|
|
|
|
private func cellView(date: Date, today: Date) -> some View {
|
|
let isFuture = date > today
|
|
let count = activityByDay[date] ?? 0
|
|
return RoundedRectangle(cornerRadius: 2)
|
|
.fill(isFuture ? Color.clear : palette.activity(level: Self.level(count)))
|
|
.frame(width: cell, height: cell)
|
|
.help(isFuture ? "" : "\(count) message\(count == 1 ? "" : "s") · \(Self.dayLabel(date))")
|
|
}
|
|
|
|
static func level(_ count: Int) -> Int {
|
|
switch count {
|
|
case 0: return 0
|
|
case 1...2: return 1
|
|
case 3...5: return 2
|
|
default: return 3
|
|
}
|
|
}
|
|
|
|
static func color(level: Int) -> Color {
|
|
switch level {
|
|
case 0: return Color.secondary.opacity(0.15)
|
|
case 1: return Color.green.opacity(0.4)
|
|
case 2: return Color.green.opacity(0.7)
|
|
default: return Color.green
|
|
}
|
|
}
|
|
|
|
private static let formatter: DateFormatter = {
|
|
let formatter = DateFormatter()
|
|
formatter.dateStyle = .medium
|
|
return formatter
|
|
}()
|
|
static func dayLabel(_ date: Date) -> String { formatter.string(from: date) }
|
|
}
|