mirror of
https://github.com/tiennm99/ccs.git
synced 2026-10-11 03:13:12 +00:00
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.
207 lines
7.5 KiB
Swift
207 lines
7.5 KiB
Swift
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 + chart style. Appearance affects the whole dropdown; chart style is
|
||
/// scoped to the spend sparkline. Both are bound directly to viewModel properties
|
||
/// whose `didSet` persist — no writeThrough() needed here.
|
||
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)
|
||
// 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)
|
||
}
|
||
}
|
||
|
||
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"
|
||
}
|
||
}
|
||
}
|