@@ -2,10 +2,11 @@ import Foundation
2import SwiftUI
2import SwiftUI
3import Combine
3import Combine
4import ServiceManagement
4import ServiceManagement
5+import UserNotifications
6/// The view model that drives the menu bar display and the popover content.
7/// The view model that drives the menu bar display and the popover content.
7final class ViewModel: ObservableObject {
8final 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.
9 @Published var balance: Int?
10 @Published var balance: Int?
11 /// `true` while a network request is in flight.
12 /// `true` while a network request is in flight.
@@ -17,43 +18,58 @@ final class ViewModel: ObservableObject {
17 /// The API key entered by the user (loaded from Keychain on init).
18 /// The API key entered by the user (loaded from Keychain on init).
18 @Published var apiKeyInput: String = ""
19 @Published var apiKeyInput: String = ""
21+ /// Timestamp of the last successful balance fetch.
22+ @Published var lastUpdated: Date?
24+ /// Brief "✓ Saved" confirmation shown after saving the API key.
25+ @Published var savedConfirmation: Bool = false
20 /// Whether launch-at-login is enabled.
27 /// Whether launch-at-login is enabled.
21 @Published var launchAtLogin: Bool {
28 @Published var launchAtLogin: Bool {
30+ guard !isInitializingLaunchAtLogin else { return }
23 updateLaunchAtLogin(launchAtLogin)
31 updateLaunchAtLogin(launchAtLogin)
27− /// Timestamp of the last successful balance fetch, for relative "x ago" display.
35+ /// Flag to suppress the didSet during initial setup.
28− @Published var lastUpdated: Date?
36+ private var isInitializingLaunchAtLogin = true
30− /// Whether an API key is currently stored in the Keychain.
31− @Published var hasAPIKey: Bool
33− private let checker = CreditsChecker()
38+ private let checker: CreditsChecking
35− init() {
40+ init(checker: CreditsChecking = CreditsChecker()) {
41+ self.checker = checker
36− let storedKey = KeychainHelper.load()
42+ apiKeyInput = KeychainHelper.load() ?? ""
37− apiKeyInput = storedKey ?? ""
38− hasAPIKey = (storedKey ?? "").isEmpty == false
39 launchAtLogin = SMAppService.mainApp.status == .enabled
43 launchAtLogin = SMAppService.mainApp.status == .enabled
44+ isInitializingLaunchAtLogin = false
42 // MARK: - Balance
47 // MARK: - Balance
44− /// `true` when there is no stored API key and no balance yet (onboarding state).
49+ /// Formats an integer with thousands grouping separator.
45− var needsOnboarding: Bool {
50+ private func formatBalance(_ value: Int) -> String {
46− !hasAPIKey && balance == nil && errorMessage == nil
51+ let formatter = NumberFormatter()
52+ formatter.numberStyle = .decimal
53+ return formatter.string(from: NSNumber(value: value)) ?? "\(value)"
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.
50 var statusBarItemText: String {
57 var statusBarItemText: String {
58+ if isLoading && balance == nil {
51 if let balance = balance {
61 if let balance = balance {
52− return "⚡\(balance)"
62+ return "⚡\(formatBalance(balance))"
54 return "⚡?"
64 return "⚡?"
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)
57 /// The color for the balance display based on thresholds.
73 /// The color for the balance display based on thresholds.
58 var balanceColor: Color {
74 var balanceColor: Color {
59 guard let balance = balance else { return .secondary }
75 guard let balance = balance else { return .secondary }
@@ -62,12 +78,12 @@ final class ViewModel: ObservableObject {
62 return .red
78 return .red
65− /// A human-readable relative time for the last update, e.g. "2m ago".
81+ /// Relative time string for the last successful update, or nil if never updated.
66− var relativeUpdateText: String? {
82+ var lastUpdatedText: String? {
67− guard let lastUpdated else { return nil }
83+ guard let lastUpdated = lastUpdated else { return nil }
68 let formatter = RelativeDateTimeFormatter()
84 let formatter = RelativeDateTimeFormatter()
69− formatter.unitsStyle = .abbreviated
85+ formatter.unitsStyle = .short
70− return formatter.localizedString(for: lastUpdated, relativeTo: Date())
86+ return "Updated \(formatter.localizedString(for: lastUpdated, relativeTo: Date()))"
73 /// Refreshes the balance from the API. No-op if no API key is set.
89 /// Refreshes the balance from the API. No-op if no API key is set.
@@ -82,17 +98,53 @@ final class ViewModel: ObservableObject {
83 Task { @MainActor in
99 Task { @MainActor in
84 isLoading = true
100 isLoading = true
85− errorMessage = nil
101+ // Don't clear errorMessage here — keep showing the last error
102+ // until we have a successful result.
87 let result = try await checker.fetchBalance(apiKey: key)
104 let result = try await checker.fetchBalance(apiKey: key)
105+ // Check for low-balance threshold crossing before updating
106+ let previousBalance = balance
88 balance = result
107 balance = result
89 lastUpdated = Date()
109 lastUpdated = Date()
112+ // Low-balance notification: only on threshold crossing (≥10 → <10)
113+ if let prev = previousBalance, prev >= 10, result < 10 {
114+ sendLowBalanceNotification(balance: result)
90 } catch {
116 } 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).
92 errorMessage = error.localizedDescription
120 errorMessage = error.localizedDescription
93− lastUpdated = nil
121+ isLoading = false
126+ // MARK: - Low Balance Notification
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 {
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
142+ let request = UNNotificationRequest(
143+ identifier: "low-balance-alert",
@@ -103,15 +155,20 @@ final class ViewModel: ObservableObject {
103 let key = apiKeyInput.trimmingCharacters(in: .whitespacesAndNewlines)
155 let key = apiKeyInput.trimmingCharacters(in: .whitespacesAndNewlines)
104 if key.isEmpty {
156 if key.isEmpty {
105 KeychainHelper.delete()
157 KeychainHelper.delete()
107 balance = nil
158 balance = nil
108 errorMessage = nil
159 errorMessage = nil
109 lastUpdated = nil
160 lastUpdated = nil
111 KeychainHelper.save(key)
162 KeychainHelper.save(key)
113 refresh()
163 refresh()
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
117 // MARK: - Launch at Login
174 // MARK: - Launch at Login
@@ -128,183 +185,102 @@ final class ViewModel: ObservableObject {
128// MARK: - MenuView
185// MARK: - MenuView
130/// The SwiftUI view shown inside the popover when the menu bar item is clicked.
187/// The SwiftUI view shown inside the popover when the menu bar item is clicked.
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.
135struct MenuView: View {
188struct MenuView: View {
136 @ObservedObject var viewModel: ViewModel
189 @ObservedObject var viewModel: ViewModel
138 var body: some View {
191 var body: some View {
139− VStack(spacing: 0) {
192+ VStack(spacing: 16) {
140− heroSection
193+ // Balance display
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)
162− /// A light vertical spacer between sections — spacing, not borders.
163− private var spacer: some View {
164− Spacer().frame(height: 16)
169− /// The balance number is the hero — big, bold, beautiful. Everything else
171− private var heroSection: some View {
172− VStack(spacing: 6) {
194+ VStack(spacing: 4) {
174 if viewModel.isLoading && viewModel.balance == nil {
195 if viewModel.isLoading && viewModel.balance == nil {
175 ProgressView()
196 ProgressView()
176− .controlSize(.small)
197+ .scaleEffect(0.8)
200+ .foregroundColor(.secondary)
177− } else if let balance = viewModel.balance {
201+ } else if viewModel.balance != nil {
178− Text("\(balance)")
202+ Text(viewModel.formattedBalance)
179− .font(.system(size: 52, weight: .heavy, design: .rounded))
203+ .font(.system(size: 42, weight: .bold, design: .rounded))
181 .foregroundColor(viewModel.balanceColor)
204 .foregroundColor(viewModel.balanceColor)
182− .contentTransition(.opacity)
205+ Text("credits")
183− } else if viewModel.needsOnboarding {
206+ .font(.caption)
184− Image(systemName: "bolt.fill")
207+ .foregroundColor(.secondary)
185− .font(.system(size: 40, weight: .regular))
186− .symbolRenderingMode(.hierarchical)
187− .foregroundStyle(.secondary)
189− Text("—")
209+ Text("?")
190− .font(.system(size: 52, weight: .heavy, design: .rounded))
210+ .font(.system(size: 42, weight: .bold, design: .rounded))
211+ .foregroundColor(.secondary)
191 .foregroundColor(.secondary)
214 .foregroundColor(.secondary)
197− .font(.system(size: 11, weight: .medium, design: .rounded))
198− .foregroundColor(.secondary)
199− .textCase(.uppercase)
202− .frame(maxWidth: .infinity)
208− /// Subtle "Updated 2m ago" or "Not connected" — plain text, no icons.
218+ // Last updated time
209− private var statusRow: some View {
219+ if let lastUpdatedText = viewModel.lastUpdatedText {
210− Group {
220+ Text(lastUpdatedText)
211− if let relative = viewModel.relativeUpdateText {
221+ .font(.caption2)
212− Text("Updated \(relative)")
222+ .foregroundColor(.secondary)
214− Text("Not connected")
217− .font(.system(size: 11, weight: .regular, design: .rounded))
218− .foregroundStyle(.tertiary)
224− /// Minimal text button — no icon rotation, just a text change.
225+ if let error = viewModel.errorMessage {
225− private var refreshButton: some View {
226+ Text(error)
226− Button(action: { viewModel.refresh() }) {
227+ .font(.caption)
227− Text(viewModel.isLoading ? "Refreshing…" : "Refresh")
228− .font(.system(size: 12, weight: .medium, design: .rounded))
229− .foregroundColor(.secondary)
228+ .foregroundColor(.red)
230− }
229+ .multilineTextAlignment(.center)
231− .buttonStyle(.plain)
232− .disabled(viewModel.isLoading)
233− .padding(.top, 6)
230+ .padding(.horizontal)
236− // MARK: - API Key
233+ Divider()
238− /// Clean SecureField + Save button. No DisclosureGroup, no lock icons.
235+ // Refresh button
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() }
247− Button(action: { viewModel.saveAPIKey() }) {
236+ Button(action: { viewModel.refresh() }) {
248− Text("Save")
237+ Label("Refresh now", systemImage: "arrow.clockwise")
249− .font(.system(size: 13, weight: .medium, design: .rounded))
251− .buttonStyle(.borderedProminent)
252− .controlSize(.small)
254−
239+ .disabled(viewModel.isLoading)
255− if viewModel.hasAPIKey {
240+
242+ VStack(alignment: .leading, spacing: 6) {
246+ SecureField("sk-...", text: $viewModel.apiKeyInput)
247+ .textFieldStyle(.roundedBorder)
249+ viewModel.saveAPIKey()
252+ if viewModel.savedConfirmation {
256− Text("✓ Saved")
253+ Text("✓ Saved")
257− .font(.system(size: 11, weight: .medium, design: .rounded))
254+ .font(.caption)
258− .foregroundColor(.green)
255+ .foregroundColor(.green)
259− } else if let url = URL(string: "https://hyper.charm.land") {
256+ .transition(.opacity)
261− NSWorkspace.shared.open(url)
263− Text("Get your key at hyper.charm.land")
264− .font(.system(size: 11, weight: .regular, design: .rounded))
265− .foregroundColor(.secondary)
267− .buttonStyle(.plain)
272− // MARK: - Footer
260+ // Launch at login toggle
274− /// Launch at Login toggle on the left, Quit on the right. Very subtle.
275− private var footerRow: some View {
277 Toggle("Launch at Login", isOn: $viewModel.launchAtLogin)
261 Toggle("Launch at Login", isOn: $viewModel.launchAtLogin)
278 .toggleStyle(.switch)
262 .toggleStyle(.switch)
279− .controlSize(.small)
280− .font(.system(size: 12, weight: .regular, design: .rounded))
266+ if let url = URL(string: "https://hyper.charm.land") {
267+ Button("Open hyper.charm.land") {
268+ NSWorkspace.shared.open(url)
282− Spacer()
271+ Spacer()
284− Button {
277+ Button("Quit HyperCredits") {
285 NSApplication.shared.terminate(nil)
278 NSApplication.shared.terminate(nil)
288− .font(.system(size: 12, weight: .regular, design: .rounded))
289− .foregroundColor(.secondary)
291− .buttonStyle(.plain)
292 .keyboardShortcut("q")
280 .keyboardShortcut("q")
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)
282+ .padding(16)
304− }
283+ .frame(width: 280)
305−
284+ .animation(.easeInOut(duration: 0.2), value: viewModel.savedConfirmation)
306− /// Reads the marketing version from the main bundle.
307− private var appVersion: String {
308− Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String ?? "1.0"