M4+: mirror the desktop design language onto mobile (Home/Projects/To-Dos/Sessions)
Design system: Palette (teal accent + the Mac's categorical status colors, status→color refined by turn disposition), StatusStyle glyphs/labels, a card surface modifier, app-wide teal tint. Fixes the reported bug: Sessions now hides archived chats and uses disposition to split NEEDS YOU from DONE (a finished-the-work turn no longer shows as needing you). Rows gain status dots, favorite star, approval-count badge, diffstat; swipe to favorite/archive/delete. New surfaces, full control-scope parity with the Mac: - Home: greeting + day-streak flame, GitHub-style activity grid, stat cards, quick to-dos, and a start-chat composer (project picker + Auto). - Projects: per-project session counts → project detail with a scoped composer. - To-dos: capture, group by project, complete/dispatch/delete. - Session detail: a control menu (rename, favorite, interrupt, integrate merge/squash/rebase, archive, delete). RemoteStore handles the dashboard event + exposes all control intents; claims control scope. A NUCLEIC_DEMO/NUCLEIC_TAB env seam seeds mock state for offline UI preview. iOS app builds for the iOS 27 simulator; Home + Sessions verified rendering via screenshots. Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
This commit is contained in:
@@ -0,0 +1,169 @@
|
||||
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 }
|
||||
private var activityByDay: [Date: Int] {
|
||||
Dictionary(store.dashboard.activity.map { (Calendar.current.startOfDay(for: $0.day), $0.count) },
|
||||
uniquingKeysWith: +)
|
||||
}
|
||||
|
||||
private var streak: Int {
|
||||
let cal = Calendar.current
|
||||
var day = cal.startOfDay(for: Date())
|
||||
if (activityByDay[day] ?? 0) == 0 { day = cal.date(byAdding: .day, value: -1, to: day)! }
|
||||
var count = 0
|
||||
while (activityByDay[day] ?? 0) > 0 {
|
||||
count += 1
|
||||
day = cal.date(byAdding: .day, value: -1, to: day)!
|
||||
}
|
||||
return count
|
||||
}
|
||||
|
||||
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 {
|
||||
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()
|
||||
if streak > 0 { StreakBadge(days: streak) }
|
||||
}
|
||||
|
||||
statCards
|
||||
|
||||
VStack(alignment: .leading, spacing: 10) {
|
||||
Text("Activity").font(.headline)
|
||||
ActivityGrid(activityByDay: activityByDay)
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.card()
|
||||
|
||||
QuickTodos()
|
||||
|
||||
StartChatComposer()
|
||||
}
|
||||
.padding()
|
||||
}
|
||||
.navigationTitle("Home")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.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: counts.activeChats, label: "Active", icon: "bolt")
|
||||
StatCard(value: counts.messages, label: "Messages", icon: "paperplane")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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(Palette.accent)
|
||||
Text("\(value)").font(.title2.weight(.semibold).monospacedDigit())
|
||||
Text(label).font(.caption).foregroundStyle(.secondary)
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.card(padding: 12)
|
||||
}
|
||||
}
|
||||
|
||||
/// A flame whose heat grows with the streak (mirrors the Mac StreakBadge).
|
||||
struct StreakBadge: View {
|
||||
let days: Int
|
||||
private var intensity: Double { min(1, Double(max(0, days - 1)) / 20) }
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: 6) {
|
||||
Image(systemName: "flame.fill")
|
||||
.font(.system(size: 18 + 8 * intensity))
|
||||
.foregroundStyle(
|
||||
LinearGradient(colors: [
|
||||
Color(hue: 0.03, saturation: 0.9, brightness: 1),
|
||||
Color(hue: 0.10 + 0.04 * intensity, saturation: 0.9, brightness: 1),
|
||||
], startPoint: .top, endPoint: .bottom))
|
||||
VStack(alignment: .leading, spacing: -2) {
|
||||
Text("\(days)").font(.headline.monospacedDigit())
|
||||
Text("day streak").font(.caption2).foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
.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]
|
||||
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)!
|
||||
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)!
|
||||
RoundedRectangle(cornerRadius: 2)
|
||||
.fill(date > today ? Color.clear : Palette.activity(level: Self.level(activityByDay[date] ?? 0)))
|
||||
.frame(width: cell, height: cell)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
|
||||
static func level(_ count: Int) -> Int {
|
||||
switch count {
|
||||
case 0: 0
|
||||
case 1...2: 1
|
||||
case 3...5: 2
|
||||
default: 3
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user