Files
ccs/macos-bar/Sources/CCSBarApp/BarPreferencesView.swift
T
Tam Nhu Tran 0e0b912187 feat(bar-app): subscription-first cockpit with detailed quota card
Lead the dropdown with subscriptions: a dedicated card shows the binding
window as the hero (gauge, remaining %, reset countdown, and a burn-rate
'left at this pace' line), a secondary window, and the Opus/Sonnet split for
Max, with a stale marker for older Codex data. Demote spend/usage to a compact
strip and surface cross-tool headroom. Add pure burn-rate / time-to-exhaustion
and binding-window math in Core. Kill the menu scroll indicator for real
(NSScrollView hider) so content stays aligned. Spend-cap alerts default off
(pay-per-use, opt-in).
2026-06-09 14:40:03 -04:00

182 lines
6.3 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import SwiftUI
import CCSBarCore
/// Preferences sheet reachable from the dropdown footer. Lets the user pick the
/// menu-bar glance mode and toggle / tune each alert rule. Writes through to
/// UserDefaults on every change and tells the view model to re-read prefs so the
/// title updates live and the next poll re-evaluates with the new settings.
struct BarPreferencesView: View {
@ObservedObject var viewModel: BarViewModel
let prefs: BarPreferences
@Environment(\.dismiss) private var dismiss
// Local editable mirror of the persisted prefs. Loaded on appear; each change
// is written through immediately so there is no separate "save" step.
@State private var draft = BarAlertPrefs()
// Quota levels are edited as free text and parsed on commit; keeping the raw
// string in @State avoids fighting the user's keystrokes mid-edit.
@State private var levelsText = ""
var body: some View {
VStack(alignment: .leading, spacing: 0) {
header
Divider()
Form {
glanceSection
quotaSection
spendSection
accountSection
deliveryHint
}
.formStyle(.grouped)
Divider()
footer
}
.frame(width: 360, height: 460)
.onAppear(perform: hydrate)
}
private var header: some View {
HStack(spacing: 8) {
Image(systemName: "bell.badge")
.foregroundStyle(BarTheme.accent)
Text("Alerts & Glance").font(.headline)
Spacer()
}
.padding(.horizontal, 14)
.padding(.vertical, 10)
}
private var glanceSection: some View {
Section("Menu-bar glance") {
Picker("Show in menu bar", selection: $draft.glanceMode) {
ForEach(BarGlanceMode.allCases, id: \.self) { mode in
Text(glanceLabel(mode)).tag(mode)
}
}
.onChange(of: draft.glanceMode) { _ in writeThrough() }
}
}
private var quotaSection: some View {
Section("Quota") {
Toggle("Alert on low quota", isOn: $draft.quotaEnabled)
.onChange(of: draft.quotaEnabled) { _ in writeThrough() }
HStack {
Text("Levels (%)")
Spacer()
TextField("20,10,0", text: $levelsText)
.multilineTextAlignment(.trailing)
.frame(width: 120)
.onSubmit { commitLevels() }
}
.disabled(!draft.quotaEnabled)
Text("Fires once per account at the most-severe level crossed, then again after the next quota reset.")
.font(.caption2)
.foregroundStyle(.secondary)
}
}
/// Pay-per-use spend caps. Opt-in (OFF by default) and labelled for pool
/// accounts, NOT subscriptions: flat-rate subscription plans have no spend to
/// cap, so these alerts only make sense for metered pool usage.
private var spendSection: some View {
Section("Opt-in · pay-per-use spend") {
Toggle("Daily spend cap (pool accounts)", isOn: $draft.dailySpendEnabled)
.onChange(of: draft.dailySpendEnabled) { _ in writeThrough() }
capRow(label: "Daily cap", value: $draft.dailyCapUSD, enabled: draft.dailySpendEnabled)
Toggle("Monthly spend cap (pool accounts)", isOn: $draft.monthSpendEnabled)
.onChange(of: draft.monthSpendEnabled) { _ in writeThrough() }
capRow(label: "Month cap", value: $draft.monthCapUSD, enabled: draft.monthSpendEnabled)
Text("Subscriptions are flat-rate and unaffected. These caps only watch metered pay-per-use pool spend, and are off until you enable them.")
.font(.caption2)
.foregroundStyle(.secondary)
}
}
private var accountSection: some View {
Section("Account state") {
Toggle("Alert when an account needs re-auth", isOn: $draft.reauthEnabled)
.onChange(of: draft.reauthEnabled) { _ in writeThrough() }
Toggle("Alert when an account is paused / cooling down", isOn: $draft.cooldownPausedEnabled)
.onChange(of: draft.cooldownPausedEnabled) { _ in writeThrough() }
}
}
/// Always-present delivery note. We don't synchronously read the live UN
/// authorization state here (it's async and ad-hoc signing makes the prompt
/// land late), so rather than a flickering "denied" branch we tell the user
/// where alerts surface either way: as system notifications when allowed, and
/// always in the in-menu alert list.
private var deliveryHint: some View {
Section {
HStack(spacing: 6) {
Image(systemName: "info.circle")
.foregroundStyle(.secondary)
Text("Alerts show as system notifications when allowed (System Settings Notifications) and always appear in the menu's Alerts list.")
.font(.caption2)
.foregroundStyle(.secondary)
}
}
}
private func capRow(label: String, value: Binding<Double>, enabled: Bool) -> some View {
HStack {
Text(label)
Spacer()
Text("$")
.foregroundStyle(.secondary)
TextField("0", value: value, format: .number)
.multilineTextAlignment(.trailing)
.frame(width: 90)
.onSubmit { writeThrough() }
.onChange(of: value.wrappedValue) { _ in writeThrough() }
}
.disabled(!enabled)
}
private var footer: some View {
HStack {
Spacer()
Button("Done") { commitLevels(); dismiss() }
.keyboardShortcut(.defaultAction)
}
.padding(.horizontal, 14)
.padding(.vertical, 10)
}
// MARK: State plumbing
private func hydrate() {
draft = prefs.load()
levelsText = BarAlertPrefsStore.encodeQuotaLevels(draft.quotaLevels)
}
/// Parse the free-text levels field into normalized ints, then persist.
private func commitLevels() {
let parsed = BarAlertPrefsStore.parseQuotaLevels(levelsText)
if !parsed.isEmpty { draft.quotaLevels = parsed }
// Reflect the normalized form back into the field so the user sees what stuck.
levelsText = BarAlertPrefsStore.encodeQuotaLevels(draft.quotaLevels)
writeThrough()
}
/// Save the current draft and ask the view model to re-read it so the live
/// title + next evaluation pick up the change.
private func writeThrough() {
prefs.save(draft)
viewModel.reloadPrefs()
}
private func glanceLabel(_ mode: BarGlanceMode) -> String {
switch mode {
case .auto: return "Auto (smart)"
case .todaySpend: return "Today's spend"
case .monthSpend: return "This month's spend"
case .lowestQuota: return "Lowest quota"
case .accountCount: return "Active account count"
}
}
}