on master help sign in
feat: clean minimal UI redesign
b2430c2iko
HyperCreditsMenubar/BalanceRingView.swift+14−27
@@ -1,21 +1,18 @@
1import SwiftUI
1import SwiftUI
2
2
3−/// A circular progress "activity ring" that visualises the credit balance
3+/// A very thin, very subtle circular progress ring that visualises the credit
4−/// relative to a reference maximum. The arc fills clockwise from 12 o'clock
4+/// balance relative to a reference maximum. Not currently used in the main
5−/// and uses a gradient driven by the balance colour, giving a quick at-a-glance
5+/// popover — the balance number is the hero, not a ring — but kept as a
6−/// sense of how healthy the balance is.
6+/// clean, minimal implementation that could be layered behind the number
7+/// if a visual indicator is desired.
7///
8///
8−/// When the balance exceeds `referenceMax` the ring simply renders as full
9+/// The track is nearly invisible (0.08 opacity) and the arc is a flat color
9−/// (clamped to 1.0) so very large balances still look good rather than
10+/// (no gradient, no shadow) to stay understated.
10−/// wrapping around.
11struct BalanceRingView: View {
11struct BalanceRingView: View {
12 let balance: Int?
12 let balance: Int?
13 let color: Color
13 let color: Color
14− /// The balance value that maps to a full ring. 500 is a sensible default
14+ /// The balance value that maps to a full ring.
15− /// that keeps low double-digit balances visible on the arc.
16 var referenceMax: Int = 500
15 var referenceMax: Int = 500
17− /// True while a fetch is in flight — dims the ring slightly.
18− var isLoading: Bool = false
19
16
20 /// Progress in the range 0...1.
17 /// Progress in the range 0...1.
21 private var progress: Double {
18 private var progress: Double {
@@ -25,29 +22,19 @@ struct BalanceRingView: View {
25
22
26 var body: some View {
23 var body: some View {
27 ZStack {
24 ZStack {
28− // Track
25+ // Track — barely visible
29 Circle()
26 Circle()
30− .stroke(color.opacity(0.15), lineWidth: ringWidth)
27+ .stroke(color.opacity(0.08), lineWidth: 3)
31
28
32− // Progress arc
29+ // Progress arc — flat color, no gradient, no shadow
33 Circle()
30 Circle()
34 .trim(from: 0, to: progress)
31 .trim(from: 0, to: progress)
35 .stroke(
32 .stroke(
36− AngularGradient(
33+ color,
37− colors: [color.opacity(0.6), color],
38− center: .center,
39− startAngle: .degrees(0),
40− endAngle: .degrees(360 * progress)
41− ),
42− style: StrokeStyle(lineWidth: ringWidth, lineCap: .round)
34+ style: StrokeStyle(lineWidth: 3, lineCap: .round)
43 )
35 )
44 .rotationEffect(.degrees(-90)) // start from top
36 .rotationEffect(.degrees(-90)) // start from top
45− .shadow(color: color.opacity(0.35), radius: 4, x: 0, y: 2)
46− .opacity(isLoading ? 0.45 : 1.0)
47 }
37 }
48− .animation(.easeInOut(duration: 0.6), value: progress)
38+ .animation(.easeInOut(duration: 0.4), value: progress)
49− .animation(.easeInOut(duration: 0.25), value: isLoading)
50 }
39 }
51−
52− private let ringWidth: CGFloat = 9
53}
40}
HyperCreditsMenubar/HyperCreditsApp.swift+12−5
@@ -42,9 +42,14 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
42 popover.behavior = .transient
42 popover.behavior = .transient
43 popover.contentViewController = NSHostingController(rootView: MenuView(viewModel: viewModel))
43 popover.contentViewController = NSHostingController(rootView: MenuView(viewModel: viewModel))
44
44
45− // Observe balance changes to update the menu bar title
45+ // Observe balance changes to update the menu bar title.
46+ // Shows "⚡…" while loading (no balance yet), otherwise the number.
46− statusTextCancellable = viewModel.$balance
47+ statusTextCancellable = viewModel.$isLoading
48+ .combineLatest(viewModel.$balance)
49+ .map { [weak viewModel] isLoading, balance in
50+ if isLoading && balance == nil { return "…" }
47− .map { [weak viewModel] _ in viewModel?.statusBarItemText ?? "⚡?" }
51+ return viewModel?.statusBarItemText ?? "⚡?"
52+ }
48 .removeDuplicates()
53 .removeDuplicates()
49 .receive(on: RunLoop.main)
54 .receive(on: RunLoop.main)
50 .sink { [weak self] text in
55 .sink { [weak self] text in
@@ -125,14 +130,16 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
125
130
126 // MARK: - Loading pulse
131 // MARK: - Loading pulse
127
132
133+ /// Subtle pulse: 0.3s interval, opacity oscillates gently between 0.6–1.0.
128 private func startPulse() {
134 private func startPulse() {
129 pulseStep = 0
135 pulseStep = 0
130 pulseTimer?.invalidate()
136 pulseTimer?.invalidate()
131− let timer = Timer(timeInterval: 0.12, repeats: true) { [weak self] _ in
137+ let timer = Timer(timeInterval: 0.3, repeats: true) { [weak self] _ in
132 guard let self else { return }
138 guard let self else { return }
133 self.pulseStep += 1
139 self.pulseStep += 1
134 let wave = sin(Double(self.pulseStep) * 0.5)
140 let wave = sin(Double(self.pulseStep) * 0.5)
141+ // 0.6 – 1.0 opacity range (subtle, not flashy)
135− let opacity = 0.35 + 0.65 * (0.5 + 0.5 * wave)
142+ let opacity = 0.8 + 0.2 * wave
136 self.statusItem.button?.image = self.statusBarIcon(
143 self.statusItem.button?.image = self.statusBarIcon(
137 color: self.viewModel.balanceColor,
144 color: self.viewModel.balanceColor,
138 opacity: opacity
145 opacity: opacity
HyperCreditsMenubar/MenuView.swift+130−316
@@ -129,368 +129,182 @@ final class ViewModel: ObservableObject {
129
129
130/// The SwiftUI view shown inside the popover when the menu bar item is clicked.
130/// The SwiftUI view shown inside the popover when the menu bar item is clicked.
131///
131///
132−/// Redesigned with a card-based layout, a hero balance ring, animated numeric
132+/// Minimal, airy design: one big balance number as the hero, generous spacing,
133−/// transitions, a collapsible API-key section, and a polished footer. All
133+/// restrained color (balance color is the only accent), no cards or material
134−/// underlying ViewModel logic (API calls, Keychain, launch-at-login) is
134+/// backgrounds. Inspired by Apple's Battery widget and trading apps.
135−/// unchanged — only the presentation layer has been rewritten.
136struct MenuView: View {
135struct MenuView: View {
137 @ObservedObject var viewModel: ViewModel
136 @ObservedObject var viewModel: ViewModel
138
137
139− /// Controls whether the API-key disclosure section is expanded.
138+ var body: some View {
140− @State private var isAPIKeyExpanded: Bool = false
139+ VStack(spacing: 0) {
141− /// Drives the popover content entrance animation.
140+ heroSection
142− @State private var appeared: Bool = false
141+ statusRow
142+ refreshButton
143
143
144− private let hyperAccent = Color(
144+ spacer
145− red: 0.906, green: 0.345, blue: 0.078, opacity: 1.0
146− )
147
145
148− var body: some View {
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
146+ apiKeySection
161− footerSection
147+
162− }
148+ spacer
163− .padding(16)
149+
164− .frame(maxWidth: .infinity)
150+ footerRow
151+ versionText
165 }
152 }
153+ .padding(20)
166− .frame(width: 320, height: 460)
154+ .frame(width: 280)
167− .background(
155+ .fixedSize(horizontal: false, vertical: true)
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)
156+ .background(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)
157+ .animation(.easeInOut(duration: 0.2), value: viewModel.balance)
193− .animation(.easeInOut(duration: 0.3), value: viewModel.errorMessage)
158+ .animation(.easeInOut(duration: 0.2), value: viewModel.errorMessage)
194− .animation(.easeInOut(duration: 0.3), value: viewModel.needsOnboarding)
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)
195 }
165 }
196
166
197 // MARK: - Hero
167 // MARK: - Hero
198
168
169+ /// The balance number is the hero — big, bold, beautiful. Everything else
170+ /// is secondary.
199 private var heroSection: some View {
171 private var heroSection: some View {
200− VStack(spacing: 8) {
172+ VStack(spacing: 6) {
201− ZStack {
173+ Group {
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 {
174+ if viewModel.isLoading && viewModel.balance == nil {
211− ProgressView()
175+ ProgressView()
212− .scaleEffect(0.9)
176+ .controlSize(.small)
213− } else if let balance = viewModel.balance {
177+ } else if let balance = viewModel.balance {
214− Text("\(balance)")
178+ Text("\(balance)")
215− .font(.system(size: 38, weight: .heavy, design: .rounded))
179+ .font(.system(size: 52, weight: .heavy, design: .rounded))
216− .monospacedDigit()
180+ .monospacedDigit()
217− .foregroundColor(viewModel.balanceColor)
181+ .foregroundColor(viewModel.balanceColor)
218− .contentTransition(.opacity)
182+ .contentTransition(.opacity)
219− } else if viewModel.needsOnboarding {
183+ } else if viewModel.needsOnboarding {
220− Image(systemName: "bolt.fill")
184+ Image(systemName: "bolt.fill")
221− .font(.system(size: 34, weight: .bold))
185+ .font(.system(size: 40, weight: .regular))
222− .symbolRenderingMode(.hierarchical)
186+ .symbolRenderingMode(.hierarchical)
223− .foregroundStyle(.secondary)
187+ .foregroundStyle(.secondary)
224− } else {
225− Text("?")
226− .font(.system(size: 38, weight: .heavy, design: .rounded))
227− .foregroundColor(.secondary)
228− }
229−
230− Text("credits")
231− .font(.system(size: 11, weight: .medium, design: .rounded))
232− .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…")
246 } else {
188 } else {
247− Text("Not yet updated")
189+ Text("—")
190+ .font(.system(size: 52, weight: .heavy, design: .rounded))
191+ .foregroundColor(.secondary)
248 }
192 }
249 }
193 }
250− .font(.system(size: 11, weight: .regular, design: .rounded))
194+ .frame(height: 56)
251− .foregroundStyle(.tertiary)
252
195
253− refreshButton
196+ Text("credits")
197+ .font(.system(size: 11, weight: .medium, design: .rounded))
198+ .foregroundColor(.secondary)
199+ .textCase(.uppercase)
200+ .tracking(1.5)
254 }
201 }
255− .padding(16)
256 .frame(maxWidth: .infinity)
202 .frame(maxWidth: .infinity)
257− .background(
203+ .padding(.top, 4)
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 }
204 }
267
205
268− // MARK: - Refresh button
206+ // MARK: - Status
269
207
270− private var refreshButton: some View {
208+ /// Subtle "Updated 2m ago" or "Not connected" — plain text, no icons.
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))
284− }
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− }
296−
297− // MARK: - Onboarding
298−
299− private var onboardingCard: some View {
209+ private var statusRow: some View {
300− VStack(alignment: .leading, spacing: 10) {
210+ Group {
301− HStack(spacing: 10) {
211+ if let relative = viewModel.relativeUpdateText {
302− Image(systemName: "key.fill")
212+ Text("Updated \(relative)")
303− .font(.system(size: 22, weight: .semibold))
213+ } else {
304− .symbolRenderingMode(.hierarchical)
214+ Text("Not connected")
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 }
215 }
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 }
216 }
217+ .font(.system(size: 11, weight: .regular, design: .rounded))
218+ .foregroundStyle(.tertiary)
329− .padding(14)
219+ .padding(.top, 2)
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 }
220 }
340
221
341− // MARK: - Error
222+ // MARK: - Refresh
342
223
224+ /// Minimal text button — no icon rotation, just a text change.
343− private func errorCard(_ message: String) -> some View {
225+ private var refreshButton: 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− }
356− Button(action: { viewModel.refresh() }) {
226+ Button(action: { viewModel.refresh() }) {
357− HStack(spacing: 4) {
227+ Text(viewModel.isLoading ? "Refreshing…" : "Refresh")
358− Image(systemName: "arrow.clockwise")
359− .font(.system(size: 10, weight: .bold))
360− Text("Retry")
361− .font(.system(size: 12, weight: .medium, design: .rounded))
228+ .font(.system(size: 12, weight: .medium, design: .rounded))
362− }
363− .foregroundColor(.red.opacity(0.85))
229+ .foregroundColor(.secondary)
364− }
365− .buttonStyle(.plain)
366 }
230 }
231+ .buttonStyle(.plain)
232+ .disabled(viewModel.isLoading)
367− .padding(12)
233+ .padding(.top, 6)
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 }
234 }
378
235
379− // MARK: - API key section
236+ // MARK: - API Key
380
237
238+ /// Clean SecureField + Save button. No DisclosureGroup, no lock icons.
381 private var apiKeySection: some View {
239 private var apiKeySection: some View {
382− DisclosureGroup(isExpanded: $isAPIKeyExpanded) {
240+ VStack(spacing: 8) {
383− VStack(alignment: .leading, spacing: 8) {
384− HStack(spacing: 8) {
241+ 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)
242+ SecureField("Hyper API Key", text: $viewModel.apiKeyInput)
390− .textFieldStyle(.roundedBorder)
243+ .textFieldStyle(.roundedBorder)
391− .font(.system(size: 13, design: .monospaced))
244+ .font(.system(size: 13, design: .monospaced))
392− }
245+ .onSubmit { viewModel.saveAPIKey() }
246+
393 Button(action: { viewModel.saveAPIKey() }) {
247 Button(action: { viewModel.saveAPIKey() }) {
394− Text("Save API Key")
248+ Text("Save")
395 .font(.system(size: 13, weight: .medium, design: .rounded))
249 .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 }
250 }
404− .buttonStyle(.plain)
251+ .buttonStyle(.borderedProminent)
405− .transition(.opacity)
252+ .controlSize(.small)
406 }
253 }
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)
420− }
421− }
422− }
423− .accentColor(hyperAccent)
424− .padding(12)
425− .background(
426− RoundedRectangle(cornerRadius: 12, style: .continuous)
427− .fill(.ultraThinMaterial)
428− )
429− }
430−
431− // MARK: - Footer
432
254
433− private var footerSection: some View {
255+ if viewModel.hasAPIKey {
434− VStack(spacing: 8) {
256+ Text("✓ Saved")
435− // Launch at login
257+ .font(.system(size: 11, weight: .medium, design: .rounded))
436− HStack {
258+ .foregroundColor(.green)
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) {
456− if let url = URL(string: "https://hyper.charm.land") {
259+ } else if let url = URL(string: "https://hyper.charm.land") {
457− Button {
458− 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)
467− }
468− .buttonStyle(.plain)
469− }
470− Spacer()
471 Button {
260 Button {
472− NSApplication.shared.terminate(nil)
261+ NSWorkspace.shared.open(url)
473 } label: {
262 } label: {
474− Text("Quit")
263+ Text("Get your key at hyper.charm.land")
475 .font(.system(size: 11, weight: .regular, design: .rounded))
264 .font(.system(size: 11, weight: .regular, design: .rounded))
476 .foregroundColor(.secondary)
265 .foregroundColor(.secondary)
477 }
266 }
478 .buttonStyle(.plain)
267 .buttonStyle(.plain)
479− .keyboardShortcut("q")
480 }
268 }
481− .padding(.horizontal, 4)
269+ }
270+ }
482
271
483− // Version
272+ // MARK: - Footer
484− Text("HyperCredits \(appVersion)")
273+
274+ /// Launch at Login toggle on the left, Quit on the right. Very subtle.
275+ private var footerRow: some View {
276+ HStack {
277+ Toggle("Launch at Login", isOn: $viewModel.launchAtLogin)
278+ .toggleStyle(.switch)
279+ .controlSize(.small)
280+ .font(.system(size: 12, weight: .regular, design: .rounded))
281+
282+ Spacer()
283+
284+ Button {
285+ NSApplication.shared.terminate(nil)
286+ } label: {
287+ Text("Quit")
485− .font(.system(size: 10, weight: .regular, design: .rounded))
288+ .font(.system(size: 12, weight: .regular, design: .rounded))
486− .foregroundStyle(.tertiary)
289+ .foregroundColor(.secondary)
290+ }
291+ .buttonStyle(.plain)
292+ .keyboardShortcut("q")
487 }
293 }
488 }
294 }
489
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+
490− /// Reads the marketing version + build from the main bundle.
306+ /// Reads the marketing version from the main bundle.
491 private var appVersion: String {
307 private var appVersion: String {
492− let v = Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String ?? "1.0"
308+ 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 }
309 }
496}
310}