@@ -2,10 +2,11 @@ import Foundation
22import SwiftUI
33import Combine
44import ServiceManagement
5+import UserNotifications
56
67/// The view model that drives the menu bar display and the popover content.
78final class ViewModel: ObservableObject {
8− /// The last known balance. `nil` when loading, error, or no API key.
9+ /// The last known balance. `nil` when there has never been a successful fetch.
910 @Published var balance: Int?
1011
1112 /// `true` while a network request is in flight.
@@ -17,43 +18,58 @@ final class ViewModel: ObservableObject {
1718 /// The API key entered by the user (loaded from Keychain on init).
1819 @Published var apiKeyInput: String = ""
1920
21+ /// Timestamp of the last successful balance fetch.
22+ @Published var lastUpdated: Date?
23+
24+ /// Brief "✓ Saved" confirmation shown after saving the API key.
25+ @Published var savedConfirmation: Bool = false
26+
2027 /// Whether launch-at-login is enabled.
2128 @Published var launchAtLogin: Bool {
2229 didSet {
30+ guard !isInitializingLaunchAtLogin else { return }
2331 updateLaunchAtLogin(launchAtLogin)
2432 }
2533 }
2634
27− /// Timestamp of the last successful balance fetch, for relative "x ago" display.
28− @Published var lastUpdated: Date?
29−
30− /// Whether an API key is currently stored in the Keychain.
31− @Published var hasAPIKey: Bool
35+ /// Flag to suppress the didSet during initial setup.
36+ private var isInitializingLaunchAtLogin = true
3237
33− private let checker = CreditsChecker()
38+ private let checker: CreditsChecking
3439
35− init() {
36− let storedKey = KeychainHelper.load()
37− apiKeyInput = storedKey ?? ""
38− hasAPIKey = (storedKey ?? "").isEmpty == false
40+ init(checker: CreditsChecking = CreditsChecker()) {
41+ self.checker = checker
42+ apiKeyInput = KeychainHelper.load() ?? ""
3943 launchAtLogin = SMAppService.mainApp.status == .enabled
44+ isInitializingLaunchAtLogin = false
4045 }
4146
4247 // MARK: - Balance
4348
44− /// `true` when there is no stored API key and no balance yet (onboarding state).
45− var needsOnboarding: Bool {
46− !hasAPIKey && balance == nil && errorMessage == nil
49+ /// Formats an integer with thousands grouping separator.
50+ private func formatBalance(_ value: Int) -> String {
51+ let formatter = NumberFormatter()
52+ formatter.numberStyle = .decimal
53+ return formatter.string(from: NSNumber(value: value)) ?? "\(value)"
4754 }
4855
49− /// The text to show in the menu bar: `⚡{balance}` or `⚡?`.
56+ /// The text to show in the menu bar: `⚡…` while loading, `⚡?` when no balance, `⚡{balance}` otherwise.
5057 var statusBarItemText: String {
58+ if isLoading && balance == nil {
59+ return "⚡…"
60+ }
5161 if let balance = balance {
52− return "⚡\(balance)"
62+ return "⚡\(formatBalance(balance))"
5363 }
5464 return "⚡?"
5565 }
5666
67+ /// The formatted balance string for display in the popover.
68+ var formattedBalance: String {
69+ guard let balance = balance else { return "?" }
70+ return formatBalance(balance)
71+ }
72+
5773 /// The color for the balance display based on thresholds.
5874 var balanceColor: Color {
5975 guard let balance = balance else { return .secondary }
@@ -62,12 +78,12 @@ final class ViewModel: ObservableObject {
6278 return .red
6379 }
6480
65− /// A human-readable relative time for the last update, e.g. "2m ago".
66− var relativeUpdateText: String? {
67− guard let lastUpdated else { return nil }
81+ /// Relative time string for the last successful update, or nil if never updated.
82+ var lastUpdatedText: String? {
83+ guard let lastUpdated = lastUpdated else { return nil }
6884 let formatter = RelativeDateTimeFormatter()
69− formatter.unitsStyle = .abbreviated
70− return formatter.localizedString(for: lastUpdated, relativeTo: Date())
85+ formatter.unitsStyle = .short
86+ return "Updated \(formatter.localizedString(for: lastUpdated, relativeTo: Date()))"
7187 }
7288
7389 /// Refreshes the balance from the API. No-op if no API key is set.
@@ -82,17 +98,53 @@ final class ViewModel: ObservableObject {
8298
8399 Task { @MainActor in
84100 isLoading = true
85− errorMessage = nil
101+ // Don't clear errorMessage here — keep showing the last error
102+ // until we have a successful result.
86103 do {
87104 let result = try await checker.fetchBalance(apiKey: key)
105+ // Check for low-balance threshold crossing before updating
106+ let previousBalance = balance
88107 balance = result
108+ errorMessage = nil
89109 lastUpdated = Date()
110+ isLoading = false
111+
112+ // Low-balance notification: only on threshold crossing (≥10 → <10)
113+ if let prev = previousBalance, prev >= 10, result < 10 {
114+ sendLowBalanceNotification(balance: result)
115+ }
90116 } catch {
91− balance = nil
117+ // Keep the stale balance value — don't wipe it on error.
118+ // Only set balance = nil if there was never a successful fetch
119+ // (it's already nil in that case, so nothing to do).
92120 errorMessage = error.localizedDescription
93− lastUpdated = nil
121+ isLoading = false
122+ }
123+ }
124+ }
125+
126+ // MARK: - Low Balance Notification
127+
128+ private func sendLowBalanceNotification(balance: Int) {
129+ let center = UNUserNotificationCenter.current()
130+ center.getNotificationSettings { settings in
131+ guard settings.authorizationStatus == .authorized || settings.authorizationStatus == .provisional else {
132+ return
94133 }
95− isLoading = false
134+ let content = UNMutableNotificationContent()
135+ content.title = "Low Hyper Credits"
136+ let formatter = NumberFormatter()
137+ formatter.numberStyle = .decimal
138+ let balanceStr = formatter.string(from: NSNumber(value: balance)) ?? "\(balance)"
139+ content.body = "Low Hyper credits: \(balanceStr) remaining"
140+ content.sound = .default
141+
142+ let request = UNNotificationRequest(
143+ identifier: "low-balance-alert",
144+ content: content,
145+ trigger: nil
146+ )
147+ center.add(request)
96148 }
97149 }
98150
@@ -103,15 +155,20 @@ final class ViewModel: ObservableObject {
103155 let key = apiKeyInput.trimmingCharacters(in: .whitespacesAndNewlines)
104156 if key.isEmpty {
105157 KeychainHelper.delete()
106− hasAPIKey = false
107158 balance = nil
108159 errorMessage = nil
109160 lastUpdated = nil
110161 } else {
111162 KeychainHelper.save(key)
112− hasAPIKey = true
113163 refresh()
114164 }
165+
166+ // Show "✓ Saved" confirmation, auto-reset after 2 seconds
167+ savedConfirmation = true
168+ Task { @MainActor in
169+ try? await Task.sleep(nanoseconds: 2_000_000_000)
170+ savedConfirmation = false
171+ }
115172 }
116173
117174 // MARK: - Launch at Login
@@ -128,183 +185,102 @@ final class ViewModel: ObservableObject {
128185// MARK: - MenuView
129186
130187/// The SwiftUI view shown inside the popover when the menu bar item is clicked.
131−///
132−/// Minimal, airy design: one big balance number as the hero, generous spacing,
133−/// restrained color (balance color is the only accent), no cards or material
134−/// backgrounds. Inspired by Apple's Battery widget and trading apps.
135188struct MenuView: View {
136189 @ObservedObject var viewModel: ViewModel
137190
138191 var body: some View {
139− VStack(spacing: 0) {
140− heroSection
141− statusRow
142− refreshButton
143−
144− spacer
145−
146− apiKeySection
147−
148− spacer
149−
150− footerRow
151− versionText
152− }
153− .padding(20)
154− .frame(width: 280)
155− .fixedSize(horizontal: false, vertical: true)
156− .background(Color(nsColor: .windowBackgroundColor))
157− .animation(.easeInOut(duration: 0.2), value: viewModel.balance)
158− .animation(.easeInOut(duration: 0.2), value: viewModel.errorMessage)
159− .animation(.easeInOut(duration: 0.2), value: viewModel.hasAPIKey)
160− }
161−
162− /// A light vertical spacer between sections — spacing, not borders.
163− private var spacer: some View {
164− Spacer().frame(height: 16)
165− }
166−
167− // MARK: - Hero
168−
169− /// The balance number is the hero — big, bold, beautiful. Everything else
170− /// is secondary.
171− private var heroSection: some View {
172− VStack(spacing: 6) {
173− Group {
192+ VStack(spacing: 16) {
193+ // Balance display
194+ VStack(spacing: 4) {
174195 if viewModel.isLoading && viewModel.balance == nil {
175196 ProgressView()
176− .controlSize(.small)
177− } else if let balance = viewModel.balance {
178− Text("\(balance)")
179− .font(.system(size: 52, weight: .heavy, design: .rounded))
180− .monospacedDigit()
197+ .scaleEffect(0.8)
198+ Text("Loading…")
199+ .font(.caption)
200+ .foregroundColor(.secondary)
201+ } else if viewModel.balance != nil {
202+ Text(viewModel.formattedBalance)
203+ .font(.system(size: 42, weight: .bold, design: .rounded))
181204 .foregroundColor(viewModel.balanceColor)
182− .contentTransition(.opacity)
183− } else if viewModel.needsOnboarding {
184− Image(systemName: "bolt.fill")
185− .font(.system(size: 40, weight: .regular))
186− .symbolRenderingMode(.hierarchical)
187− .foregroundStyle(.secondary)
205+ Text("credits")
206+ .font(.caption)
207+ .foregroundColor(.secondary)
188208 } else {
189− Text("—")
190− .font(.system(size: 52, weight: .heavy, design: .rounded))
209+ Text("?")
210+ .font(.system(size: 42, weight: .bold, design: .rounded))
211+ .foregroundColor(.secondary)
212+ Text("credits")
213+ .font(.caption)
191214 .foregroundColor(.secondary)
192215 }
193216 }
194− .frame(height: 56)
195−
196− Text("credits")
197− .font(.system(size: 11, weight: .medium, design: .rounded))
198− .foregroundColor(.secondary)
199− .textCase(.uppercase)
200− .tracking(1.5)
201− }
202− .frame(maxWidth: .infinity)
203− .padding(.top, 4)
204− }
205−
206− // MARK: - Status
207217
208− /// Subtle "Updated 2m ago" or "Not connected" — plain text, no icons.
209− private var statusRow: some View {
210− Group {
211− if let relative = viewModel.relativeUpdateText {
212− Text("Updated \(relative)")
213− } else {
214− Text("Not connected")
218+ // Last updated time
219+ if let lastUpdatedText = viewModel.lastUpdatedText {
220+ Text(lastUpdatedText)
221+ .font(.caption2)
222+ .foregroundColor(.secondary)
215223 }
216− }
217− .font(.system(size: 11, weight: .regular, design: .rounded))
218− .foregroundStyle(.tertiary)
219− .padding(.top, 2)
220− }
221−
222− // MARK: - Refresh
223224
224− /// Minimal text button — no icon rotation, just a text change.
225− private var refreshButton: some View {
226− Button(action: { viewModel.refresh() }) {
227− Text(viewModel.isLoading ? "Refreshing…" : "Refresh")
228− .font(.system(size: 12, weight: .medium, design: .rounded))
229− .foregroundColor(.secondary)
230− }
231− .buttonStyle(.plain)
232− .disabled(viewModel.isLoading)
233− .padding(.top, 6)
234− }
225+ if let error = viewModel.errorMessage {
226+ Text(error)
227+ .font(.caption)
228+ .foregroundColor(.red)
229+ .multilineTextAlignment(.center)
230+ .padding(.horizontal)
231+ }
235232
236− // MARK: - API Key
233+ Divider()
237234
238− /// Clean SecureField + Save button. No DisclosureGroup, no lock icons.
239− private var apiKeySection: some View {
240− VStack(spacing: 8) {
241− HStack(spacing: 8) {
242− SecureField("Hyper API Key", text: $viewModel.apiKeyInput)
243− .textFieldStyle(.roundedBorder)
244− .font(.system(size: 13, design: .monospaced))
245− .onSubmit { viewModel.saveAPIKey() }
246−
247− Button(action: { viewModel.saveAPIKey() }) {
248− Text("Save")
249− .font(.system(size: 13, weight: .medium, design: .rounded))
250− }
251− .buttonStyle(.borderedProminent)
252− .controlSize(.small)
235+ // Refresh button
236+ Button(action: { viewModel.refresh() }) {
237+ Label("Refresh now", systemImage: "arrow.clockwise")
253238 }
254−
255− if viewModel.hasAPIKey {
256− Text("✓ Saved")
257− .font(.system(size: 11, weight: .medium, design: .rounded))
258− .foregroundColor(.green)
259− } else if let url = URL(string: "https://hyper.charm.land") {
260− Button {
261− NSWorkspace.shared.open(url)
262− } label: {
263− Text("Get your key at hyper.charm.land")
264− .font(.system(size: 11, weight: .regular, design: .rounded))
265− .foregroundColor(.secondary)
239+ .disabled(viewModel.isLoading)
240+
241+ // API key section
242+ VStack(alignment: .leading, spacing: 6) {
243+ Text("API Key")
244+ .font(.headline)
245+ HStack {
246+ SecureField("sk-...", text: $viewModel.apiKeyInput)
247+ .textFieldStyle(.roundedBorder)
248+ Button("Save") {
249+ viewModel.saveAPIKey()
250+ }
251+ }
252+ if viewModel.savedConfirmation {
253+ Text("✓ Saved")
254+ .font(.caption)
255+ .foregroundColor(.green)
256+ .transition(.opacity)
266257 }
267− .buttonStyle(.plain)
268258 }
269− }
270− }
271259
272− // MARK: - Footer
273−
274− /// Launch at Login toggle on the left, Quit on the right. Very subtle.
275− private var footerRow: some View {
276− HStack {
260+ // Launch at login toggle
277261 Toggle("Launch at Login", isOn: $viewModel.launchAtLogin)
278262 .toggleStyle(.switch)
279− .controlSize(.small)
280− .font(.system(size: 12, weight: .regular, design: .rounded))
281263
282− Spacer()
264+ // Links
265+ HStack {
266+ if let url = URL(string: "https://hyper.charm.land") {
267+ Button("Open hyper.charm.land") {
268+ NSWorkspace.shared.open(url)
269+ }
270+ }
271+ Spacer()
272+ }
273+
274+ Divider()
283275
284− Button {
276+ // Quit
277+ Button("Quit HyperCredits") {
285278 NSApplication.shared.terminate(nil)
286− } label: {
287− Text("Quit")
288− .font(.system(size: 12, weight: .regular, design: .rounded))
289− .foregroundColor(.secondary)
290279 }
291− .buttonStyle(.plain)
292280 .keyboardShortcut("q")
293281 }
294− }
295−
296− // MARK: - Version
297−
298− /// Tiny version label at the very bottom.
299− private var versionText: some View {
300− Text("v\(appVersion)")
301− .font(.system(size: 10, weight: .regular, design: .rounded))
302− .foregroundStyle(.tertiary)
303− .padding(.top, 10)
304− }
305−
306− /// Reads the marketing version from the main bundle.
307− private var appVersion: String {
308− Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String ?? "1.0"
282+ .padding(16)
283+ .frame(width: 280)
284+ .animation(.easeInOut(duration: 0.2), value: viewModel.savedConfirmation)
309285 }
310286}