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 = .constant(false) 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 } } /// 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) } } 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") } } } /// 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 = [] 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.. 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) } }