mirror of
https://github.com/tiennm99/ccs.git
synced 2026-10-11 03:13:12 +00:00
Add a theme token system (CCSBarCore/BarTheme.swift): a BarAppearance enum (system/light/dark, default dark to preserve the current look), light+dark token palettes, a colorScheme-driven resolver, and a SwiftUI EnvironmentKey. The root forces .preferredColorScheme so Light renders light even under a dark macOS, and paints an explicit light window surface. Thread the tokens (accent, headroom bands, subscription, card surface, track) through every view so the whole dropdown themes consistently, with chip text blending toward black on light. Add an appearance picker in Preferences, persisted in UserDefaults. Dark accent/subscription values are locked byte-identical and harness-guarded; pool-account rows now share the muted band palette for cross-section consistency.
185 lines
6.1 KiB
Swift
185 lines
6.1 KiB
Swift
import SwiftUI
|
||
import CCSBarCore
|
||
|
||
/// Demoted spend strip + surface/model breakdown.
|
||
///
|
||
/// Spend is informational pool context, NEVER the headline — so the loud 2×2
|
||
/// StatCell grid + 28pt sparkline that used to dominate the dropdown collapses
|
||
/// into one muted caption line ("today $NN · 7d $N.Nk") with an optional thin
|
||
/// inline sparkline. The By-surface / Top-models breakdowns stay, tightened and
|
||
/// subordinate, below the pool accounts.
|
||
///
|
||
/// When the trailing 30 days carry no spend the strip reads the honest idle line
|
||
/// ("No usage in N days") instead of three dead "$0.00" cells.
|
||
struct BarAnalyticsView: View {
|
||
@Environment(\.barTheme) private var theme
|
||
let analytics: BarAnalytics
|
||
/// Which slot of the dropdown this instance renders. `.spend` is the thin strip
|
||
/// placed below the subscriptions cockpit; `.breakdown` is the by-surface /
|
||
/// top-models detail placed below the pool accounts.
|
||
enum Section { case spend, breakdown }
|
||
var section: Section = .spend
|
||
|
||
private var lastActive: String? {
|
||
BarFormatting.lastActiveLabel(
|
||
iso: analytics.lastActivityAt, daysSince: analytics.daysSinceLastActivity)
|
||
}
|
||
|
||
var body: some View {
|
||
switch section {
|
||
case .spend:
|
||
spendStrip
|
||
case .breakdown:
|
||
breakdown
|
||
}
|
||
}
|
||
|
||
/// By-surface + top-models detail, tightened and subordinate.
|
||
@ViewBuilder private var breakdown: some View {
|
||
VStack(alignment: .leading, spacing: 6) {
|
||
// Surface breakdown: "how much Claude Code vs Codex" — only shown when
|
||
// the backend supplies at least one surface entry. Top 5 keeps it compact.
|
||
if !analytics.bySurface.isEmpty {
|
||
SectionLabel("By surface")
|
||
let peakSurface = analytics.bySurface.map(\.cost).max() ?? 1
|
||
ForEach(analytics.bySurface.prefix(5)) { surface in
|
||
SurfaceBar(surface: surface, peak: peakSurface)
|
||
}
|
||
}
|
||
|
||
// Top models.
|
||
if !analytics.topModels.isEmpty {
|
||
let scope = analytics.topModelsWindow == "30d" ? "30d" : "all-time"
|
||
SectionLabel("Top models · \(scope)")
|
||
let peak = analytics.topModels.map(\.cost).max() ?? 1
|
||
ForEach(analytics.topModels.prefix(4)) { model in
|
||
ModelBar(model: model, peak: peak)
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
/// True when there's any surface/model detail worth a divider + section.
|
||
var hasBreakdown: Bool {
|
||
!analytics.bySurface.isEmpty || !analytics.topModels.isEmpty
|
||
}
|
||
|
||
/// The collapsed informational spend strip: a "SPEND" label, one muted caption
|
||
/// line, and a thin inline 30-day sparkline when there is real spend.
|
||
private var spendStrip: some View {
|
||
VStack(alignment: .leading, spacing: 3) {
|
||
SectionLabel("Spend")
|
||
if analytics.hasRecentData {
|
||
Text(spendCaption)
|
||
.font(.caption2)
|
||
.foregroundStyle(.secondary)
|
||
if !sparklineIsEmpty {
|
||
Sparkline(values: analytics.byDay.map(\.cost), accent: theme.accent)
|
||
.frame(height: 16)
|
||
}
|
||
} else {
|
||
Text(idleCaption)
|
||
.font(.caption2)
|
||
.foregroundStyle(.secondary)
|
||
}
|
||
}
|
||
}
|
||
|
||
/// One-line rollup: "today $NN · 7d $N.Nk · 30d $N.Nk".
|
||
private var spendCaption: String {
|
||
"today \(BarFormatting.money(analytics.today.cost))"
|
||
+ " · 7d \(BarFormatting.money(analytics.last7d.cost))"
|
||
+ " · 30d \(BarFormatting.money(analytics.last30d.cost))"
|
||
}
|
||
|
||
/// Honest idle caption when there's no recent spend, folding in last-active.
|
||
private var idleCaption: String {
|
||
let headline =
|
||
analytics.daysSinceLastActivity.map { "No usage in \($0) days" } ?? "No usage in 30 days"
|
||
if let lastActive { return "\(headline) · \(lastActive.lowercased())" }
|
||
return headline
|
||
}
|
||
|
||
private var sparklineIsEmpty: Bool {
|
||
analytics.byDay.allSatisfy { $0.cost <= 0 }
|
||
}
|
||
}
|
||
|
||
/// One usage-surface row: surface name + proportional accent bar + cost and
|
||
/// request count. Mirrors ModelBar visually so the two sections feel cohesive.
|
||
private struct SurfaceBar: View {
|
||
@Environment(\.barTheme) private var theme
|
||
let surface: BarAnalyticsSurface
|
||
let peak: Double
|
||
|
||
var body: some View {
|
||
GeometryReader { geo in
|
||
let fraction = peak > 0 ? CGFloat(surface.cost / peak) : 0
|
||
ZStack(alignment: .leading) {
|
||
RoundedRectangle(cornerRadius: 5)
|
||
.fill(theme.accent.opacity(0.16))
|
||
.frame(width: max(8, geo.size.width * fraction))
|
||
HStack {
|
||
Text(surface.surface)
|
||
.font(.caption)
|
||
.lineLimit(1)
|
||
.truncationMode(.middle)
|
||
Spacer()
|
||
HStack(spacing: 4) {
|
||
Text(BarFormatting.count(surface.requests))
|
||
.font(.system(.caption2, design: .monospaced))
|
||
.foregroundStyle(.tertiary)
|
||
Text(BarFormatting.money(surface.cost))
|
||
.font(.system(.caption, design: .monospaced))
|
||
.foregroundStyle(.secondary)
|
||
}
|
||
}
|
||
.padding(.horizontal, 8)
|
||
}
|
||
}
|
||
.frame(height: 22)
|
||
}
|
||
}
|
||
|
||
/// One top-model row: name + spend with a proportional accent bar behind.
|
||
private struct ModelBar: View {
|
||
@Environment(\.barTheme) private var theme
|
||
let model: BarAnalytics.Model
|
||
let peak: Double
|
||
|
||
var body: some View {
|
||
GeometryReader { geo in
|
||
let fraction = peak > 0 ? CGFloat(model.cost / peak) : 0
|
||
ZStack(alignment: .leading) {
|
||
RoundedRectangle(cornerRadius: 5)
|
||
.fill(theme.accent.opacity(0.16))
|
||
.frame(width: max(8, geo.size.width * fraction))
|
||
HStack {
|
||
Text(model.model)
|
||
.font(.caption)
|
||
.lineLimit(1)
|
||
.truncationMode(.middle)
|
||
Spacer()
|
||
Text(BarFormatting.money(model.cost))
|
||
.font(.system(.caption, design: .monospaced))
|
||
.foregroundStyle(.secondary)
|
||
}
|
||
.padding(.horizontal, 8)
|
||
}
|
||
}
|
||
.frame(height: 22)
|
||
}
|
||
}
|
||
|
||
/// Uppercase section divider label.
|
||
struct SectionLabel: View {
|
||
let text: String
|
||
init(_ text: String) { self.text = text }
|
||
var body: some View {
|
||
Text(text.uppercased())
|
||
.font(.system(size: 10, weight: .bold))
|
||
.foregroundStyle(.secondary)
|
||
.padding(.top, 1)
|
||
}
|
||
}
|