@@ -24,15 +24,28 @@ final class ViewModel: ObservableObject {
2424 }
2525 }
2626
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
32+
2733 private let checker = CreditsChecker()
2834
2935 init() {
30− apiKeyInput = KeychainHelper.load() ?? ""
36+ let storedKey = KeychainHelper.load()
37+ apiKeyInput = storedKey ?? ""
38+ hasAPIKey = (storedKey ?? "").isEmpty == false
3139 launchAtLogin = SMAppService.mainApp.status == .enabled
3240 }
3341
3442 // MARK: - Balance
3543
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
47+ }
48+
3649 /// The text to show in the menu bar: `⚡{balance}` or `⚡?`.
3750 var statusBarItemText: String {
3851 if let balance = balance {
@@ -49,12 +62,21 @@ final class ViewModel: ObservableObject {
4962 return .red
5063 }
5164
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 }
68+ let formatter = RelativeDateTimeFormatter()
69+ formatter.unitsStyle = .abbreviated
70+ return formatter.localizedString(for: lastUpdated, relativeTo: Date())
71+ }
72+
5273 /// Refreshes the balance from the API. No-op if no API key is set.
5374 func refresh() {
5475 let key = apiKeyInput.trimmingCharacters(in: .whitespacesAndNewlines)
5576 guard !key.isEmpty else {
5677 balance = nil
5778 errorMessage = nil
79+ lastUpdated = nil
5880 return
5981 }
6082
@@ -64,9 +86,11 @@ final class ViewModel: ObservableObject {
6486 do {
6587 let result = try await checker.fetchBalance(apiKey: key)
6688 balance = result
89+ lastUpdated = Date()
6790 } catch {
6891 balance = nil
6992 errorMessage = error.localizedDescription
93+ lastUpdated = nil
7094 }
7195 isLoading = false
7296 }
@@ -79,10 +103,13 @@ final class ViewModel: ObservableObject {
79103 let key = apiKeyInput.trimmingCharacters(in: .whitespacesAndNewlines)
80104 if key.isEmpty {
81105 KeychainHelper.delete()
106+ hasAPIKey = false
82107 balance = nil
83108 errorMessage = nil
109+ lastUpdated = nil
84110 } else {
85111 KeychainHelper.save(key)
112+ hasAPIKey = true
86113 refresh()
87114 }
88115 }
@@ -101,88 +128,379 @@ final class ViewModel: ObservableObject {
101128// MARK: - MenuView
102129
103130/// The SwiftUI view shown inside the popover when the menu bar item is clicked.
131+///
132+/// Redesigned with a card-based layout, a hero balance ring, animated numeric
133+/// transitions, a collapsible API-key section, and a polished footer. All
134+/// underlying ViewModel logic (API calls, Keychain, launch-at-login) is
135+/// unchanged — only the presentation layer has been rewritten.
104136struct MenuView: View {
105137 @ObservedObject var viewModel: ViewModel
106138
139+ /// Controls whether the API-key disclosure section is expanded.
140+ @State private var isAPIKeyExpanded: Bool = false
141+ /// Drives the popover content entrance animation.
142+ @State private var appeared: Bool = false
143+
144+ private let hyperAccent = Color(
145+ red: 0.906, green: 0.345, blue: 0.078, opacity: 1.0
146+ )
147+
107148 var body: some View {
108− VStack(spacing: 16) {
109− // Balance display
110− VStack(spacing: 4) {
111− if viewModel.isLoading && viewModel.balance == nil {
112− ProgressView()
113− .scaleEffect(0.8)
114− Text("Loading…")
115− .font(.caption)
116− .foregroundColor(.secondary)
117− } else if let balance = viewModel.balance {
118− Text("\(balance)")
119− .font(.system(size: 42, weight: .bold, design: .rounded))
120− .foregroundColor(viewModel.balanceColor)
149+ ScrollView {
150+ VStack(spacing: 14) {
151+ heroSection
152+ if viewModel.needsOnboarding {
153+ onboardingCard
154+ .transition(.opacity.combined(with: .move(edge: .top)))
155+ }
156+ if let error = viewModel.errorMessage {
157+ errorCard(error)
158+ .transition(.opacity.combined(with: .scale(scale: 0.95)))
159+ }
160+ apiKeySection
161+ footerSection
162+ }
163+ .padding(16)
164+ .frame(maxWidth: .infinity)
165+ }
166+ .frame(width: 320, height: 460)
167+ .background(
168+ ZStack {
169+ // Subtle radial glow tinted by the balance color.
170+ Circle()
171+ .fill(
172+ RadialGradient(
173+ colors: [
174+ viewModel.balanceColor.opacity(0.10),
175+ Color.clear,
176+ ],
177+ center: .top,
178+ startRadius: 10,
179+ endRadius: 260
180+ )
181+ )
182+ .blur(radius: 20)
183+ .offset(y: -60)
184+ Color(nsColor: .windowBackgroundColor)
185+ }
186+ .ignoresSafeArea()
187+ )
188+ .opacity(appeared ? 1 : 0)
189+ .offset(y: appeared ? 0 : 8)
190+ .animation(.easeOut(duration: 0.28), value: appeared)
191+ .onAppear { appeared = true }
192+ .animation(.easeInOut(duration: 0.3), value: viewModel.balance)
193+ .animation(.easeInOut(duration: 0.3), value: viewModel.errorMessage)
194+ .animation(.easeInOut(duration: 0.3), value: viewModel.needsOnboarding)
195+ }
196+
197+ // MARK: - Hero
198+
199+ private var heroSection: some View {
200+ VStack(spacing: 8) {
201+ ZStack {
202+ BalanceRingView(
203+ balance: viewModel.balance,
204+ color: viewModel.balanceColor,
205+ isLoading: viewModel.isLoading
206+ )
207+ .frame(width: 120, height: 120)
208+
209+ VStack(spacing: 2) {
210+ if viewModel.isLoading && viewModel.balance == nil {
211+ ProgressView()
212+ .scaleEffect(0.9)
213+ } else if let balance = viewModel.balance {
214+ Text("\(balance)")
215+ .font(.system(size: 38, weight: .heavy, design: .rounded))
216+ .monospacedDigit()
217+ .foregroundColor(viewModel.balanceColor)
218+ .contentTransition(.numericTransition(for: Double(balance)))
219+ } else if viewModel.needsOnboarding {
220+ Image(systemName: "bolt.fill")
221+ .font(.system(size: 34, weight: .bold))
222+ .symbolRenderingMode(.hierarchical)
223+ .foregroundStyle(.secondary)
224+ } else {
225+ Text("?")
226+ .font(.system(size: 38, weight: .heavy, design: .rounded))
227+ .foregroundColor(.secondary)
228+ }
229+
121230 Text("credits")
122− .font(.caption)
231+ .font(.system(size: 11, weight: .medium, design: .rounded))
123232 .foregroundColor(.secondary)
233+ .textCase(.uppercase)
234+ .tracking(1.2)
235+ }
236+ }
237+ .padding(.top, 4)
238+
239+ // Last-updated + refresh row
240+ HStack(spacing: 6) {
241+ if let relative = viewModel.relativeUpdateText {
242+ Label(relative, systemImage: "clock")
243+ .labelStyle(.titleAndIcon)
244+ } else if viewModel.isLoading {
245+ Text("Updating…")
124246 } else {
125− Text("?")
126− .font(.system(size: 42, weight: .bold, design: .rounded))
127− .foregroundColor(.secondary)
128− Text("credits")
129− .font(.caption)
130− .foregroundColor(.secondary)
247+ Text("Not yet updated")
131248 }
132249 }
250+ .font(.system(size: 11, weight: .regular, design: .rounded))
251+ .foregroundStyle(.tertiary)
133252
134− if let error = viewModel.errorMessage {
135− Text(error)
136− .font(.caption)
137− .foregroundColor(.red)
138− .multilineTextAlignment(.center)
139− .padding(.horizontal)
253+ refreshButton
254+ }
255+ .padding(16)
256+ .frame(maxWidth: .infinity)
257+ .background(
258+ RoundedRectangle(cornerRadius: 16, style: .continuous)
259+ .fill(.regularMaterial)
260+ )
261+ .overlay(
262+ RoundedRectangle(cornerRadius: 16, style: .continuous)
263+ .strokeBorder(viewModel.balanceColor.opacity(0.15), lineWidth: 1)
264+ )
265+ .shadow(color: .black.opacity(0.08), radius: 8, x: 0, y: 3)
266+ }
267+
268+ // MARK: - Refresh button
269+
270+ private var refreshButton: some View {
271+ Button(action: { viewModel.refresh() }) {
272+ HStack(spacing: 5) {
273+ Image(systemName: "arrow.clockwise")
274+ .symbolRenderingMode(.hierarchical)
275+ .rotationEffect(.degrees(viewModel.isLoading ? 360 : 0))
276+ .animation(
277+ viewModel.isLoading
278+ ? .linear(duration: 0.9).repeatForever(autoreverses: false)
279+ : .easeOut(duration: 0.3),
280+ value: viewModel.isLoading
281+ )
282+ Text(viewModel.isLoading ? "Refreshing" : "Refresh")
283+ .font(.system(size: 12, weight: .medium, design: .rounded))
140284 }
285+ .padding(.horizontal, 14)
286+ .padding(.vertical, 6)
287+ .background(
288+ Capsule()
289+ .fill(hyperAccent.opacity(viewModel.isLoading ? 0.15 : 0.12))
290+ )
291+ .foregroundColor(hyperAccent)
292+ }
293+ .buttonStyle(.plain)
294+ .disabled(viewModel.isLoading)
295+ }
141296
142− Divider()
297+ // MARK: - Onboarding
143298
144− // Refresh button
299+ private var onboardingCard: some View {
300+ VStack(alignment: .leading, spacing: 10) {
301+ HStack(spacing: 10) {
302+ Image(systemName: "key.fill")
303+ .font(.system(size: 22, weight: .semibold))
304+ .symbolRenderingMode(.hierarchical)
305+ .foregroundStyle(hyperAccent)
306+ VStack(alignment: .leading, spacing: 2) {
307+ Text("Welcome to HyperCredits")
308+ .font(.system(size: 15, weight: .semibold, design: .rounded))
309+ Text("Connect your account to see your balance here.")
310+ .font(.system(size: 12, weight: .regular, design: .rounded))
311+ .foregroundStyle(.secondary)
312+ }
313+ }
314+ Button {
315+ withAnimation(.easeInOut(duration: 0.3)) {
316+ isAPIKeyExpanded = true
317+ }
318+ } label: {
319+ HStack(spacing: 4) {
320+ Text("Add your API key")
321+ .font(.system(size: 12, weight: .medium, design: .rounded))
322+ Image(systemName: "arrow.right")
323+ .font(.system(size: 10, weight: .bold))
324+ }
325+ .foregroundColor(hyperAccent)
326+ }
327+ .buttonStyle(.plain)
328+ }
329+ .padding(14)
330+ .frame(maxWidth: .infinity, alignment: .leading)
331+ .background(
332+ RoundedRectangle(cornerRadius: 12, style: .continuous)
333+ .fill(hyperAccent.opacity(0.08))
334+ )
335+ .overlay(
336+ RoundedRectangle(cornerRadius: 12, style: .continuous)
337+ .strokeBorder(hyperAccent.opacity(0.25), lineWidth: 1)
338+ )
339+ }
340+
341+ // MARK: - Error
342+
343+ private func errorCard(_ message: String) -> some View {
344+ VStack(alignment: .leading, spacing: 8) {
345+ HStack(alignment: .top, spacing: 8) {
346+ Image(systemName: "exclamationmark.triangle.fill")
347+ .font(.system(size: 14, weight: .semibold))
348+ .symbolRenderingMode(.hierarchical)
349+ .foregroundStyle(.red.opacity(0.8))
350+ Text(message)
351+ .font(.system(size: 12, weight: .regular, design: .rounded))
352+ .foregroundColor(.red.opacity(0.85))
353+ .multilineTextAlignment(.leading)
354+ .fixedSize(horizontal: false, vertical: true)
355+ }
145356 Button(action: { viewModel.refresh() }) {
146− Label("Refresh now", systemImage: "arrow.clockwise")
357+ HStack(spacing: 4) {
358+ Image(systemName: "arrow.clockwise")
359+ .font(.system(size: 10, weight: .bold))
360+ Text("Retry")
361+ .font(.system(size: 12, weight: .medium, design: .rounded))
362+ }
363+ .foregroundColor(.red.opacity(0.85))
147364 }
148− .disabled(viewModel.isLoading)
149−
150− // API key section
151− VStack(alignment: .leading, spacing: 6) {
152− Text("API Key")
153− .font(.headline)
154− HStack {
155− SecureField("sk-...", text: $viewModel.apiKeyInput)
365+ .buttonStyle(.plain)
366+ }
367+ .padding(12)
368+ .frame(maxWidth: .infinity, alignment: .leading)
369+ .background(
370+ RoundedRectangle(cornerRadius: 12, style: .continuous)
371+ .fill(.red.opacity(0.07))
372+ )
373+ .overlay(
374+ RoundedRectangle(cornerRadius: 12, style: .continuous)
375+ .strokeBorder(.red.opacity(0.2), lineWidth: 1)
376+ )
377+ }
378+
379+ // MARK: - API key section
380+
381+ private var apiKeySection: some View {
382+ DisclosureGroup(isExpanded: $isAPIKeyExpanded) {
383+ VStack(alignment: .leading, spacing: 8) {
384+ HStack(spacing: 8) {
385+ Image(systemName: "key.fill")
386+ .font(.system(size: 12, weight: .semibold))
387+ .symbolRenderingMode(.hierarchical)
388+ .foregroundStyle(hyperAccent)
389+ SecureField("sk-…", text: $viewModel.apiKeyInput)
156390 .textFieldStyle(.roundedBorder)
157− Button("Save") {
158− viewModel.saveAPIKey()
159− }
391+ .font(.system(size: 13, design: .monospaced))
392+ }
393+ Button(action: { viewModel.saveAPIKey() }) {
394+ Text("Save API Key")
395+ .font(.system(size: 13, weight: .medium, design: .rounded))
396+ .frame(maxWidth: .infinity)
397+ .padding(.vertical, 7)
398+ .background(
399+ RoundedRectangle(cornerRadius: 8, style: .continuous)
400+ .fill(hyperAccent.opacity(viewModel.apiKeyInput.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty ? 0.3 : 1.0))
401+ )
402+ .foregroundColor(.white)
403+ }
404+ .buttonStyle(.plain)
405+ .transition(.opacity)
406+ }
407+ .padding(.top, 10)
408+ } label: {
409+ HStack(spacing: 6) {
410+ Image(systemName: "lock.fill")
411+ .font(.system(size: 12, weight: .semibold))
412+ .symbolRenderingMode(.hierarchical)
413+ .foregroundStyle(.secondary)
414+ Text(viewModel.hasAPIKey ? "API Key saved" : "API Key")
415+ .font(.system(size: 13, weight: .medium, design: .rounded))
416+ if viewModel.hasAPIKey {
417+ Image(systemName: "checkmark.circle.fill")
418+ .font(.system(size: 11))
419+ .foregroundStyle(.green)
160420 }
161421 }
422+ }
423+ .accentColor(hyperAccent)
424+ .padding(12)
425+ .background(
426+ RoundedRectangle(cornerRadius: 12, style: .continuous)
427+ .fill(.ultraThinMaterial)
428+ )
429+ }
162430
163− // Launch at login toggle
164− Toggle("Launch at Login", isOn: $viewModel.launchAtLogin)
165− .toggleStyle(.switch)
431+ // MARK: - Footer
166432
167− // Links
433+ private var footerSection: some View {
434+ VStack(spacing: 8) {
435+ // Launch at login
168436 HStack {
437+ Label("Launch at Login", systemImage: "arrow.up.forward.square")
438+ .labelStyle(.titleAndIcon)
439+ .font(.system(size: 12, weight: .regular, design: .rounded))
440+ .symbolRenderingMode(.hierarchical)
441+ Spacer()
442+ Toggle("", isOn: $viewModel.launchAtLogin)
443+ .toggleStyle(.switch)
444+ .controlSize(.small)
445+ .labelsHidden()
446+ }
447+ .padding(.horizontal, 12)
448+ .padding(.vertical, 8)
449+ .background(
450+ RoundedRectangle(cornerRadius: 10, style: .continuous)
451+ .fill(.ultraThinMaterial)
452+ )
453+
454+ // Links row
455+ HStack(spacing: 0) {
169456 if let url = URL(string: "https://hyper.charm.land") {
170− Button("Open hyper.charm.land") {
457+ Button {
171458 NSWorkspace.shared.open(url)
459+ } label: {
460+ HStack(spacing: 4) {
461+ Image(systemName: "arrow.up.right.square")
462+ .font(.system(size: 10, weight: .semibold))
463+ Text("hyper.charm.land")
464+ .font(.system(size: 11, weight: .regular, design: .rounded))
465+ }
466+ .foregroundColor(.secondary)
172467 }
468+ .buttonStyle(.plain)
173469 }
174470 Spacer()
471+ Button {
472+ NSApplication.shared.terminate(nil)
473+ } label: {
474+ Text("Quit")
475+ .font(.system(size: 11, weight: .regular, design: .rounded))
476+ .foregroundColor(.secondary)
477+ }
478+ .buttonStyle(.plain)
479+ .keyboardShortcut("q")
175480 }
481+ .padding(.horizontal, 4)
176482
177− Divider()
178−
179− // Quit
180− Button("Quit HyperCredits") {
181− NSApplication.shared.terminate(nil)
182− }
183− .keyboardShortcut("q")
483+ // Version
484+ Text("HyperCredits \(appVersion)")
485+ .font(.system(size: 10, weight: .regular, design: .rounded))
486+ .foregroundStyle(.tertiary)
184487 }
185− .padding(16)
186− .frame(width: 280)
488+ }
489+
490+ /// Reads the marketing version + build from the main bundle.
491+ private var appVersion: String {
492+ let v = Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String ?? "1.0"
493+ let b = Bundle.main.object(forInfoDictionaryKey: "CFBundleVersion") as? String ?? "1"
494+ return "v\(v).\(b)"
495+ }
496+}
497+
498+// MARK: - Numeric content transition helper
499+
500+private extension ContentTransition {
501+ /// Returns a numeric content transition suitable for animating balance changes.
502+ /// Falls back gracefully across OS versions.
503+ static func numericTransition(for value: Double) -> ContentTransition {
504+ .numericValue(value: value)
187505 }
188506}