Files
ccs/macos-bar/Sources/CCSBarApp/BarPreferencesView.swift
T
Tam Nhu Tran b950b41503 fix(bar-app): settings as standalone window, real theme forcing, roomier layout
Move Settings out of the menu-bar popover into a standalone resizable AppKit
window so clicking it no longer steals focus and dismisses the bar (the .sheet
inside MenuBarExtra(.window) was the root cause). Force the actual NSWindow
appearance (aqua/darkAqua/system) on both the popover and the settings window
so Light/Dark visibly flips materials and semantic colors, not just custom
tokens. Replace the fragile popover quit dialog with an inline two-step
arm/confirm. Widen the dropdown and increase spacing/type for readability. Fill
the settings window responsively and make Done close it via the window
controller (dismiss() is a no-op in a hosted NSWindow).
2026-06-09 16:44:10 -04:00

201 lines
7.2 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(\.barTheme) private var theme
// 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 {
appearanceSection
glanceSection
quotaSection
spendSection
accountSection
deliveryHint
}
.formStyle(.grouped)
Divider()
footer
}
// Fill the hosting window responsively (it is a real resizable NSWindow now,
// not a fixed 360x460 sheet) so there are no dead margins and resizing works.
.frame(minWidth: 420, idealWidth: 460, maxWidth: .infinity,
minHeight: 520, idealHeight: 600, maxHeight: .infinity)
.onAppear(perform: hydrate)
}
private var header: some View {
HStack(spacing: 8) {
Image(systemName: "bell.badge")
.foregroundStyle(theme.accent)
Text("Alerts & Glance").font(.headline)
Spacer()
}
.padding(.horizontal, 14)
.padding(.vertical, 10)
}
/// Theme picker — placed first because it affects the whole dropdown, so it is
/// the most prominent setting. Bound directly to `$viewModel.appearance` (NOT
/// the alert-pref draft): appearance is global chrome, its `didSet` persists,
/// and @Published drives the live re-render — no writeThrough() needed.
private var appearanceSection: some View {
Section("Appearance") {
Picker("Menu bar theme", selection: $viewModel.appearance) {
Text("System").tag(BarAppearance.system)
Text("Light").tag(BarAppearance.light)
Text("Dark").tag(BarAppearance.dark)
}
.pickerStyle(.segmented)
}
}
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(); SettingsWindowController.shared.close() }
.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"
}
}
}