mirror of
https://github.com/tiennm99/ccs.git
synced 2026-08-13 18:22:39 +00:00
feat(bar-app): tune density and add spend chart style toggle
Narrow the dropdown (380 to 360) and give it more vertical room (620 to 700). Tighten the subscription cards (less spacing/padding) since they carry only a few window rows. Make the spend chart taller (18 to 30) and let the user pick its style in Settings: chunk bars (default) or a line graph with a subtle area fill. Persist the choice in UserDefaults.
This commit is contained in:
@@ -19,6 +19,9 @@ struct BarAnalyticsView: View {
|
|||||||
/// top-models detail placed below the pool accounts.
|
/// top-models detail placed below the pool accounts.
|
||||||
enum Section { case spend, breakdown }
|
enum Section { case spend, breakdown }
|
||||||
var section: Section = .spend
|
var section: Section = .spend
|
||||||
|
/// Controls whether the spend sparkline renders as bars or a line graph.
|
||||||
|
/// Passed in by BarMenuView so it reflects the user's persisted choice live.
|
||||||
|
var spendChartStyle: SpendChartStyle = .bars
|
||||||
|
|
||||||
private var lastActive: String? {
|
private var lastActive: String? {
|
||||||
BarFormatting.lastActiveLabel(
|
BarFormatting.lastActiveLabel(
|
||||||
@@ -74,8 +77,10 @@ struct BarAnalyticsView: View {
|
|||||||
.font(.caption2)
|
.font(.caption2)
|
||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
if !sparklineIsEmpty {
|
if !sparklineIsEmpty {
|
||||||
Sparkline(values: analytics.byDay.map(\.cost), accent: theme.accent)
|
// height: 30 (up from 18) so daily spend gradations are clearly readable.
|
||||||
.frame(height: 18)
|
Sparkline(values: analytics.byDay.map(\.cost), accent: theme.accent,
|
||||||
|
style: spendChartStyle)
|
||||||
|
.frame(height: 30)
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
Text(idleCaption)
|
Text(idleCaption)
|
||||||
|
|||||||
@@ -50,9 +50,12 @@ struct BarMenuView: View {
|
|||||||
accountsSection
|
accountsSection
|
||||||
|
|
||||||
// (3) SPEND — demoted to a thin informational strip below the cockpit.
|
// (3) SPEND — demoted to a thin informational strip below the cockpit.
|
||||||
|
// spendChartStyle is threaded from the viewModel so a live change in
|
||||||
|
// Settings immediately updates the chart without a refresh.
|
||||||
if let analytics = viewModel.analytics {
|
if let analytics = viewModel.analytics {
|
||||||
Divider()
|
Divider()
|
||||||
BarAnalyticsView(analytics: analytics, section: .spend)
|
BarAnalyticsView(analytics: analytics, section: .spend,
|
||||||
|
spendChartStyle: viewModel.spendChartStyle)
|
||||||
}
|
}
|
||||||
|
|
||||||
// (4) POOL ACCOUNTS — compact generic rows, subordinate.
|
// (4) POOL ACCOUNTS — compact generic rows, subordinate.
|
||||||
@@ -72,17 +75,18 @@ struct BarMenuView: View {
|
|||||||
.padding(14)
|
.padding(14)
|
||||||
}
|
}
|
||||||
.scrollIndicators(.never)
|
.scrollIndicators(.never)
|
||||||
// 620 fits the full reordered layout (subscription cards + spend strip +
|
// 700 gives more vertical breathing room before scroll engages — useful
|
||||||
// pool rows + surface/models) plus the added vertical breathing room
|
// for 3-4 subscription cards each carrying multiple quota windows.
|
||||||
// before scroll engages, for a typical 1-4 account setup. Scroll engages
|
// Scroll still engages gracefully on genuine overflow.
|
||||||
// gracefully only on real overflow.
|
.frame(maxHeight: 700)
|
||||||
.frame(maxHeight: 620)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Divider()
|
Divider()
|
||||||
footer
|
footer
|
||||||
}
|
}
|
||||||
.frame(width: 380)
|
// 360 is narrower than the old 380, keeping the popover compact while still
|
||||||
|
// fitting the bar-list fixed column widths (label 32 + bar 110 + pct 32 + chip 48).
|
||||||
|
.frame(width: 360)
|
||||||
.onAppear {
|
.onAppear {
|
||||||
viewModel.onOpen()
|
viewModel.onOpen()
|
||||||
// Disarm quit on every popover open so a stale armed state never persists.
|
// Disarm quit on every popover open so a stale armed state never persists.
|
||||||
|
|||||||
@@ -51,10 +51,9 @@ struct BarPreferencesView: View {
|
|||||||
.padding(.vertical, 10)
|
.padding(.vertical, 10)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Theme picker — placed first because it affects the whole dropdown, so it is
|
/// Theme + chart style. Appearance affects the whole dropdown; chart style is
|
||||||
/// the most prominent setting. Bound directly to `$viewModel.appearance` (NOT
|
/// scoped to the spend sparkline. Both are bound directly to viewModel properties
|
||||||
/// the alert-pref draft): appearance is global chrome, its `didSet` persists,
|
/// whose `didSet` persist — no writeThrough() needed here.
|
||||||
/// and @Published drives the live re-render — no writeThrough() needed.
|
|
||||||
private var appearanceSection: some View {
|
private var appearanceSection: some View {
|
||||||
Section("Appearance") {
|
Section("Appearance") {
|
||||||
Picker("Menu bar theme", selection: $viewModel.appearance) {
|
Picker("Menu bar theme", selection: $viewModel.appearance) {
|
||||||
@@ -63,6 +62,13 @@ struct BarPreferencesView: View {
|
|||||||
Text("Dark").tag(BarAppearance.dark)
|
Text("Dark").tag(BarAppearance.dark)
|
||||||
}
|
}
|
||||||
.pickerStyle(.segmented)
|
.pickerStyle(.segmented)
|
||||||
|
// Changing this live-updates the spend chart in the open dropdown because
|
||||||
|
// viewModel.spendChartStyle is @Published and SpendChartStyleStore persists it.
|
||||||
|
Picker("Spend graph", selection: $viewModel.spendChartStyle) {
|
||||||
|
Text("Bars").tag(SpendChartStyle.bars)
|
||||||
|
Text("Line").tag(SpendChartStyle.line)
|
||||||
|
}
|
||||||
|
.pickerStyle(.segmented)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -24,7 +24,9 @@ struct BarSubscriptionCard: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
VStack(alignment: .leading, spacing: 6) {
|
// spacing: 4 (down from 6) and vertical padding: 8 (down from 11) keep the
|
||||||
|
// card compact so 2-3 cards fit in the dropdown without triggering scroll.
|
||||||
|
VStack(alignment: .leading, spacing: 4) {
|
||||||
titleRow
|
titleRow
|
||||||
if windows.isEmpty {
|
if windows.isEmpty {
|
||||||
emptyState
|
emptyState
|
||||||
@@ -33,7 +35,7 @@ struct BarSubscriptionCard: View {
|
|||||||
staleFootnote
|
staleFootnote
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.padding(.vertical, 11)
|
.padding(.vertical, 8)
|
||||||
.padding(.horizontal, 10)
|
.padding(.horizontal, 10)
|
||||||
.frame(maxWidth: .infinity, alignment: .leading)
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
.background(
|
.background(
|
||||||
@@ -71,7 +73,9 @@ struct BarSubscriptionCard: View {
|
|||||||
let ordered = orderedWindows
|
let ordered = orderedWindows
|
||||||
let bindingKey = binding?.key
|
let bindingKey = binding?.key
|
||||||
|
|
||||||
return VStack(alignment: .leading, spacing: 5) {
|
// spacing: 4 (down from 5) — rows are already compact; tighter gap fits more
|
||||||
|
// without hurting legibility.
|
||||||
|
return VStack(alignment: .leading, spacing: 4) {
|
||||||
ForEach(ordered) { w in
|
ForEach(ordered) { w in
|
||||||
windowBarRow(w, isBinding: w.key == bindingKey)
|
windowBarRow(w, isBinding: w.key == bindingKey)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -25,6 +25,11 @@ final class BarViewModel: ObservableObject {
|
|||||||
/// Which figure leads the always-on title. Persisted; a change re-derives
|
/// Which figure leads the always-on title. Persisted; a change re-derives
|
||||||
/// `statusTitle` live because it is @Published.
|
/// `statusTitle` live because it is @Published.
|
||||||
@Published var glanceMode: BarGlanceMode
|
@Published var glanceMode: BarGlanceMode
|
||||||
|
/// Render style for the spend sparkline (bars or line). Persisted via
|
||||||
|
/// SpendChartStyleStore; didSet mirrors the BarAppearance/iconStyle pattern.
|
||||||
|
@Published var spendChartStyle: SpendChartStyle {
|
||||||
|
didSet { SpendChartStyleStore.save(spendChartStyle) }
|
||||||
|
}
|
||||||
/// The alerts the most recent evaluation wanted delivered, surfaced in the
|
/// The alerts the most recent evaluation wanted delivered, surfaced in the
|
||||||
/// dropdown so users who deny notifications still see the conditions.
|
/// dropdown so users who deny notifications still see the conditions.
|
||||||
@Published var activeAlerts: [BarNotification] = []
|
@Published var activeAlerts: [BarNotification] = []
|
||||||
@@ -51,6 +56,7 @@ final class BarViewModel: ObservableObject {
|
|||||||
self.iconStyle = MenuBarIcon.loadStyle()
|
self.iconStyle = MenuBarIcon.loadStyle()
|
||||||
self.appearance = BarAppearanceStore.load()
|
self.appearance = BarAppearanceStore.load()
|
||||||
self.glanceMode = prefs.load().glanceMode
|
self.glanceMode = prefs.load().glanceMode
|
||||||
|
self.spendChartStyle = SpendChartStyleStore.load()
|
||||||
reconnect()
|
reconnect()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,27 +1,95 @@
|
|||||||
import SwiftUI
|
import SwiftUI
|
||||||
import CCSBarCore
|
import CCSBarCore
|
||||||
|
|
||||||
/// A compact bar sparkline for daily values (e.g. cost per day over 7 days).
|
/// A compact sparkline for daily values (e.g. cost per day over 30 days).
|
||||||
/// Zero-value days render as faint placeholders so the cadence stays readable.
|
///
|
||||||
|
/// Two render styles controlled by `style`:
|
||||||
|
/// - `.bars` (default): the original RoundedRectangle bar chart. Zero-value days
|
||||||
|
/// render as faint placeholders so the cadence stays readable.
|
||||||
|
/// - `.line`: a Path-based polyline through the normalized points, stroked ~1.5pt,
|
||||||
|
/// with a subtle area fill (accent at ~0.15 opacity) under the curve. Better for
|
||||||
|
/// reading trend direction over a long window. Falls back to a flat baseline when
|
||||||
|
/// count < 2 or all values are zero.
|
||||||
struct Sparkline: View {
|
struct Sparkline: View {
|
||||||
let values: [Double]
|
let values: [Double]
|
||||||
// Default is the dark preset's accent: a default argument can't read the
|
// Default is the dark preset's accent: a default argument can't read the
|
||||||
// environment, so this is the static fallback. Live callers pass the themed
|
// environment, so this is the static fallback. Live callers pass the themed
|
||||||
// `theme.accent` from the parent so the rendered bar follows the chosen theme.
|
// `theme.accent` from the parent so the rendered bar follows the chosen theme.
|
||||||
var accent: Color = BarTheme.dark.accent
|
var accent: Color = BarTheme.dark.accent
|
||||||
|
/// Render mode. Default `.bars` preserves the original look; `.line` draws a
|
||||||
|
/// trend line instead.
|
||||||
|
var style: SpendChartStyle = .bars
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
GeometryReader { geo in
|
GeometryReader { geo in
|
||||||
let peak = max(values.max() ?? 0, 0.0001)
|
switch style {
|
||||||
HStack(alignment: .bottom, spacing: 3) {
|
case .bars:
|
||||||
ForEach(Array(values.enumerated()), id: \.offset) { _, value in
|
barsBody(in: geo.size)
|
||||||
let height = CGFloat(value / peak) * geo.size.height
|
case .line:
|
||||||
RoundedRectangle(cornerRadius: 2)
|
lineBody(in: geo.size)
|
||||||
.fill(value > 0 ? accent : Color.secondary.opacity(0.2))
|
}
|
||||||
.frame(height: max(2, height))
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: Bar render (original)
|
||||||
|
|
||||||
|
private func barsBody(in size: CGSize) -> some View {
|
||||||
|
let peak = max(values.max() ?? 0, 0.0001)
|
||||||
|
return HStack(alignment: .bottom, spacing: 3) {
|
||||||
|
ForEach(Array(values.enumerated()), id: \.offset) { _, value in
|
||||||
|
let height = CGFloat(value / peak) * size.height
|
||||||
|
RoundedRectangle(cornerRadius: 2)
|
||||||
|
.fill(value > 0 ? accent : Color.secondary.opacity(0.2))
|
||||||
|
.frame(height: max(2, height))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .bottom)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: Line render
|
||||||
|
|
||||||
|
/// Polyline path connecting the normalized data points. x is evenly spaced
|
||||||
|
/// across the width; y is inverted so a larger value is higher (closer to 0).
|
||||||
|
@ViewBuilder private func lineBody(in size: CGSize) -> some View {
|
||||||
|
let peak = max(values.max() ?? 0, 0.0001)
|
||||||
|
let count = values.count
|
||||||
|
let allZero = values.allSatisfy { $0 <= 0 }
|
||||||
|
|
||||||
|
if count < 2 || allZero {
|
||||||
|
// Flat baseline — nothing to show; render a faint hairline so the area
|
||||||
|
// is not invisible on an idle spend strip.
|
||||||
|
Path { p in
|
||||||
|
p.move(to: CGPoint(x: 0, y: size.height))
|
||||||
|
p.addLine(to: CGPoint(x: size.width, y: size.height))
|
||||||
|
}
|
||||||
|
.stroke(accent.opacity(0.2), lineWidth: 1)
|
||||||
|
} else {
|
||||||
|
// Build points: x evenly spaced, y inverted (0 = top = max value).
|
||||||
|
let pts: [CGPoint] = values.enumerated().map { i, v in
|
||||||
|
let x = CGFloat(i) / CGFloat(count - 1) * size.width
|
||||||
|
let y = size.height - CGFloat(v / peak) * size.height
|
||||||
|
return CGPoint(x: x, y: y)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Area fill: close the path by dropping to the bottom edge.
|
||||||
|
let fillPath = Path { p in
|
||||||
|
p.move(to: CGPoint(x: pts[0].x, y: size.height))
|
||||||
|
p.addLine(to: pts[0])
|
||||||
|
for pt in pts.dropFirst() { p.addLine(to: pt) }
|
||||||
|
p.addLine(to: CGPoint(x: pts[pts.count - 1].x, y: size.height))
|
||||||
|
p.closeSubpath()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stroke path.
|
||||||
|
let strokePath = Path { p in
|
||||||
|
p.move(to: pts[0])
|
||||||
|
for pt in pts.dropFirst() { p.addLine(to: pt) }
|
||||||
|
}
|
||||||
|
|
||||||
|
ZStack {
|
||||||
|
fillPath.fill(accent.opacity(0.15))
|
||||||
|
strokePath.stroke(accent, lineWidth: 1.5)
|
||||||
}
|
}
|
||||||
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .bottom)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1347,6 +1347,38 @@ do {
|
|||||||
defaults.removePersistentDomain(forName: suiteName)
|
defaults.removePersistentDomain(forName: suiteName)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: SpendChartStyle — enum stability + store round-trip
|
||||||
|
|
||||||
|
// Default is .bars (the pre-existing render mode; no visual regression on upgrade).
|
||||||
|
check(SpendChartStyle.bars.rawValue == "bars", "SpendChartStyle .bars rawValue stable")
|
||||||
|
check(SpendChartStyle.line.rawValue == "line", "SpendChartStyle .line rawValue stable")
|
||||||
|
check(SpendChartStyle(rawValue: "bars") == .bars, "SpendChartStyle round-trip bars")
|
||||||
|
check(SpendChartStyle(rawValue: "line") == .line, "SpendChartStyle round-trip line")
|
||||||
|
check(SpendChartStyle(rawValue: "unknown") == nil, "SpendChartStyle unknown rawValue -> nil")
|
||||||
|
check(SpendChartStyle.allCases.count == 2, "SpendChartStyle has exactly 2 cases")
|
||||||
|
|
||||||
|
// Store round-trip: save .line -> load() == .line; absent key defaults to .bars.
|
||||||
|
do {
|
||||||
|
let suiteName = "ccs-bar-check-chart-\(ProcessInfo.processInfo.globallyUniqueString)"
|
||||||
|
let defaults = UserDefaults(suiteName: suiteName)!
|
||||||
|
// Absent key -> .bars (the default).
|
||||||
|
let raw = defaults.string(forKey: SpendChartStyleStore.defaultsKey)
|
||||||
|
?? SpendChartStyle.bars.rawValue
|
||||||
|
check(SpendChartStyle(rawValue: raw) == .bars,
|
||||||
|
"SpendChartStyleStore defaults to .bars on absent key")
|
||||||
|
// Save .line -> load .line.
|
||||||
|
defaults.set(SpendChartStyle.line.rawValue, forKey: SpendChartStyleStore.defaultsKey)
|
||||||
|
let back = SpendChartStyle(rawValue:
|
||||||
|
defaults.string(forKey: SpendChartStyleStore.defaultsKey) ?? "")
|
||||||
|
check(back == .line, "SpendChartStyleStore round-trips save(.line) -> .line")
|
||||||
|
// Garbage -> nil (coalesces to .bars on live load).
|
||||||
|
defaults.set("nonsense", forKey: SpendChartStyleStore.defaultsKey)
|
||||||
|
let g = SpendChartStyle(rawValue:
|
||||||
|
defaults.string(forKey: SpendChartStyleStore.defaultsKey) ?? "")
|
||||||
|
check(g == nil, "SpendChartStyleStore garbage raw -> nil (coalesces to .bars)")
|
||||||
|
defaults.removePersistentDomain(forName: suiteName)
|
||||||
|
}
|
||||||
|
|
||||||
// cleanup
|
// cleanup
|
||||||
try? FileManager.default.removeItem(atPath: tmp)
|
try? FileManager.default.removeItem(atPath: tmp)
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,35 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
// MARK: - SpendChartStyle
|
||||||
|
|
||||||
|
/// User-selectable render style for the spend sparkline in the dropdown.
|
||||||
|
///
|
||||||
|
/// `.bars` (default) draws the existing RoundedRectangle bar chart.
|
||||||
|
/// `.line` draws a Path-based line graph with a faint area fill, which is
|
||||||
|
/// better for spotting trend direction across the 30-day window.
|
||||||
|
///
|
||||||
|
/// Sendable + CaseIterable so the harness can iterate all cases and the value
|
||||||
|
/// can cross actor boundaries safely.
|
||||||
|
public enum SpendChartStyle: String, CaseIterable, Sendable {
|
||||||
|
case bars
|
||||||
|
case line
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - SpendChartStyleStore
|
||||||
|
|
||||||
|
/// Persists the chosen spend-chart style. Mirrors the BarAppearanceStore pattern:
|
||||||
|
/// a UserDefaults key, a static load, and a static save. The `?? .bars` fallback
|
||||||
|
/// on a nil/unrecognized raw value is the sole source of the default.
|
||||||
|
public enum SpendChartStyleStore {
|
||||||
|
public static let defaultsKey = "ccsbar.spendChartStyle"
|
||||||
|
|
||||||
|
public static func load() -> SpendChartStyle {
|
||||||
|
let raw = UserDefaults.standard.string(forKey: defaultsKey)
|
||||||
|
?? SpendChartStyle.bars.rawValue
|
||||||
|
return SpendChartStyle(rawValue: raw) ?? .bars
|
||||||
|
}
|
||||||
|
|
||||||
|
public static func save(_ style: SpendChartStyle) {
|
||||||
|
UserDefaults.standard.set(style.rawValue, forKey: defaultsKey)
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user