Files
nucleic-remote-ios/NucleicRemote/NucleicRemote/Views/HomeView.swift
T
abkslmandClaude Opus 4.8 8c115e3af1 iPad: lead the dashboard with a "Needs you" section
The Home dashboard only surfaced running sessions and rollup counts, so chats
blocking on you (an approval or your next prompt) were visible only as sidebar
washes. Add a "Needs you" card pinned above the stat cards (amber header +
border) listing sessions where status.needsYou(disposition) holds, each tapping
into its detail. Mirrors the Mac home's "Needs attention" lead. Shared by
iPhone and iPad; hidden when nothing's waiting. Verified in the iPad simulator.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-07-04 15:33:25 -07:00

321 lines
14 KiB
Swift

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
/// Set by the iPad `Cmd+N` shortcut to focus the start-chat composer below (consumed there).
var focusNewChat: Binding<Bool> = .constant(false)
private var counts: DashboardCounts { store.dashboard.counts }
/// Sessions blocking on a human — the dashboard's "which session needs me?" answer (the Mac
/// home §2). Uses turn disposition so a finished-the-work chat doesn't count; archived hidden.
private var needsAttention: [WireSessionSummary] {
store.liveSessions
.filter { $0.status.needsYou($0.disposition) }
.sorted { $0.updatedAt > $1.updatedAt }
}
/// 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 }
}
/// Outstanding to-dos — everything not yet done (open + dispatched), matching the count the
/// To-dos tab lists. The Mac surfaces this elsewhere on its home; the phone gets a stat card.
private var openTodoCount: Int { store.dashboard.todos.filter { $0.status != .done }.count }
private var activityByDay: [Date: Int] {
Dictionary(store.dashboard.activity.map { (Calendar.current.startOfDay(for: $0.day), $0.count) },
uniquingKeysWith: +)
}
/// 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: +)
}
/// 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 {
// 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()
VStack(alignment: .trailing, spacing: 8) {
if streak.streak > 0 { StreakBadge(days: streak.streak, freezes: streak.availableFreezes) }
// The Mac's service-status pill, fed by the dashboard projection.
StatusFeedPill(feeds: store.dashboard.statusFeeds)
}
}
// "Which session needs me?" — pinned above everything when a chat is blocking
// on you (an approval or your next prompt), so it leads the dashboard.
if !needsAttention.isEmpty { NeedsAttentionSection(sessions: needsAttention) }
statCards
// The Mac's usage gauges (5-hour / weekly windows); hidden when the host
// doesn't project usage.
QuotaCard(usage: store.dashboard.usage)
VStack(alignment: .leading, spacing: 10) {
Text("Activity").font(.headline)
ActivityGrid(
activityByDay: activityByDay, tokensByDay: tokensByDay,
frozenDays: streak.frozenDays)
}
.frame(maxWidth: .infinity, alignment: .leading)
.card()
if !running.isEmpty { InProgressSessions(sessions: running) }
QuickTodos()
StartChatComposer(focusNewChat: focusNewChat)
}
.padding()
// Cap + center the dashboard on a wide iPad so it reads like the Mac home rather
// than a stretched phone; a no-op at phone width.
.readableColumn()
}
.navigationTitle("Home")
.navigationBarTitleDisplayMode(.inline)
.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 {
// Flow the cards into two rows of three so each gets ~a third of the width rather than a
// sixth — enough room for labels like "In progress" and full token counts to render cleanly
// instead of wrapping or truncating.
LazyVGrid(columns: Array(repeating: GridItem(.flexible(), spacing: 12), count: 3), 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: openTodoCount, label: "To-dos", icon: "checklist")
StatCard(value: counts.messages, label: "Messages", icon: "paperplane")
StatCard(values: TokenCount.adaptiveCandidates(counts.tokens), label: "Tokens", icon: "text.word.spacing")
}
}
}
/// The dashboard's attention list: chats blocking on you (an approval or your next prompt),
/// pinned at the very top. Tapping a row drops into that session. Header tinted in the attention
/// colour so it reads as urgent, matching the Mac home's "Needs attention" lead.
private struct NeedsAttentionSection: View {
let sessions: [WireSessionSummary]
var body: some View {
VStack(alignment: .leading, spacing: 10) {
HStack {
Label("Needs you", systemImage: "exclamationmark.triangle.fill")
.font(.headline).foregroundStyle(Palette.attention)
Spacer()
Text("\(sessions.count)")
.font(.subheadline.weight(.semibold)).foregroundStyle(Palette.attention)
}
ForEach(sessions.prefix(6), id: \.sessionID) { summary in
NavigationLink {
SessionDetailView(sessionID: summary.sessionID)
} label: {
SessionRow(summary: summary)
}
.buttonStyle(.plain)
.hoverEffect(.highlight)
}
}
.frame(maxWidth: .infinity, alignment: .leading)
.card()
.overlay(
RoundedRectangle(cornerRadius: 14)
.strokeBorder(Palette.attention.opacity(0.35), lineWidth: 1))
}
}
/// 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)
.hoverEffect(.highlight)
}
}
.frame(maxWidth: .infinity, alignment: .leading)
.card()
}
}
private struct StatCard: View {
/// Candidate renderings of the value, widest first: the card shows the fullest one its width
/// allows (via `ViewThatFits`) and falls back to the narrowest. A single entry for the plain
/// integer cards; several for the token card, so a wide card shows more digits than "1.6B".
let values: [String]
let label: String
let icon: String
init(value: Int, label: String, icon: String) {
self.init(values: ["\(value)"], label: label, icon: icon)
}
init(value: String, label: String, icon: String) {
self.init(values: [value], label: label, icon: icon)
}
init(values: [String], label: String, icon: String) {
self.values = values.isEmpty ? [""] : values
self.label = label
self.icon = icon
}
var body: some View {
VStack(alignment: .leading, spacing: 4) {
Image(systemName: icon).foregroundStyle(Palette.accent)
// Show the fullest value the card's width fits, shrinking toward the compact form when
// narrow; ViewThatFits renders the last candidate if none fit, so it never overflows.
ViewThatFits(in: .horizontal) {
ForEach(Array(values.enumerated()), id: \.offset) { _, value in
Text(value).font(.title2.weight(.semibold).monospacedDigit())
.lineLimit(1)
}
}
Text(label).font(.caption).foregroundStyle(.secondary)
}
.frame(maxWidth: .infinity, alignment: .leading)
.card(padding: 12)
}
}
/// A lightning bolt whose charge grows with the streak (mirrors the Mac StreakBadge).
struct StreakBadge: View {
let days: Int
/// 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) {
Image(systemName: "bolt.fill")
.font(.system(size: 18 + 8 * intensity))
.foregroundStyle(
LinearGradient(colors: [
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))
.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)
}
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]
/// 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] = [:]
/// 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)!
// Brightness relative to *this user's* activity, outliers excluded so a few marathon
// days don't crush the everyday range. Shades by tokens-per-day, falling back to message
// count for days without recorded usage. Computed once for the whole grid (mirrors Mac).
let shading = ActivityShading(tokensByDay: tokensByDay, messagesByDay: 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)!
let isFuture = date > today
let isFrozen = !isFuture && frozenDays.contains(date)
RoundedRectangle(cornerRadius: 2)
.fill(isFuture ? Color.clear
: isFrozen ? Palette.frozen
: Palette.activity(intensity: shading.intensity(
tokens: tokensByDay[date] ?? 0, messages: activityByDay[date] ?? 0)))
.frame(width: cell, height: cell)
}
}
}
}
.frame(maxWidth: .infinity, alignment: .leading)
}
}