diff --git a/Sources/CodexBar/StatusItemController+MenuTypes.swift b/Sources/CodexBar/StatusItemController+MenuTypes.swift index 03fc1785a..e6d515899 100644 --- a/Sources/CodexBar/StatusItemController+MenuTypes.swift +++ b/Sources/CodexBar/StatusItemController+MenuTypes.swift @@ -14,46 +14,250 @@ extension ProviderSwitcherSelection { } struct OverviewMenuCardRowView: View { + struct LiteSummary: Equatable { + let title: String + let primaryText: String + let secondaryText: String? + let progressPercent: Double? + let progressAccessibilityLabel: String? + let pacePercent: Double? + let paceOnTop: Bool + let warningMarkerPercents: [Double] + + @MainActor + static func make(for model: UsageMenuCardView.Model) -> Self? { + if let metric = model.metrics.first { + let primaryText = metric.statusText ?? metric.percentLabel + let secondaryText = [ + metric.detailLeftText, + metric.detailText, + metric.detailRightText, + metric.resetText, + ] + .compactMap { $0?.trimmingCharacters(in: .whitespacesAndNewlines) } + .first { !$0.isEmpty } + return Self( + title: UsageMenuCardView.popupMetricTitle(provider: model.provider, metric: metric), + primaryText: primaryText, + secondaryText: secondaryText, + progressPercent: metric.statusText == nil ? metric.percent : nil, + progressAccessibilityLabel: metric.statusText == nil ? metric.percentStyle.accessibilityLabel : nil, + pacePercent: metric.statusText == nil ? metric.pacePercent : nil, + paceOnTop: metric.paceOnTop, + warningMarkerPercents: metric.statusText == nil ? metric.warningMarkerPercents : []) + } + + if let providerCost = model.providerCost { + return Self( + title: providerCost.title, + primaryText: providerCost.percentLine ?? providerCost.spendLine, + secondaryText: providerCost.percentLine == nil ? nil : providerCost.spendLine, + progressPercent: providerCost.percentUsed, + progressAccessibilityLabel: L("Extra usage spent"), + pacePercent: nil, + paceOnTop: true, + warningMarkerPercents: []) + } + + if let tokenUsage = model.tokenUsage { + return Self( + title: L("cost_header_estimated"), + primaryText: tokenUsage.sessionLine, + secondaryText: tokenUsage.monthLine, + progressPercent: nil, + progressAccessibilityLabel: nil, + pacePercent: nil, + paceOnTop: true, + warningMarkerPercents: []) + } + + if let creditsText = model.creditsText { + return Self( + title: L("Credits"), + primaryText: creditsText, + secondaryText: model.creditsHintText, + progressPercent: nil, + progressAccessibilityLabel: nil, + pacePercent: nil, + paceOnTop: true, + warningMarkerPercents: []) + } + + if let resetCredits = model.codexResetCreditsText { + return Self( + title: L("Credits"), + primaryText: resetCredits, + secondaryText: model.codexResetCreditsDetailText, + progressPercent: nil, + progressAccessibilityLabel: nil, + pacePercent: nil, + paceOnTop: true, + warningMarkerPercents: []) + } + + if let placeholder = model.placeholder { + return Self( + title: L("Usage"), + primaryText: placeholder, + secondaryText: nil, + progressPercent: nil, + progressAccessibilityLabel: nil, + pacePercent: nil, + paceOnTop: true, + warningMarkerPercents: []) + } + + if let note = model.usageNotes.first?.trimmingCharacters(in: .whitespacesAndNewlines), !note.isEmpty { + return Self( + title: L("Usage"), + primaryText: note, + secondaryText: nil, + progressPercent: nil, + progressAccessibilityLabel: nil, + pacePercent: nil, + paceOnTop: true, + warningMarkerPercents: []) + } + + return nil + } + } + let model: UsageMenuCardView.Model let storageText: String? let width: CGFloat @Environment(\.menuItemHighlighted) private var isHighlighted + @Environment(\.menuCardRefreshMonitor) private var refreshMonitor var body: some View { - VStack(alignment: .leading, spacing: 0) { - UsageMenuCardHeaderSectionView( - model: self.model, - showDivider: self.hasUsageBlock, - width: self.width) - if self.hasUsageBlock { - UsageMenuCardUsageSectionView( - model: self.model, - showBottomDivider: false, - bottomPadding: 6, - width: self.width) + let liveModel = self.resolvedLiveModel(refreshMonitor: self.refreshMonitor) + VStack(alignment: .leading, spacing: 8) { + self.header(model: liveModel, subtitle: self.liveSubtitle(model: liveModel)) + if let summary = Self.LiteSummary.make(for: liveModel) { + self.summary(summary, tint: liveModel.progressColor) } if let storageText { - HStack(alignment: .firstTextBaseline, spacing: 4) { - Text("\(L("Storage")):") - .font(.footnote) - .foregroundStyle(MenuHighlightStyle.secondary(self.isHighlighted)) - Text(storageText) + self.storageLine(storageText) + } + } + .padding(.horizontal, UsageMenuCardLayout.horizontalPadding) + .padding(.vertical, 8) + .frame(width: self.width, alignment: .leading) + } + + var liteSummary: LiteSummary? { + Self.LiteSummary.make(for: self.model) + } + + @MainActor + func liteSummary(refreshMonitor: MenuCardRefreshMonitor?) -> LiteSummary? { + Self.LiteSummary.make(for: self.resolvedLiveModel(refreshMonitor: refreshMonitor)) + } + + @MainActor + func resolvedLiveModel(refreshMonitor: MenuCardRefreshMonitor?) -> UsageMenuCardView.Model { + guard self.model.usesLiveSubtitle else { return self.model } + return refreshMonitor?.model(for: self.model.provider, fallback: self.model) ?? self.model + } + + private func liveSubtitle(model: UsageMenuCardView.Model) -> MenuCardLiveSubtitle { + let fallback = MenuCardLiveSubtitle(text: model.subtitleText, style: model.subtitleStyle) + guard self.model.usesLiveSubtitle else { return fallback } + return self.refreshMonitor?.subtitle(for: self.model.provider, fallback: fallback) ?? fallback + } + + private func header(model: UsageMenuCardView.Model, subtitle: MenuCardLiveSubtitle) -> some View { + VStack(alignment: .leading, spacing: 3) { + HStack(alignment: .firstTextBaseline, spacing: UsageMenuCardLayout.headerColumnSpacing) { + Text(model.providerName) + .font(.headline) + .fontWeight(.semibold) + .foregroundStyle(MenuHighlightStyle.primary(self.isHighlighted)) + .lineLimit(1) + .truncationMode(.tail) + .layoutPriority(1) + Spacer(minLength: 8) + Text(model.email) + .font(.subheadline) + .foregroundStyle(MenuHighlightStyle.secondary(self.isHighlighted)) + .lineLimit(1) + .truncationMode(.middle) + } + + HStack(alignment: .firstTextBaseline, spacing: UsageMenuCardLayout.headerColumnSpacing) { + Text(subtitle.text) + .font(.footnote) + .foregroundStyle(self.subtitleColor(for: subtitle.style)) + .lineLimit(1) + .truncationMode(.tail) + .layoutPriority(1) + Spacer(minLength: 8) + if let plan = model.planText { + Text(plan) .font(.footnote) .foregroundStyle(MenuHighlightStyle.secondary(self.isHighlighted)) .lineLimit(1) - Spacer() + .truncationMode(.tail) } - .padding(.horizontal, UsageMenuCardLayout.horizontalPadding) - .padding(.top, self.hasUsageBlock ? 0 : 8) - .padding(.bottom, 6) - .frame(width: self.width, alignment: .leading) } } - .frame(width: self.width, alignment: .leading) } - private var hasUsageBlock: Bool { - self.model.hasUsageContent + private func summary(_ summary: LiteSummary, tint: Color) -> some View { + VStack(alignment: .leading, spacing: 4) { + HStack(alignment: .firstTextBaseline, spacing: 8) { + Text(summary.title) + .font(.footnote.weight(.medium)) + .foregroundStyle(MenuHighlightStyle.primary(self.isHighlighted)) + .lineLimit(1) + .truncationMode(.tail) + .layoutPriority(1) + Spacer(minLength: 8) + Text(summary.primaryText) + .font(.footnote) + .foregroundStyle(MenuHighlightStyle.secondary(self.isHighlighted)) + .lineLimit(1) + .truncationMode(.tail) + } + if let progressPercent = summary.progressPercent, + let progressAccessibilityLabel = summary.progressAccessibilityLabel + { + UsageProgressBar( + percent: progressPercent, + tint: tint, + accessibilityLabel: progressAccessibilityLabel, + pacePercent: summary.pacePercent, + paceOnTop: summary.paceOnTop, + warningMarkerPercents: summary.warningMarkerPercents) + } + if let secondaryText = summary.secondaryText { + Text(secondaryText) + .font(.footnote) + .foregroundStyle(MenuHighlightStyle.secondary(self.isHighlighted)) + .lineLimit(1) + .truncationMode(.tail) + } + } + } + + private func storageLine(_ storageText: String) -> some View { + HStack(alignment: .firstTextBaseline, spacing: 4) { + Text("\(L("Storage")):") + Text(storageText) + .lineLimit(1) + .truncationMode(.tail) + Spacer(minLength: 8) + } + .font(.footnote) + .foregroundStyle(MenuHighlightStyle.secondary(self.isHighlighted)) + } + + private func subtitleColor(for style: UsageMenuCardView.Model.SubtitleStyle) -> Color { + switch style { + case .info: MenuHighlightStyle.secondary(self.isHighlighted) + case .loading: MenuHighlightStyle.secondary(self.isHighlighted) + case .error: MenuHighlightStyle.error(self.isHighlighted) + } } } diff --git a/Tests/CodexBarTests/OverviewMenuCardRowViewTests.swift b/Tests/CodexBarTests/OverviewMenuCardRowViewTests.swift new file mode 100644 index 000000000..fed1ba736 --- /dev/null +++ b/Tests/CodexBarTests/OverviewMenuCardRowViewTests.swift @@ -0,0 +1,113 @@ +import CodexBarCore +import Foundation +import SwiftUI +import Testing +@testable import CodexBar + +struct OverviewMenuCardRowViewTests { + @Test + @MainActor + func `overview lite summary uses first metric progress`() throws { + let model = try Self.makeClaudeModel(usedPercent: 25) + let row = OverviewMenuCardRowView(model: model, storageText: nil, width: 310) + + let summary = try #require(row.liteSummary) + #expect(summary.progressPercent == 75) + #expect(summary.progressAccessibilityLabel == "Usage remaining") + } + + @Test + @MainActor + func `overview lite summary uses monitor resolved refreshed model`() throws { + let staleModel = try Self.makeClaudeModel(usedPercent: 25, updatedAt: Date(timeIntervalSince1970: 1)) + let refreshedModel = try Self.makeClaudeModel(usedPercent: 60, updatedAt: Date(timeIntervalSince1970: 2)) + let monitor = MenuCardRefreshMonitor { provider in + provider == .claude ? refreshedModel : nil + } + let row = OverviewMenuCardRowView(model: staleModel, storageText: nil, width: 310) + + #expect(row.liteSummary?.progressPercent == 75) + let liveSummary = try #require(row.liteSummary(refreshMonitor: monitor)) + #expect(liveSummary.progressPercent == 40) + } + + @Test + @MainActor + func `overview lite summary ignores inline dashboard only content`() { + let dashboard = InlineUsageDashboardModel( + accessibilityLabel: "Claude usage trend", + valueStyle: .currencyUSD, + kpis: [ + InlineUsageDashboardModel.KPI(title: "30d", value: "$1.25", emphasis: true), + ], + points: [ + InlineUsageDashboardModel.Point( + id: "2023-11-14", + label: "Nov 14", + value: 1.25, + accessibilityValue: "2023-11-14: $1.25"), + ], + detailLines: ["Top model: claude-sonnet-4"], + barColor: .orange) + let model = UsageMenuCardView.Model( + provider: .claude, + providerName: "Claude", + email: "user@example.com", + subtitleText: "Updated now", + subtitleStyle: .info, + planText: "Pro", + metrics: [], + usageNotes: [], + openAIAPIUsage: nil, + inlineUsageDashboard: dashboard, + creditsText: nil, + creditsRemaining: nil, + creditsHintText: nil, + creditsHintCopyText: nil, + codexResetCreditsText: nil, + codexResetCreditsDetailText: nil, + providerCost: nil, + tokenUsage: nil, + placeholder: nil, + progressColor: .orange) + let row = OverviewMenuCardRowView(model: model, storageText: nil, width: 310) + + #expect(row.liteSummary == nil) + } + + private static func makeClaudeModel( + usedPercent: Double, + updatedAt: Date = Date(timeIntervalSince1970: 1_700_000_000)) throws -> UsageMenuCardView.Model + { + let metadata = try #require(ProviderDefaults.metadata[.claude]) + let snapshot = UsageSnapshot( + primary: RateWindow( + usedPercent: usedPercent, + windowMinutes: nil, + resetsAt: updatedAt.addingTimeInterval(3600), + resetDescription: nil), + secondary: nil, + updatedAt: updatedAt, + identity: nil) + return UsageMenuCardView.Model.make(.init( + provider: .claude, + metadata: metadata, + snapshot: snapshot, + credits: nil, + creditsError: nil, + dashboard: nil, + dashboardError: nil, + tokenSnapshot: nil, + tokenError: nil, + account: AccountInfo(email: nil, plan: nil), + isRefreshing: false, + lastError: nil, + usageBarsShowUsed: false, + resetTimeDisplayStyle: .countdown, + tokenCostUsageEnabled: false, + showOptionalCreditsAndExtraUsage: true, + hidePersonalInfo: false, + usesLiveSubtitle: true, + now: updatedAt)) + } +}