From f95a93d21eb33fab3eb1e589ea0f8d0ceec20dbf Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 10 Jun 2026 10:01:24 +0000 Subject: [PATCH 1/2] Refine widget design with linear quota bars and pace chips Replace the small widget's circular gauge with a branded header, provider/window hierarchy, large rounded remaining-percent, a 70/90-ramp linear progress bar, and a tinted pace-delta chip. Apply the same treatment to medium rows and restyle the locked and empty states to match. Adopt system content margins instead of extra padding and add a systemMedium preview. https://claude.ai/code/session_01937rrjaTjCPrMYhWujdHaP --- .../Widgets/QuotaKitWidgetViews.swift | 207 +++++++++++++----- .../QuotaKitWidgets.swift | 10 + 2 files changed, 161 insertions(+), 56 deletions(-) diff --git a/CodexBarMobile/CodexBarMobile/Widgets/QuotaKitWidgetViews.swift b/CodexBarMobile/CodexBarMobile/Widgets/QuotaKitWidgetViews.swift index cfff00f6..508000dc 100644 --- a/CodexBarMobile/CodexBarMobile/Widgets/QuotaKitWidgetViews.swift +++ b/CodexBarMobile/CodexBarMobile/Widgets/QuotaKitWidgetViews.swift @@ -45,50 +45,141 @@ struct QuotaKitWidgetView: View { } } -private struct QuotaKitWidgetSmallView: View { - let provider: QuotaKitWidgetSnapshot.Provider +/// Brand colors local to the widget target: `Design/` sources are not +/// compiled into the widget extension, so the accent mirrors +/// `QuotaKitTheme.brandAccent` and the ramp mirrors `QuotaUsageColor`. +private enum WidgetPalette { + static let brandAccent = Color(red: 1.0, green: 0.73, blue: 0.08) + + static func quotaRamp(usedPercent: Double) -> Color { + if usedPercent >= 90 { return .red } + if usedPercent >= 70 { return .orange } + return .green + } +} +private struct WidgetBrandHeader: View { var body: some View { - VStack(alignment: .leading, spacing: 10) { + HStack(spacing: 5) { + Circle() + .fill(WidgetPalette.brandAccent) + .frame(width: 6, height: 6) Text(String(localized: "QuotaKit")) - .font(.caption) + .font(.caption2) .fontWeight(.semibold) .foregroundStyle(.secondary) + } + } +} - Spacer(minLength: 0) +private struct WidgetQuotaBar: View { + let usedPercent: Double + var height: CGFloat = 6 + + private var fillFraction: CGFloat { + CGFloat(max(0, min(100, 100 - self.usedPercent)) / 100) + } + + var body: some View { + GeometryReader { proxy in + ZStack(alignment: .leading) { + Capsule() + .fill(.quaternary) + Capsule() + .fill(WidgetPalette.quotaRamp(usedPercent: self.usedPercent)) + .frame(width: max(self.height, proxy.size.width * self.fillFraction)) + } + } + .frame(height: self.height) + } +} + +private struct WidgetPaceChip: View { + let pace: SyncUsagePace + var compact = false + + var body: some View { + if let deltaText = self.pace.widgetDeltaText { + HStack(spacing: 2) { + Image(systemName: self.pace.deltaPercent < 0 + ? "arrowtriangle.up.fill" + : "arrowtriangle.down.fill") + .font(.system(size: self.compact ? 6 : 7, weight: .bold)) + Text(deltaText) + .font(self.compact ? .caption2.monospacedDigit() : .caption.monospacedDigit()) + .fontWeight(.semibold) + } + .foregroundStyle(self.pace.widgetColor) + .padding(.horizontal, self.compact ? 5 : 7) + .padding(.vertical, self.compact ? 2 : 3) + .background(self.pace.widgetColor.opacity(0.13), in: Capsule()) + .lineLimit(1) + } + } +} + +private struct QuotaKitWidgetSmallView: View { + let provider: QuotaKitWidgetSnapshot.Provider + + var body: some View { + VStack(alignment: .leading, spacing: 0) { + WidgetBrandHeader() + + Spacer(minLength: 4) Text(self.provider.providerName) .font(.headline) - .lineLimit(2) + .lineLimit(1) + .minimumScaleFactor(0.85) if let window = self.provider.primaryWindow { - Gauge(value: window.remainingPercent, in: 0...100) { - Text(window.title) - } currentValueLabel: { - Text("\(Int(window.remainingPercent.rounded()))%") - } - .gaugeStyle(.accessoryCircularCapacity) - - Text(String(localized: "quota left")) + Text(window.title) .font(.caption2) .foregroundStyle(.secondary) + .lineLimit(1) + + Spacer(minLength: 4) + + HStack(alignment: .firstTextBaseline, spacing: 6) { + HStack(alignment: .firstTextBaseline, spacing: 1) { + Text("\(Int(window.remainingPercent.rounded()))") + .font(.system(size: 34, weight: .bold, design: .rounded).monospacedDigit()) + Text(verbatim: "%") + .font(.system(size: 17, weight: .semibold, design: .rounded)) + .foregroundStyle(.secondary) + } + .lineLimit(1) + .minimumScaleFactor(0.7) + + Spacer(minLength: 2) + + if let pace = window.pace { + WidgetPaceChip(pace: pace) + } + } + .padding(.bottom, 7) + + WidgetQuotaBar(usedPercent: window.usedPercent) + if let paceText = window.pace?.widgetDisplayText { Text(paceText) .font(.caption2) .fontWeight(.medium) .foregroundStyle(window.pace?.widgetColor ?? Color.secondary) .lineLimit(1) - .minimumScaleFactor(0.8) + .minimumScaleFactor(0.75) + .padding(.top, 7) } } else { + Spacer(minLength: 4) Text(self.provider.statusMessage ?? String(localized: "No quota window")) .font(.caption) .foregroundStyle(.secondary) - .lineLimit(2) + .lineLimit(3) + Spacer(minLength: 0) } } .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .leading) - .padding() } } @@ -96,56 +187,52 @@ private struct QuotaKitWidgetMediumView: View { let snapshot: QuotaKitWidgetSnapshot var body: some View { - VStack(alignment: .leading, spacing: 10) { + VStack(alignment: .leading, spacing: 11) { HStack { - Text(String(localized: "QuotaKit")) - .font(.headline) + WidgetBrandHeader() Spacer() Text(self.snapshot.generatedAt, style: .time) - .font(.caption) - .foregroundStyle(.secondary) + .font(.caption2) + .foregroundStyle(.tertiary) } let providers = Array(self.snapshot.providers.prefix(3)) ForEach(providers) { provider in - HStack(spacing: 10) { - VStack(alignment: .leading, spacing: 2) { + VStack(alignment: .leading, spacing: 4) { + HStack(alignment: .firstTextBaseline, spacing: 6) { Text(provider.providerName) .font(.subheadline) - .fontWeight(.medium) + .fontWeight(.semibold) .lineLimit(1) Text(provider.widgetSubtitle) .font(.caption2) .foregroundStyle(.secondary) .lineLimit(1) - } - Spacer() + Spacer(minLength: 6) - if let remaining = provider.primaryWindow?.remainingPercent { - VStack(alignment: .trailing, spacing: 1) { - Text("\(Int(remaining.rounded()))%") + if let window = provider.primaryWindow { + if let pace = window.pace { + WidgetPaceChip(pace: pace, compact: true) + } + Text("\(Int(window.remainingPercent.rounded()))%") .font(.subheadline.monospacedDigit()) .fontWeight(.semibold) - if let paceText = provider.primaryWindow?.pace?.widgetShortText { - Text(paceText) - .font(.caption2) - .fontWeight(.medium) - .foregroundStyle(provider.primaryWindow?.pace?.widgetColor ?? Color.secondary) - .lineLimit(1) - .minimumScaleFactor(0.75) - } + } else { + Image(systemName: provider.isError ? "exclamationmark.triangle" : "minus") + .font(.caption) + .foregroundStyle(.secondary) } - } else { - Image(systemName: provider.isError ? "exclamationmark.triangle" : "minus") - .foregroundStyle(.secondary) + } + + if let window = provider.primaryWindow { + WidgetQuotaBar(usedPercent: window.usedPercent, height: 4) } } } Spacer(minLength: 0) } - .padding() } } @@ -213,19 +300,23 @@ private struct QuotaKitWidgetLockedView: View { .font(.caption) } default: - VStack(alignment: .leading, spacing: 8) { + VStack(alignment: .leading, spacing: 0) { + WidgetBrandHeader() + Spacer(minLength: 8) Image(systemName: "lock.fill") - .font(.title2) + .font(.title3) + .foregroundStyle(WidgetPalette.brandAccent) + .padding(.bottom, 6) Text(String(localized: "QuotaKit Pro")) .font(.headline) + .padding(.bottom, 2) Text(String( format: String(localized: "Widgets are included with the %@ lifetime unlock."), ProductConfig.launchPriceCopy)) - .font(.caption) + .font(.caption2) .foregroundStyle(.secondary) } .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .leading) - .padding() } } } @@ -245,15 +336,18 @@ private struct QuotaKitWidgetEmptyView: View { .font(.caption) } default: - VStack(alignment: .leading, spacing: 8) { - Text(String(localized: "QuotaKit")) - .font(.headline) + VStack(alignment: .leading, spacing: 0) { + WidgetBrandHeader() + Spacer(minLength: 8) + Image(systemName: "arrow.triangle.2.circlepath") + .font(.title3) + .foregroundStyle(.secondary) + .padding(.bottom, 6) Text(String(localized: "Open the app after your Mac syncs to refresh widget data.")) - .font(.caption) + .font(.caption2) .foregroundStyle(.secondary) } .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .leading) - .padding() } } } @@ -310,9 +404,6 @@ private extension QuotaKitWidgetSnapshot.Provider { guard let window = self.primaryWindow else { return self.statusMessage ?? String(localized: "No quota window") } - if let paceText = window.pace?.widgetDisplayText { - return "\(window.title) · \(paceText)" - } return window.title } } @@ -325,8 +416,12 @@ private extension SyncUsagePace { return self.leftLabel } - var widgetShortText: String { - self.leftLabel + /// Signed delta with reserve framed as positive ("+81%" = 81% of the + /// window still in reserve versus expected pace); nil when on pace. + var widgetDeltaText: String? { + let reserve = Int((-self.deltaPercent).rounded()) + guard reserve != 0 else { return nil } + return String(format: "%+d%%", reserve) } var widgetColor: Color { diff --git a/CodexBarMobile/CodexBarMobileWidgets/QuotaKitWidgets.swift b/CodexBarMobile/CodexBarMobileWidgets/QuotaKitWidgets.swift index 910ba7d6..bd6ab896 100644 --- a/CodexBarMobile/CodexBarMobileWidgets/QuotaKitWidgets.swift +++ b/CodexBarMobile/CodexBarMobileWidgets/QuotaKitWidgets.swift @@ -77,6 +77,16 @@ struct QuotaKitProviderWidget: Widget { isPreview: true) } +#Preview(as: .systemMedium) { + QuotaKitProviderWidget() +} timeline: { + QuotaKitWidgetEntry( + date: Date(), + snapshot: QuotaKitWidgetPreviewData.snapshot, + isUnlocked: true, + isPreview: true) +} + #Preview(as: .accessoryRectangular) { QuotaKitProviderWidget() } timeline: { From 98c6200d5ca7c3d8dd538792a80af4d6b3512608 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 10 Jun 2026 10:09:58 +0000 Subject: [PATCH 2/2] Add widget window configuration and synced-ago indicator Switch the widget to AppIntentConfiguration with a window parameter so long-press > Edit Widget can choose the primary (session/5-hour) or secondary (weekly) rate window, falling back to primary when a provider reports only one window. Thread the chosen slot through every family's view. Add a sync freshness badge using auto-updating relative date text: compact icon + age in the small widget header, full "Synced N min ago" in the medium header, tinted orange past the 1-hour stale threshold. Extend preview data with a weekly Codex window and add a secondary-slot preview entry. https://claude.ai/code/session_01937rrjaTjCPrMYhWujdHaP --- .../Widgets/QuotaKitWidgetViews.swift | 114 ++++++++++++++---- .../QuotaKitWidgets.swift | 70 ++++++++--- 2 files changed, 147 insertions(+), 37 deletions(-) diff --git a/CodexBarMobile/CodexBarMobile/Widgets/QuotaKitWidgetViews.swift b/CodexBarMobile/CodexBarMobile/Widgets/QuotaKitWidgetViews.swift index 508000dc..5933c7dc 100644 --- a/CodexBarMobile/CodexBarMobile/Widgets/QuotaKitWidgetViews.swift +++ b/CodexBarMobile/CodexBarMobile/Widgets/QuotaKitWidgetViews.swift @@ -2,11 +2,31 @@ import CodexBarSync import SwiftUI import WidgetKit +/// Which rate-limit window a configured widget shows. Mirrors the +/// Mac-synced window order: primary is the session/5-hour window, +/// secondary is the weekly window when the provider reports one. +enum QuotaKitWidgetWindowSlot: String, Sendable { + case primary + case secondary +} + struct QuotaKitWidgetEntry: TimelineEntry { let date: Date let snapshot: QuotaKitWidgetSnapshot? let isUnlocked: Bool let isPreview: Bool + var windowSlot: QuotaKitWidgetWindowSlot = .primary +} + +extension QuotaKitWidgetSnapshot.Provider { + func window(for slot: QuotaKitWidgetWindowSlot) -> Window? { + switch slot { + case .primary: + return self.windows.first + case .secondary: + return self.windows.count > 1 ? self.windows[1] : self.windows.first + } + } } struct QuotaKitWidgetView: View { @@ -29,13 +49,21 @@ struct QuotaKitWidgetView: View { { switch family { case .systemMedium: - QuotaKitWidgetMediumView(snapshot: snapshot) + QuotaKitWidgetMediumView( + snapshot: snapshot, + windowSlot: self.entry.windowSlot) case .accessoryRectangular: - QuotaKitWidgetAccessoryRectangularView(provider: provider) + QuotaKitWidgetAccessoryRectangularView( + provider: provider, + windowSlot: self.entry.windowSlot) case .accessoryCircular: - QuotaKitWidgetAccessoryCircularView(provider: provider) + QuotaKitWidgetAccessoryCircularView( + provider: provider, + windowSlot: self.entry.windowSlot) default: - QuotaKitWidgetSmallView(provider: provider) + QuotaKitWidgetSmallView( + provider: provider, + windowSlot: self.entry.windowSlot) } } else { QuotaKitWidgetEmptyView(family: family) @@ -94,6 +122,34 @@ private struct WidgetQuotaBar: View { } } +/// Live data-age indicator. The relative `Text` style keeps counting up +/// between timeline refreshes without re-rendering the widget. +private struct WidgetSyncBadge: View { + let lastSynced: Date + var compact = false + + /// Mirrors `SyncFreshnessState.staleThreshold`; evaluated when the + /// timeline entry renders, so the tint can lag until the next refresh. + private var isStale: Bool { + Date().timeIntervalSince(self.lastSynced) > 3600 + } + + var body: some View { + HStack(spacing: 3) { + Image(systemName: "arrow.triangle.2.circlepath") + .font(.system(size: 8, weight: .semibold)) + if self.compact { + Text(self.lastSynced, style: .relative) + } else { + Text("Synced \(self.lastSynced, style: .relative) ago") + } + } + .font(.caption2) + .foregroundStyle(self.isStale ? AnyShapeStyle(.orange) : AnyShapeStyle(.tertiary)) + .lineLimit(1) + } +} + private struct WidgetPaceChip: View { let pace: SyncUsagePace var compact = false @@ -120,10 +176,15 @@ private struct WidgetPaceChip: View { private struct QuotaKitWidgetSmallView: View { let provider: QuotaKitWidgetSnapshot.Provider + let windowSlot: QuotaKitWidgetWindowSlot var body: some View { VStack(alignment: .leading, spacing: 0) { - WidgetBrandHeader() + HStack { + WidgetBrandHeader() + Spacer(minLength: 6) + WidgetSyncBadge(lastSynced: self.provider.lastUpdated, compact: true) + } Spacer(minLength: 4) @@ -132,7 +193,7 @@ private struct QuotaKitWidgetSmallView: View { .lineLimit(1) .minimumScaleFactor(0.85) - if let window = self.provider.primaryWindow { + if let window = self.provider.window(for: self.windowSlot) { Text(window.title) .font(.caption2) .foregroundStyle(.secondary) @@ -185,33 +246,35 @@ private struct QuotaKitWidgetSmallView: View { private struct QuotaKitWidgetMediumView: View { let snapshot: QuotaKitWidgetSnapshot + let windowSlot: QuotaKitWidgetWindowSlot var body: some View { VStack(alignment: .leading, spacing: 11) { HStack { WidgetBrandHeader() Spacer() - Text(self.snapshot.generatedAt, style: .time) - .font(.caption2) - .foregroundStyle(.tertiary) + WidgetSyncBadge(lastSynced: self.snapshot.generatedAt) } let providers = Array(self.snapshot.providers.prefix(3)) ForEach(providers) { provider in + let window = provider.window(for: self.windowSlot) VStack(alignment: .leading, spacing: 4) { HStack(alignment: .firstTextBaseline, spacing: 6) { Text(provider.providerName) .font(.subheadline) .fontWeight(.semibold) .lineLimit(1) - Text(provider.widgetSubtitle) + Text(window?.title + ?? provider.statusMessage + ?? String(localized: "No quota window")) .font(.caption2) .foregroundStyle(.secondary) .lineLimit(1) Spacer(minLength: 6) - if let window = provider.primaryWindow { + if let window { if let pace = window.pace { WidgetPaceChip(pace: pace, compact: true) } @@ -225,7 +288,7 @@ private struct QuotaKitWidgetMediumView: View { } } - if let window = provider.primaryWindow { + if let window { WidgetQuotaBar(usedPercent: window.usedPercent, height: 4) } } @@ -238,13 +301,14 @@ private struct QuotaKitWidgetMediumView: View { private struct QuotaKitWidgetAccessoryRectangularView: View { let provider: QuotaKitWidgetSnapshot.Provider + let windowSlot: QuotaKitWidgetWindowSlot var body: some View { VStack(alignment: .leading, spacing: 2) { Text(provider.providerName) .font(.headline) .lineLimit(1) - if let window = provider.primaryWindow { + if let window = provider.window(for: self.windowSlot) { Text(String( format: String(localized: "%lld%% left · %@"), Int64(window.remainingPercent.rounded()), @@ -269,9 +333,10 @@ private struct QuotaKitWidgetAccessoryRectangularView: View { private struct QuotaKitWidgetAccessoryCircularView: View { let provider: QuotaKitWidgetSnapshot.Provider + let windowSlot: QuotaKitWidgetWindowSlot var body: some View { - if let window = provider.primaryWindow { + if let window = provider.window(for: self.windowSlot) { Gauge(value: window.remainingPercent, in: 0...100) { Text(String(localized: "Quota")) } currentValueLabel: { @@ -375,6 +440,18 @@ enum QuotaKitWidgetPreviewData { actualUsedPercent: 37, leftLabel: "5% in reserve", rightLabel: "Lasts until reset")), + .init( + title: "Weekly", + usedPercent: 18, + remainingPercent: 82, + resetsAt: Date(timeIntervalSince1970: 1_803_400_000), + pace: .init( + stage: .slightlyBehind, + deltaPercent: -9, + expectedUsedPercent: 27, + actualUsedPercent: 18, + leftLabel: "9% in reserve", + rightLabel: "Lasts until reset")), ]), .init( id: "claude", @@ -399,15 +476,6 @@ enum QuotaKitWidgetPreviewData { ]) } -private extension QuotaKitWidgetSnapshot.Provider { - var widgetSubtitle: String { - guard let window = self.primaryWindow else { - return self.statusMessage ?? String(localized: "No quota window") - } - return window.title - } -} - private extension SyncUsagePace { var widgetDisplayText: String { if let rightLabel, !rightLabel.isEmpty { diff --git a/CodexBarMobile/CodexBarMobileWidgets/QuotaKitWidgets.swift b/CodexBarMobile/CodexBarMobileWidgets/QuotaKitWidgets.swift index bd6ab896..63185ef7 100644 --- a/CodexBarMobile/CodexBarMobileWidgets/QuotaKitWidgets.swift +++ b/CodexBarMobile/CodexBarMobileWidgets/QuotaKitWidgets.swift @@ -1,7 +1,34 @@ +import AppIntents import SwiftUI import WidgetKit -struct QuotaKitWidgetProvider: TimelineProvider { +enum QuotaWindowOption: String, CaseIterable, AppEnum { + case primary + case secondary + + static let typeDisplayRepresentation = TypeDisplayRepresentation(name: "Quota Window") + static let caseDisplayRepresentations: [QuotaWindowOption: DisplayRepresentation] = [ + .primary: "Primary (session / 5-hour)", + .secondary: "Secondary (weekly)", + ] + + var slot: QuotaKitWidgetWindowSlot { + switch self { + case .primary: .primary + case .secondary: .secondary + } + } +} + +struct QuotaKitWidgetConfigurationIntent: WidgetConfigurationIntent { + static let title: LocalizedStringResource = "QuotaKit Widget" + static let description = IntentDescription("Choose which rate-limit window the widget shows.") + + @Parameter(title: "Quota window", default: .primary) + var window: QuotaWindowOption +} + +struct QuotaKitWidgetProvider: AppIntentTimelineProvider { func placeholder(in context: Context) -> QuotaKitWidgetEntry { QuotaKitWidgetEntry( date: Date(), @@ -10,31 +37,39 @@ struct QuotaKitWidgetProvider: TimelineProvider { isPreview: true) } - func getSnapshot( - in context: Context, - completion: @escaping (QuotaKitWidgetEntry) -> Void) + func snapshot( + for configuration: QuotaKitWidgetConfigurationIntent, + in context: Context) async -> QuotaKitWidgetEntry { - completion(self.makeEntry(isPreview: context.isPreview)) + self.makeEntry( + isPreview: context.isPreview, + windowSlot: configuration.window.slot) } - func getTimeline( - in context: Context, - completion: @escaping (Timeline) -> Void) + func timeline( + for configuration: QuotaKitWidgetConfigurationIntent, + in context: Context) async -> Timeline { - let entry = self.makeEntry(isPreview: context.isPreview) - completion(Timeline( + let entry = self.makeEntry( + isPreview: context.isPreview, + windowSlot: configuration.window.slot) + return Timeline( entries: [entry], - policy: .after(Date().addingTimeInterval(30 * 60)))) + policy: .after(Date().addingTimeInterval(30 * 60))) } - private func makeEntry(isPreview: Bool) -> QuotaKitWidgetEntry { + private func makeEntry( + isPreview: Bool, + windowSlot: QuotaKitWidgetWindowSlot) -> QuotaKitWidgetEntry + { let isProUnlocked = ProEntitlementCacheStore.load() != nil let isUnlocked = isPreview || isProUnlocked return QuotaKitWidgetEntry( date: Date(), snapshot: isPreview ? QuotaKitWidgetPreviewData.snapshot : QuotaKitWidgetSnapshotStore.load(), isUnlocked: isUnlocked, - isPreview: isPreview) + isPreview: isPreview, + windowSlot: windowSlot) } } @@ -49,8 +84,9 @@ struct QuotaKitProviderWidget: Widget { private let kind = "QuotaKitProviderWidget" var body: some WidgetConfiguration { - StaticConfiguration( + AppIntentConfiguration( kind: self.kind, + intent: QuotaKitWidgetConfigurationIntent.self, provider: QuotaKitWidgetProvider()) { entry in QuotaKitWidgetView(entry: entry) @@ -75,6 +111,12 @@ struct QuotaKitProviderWidget: Widget { snapshot: QuotaKitWidgetPreviewData.snapshot, isUnlocked: true, isPreview: true) + QuotaKitWidgetEntry( + date: Date(), + snapshot: QuotaKitWidgetPreviewData.snapshot, + isUnlocked: true, + isPreview: true, + windowSlot: .secondary) } #Preview(as: .systemMedium) {