From 8db01ac7039d370403060236deb2741790ce4df6 Mon Sep 17 00:00:00 2001 From: Nucleic Date: Fri, 26 Jun 2026 01:29:27 -0700 Subject: [PATCH] nvrsion: promote trunk to dev Nucleic-Promote: 1 --- .../NucleicRemote/Views/HomeView.swift | 43 +++++++++++++------ NucleicRemote/NucleicRemote/Views/Theme.swift | 3 ++ 2 files changed, 32 insertions(+), 14 deletions(-) diff --git a/NucleicRemote/NucleicRemote/Views/HomeView.swift b/NucleicRemote/NucleicRemote/Views/HomeView.swift index 53aaa5e..2259630 100644 --- a/NucleicRemote/NucleicRemote/Views/HomeView.swift +++ b/NucleicRemote/NucleicRemote/Views/HomeView.swift @@ -19,16 +19,11 @@ struct HomeView: View { 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 + /// 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 { @@ -41,7 +36,10 @@ struct HomeView: View { } var body: some View { - NavigationStack { + // 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) { @@ -50,14 +48,14 @@ struct HomeView: View { Text(subtitle).font(.subheadline).foregroundStyle(.secondary) } Spacer() - if streak > 0 { StreakBadge(days: streak) } + if streak.streak > 0 { StreakBadge(days: streak.streak, freezes: streak.availableFreezes) } } statCards VStack(alignment: .leading, spacing: 10) { Text("Activity").font(.headline) - ActivityGrid(activityByDay: activityByDay) + ActivityGrid(activityByDay: activityByDay, frozenDays: streak.frozenDays) } .frame(maxWidth: .infinity, alignment: .leading) .card() @@ -137,6 +135,8 @@ private struct StatCard: View { /// 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 { @@ -154,6 +154,14 @@ struct StreakBadge: View { 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()) @@ -163,6 +171,9 @@ struct StreakBadge: View { /// GitHub-style activity grid: columns = weeks (old→new), rows = Sun…Sat. struct ActivityGrid: View { let activityByDay: [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 @@ -185,8 +196,12 @@ struct ActivityGrid: View { 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(date > today ? Color.clear : Palette.activity(level: Self.level(activityByDay[date] ?? 0))) + .fill(isFuture ? Color.clear + : isFrozen ? Palette.frozen + : Palette.activity(level: Self.level(activityByDay[date] ?? 0))) .frame(width: cell, height: cell) } } diff --git a/NucleicRemote/NucleicRemote/Views/Theme.swift b/NucleicRemote/NucleicRemote/Views/Theme.swift index 767a16f..454e55c 100644 --- a/NucleicRemote/NucleicRemote/Views/Theme.swift +++ b/NucleicRemote/NucleicRemote/Views/Theme.swift @@ -12,6 +12,9 @@ enum Palette { static let success = Color(red: 0.30, green: 0.80, blue: 0.45) // green static let paused = Color(red: 0.62, green: 0.49, blue: 0.93) // purple static let danger = Color(red: 0.92, green: 0.34, blue: 0.34) // red + /// Cold "iced" blue for a streak-frozen day in the activity grid — mirrors the Mac's + /// `AppPalette.frozen`. + static let frozen = Color(red: 0.26, green: 0.64, blue: 0.96) static let neutral = Color.secondary /// The orchestra (orchestration mode) signature gold — mirrors the Mac's /// `AppTheme.orchestra`. Used for the effort picker's Orchestra option and the