Files
nucleic-remote-ios/NucleicRemote/NucleicRemote/Views/HomeView.swift
T

242 lines
9.9 KiB
Swift
Raw Normal View History

import SwiftUI
import NucleicProtocol
/// The mobile home dashboard — the phone's mirror of the Mac home (UX_MACOS §2): a greeting +
/// streak, a GitHub-style activity grid, rollup stat cards, a quick to-do peek, and a
/// start-chat composer (control scope).
struct HomeView: View {
@EnvironmentObject var store: RemoteStore
private var counts: DashboardCounts { store.dashboard.counts }
/// Sessions actively working a turn — drives both the "In progress" stat card and the
/// live list pinned above the to-dos.
private var running: [WireSessionSummary] {
store.liveSessions.filter { $0.status == .running }.sorted { $0.updatedAt > $1.updatedAt }
}
private var activityByDay: [Date: Int] {
Dictionary(store.dashboard.activity.map { (Calendar.current.startOfDay(for: $0.day), $0.count) },
uniquingKeysWith: +)
2026-06-27 19:14:40 -07:00
}
/// Tokens used per day — the activity grid's primary intensity signal (falls back to message
/// count for days without recorded usage). Mirrors the Mac home.
private var tokensByDay: [Date: Int] {
Dictionary(store.dashboard.activity.map { (Calendar.current.startOfDay(for: $0.day), $0.tokens) },
uniquingKeysWith: +)
}
2026-06-26 01:18:03 -07:00
/// Current activity streak with streak freezes folded in — the run of consecutive active
/// days, where a banked freeze (one earned per five days) bridges a missed day. Shared with
/// the Mac via `StreakState`, so both devices report the same streak and frozen days.
private var streakState: StreakState {
StreakState.compute(activityByDay: activityByDay)
}
private var greeting: String {
switch Calendar.current.component(.hour, from: Date()) {
case 5..<12: "Good morning"
case 12..<17: "Good afternoon"
case 17..<22: "Good evening"
default: "Hello"
}
}
var body: some View {
2026-06-26 01:18:11 -07:00
// Computed once and threaded down so the badge and grid agree on the streak and which
// days a freeze bridged.
let streak = streakState
return NavigationStack {
ScrollView {
VStack(alignment: .leading, spacing: 22) {
HStack(alignment: .top) {
VStack(alignment: .leading, spacing: 2) {
Text(greeting).font(.largeTitle.bold())
Text(subtitle).font(.subheadline).foregroundStyle(.secondary)
}
Spacer()
2026-06-26 01:18:11 -07:00
if streak.streak > 0 { StreakBadge(days: streak.streak, freezes: streak.availableFreezes) }
}
statCards
VStack(alignment: .leading, spacing: 10) {
Text("Activity").font(.headline)
2026-06-27 19:14:44 -07:00
ActivityGrid(
activityByDay: activityByDay, tokensByDay: tokensByDay,
frozenDays: streak.frozenDays)
}
.frame(maxWidth: .infinity, alignment: .leading)
.card()
if !running.isEmpty { InProgressSessions(sessions: running) }
QuickTodos()
StartChatComposer()
}
.padding()
}
.navigationTitle("Home")
.navigationBarTitleDisplayMode(.inline)
2026-06-26 20:23:14 -07:00
.scrollDismissesKeyboard(.interactively)
.refreshable { store.refreshSessions() }
.safeAreaInset(edge: .bottom) { ConnectionChip().padding(.bottom, 8) }
}
}
private var subtitle: String {
if counts.chats == 0 { return "Start a chat below to get going." }
let p = counts.projects == 1 ? "project" : "projects"
return "\(counts.activeChats) active of \(counts.chats) chats · \(counts.projects) \(p)"
}
private var statCards: some View {
HStack(spacing: 12) {
StatCard(value: counts.projects, label: "Projects", icon: "folder")
StatCard(value: running.count, label: "In progress", icon: "circle.lefthalf.filled")
StatCard(value: counts.activeChats, label: "Active", icon: "bolt")
StatCard(value: counts.messages, label: "Messages", icon: "paperplane")
2026-06-27 19:14:53 -07:00
StatCard(value: TokenCount.abbreviated(counts.tokens), label: "Tokens", icon: "number")
}
}
}
/// Live peek at sessions actively running a turn, pinned above the to-do list on Home. Tapping
/// a row drops into the session transcript.
private struct InProgressSessions: View {
let sessions: [WireSessionSummary]
var body: some View {
VStack(alignment: .leading, spacing: 10) {
HStack {
Text("In progress").font(.headline)
Spacer()
Text("\(sessions.count)").foregroundStyle(.secondary)
}
ForEach(sessions.prefix(4), id: \.sessionID) { summary in
NavigationLink {
SessionDetailView(sessionID: summary.sessionID)
} label: {
SessionRow(summary: summary)
}
.buttonStyle(.plain)
}
}
.frame(maxWidth: .infinity, alignment: .leading)
.card()
}
}
private struct StatCard: View {
2026-06-27 19:15:00 -07:00
let value: String
let label: String
let icon: String
2026-06-27 19:15:00 -07:00
init(value: Int, label: String, icon: String) {
self.value = "\(value)"
self.label = label
self.icon = icon
}
init(value: String, label: String, icon: String) {
self.value = value
self.label = label
self.icon = icon
}
var body: some View {
VStack(alignment: .leading, spacing: 4) {
Image(systemName: icon).foregroundStyle(Palette.accent)
2026-06-27 19:15:00 -07:00
Text(value).font(.title2.weight(.semibold).monospacedDigit())
Text(label).font(.caption).foregroundStyle(.secondary)
}
.frame(maxWidth: .infinity, alignment: .leading)
.card(padding: 12)
}
}
2026-06-15 01:23:09 -07:00
/// A lightning bolt whose charge grows with the streak (mirrors the Mac StreakBadge).
struct StreakBadge: View {
let days: Int
2026-06-26 01:18:24 -07:00
/// Streak freezes in reserve (one per five days), shown as a small snowflake tally.
var freezes: Int = 0
private var intensity: Double { min(1, Double(max(0, days - 1)) / 20) }
var body: some View {
HStack(spacing: 6) {
2026-06-15 01:23:09 -07:00
Image(systemName: "bolt.fill")
.font(.system(size: 18 + 8 * intensity))
.foregroundStyle(
LinearGradient(colors: [
2026-06-15 01:23:09 -07:00
Color(hue: 0.55 - 0.05 * intensity, saturation: 0.5 + 0.2 * intensity, brightness: 1),
Color(hue: 0.60, saturation: 0.85 + 0.15 * intensity, brightness: 1),
], startPoint: .top, endPoint: .bottom))
2026-06-15 01:23:09 -07:00
.shadow(color: Color(hue: 0.56, saturation: 0.85, brightness: 1).opacity(0.3 + 0.5 * intensity),
radius: 1 + 6 * intensity)
VStack(alignment: .leading, spacing: -2) {
Text("\(days)").font(.headline.monospacedDigit())
Text("day streak").font(.caption2).foregroundStyle(.secondary)
}
2026-06-26 01:18:24 -07:00
if freezes > 0 {
HStack(spacing: 2) {
Image(systemName: "snowflake")
Text("\(freezes)").monospacedDigit()
}
.font(.caption.weight(.semibold))
.foregroundStyle(Palette.frozen)
}
}
.padding(.horizontal, 12).padding(.vertical, 8)
.background(Color(.secondarySystemBackground), in: Capsule())
}
}
/// GitHub-style activity grid: columns = weeks (old→new), rows = Sun…Sat.
struct ActivityGrid: View {
let activityByDay: [Date: Int]
2026-06-27 19:15:07 -07:00
/// Tokens used per day — the primary intensity signal; days absent here fall back to their
/// message count (mirrors the Mac grid via `ActivityShading`).
var tokensByDay: [Date: Int] = [:]
2026-06-26 01:18:29 -07:00
/// Missed days a streak freeze kept alive — drawn in the frozen blue rather than the
/// empty-day gray (mirrors the Mac grid).
var frozenDays: Set<Date> = []
private let cell: CGFloat = 13
private let gap: CGFloat = 3
var body: some View {
GeometryReader { geo in
let weeks = max(8, Int((geo.size.width + gap) / (cell + gap)))
grid(weeks: weeks)
}
.frame(height: cell * 7 + gap * 6)
}
private func grid(weeks: Int) -> some View {
let cal = Calendar.current
let today = cal.startOfDay(for: Date())
let weekday = cal.component(.weekday, from: today)
let startOfWeek = cal.date(byAdding: .day, value: -(weekday - 1), to: today)!
let gridStart = cal.date(byAdding: .day, value: -7 * (weeks - 1), to: startOfWeek)!
2026-06-27 15:46:04 -07:00
// Brightness relative to *this user's* activity, outliers excluded so a few marathon
// days don't crush the everyday range. Computed once for the whole grid (mirrors Mac).
let scale = ActivityScale(activityByDay: activityByDay)
return HStack(alignment: .top, spacing: gap) {
ForEach(0..<weeks, id: \.self) { col in
VStack(spacing: gap) {
ForEach(0..<7, id: \.self) { row in
let date = cal.date(byAdding: .day, value: col * 7 + row, to: gridStart)!
2026-06-26 01:18:36 -07:00
let isFuture = date > today
let isFrozen = !isFuture && frozenDays.contains(date)
RoundedRectangle(cornerRadius: 2)
2026-06-26 01:18:36 -07:00
.fill(isFuture ? Color.clear
: isFrozen ? Palette.frozen
2026-06-27 15:46:04 -07:00
: Palette.activity(intensity: scale.intensity(for: activityByDay[date] ?? 0)))
.frame(width: cell, height: cell)
}
}
}
}
.frame(maxWidth: .infinity, alignment: .leading)
}
}