chore: import upstream snapshot with attribution
This commit is contained in:
@@ -0,0 +1,228 @@
|
||||
import AppIntents
|
||||
import CodexBarCore
|
||||
import WidgetKit
|
||||
|
||||
enum BurnProviderChoice: String, AppEnum {
|
||||
case codex
|
||||
case claude
|
||||
|
||||
static let typeDisplayRepresentation = TypeDisplayRepresentation(name: "Provider")
|
||||
|
||||
static let caseDisplayRepresentations: [BurnProviderChoice: DisplayRepresentation] = [
|
||||
.codex: DisplayRepresentation(title: "Codex"),
|
||||
.claude: DisplayRepresentation(title: "Claude"),
|
||||
]
|
||||
|
||||
var provider: UsageProvider {
|
||||
switch self {
|
||||
case .codex: .codex
|
||||
case .claude: .claude
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
enum BurnWindowChoice: String, AppEnum {
|
||||
case session
|
||||
case weekly
|
||||
|
||||
static let typeDisplayRepresentation = TypeDisplayRepresentation(name: "Usage window")
|
||||
|
||||
static let caseDisplayRepresentations: [BurnWindowChoice: DisplayRepresentation] = [
|
||||
.session: DisplayRepresentation(title: "Session (5-hour)"),
|
||||
.weekly: DisplayRepresentation(title: "Weekly (7-day)"),
|
||||
]
|
||||
}
|
||||
|
||||
struct BurnDownSelectionIntent: AppIntent, WidgetConfigurationIntent {
|
||||
static let title: LocalizedStringResource = "Burn Down"
|
||||
static let description = IntentDescription("Select the provider and usage window to display.")
|
||||
|
||||
@Parameter(title: "Provider", default: .codex)
|
||||
var provider: BurnProviderChoice
|
||||
|
||||
@Parameter(title: "Usage window", default: .session)
|
||||
var window: BurnWindowChoice
|
||||
|
||||
init() {
|
||||
self.provider = .codex
|
||||
self.window = .session
|
||||
}
|
||||
}
|
||||
|
||||
struct BurnProviderSelectionIntent: AppIntent, WidgetConfigurationIntent {
|
||||
static let title: LocalizedStringResource = "Burn Down Provider"
|
||||
static let description = IntentDescription("Select the provider to display.")
|
||||
|
||||
@Parameter(title: "Provider", default: .codex)
|
||||
var provider: BurnProviderChoice
|
||||
|
||||
init() {
|
||||
self.provider = .codex
|
||||
}
|
||||
}
|
||||
|
||||
struct BurnDownEntry: TimelineEntry {
|
||||
let date: Date
|
||||
let provider: UsageProvider
|
||||
let window: BurnWindowChoice
|
||||
let snapshot: WidgetSnapshot
|
||||
}
|
||||
|
||||
struct CombinedBurnDownEntry: TimelineEntry {
|
||||
let date: Date
|
||||
let provider: UsageProvider
|
||||
let snapshot: WidgetSnapshot
|
||||
}
|
||||
|
||||
struct BurnDownState {
|
||||
private static let sessionWindowMinutes = 5 * 60
|
||||
private static let weeklyWindowMinutes = 7 * 24 * 60
|
||||
|
||||
let entry: WidgetSnapshot.ProviderEntry
|
||||
let selection: BurnWindowChoice
|
||||
let now: Date
|
||||
|
||||
init?(
|
||||
snapshot: WidgetSnapshot,
|
||||
provider: UsageProvider,
|
||||
selection: BurnWindowChoice,
|
||||
now: Date = Date())
|
||||
{
|
||||
guard let entry = snapshot.entries.first(where: { $0.provider == provider }) else { return nil }
|
||||
self.entry = entry
|
||||
self.selection = selection
|
||||
self.now = now
|
||||
}
|
||||
|
||||
var secondaryGloballyCapsPrimary: Bool {
|
||||
switch self.entry.provider {
|
||||
case .codex, .claude: true
|
||||
default: false
|
||||
}
|
||||
}
|
||||
|
||||
var secondaryExhausted: Bool {
|
||||
guard self.secondaryGloballyCapsPrimary, let secondary = self.secondaryWindow else { return false }
|
||||
guard secondary.remainingPercent <= 0 else { return false }
|
||||
return secondary.resetsAt.map { $0 > self.now } ?? true
|
||||
}
|
||||
|
||||
var primaryWindow: RateWindow? {
|
||||
guard let primary = self.window(minutes: Self.sessionWindowMinutes) else { return nil }
|
||||
guard self.secondaryExhausted, primary.remainingPercent > 0 else { return primary }
|
||||
return RateWindow(
|
||||
usedPercent: 100,
|
||||
windowMinutes: primary.windowMinutes,
|
||||
resetsAt: primary.resetsAt,
|
||||
resetDescription: primary.resetDescription,
|
||||
nextRegenPercent: primary.nextRegenPercent)
|
||||
}
|
||||
|
||||
var secondaryWindow: RateWindow? {
|
||||
self.window(minutes: Self.weeklyWindowMinutes)
|
||||
}
|
||||
|
||||
var selectedWindow: RateWindow? {
|
||||
switch self.selection {
|
||||
case .session: self.primaryWindow
|
||||
case .weekly: self.secondaryWindow
|
||||
}
|
||||
}
|
||||
|
||||
var blankPrimaryChart: Bool {
|
||||
self.selection == .session
|
||||
&& self.secondaryExhausted
|
||||
&& self.window(minutes: Self.sessionWindowMinutes) != nil
|
||||
}
|
||||
|
||||
var selectedResetOverride: Date? {
|
||||
self.blankPrimaryChart ? self.secondaryWindow?.resetsAt : nil
|
||||
}
|
||||
|
||||
private func window(minutes: Int) -> RateWindow? {
|
||||
[self.entry.primary, self.entry.secondary]
|
||||
.compactMap(\.self)
|
||||
.first { $0.windowMinutes == minutes }
|
||||
}
|
||||
}
|
||||
|
||||
enum BurnDownRefreshSchedule {
|
||||
private static let maximumInterval: TimeInterval = 30 * 60
|
||||
|
||||
static func nextRefresh(
|
||||
snapshot: WidgetSnapshot,
|
||||
provider: UsageProvider,
|
||||
now: Date = Date()) -> Date
|
||||
{
|
||||
let fallback = now.addingTimeInterval(self.maximumInterval)
|
||||
guard let entry = snapshot.entries.first(where: { $0.provider == provider }) else { return fallback }
|
||||
let nextReset = [entry.primary?.resetsAt, entry.secondary?.resetsAt]
|
||||
.compactMap(\.self)
|
||||
.filter { $0 > now }
|
||||
.min()?
|
||||
.addingTimeInterval(1)
|
||||
return min(fallback, nextReset ?? fallback)
|
||||
}
|
||||
}
|
||||
|
||||
struct BurnDownTimelineProvider: AppIntentTimelineProvider {
|
||||
func placeholder(in context: Context) -> BurnDownEntry {
|
||||
BurnDownEntry(
|
||||
date: Date(),
|
||||
provider: .codex,
|
||||
window: .session,
|
||||
snapshot: WidgetPreviewData.snapshot())
|
||||
}
|
||||
|
||||
func snapshot(for configuration: BurnDownSelectionIntent, in context: Context) async -> BurnDownEntry {
|
||||
BurnDownEntry(
|
||||
date: Date(),
|
||||
provider: configuration.provider.provider,
|
||||
window: configuration.window,
|
||||
snapshot: WidgetSnapshotStore.load() ?? WidgetPreviewData.snapshot())
|
||||
}
|
||||
|
||||
func timeline(
|
||||
for configuration: BurnDownSelectionIntent,
|
||||
in context: Context) async -> Timeline<BurnDownEntry>
|
||||
{
|
||||
let entry = BurnDownEntry(
|
||||
date: Date(),
|
||||
provider: configuration.provider.provider,
|
||||
window: configuration.window,
|
||||
snapshot: WidgetSnapshotStore.load() ?? WidgetPreviewData.emptySnapshot())
|
||||
let refresh = BurnDownRefreshSchedule.nextRefresh(snapshot: entry.snapshot, provider: entry.provider)
|
||||
return Timeline(entries: [entry], policy: .after(refresh))
|
||||
}
|
||||
}
|
||||
|
||||
struct CombinedBurnDownTimelineProvider: AppIntentTimelineProvider {
|
||||
func placeholder(in context: Context) -> CombinedBurnDownEntry {
|
||||
CombinedBurnDownEntry(
|
||||
date: Date(),
|
||||
provider: .codex,
|
||||
snapshot: WidgetPreviewData.snapshot())
|
||||
}
|
||||
|
||||
func snapshot(
|
||||
for configuration: BurnProviderSelectionIntent,
|
||||
in context: Context) async -> CombinedBurnDownEntry
|
||||
{
|
||||
CombinedBurnDownEntry(
|
||||
date: Date(),
|
||||
provider: configuration.provider.provider,
|
||||
snapshot: WidgetSnapshotStore.load() ?? WidgetPreviewData.snapshot())
|
||||
}
|
||||
|
||||
func timeline(
|
||||
for configuration: BurnProviderSelectionIntent,
|
||||
in context: Context) async -> Timeline<CombinedBurnDownEntry>
|
||||
{
|
||||
let entry = CombinedBurnDownEntry(
|
||||
date: Date(),
|
||||
provider: configuration.provider.provider,
|
||||
snapshot: WidgetSnapshotStore.load() ?? WidgetPreviewData.emptySnapshot())
|
||||
let refresh = BurnDownRefreshSchedule.nextRefresh(snapshot: entry.snapshot, provider: entry.provider)
|
||||
return Timeline(entries: [entry], policy: .after(refresh))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,682 @@
|
||||
import AppKit
|
||||
import CodexBarCore
|
||||
import SwiftUI
|
||||
import WidgetKit
|
||||
|
||||
// MARK: - Entry View
|
||||
|
||||
struct BurnDownWidgetView: View {
|
||||
let entry: BurnDownEntry
|
||||
|
||||
var body: some View {
|
||||
let state = BurnDownState(
|
||||
snapshot: self.entry.snapshot,
|
||||
provider: self.entry.provider,
|
||||
selection: self.entry.window)
|
||||
|
||||
Group {
|
||||
if let state, let window = state.selectedWindow {
|
||||
BurnDownLayout(
|
||||
window: window,
|
||||
provider: self.entry.provider,
|
||||
blankChart: state.blankPrimaryChart,
|
||||
resetsAtOverride: state.selectedResetOverride)
|
||||
} else {
|
||||
self.emptyState
|
||||
}
|
||||
}
|
||||
.containerBackground(for: .widget) {
|
||||
BurnWidgetBackground()
|
||||
}
|
||||
}
|
||||
|
||||
private var emptyState: some View {
|
||||
VStack(spacing: 6) {
|
||||
Text("Open CodexBar")
|
||||
.font(.body)
|
||||
.fontWeight(.semibold)
|
||||
Text("Usage data will appear once the app refreshes.")
|
||||
.font(.caption)
|
||||
.multilineTextAlignment(.center)
|
||||
.opacity(0.55)
|
||||
}
|
||||
.padding(12)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Main Layout
|
||||
|
||||
private struct BurnDownLayout: View {
|
||||
@Environment(\.widgetRenderingMode) private var renderingMode
|
||||
@Environment(\.colorScheme) private var colorScheme
|
||||
|
||||
let window: RateWindow
|
||||
let provider: UsageProvider
|
||||
/// True when the session window is blocked because the weekly budget is exhausted:
|
||||
/// suppress the chart and retarget "Resets in" to the weekly reset.
|
||||
var blankChart = false
|
||||
/// When set, "Resets in" counts down to this date (the weekly reset) instead of the
|
||||
/// session window's own reset.
|
||||
var resetsAtOverride: Date?
|
||||
|
||||
var body: some View {
|
||||
let dark = self.colorScheme == .dark
|
||||
let isMonochrome = self.renderingMode != .fullColor
|
||||
let geom = BurnGeom(window: self.window)
|
||||
let theme = BurnTheme(provider: self.provider, geom: geom, dark: dark, isMonochrome: isMonochrome)
|
||||
let windowMins = self.window.windowMinutes ?? 300
|
||||
let isDailyWindow = windowMins >= 1440
|
||||
let now = Date()
|
||||
let estimatedResetMinutes = self.blankChart || geom.tNow >= 1
|
||||
? nil
|
||||
: (1 - geom.tNow) * Double(windowMins)
|
||||
let explicitReset = self.blankChart
|
||||
? self.resetsAtOverride
|
||||
: self.resetsAtOverride ?? self.window.resetsAt
|
||||
let effectiveResetAt = burnEffectiveResetDate(
|
||||
explicitResetAt: explicitReset,
|
||||
estimatedResetMinutes: estimatedResetMinutes,
|
||||
now: now)
|
||||
let resetsIn = effectiveResetAt.map { max(0, $0.timeIntervalSince(now) / 60) } ?? 0
|
||||
let outInMins = geom.slope < -0.01 ? (geom.vNow / -geom.slope) * Double(windowMins) : Double.infinity
|
||||
// Very early in the window a single sample can't forecast a credible run-out: a
|
||||
// tiny burst right after reset extrapolates to "runs dry in minutes" even at ~99%
|
||||
// remaining. Match the design's fresh-window behaviour ("Runs out: after reset")
|
||||
// and only surface the estimate once enough of the window has elapsed to trust the
|
||||
// average burn rate.
|
||||
let windowEstablished = geom.tNow >= 0.08
|
||||
let runsDryBefore = geom.runsOut && outInMins < resetsIn && windowEstablished
|
||||
|
||||
let sign = geom.margin >= 0 ? "+" : "−"
|
||||
let badgeNum = "\(sign)\(abs(Int(geom.margin.rounded())))%"
|
||||
// Per the design's edge states, "fresh" and "spent" show only the glyph + word
|
||||
// (◆ full / ■ spent) with no pace number — the margin is meaningless once the
|
||||
// budget is full or gone.
|
||||
let showBadgeNumber = !geom.depleted && !geom.fresh
|
||||
let statusWord: String = geom.depleted ? "spent" : geom.fresh ? "full"
|
||||
: geom.status == .ahead ? "conserving" : geom.status == .behind ? "over pace" : "on pace"
|
||||
let arrow: String = geom.depleted ? "■" : geom.fresh ? "◆"
|
||||
: geom.status == .ahead ? "▲" : geom.status == .behind ? "▼" : "●"
|
||||
|
||||
let axisDates = burnAxisDateRange(
|
||||
effectiveResetAt: effectiveResetAt,
|
||||
windowMinutes: windowMins,
|
||||
now: now)
|
||||
let startLabel = burnAxisLabel(axisDates.start, isDailyWindow: isDailyWindow)
|
||||
let resetLabel = burnAxisLabel(axisDates.reset, isDailyWindow: isDailyWindow)
|
||||
|
||||
VStack(spacing: 0) {
|
||||
// Header: brand + pace badge
|
||||
HStack(alignment: .top) {
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
HStack(spacing: 6) {
|
||||
Circle()
|
||||
.fill(theme.brandDot)
|
||||
.frame(width: 7, height: 7)
|
||||
.shadow(color: theme.brandDot.opacity(0.7), radius: 3.5)
|
||||
Text(burnProviderName(self.provider))
|
||||
.font(.system(size: 14.5, weight: .semibold))
|
||||
.foregroundStyle(theme.text)
|
||||
.lineLimit(1)
|
||||
}
|
||||
Text(burnWindowLabel(self.window.windowMinutes))
|
||||
.font(.system(size: 11))
|
||||
.foregroundStyle(theme.sub)
|
||||
.kerning(0.2)
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
VStack(alignment: .trailing, spacing: 1) {
|
||||
if showBadgeNumber {
|
||||
Text(badgeNum)
|
||||
.font(.system(size: 14, weight: .semibold))
|
||||
.foregroundStyle(theme.statusColor)
|
||||
.monospacedDigit()
|
||||
}
|
||||
HStack(spacing: 3) {
|
||||
Text(arrow)
|
||||
.font(.system(size: 8))
|
||||
.foregroundStyle(theme.statusColor)
|
||||
Text(statusWord)
|
||||
.font(.system(size: 10.5))
|
||||
.foregroundStyle(theme.sub)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Body: stats + hero / chart
|
||||
HStack(alignment: .bottom, spacing: 13) {
|
||||
// Left: stats + hero %
|
||||
VStack(alignment: .leading, spacing: 0) {
|
||||
VStack(spacing: 5) {
|
||||
BurnResetStatRow(resetAt: effectiveResetAt, theme: theme)
|
||||
BurnStatRow(
|
||||
label: geom.depleted ? "Ran out" : runsDryBefore ? "Runs out in" : "Runs out",
|
||||
value: geom
|
||||
.depleted ? "budget spent" : runsDryBefore ? "~\(burnFmtDuration(outInMins))" :
|
||||
"after reset",
|
||||
theme: theme,
|
||||
danger: geom.depleted || runsDryBefore)
|
||||
}
|
||||
.padding(.top, 8)
|
||||
|
||||
Spacer()
|
||||
|
||||
HStack(alignment: .lastTextBaseline, spacing: 5) {
|
||||
Text("\(Int(geom.vNow.rounded()))")
|
||||
.font(.system(size: 41, weight: .semibold))
|
||||
.foregroundStyle(geom.depleted ? theme.danger : theme.text)
|
||||
.monospacedDigit()
|
||||
.lineLimit(1)
|
||||
.minimumScaleFactor(0.75)
|
||||
Text("%")
|
||||
.font(.system(size: 19, weight: .medium))
|
||||
.foregroundStyle(theme.sub)
|
||||
Text("left")
|
||||
.font(.system(size: 11))
|
||||
.foregroundStyle(theme.sub)
|
||||
}
|
||||
}
|
||||
.frame(width: 143, alignment: .leading)
|
||||
|
||||
// Right: chart + axis. Blanked when the session window is blocked by the
|
||||
// weekly cap — there's no session burn to chart until the weekly resets.
|
||||
VStack(spacing: 2) {
|
||||
if self.blankChart {
|
||||
Color.clear.frame(height: 84)
|
||||
Color.clear.frame(height: 13)
|
||||
} else {
|
||||
BurnChartCanvas(
|
||||
geom: geom,
|
||||
theme: theme)
|
||||
.frame(height: 84)
|
||||
|
||||
BurnAxisRow(
|
||||
startLabel: startLabel,
|
||||
resetLabel: resetLabel,
|
||||
tNow: geom.tNow,
|
||||
theme: theme)
|
||||
.frame(height: 13)
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 15)
|
||||
.padding(.top, 13)
|
||||
.padding(.bottom, 12)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Stat Row
|
||||
|
||||
private struct BurnStatRow: View {
|
||||
let label: String
|
||||
let value: String
|
||||
let theme: BurnTheme
|
||||
let danger: Bool
|
||||
|
||||
var body: some View {
|
||||
HStack(alignment: .firstTextBaseline) {
|
||||
Text(self.label)
|
||||
.font(.system(size: 11.5))
|
||||
.foregroundStyle(self.theme.sub)
|
||||
Spacer()
|
||||
Text(self.value)
|
||||
.font(.system(size: 12.5, weight: .semibold))
|
||||
.foregroundStyle(self.danger ? self.theme.danger : self.theme.text)
|
||||
.monospacedDigit()
|
||||
.lineLimit(1)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct BurnResetStatRow: View {
|
||||
let resetAt: Date?
|
||||
let theme: BurnTheme
|
||||
|
||||
var body: some View {
|
||||
HStack(alignment: .firstTextBaseline) {
|
||||
Text("Resets in")
|
||||
.font(.system(size: 11.5))
|
||||
.foregroundStyle(self.theme.sub)
|
||||
Spacer()
|
||||
if let resetAt {
|
||||
Text(resetAt, style: .relative)
|
||||
.font(.system(size: 12.5, weight: .semibold))
|
||||
.foregroundStyle(self.theme.text)
|
||||
.monospacedDigit()
|
||||
.lineLimit(1)
|
||||
} else {
|
||||
Text("—")
|
||||
.font(.system(size: 12.5, weight: .semibold))
|
||||
.foregroundStyle(self.theme.text)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Axis Row
|
||||
|
||||
private struct BurnAxisRow: View {
|
||||
let startLabel: String
|
||||
let resetLabel: String
|
||||
let tNow: Double
|
||||
let theme: BurnTheme
|
||||
|
||||
var body: some View {
|
||||
GeometryReader { geo in
|
||||
// Hide "now" when it would collide with the start/reset labels. The edge labels
|
||||
// are anchored to the ends, so estimate their widths (≈9.5pt monospaced digits)
|
||||
// and only show "now" when it clears both with a small gap — otherwise the
|
||||
// now-dot on the chart already conveys position. Matches the design's rule that
|
||||
// "now" hides near an end label.
|
||||
let w = geo.size.width
|
||||
let approxChar: CGFloat = 5.8
|
||||
let nowX = self.tNow * w
|
||||
let nowHalf: CGFloat = 13
|
||||
let gap: CGFloat = 6
|
||||
let clearsStart = nowX - nowHalf > CGFloat(self.startLabel.count) * approxChar + gap
|
||||
let clearsReset = nowX + nowHalf < w - CGFloat(self.resetLabel.count) * approxChar - gap
|
||||
let showNow = self.tNow > 0.05 && self.tNow < 0.95 && clearsStart && clearsReset
|
||||
|
||||
ZStack(alignment: .leading) {
|
||||
Text(self.startLabel)
|
||||
.font(.system(size: 9.5))
|
||||
.foregroundStyle(self.theme.sub)
|
||||
.monospacedDigit()
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
|
||||
if showNow {
|
||||
Text("now")
|
||||
.font(.system(size: 9.5, weight: .semibold))
|
||||
.foregroundStyle(self.theme.text)
|
||||
.position(x: nowX, y: geo.size.height / 2)
|
||||
}
|
||||
|
||||
Text(self.resetLabel)
|
||||
.font(.system(size: 9.5))
|
||||
.foregroundStyle(self.theme.sub)
|
||||
.monospacedDigit()
|
||||
.frame(maxWidth: .infinity, alignment: .trailing)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Chart Canvas
|
||||
|
||||
private struct BurnChartCanvas: View {
|
||||
let geom: BurnGeom
|
||||
let theme: BurnTheme
|
||||
|
||||
var body: some View {
|
||||
Canvas { context, size in
|
||||
let w = size.width
|
||||
let h = size.height
|
||||
let padT: CGFloat = 8
|
||||
let padB: CGFloat = 2
|
||||
let padL: CGFloat = 1
|
||||
let padR: CGFloat = 1
|
||||
|
||||
func X(_ t: Double) -> CGFloat {
|
||||
padL + CGFloat(t) * (w - padL - padR)
|
||||
}
|
||||
func Y(_ v: Double) -> CGFloat {
|
||||
padT + CGFloat(1 - v / 100) * (h - padT - padB)
|
||||
}
|
||||
|
||||
let tNow = self.geom.tNow
|
||||
let vNow = self.geom.vNow
|
||||
|
||||
// --- Now vertical hairline ---
|
||||
do {
|
||||
var p = Path()
|
||||
p.move(to: CGPoint(x: X(tNow), y: Y(100)))
|
||||
p.addLine(to: CGPoint(x: X(tNow), y: Y(0)))
|
||||
context.stroke(p, with: .color(self.theme.chartGrid), lineWidth: 1)
|
||||
}
|
||||
|
||||
// --- Baseline ---
|
||||
do {
|
||||
var p = Path()
|
||||
p.move(to: CGPoint(x: X(0), y: Y(0)))
|
||||
p.addLine(to: CGPoint(x: X(1), y: Y(0)))
|
||||
context.stroke(p, with: .color(self.theme.chartGrid), lineWidth: 1)
|
||||
}
|
||||
|
||||
// --- Area fill (gradient from actual line down to baseline) ---
|
||||
do {
|
||||
var p = Path()
|
||||
p.move(to: CGPoint(x: X(0), y: Y(100)))
|
||||
p.addLine(to: CGPoint(x: X(tNow), y: Y(vNow)))
|
||||
p.addLine(to: CGPoint(x: X(tNow), y: Y(0)))
|
||||
p.addLine(to: CGPoint(x: X(0), y: Y(0)))
|
||||
p.closeSubpath()
|
||||
|
||||
let gradient = Gradient(stops: [
|
||||
.init(color: self.theme.chartFillTop.opacity(self.theme.chartFillTopOpacity), location: 0),
|
||||
.init(color: self.theme.chartFillTop.opacity(0), location: 0.92),
|
||||
])
|
||||
context.fill(
|
||||
p,
|
||||
with: .linearGradient(
|
||||
gradient,
|
||||
startPoint: CGPoint(x: 0, y: padT),
|
||||
endPoint: CGPoint(x: 0, y: h)))
|
||||
}
|
||||
|
||||
// --- Ideal line (dashed, knocked back) ---
|
||||
do {
|
||||
var p = Path()
|
||||
p.move(to: CGPoint(x: X(0), y: Y(100)))
|
||||
p.addLine(to: CGPoint(x: X(1), y: Y(0)))
|
||||
context.stroke(
|
||||
p,
|
||||
with: .color(self.theme.chartIdeal),
|
||||
style: StrokeStyle(lineWidth: 1.4, lineCap: .round, dash: [2.5, 3]))
|
||||
}
|
||||
|
||||
// --- Projection (fine dotted) ---
|
||||
if self.geom.slope < -0.01 {
|
||||
var p = Path()
|
||||
p.move(to: CGPoint(x: X(tNow), y: Y(vNow)))
|
||||
p.addLine(to: CGPoint(x: X(self.geom.projT), y: Y(self.geom.projV)))
|
||||
context.stroke(
|
||||
p,
|
||||
with: .color(self.theme.chartProj.opacity(0.95)),
|
||||
style: StrokeStyle(lineWidth: 1.6, lineCap: .round, dash: [0.5, 3.5]))
|
||||
}
|
||||
|
||||
// --- Actual line (solid, hero) ---
|
||||
do {
|
||||
var p = Path()
|
||||
p.move(to: CGPoint(x: X(0), y: Y(100)))
|
||||
p.addLine(to: CGPoint(x: X(tNow), y: Y(vNow)))
|
||||
context.stroke(
|
||||
p,
|
||||
with: .color(self.theme.chartLine),
|
||||
style: StrokeStyle(lineWidth: 2.4, lineCap: .round, lineJoin: .round))
|
||||
}
|
||||
|
||||
// --- Now dot (filled, with ring punched in bg color) ---
|
||||
let dotCenter = CGPoint(x: X(tNow), y: Y(vNow))
|
||||
let ringPath = Path(ellipseIn: CGRect(
|
||||
x: dotCenter.x - 5.4, y: dotCenter.y - 5.4, width: 10.8, height: 10.8))
|
||||
context.fill(ringPath, with: .color(self.theme.chartNowRing))
|
||||
let dotPath = Path(ellipseIn: CGRect(
|
||||
x: dotCenter.x - 3.4, y: dotCenter.y - 3.4, width: 6.8, height: 6.8))
|
||||
context.fill(dotPath, with: .color(self.theme.chartNowDot))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Background
|
||||
|
||||
struct BurnWidgetBackground: View {
|
||||
@Environment(\.widgetRenderingMode) private var renderingMode
|
||||
@Environment(\.colorScheme) private var colorScheme
|
||||
|
||||
var body: some View {
|
||||
if self.renderingMode == .fullColor {
|
||||
let dark = self.colorScheme == .dark
|
||||
LinearGradient(
|
||||
colors: dark
|
||||
? [BurnPalette.darkBgTop, BurnPalette.darkBgBottom]
|
||||
: [BurnPalette.lightBgTop, BurnPalette.lightBgBottom],
|
||||
startPoint: .init(x: 0.15, y: 0),
|
||||
endPoint: .init(x: 0.85, y: 1))
|
||||
.overlay(alignment: .top) {
|
||||
LinearGradient(
|
||||
colors: [
|
||||
Color.white.opacity(dark ? 0.04 : 0.60),
|
||||
Color.white.opacity(0),
|
||||
],
|
||||
startPoint: .top,
|
||||
endPoint: .center)
|
||||
}
|
||||
} else {
|
||||
Color.clear
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Theme
|
||||
|
||||
struct BurnTheme {
|
||||
let text: Color
|
||||
let sub: Color
|
||||
let hair: Color
|
||||
let accent: Color
|
||||
let statusColor: Color
|
||||
let danger: Color
|
||||
let brandDot: Color
|
||||
let chartLine: Color
|
||||
let chartFillTop: Color
|
||||
let chartFillTopOpacity: Double
|
||||
let chartIdeal: Color
|
||||
let chartProj: Color
|
||||
let chartGrid: Color
|
||||
let chartNowDot: Color
|
||||
let chartNowRing: Color
|
||||
|
||||
init(provider: UsageProvider, geom: BurnGeom, dark: Bool, isMonochrome: Bool) {
|
||||
if isMonochrome {
|
||||
let fg = dark ? Color.white : Color.black
|
||||
self.text = fg.opacity(dark ? 0.95 : 0.90)
|
||||
self.sub = fg.opacity(dark ? 0.50 : 0.46)
|
||||
self.hair = fg.opacity(dark ? 0.13 : 0.11)
|
||||
self.accent = fg.opacity(dark ? 0.95 : 0.85)
|
||||
self.statusColor = fg.opacity(dark ? 0.90 : 0.80)
|
||||
self.danger = fg.opacity(dark ? 0.95 : 0.85)
|
||||
self.brandDot = fg.opacity(dark ? 0.85 : 0.72)
|
||||
self.chartLine = fg.opacity(dark ? 0.95 : 0.85)
|
||||
self.chartFillTop = fg.opacity(dark ? 0.95 : 0.85)
|
||||
self.chartFillTopOpacity = dark ? 0.20 : 0.16
|
||||
self.chartIdeal = fg.opacity(dark ? 0.36 : 0.30)
|
||||
self.chartProj = fg.opacity(dark ? 0.60 : 0.48)
|
||||
self.chartGrid = fg.opacity(dark ? 0.12 : 0.10)
|
||||
self.chartNowDot = fg.opacity(dark ? 1.0 : 0.92)
|
||||
self.chartNowRing = dark
|
||||
? Color(red: 0.10, green: 0.10, blue: 0.12).opacity(0.92)
|
||||
: Color(red: 0.96, green: 0.96, blue: 0.97).opacity(0.95)
|
||||
} else {
|
||||
let accentColor = Self.accentColor(geom.status, dark: dark)
|
||||
self.accent = accentColor
|
||||
self.statusColor = accentColor
|
||||
self.text = dark ? Color.white.opacity(0.98) : Color(white: 0.20)
|
||||
self.sub = dark ? Color(white: 0.60) : Color(white: 0.45)
|
||||
self.hair = dark ? Color.white.opacity(0.10) : Color.black.opacity(0.09)
|
||||
self.danger = BurnPalette.behindDark
|
||||
self.brandDot = Self.brandDotColor(provider)
|
||||
self.chartLine = accentColor
|
||||
self.chartFillTop = accentColor
|
||||
self.chartFillTopOpacity = dark ? 0.30 : 0.22
|
||||
self.chartIdeal = dark ? Color.white.opacity(0.45) : Color.black.opacity(0.50)
|
||||
// Projection goes red when behind (the one allowed color cue in full-color mode)
|
||||
self.chartProj = geom.status == .behind ? BurnPalette.behindDark : accentColor
|
||||
self.chartGrid = dark ? Color.white.opacity(0.10) : Color.black.opacity(0.09)
|
||||
self.chartNowDot = accentColor
|
||||
self.chartNowRing = dark ? BurnPalette.darkBgBottom : BurnPalette.lightBgBottom
|
||||
}
|
||||
}
|
||||
|
||||
private static func accentColor(_ status: BurnGeom.Status, dark: Bool) -> Color {
|
||||
switch status {
|
||||
case .ahead: dark ? BurnPalette.aheadDark : BurnPalette.aheadLight
|
||||
case .onpace: dark ? BurnPalette.onpaceDark : BurnPalette.onpaceLight
|
||||
case .behind: dark ? BurnPalette.behindDark : BurnPalette.behindLight
|
||||
}
|
||||
}
|
||||
|
||||
private static func brandDotColor(_ provider: UsageProvider) -> Color {
|
||||
switch provider {
|
||||
case .claude: BurnPalette.claudeDot
|
||||
case .codex: BurnPalette.codexDot
|
||||
case .gemini: BurnPalette.geminiDot
|
||||
default: BurnPalette.genericDot
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Palette
|
||||
|
||||
enum BurnPalette {
|
||||
// Status accents — approximated from OKLCH (L=0.80 dark, L=0.62 light)
|
||||
// oklch(0.80 0.15 152) / oklch(0.62 0.15 152) — green
|
||||
static let aheadDark = Color(red: 0.306, green: 0.800, blue: 0.506)
|
||||
static let aheadLight = Color(red: 0.192, green: 0.620, blue: 0.376)
|
||||
// oklch(0.80 0.11 236) / oklch(0.62 0.11 236) — blue
|
||||
static let onpaceDark = Color(red: 0.408, green: 0.668, blue: 0.910)
|
||||
static let onpaceLight = Color(red: 0.264, green: 0.474, blue: 0.712)
|
||||
// oklch(0.72 0.19 26) / oklch(0.60 0.19 26) — red-orange
|
||||
static let behindDark = Color(red: 0.922, green: 0.420, blue: 0.227)
|
||||
static let behindLight = Color(red: 0.762, green: 0.294, blue: 0.137)
|
||||
|
||||
// Brand identity dots — always the LLM's hue
|
||||
static let claudeDot = Color(red: 0.880, green: 0.580, blue: 0.180) // clay/amber, hue 48
|
||||
static let codexDot = Color(red: 0.120, green: 0.780, blue: 0.598) // teal, hue 168
|
||||
static let geminiDot = Color(red: 0.420, green: 0.440, blue: 0.900) // indigo, hue 268
|
||||
static let genericDot = Color(white: 0.60)
|
||||
|
||||
// Backgrounds
|
||||
static let darkBgTop = Color(red: 0.108, green: 0.108, blue: 0.132)
|
||||
static let darkBgBottom = Color(red: 0.132, green: 0.132, blue: 0.156)
|
||||
static let lightBgTop = Color(white: 0.990)
|
||||
static let lightBgBottom = Color(red: 0.940, green: 0.940, blue: 0.960)
|
||||
}
|
||||
|
||||
// MARK: - Geometry
|
||||
|
||||
struct BurnGeom {
|
||||
enum Status { case ahead, onpace, behind }
|
||||
|
||||
let vNow: Double // % remaining (0..100)
|
||||
let tNow: Double // position in window (0..1)
|
||||
let idealNow: Double // what you should have left = 100 * (1 - tNow)
|
||||
let margin: Double // vNow - idealNow; + = conserving, − = over pace
|
||||
let slope: Double // %/unit-t (negative = burning)
|
||||
let projT: Double // t where projection ends
|
||||
let projV: Double // v where projection ends
|
||||
let runsOut: Bool // projection hits 0 inside the window
|
||||
|
||||
var status: Status {
|
||||
self.margin > 4 ? .ahead : self.margin < -4 ? .behind : .onpace
|
||||
}
|
||||
|
||||
var depleted: Bool {
|
||||
self.vNow <= 0.5
|
||||
}
|
||||
|
||||
var fresh: Bool {
|
||||
self.vNow >= 99.5
|
||||
}
|
||||
|
||||
init(window: RateWindow) {
|
||||
let remaining = max(0, min(100, window.remainingPercent))
|
||||
self.vNow = remaining
|
||||
|
||||
let t: Double
|
||||
if let resetsAt = window.resetsAt, let windowMins = window.windowMinutes, windowMins > 0 {
|
||||
let minutesUntilReset = max(0, resetsAt.timeIntervalSinceNow / 60)
|
||||
let minutesElapsed = Double(windowMins) - minutesUntilReset
|
||||
t = max(0.001, min(0.999, minutesElapsed / Double(windowMins)))
|
||||
} else {
|
||||
t = max(0.001, min(0.999, window.usedPercent / 100.0))
|
||||
}
|
||||
self.tNow = t
|
||||
self.idealNow = 100.0 * (1.0 - t)
|
||||
self.margin = remaining - self.idealNow
|
||||
|
||||
let slope = t > 0.001 ? (remaining - 100.0) / t : -remaining
|
||||
self.slope = slope
|
||||
|
||||
if slope < -0.01 {
|
||||
let tOut = t + remaining / -slope
|
||||
if tOut <= 1.0 {
|
||||
self.projT = tOut
|
||||
self.projV = 0
|
||||
self.runsOut = true
|
||||
} else {
|
||||
self.projT = 1.0
|
||||
self.projV = max(0, remaining + slope * (1.0 - t))
|
||||
self.runsOut = false
|
||||
}
|
||||
} else {
|
||||
self.projT = 1.0
|
||||
self.projV = remaining
|
||||
self.runsOut = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Helpers
|
||||
|
||||
func burnWindowLabel(_ windowMinutes: Int?) -> String {
|
||||
guard let mins = windowMinutes else { return "Usage limit" }
|
||||
if mins < 60 { return "\(mins)-minute limit" }
|
||||
let hours = mins / 60
|
||||
if hours < 24 { return "\(hours)-hour limit" }
|
||||
return "\(hours / 24)-day limit"
|
||||
}
|
||||
|
||||
func burnEffectiveResetDate(
|
||||
explicitResetAt: Date?,
|
||||
estimatedResetMinutes: Double?,
|
||||
now: Date) -> Date?
|
||||
{
|
||||
if let explicitResetAt {
|
||||
return explicitResetAt > now ? explicitResetAt : nil
|
||||
}
|
||||
guard let estimatedResetMinutes, estimatedResetMinutes > 0 else { return nil }
|
||||
return now.addingTimeInterval(estimatedResetMinutes * 60)
|
||||
}
|
||||
|
||||
func burnAxisDateRange(
|
||||
effectiveResetAt: Date?,
|
||||
windowMinutes: Int,
|
||||
now: Date) -> (start: Date, reset: Date)
|
||||
{
|
||||
let reset = effectiveResetAt ?? now
|
||||
return (reset.addingTimeInterval(-Double(windowMinutes) * 60), reset)
|
||||
}
|
||||
|
||||
func burnCompactWindowLabel(_ windowMinutes: Int?, fallback: String) -> String {
|
||||
guard let minutes = windowMinutes else { return fallback }
|
||||
if minutes < 60 { return "\(minutes)M" }
|
||||
let hours = minutes / 60
|
||||
if hours < 24 { return "\(hours)H" }
|
||||
return "\(hours / 24)D"
|
||||
}
|
||||
|
||||
func burnFmtDuration(_ minutes: Double) -> String {
|
||||
guard minutes.isFinite, minutes > 0 else { return "—" }
|
||||
if minutes >= 1440 {
|
||||
let d = Int(minutes / 1440)
|
||||
let h = Int(minutes / 60) % 24
|
||||
return "\(d)d \(h)h"
|
||||
}
|
||||
let h = Int(minutes / 60)
|
||||
let m = Int(minutes) % 60
|
||||
if h <= 0 { return "\(max(1, m))m" }
|
||||
return "\(h)h \(String(format: "%02d", m))m"
|
||||
}
|
||||
|
||||
func burnAxisLabel(_ date: Date, isDailyWindow: Bool) -> String {
|
||||
let f = DateFormatter()
|
||||
if isDailyWindow {
|
||||
// A rolling multi-day window's start and reset fall on the same weekday, so "EEE"
|
||||
// would print the same label at both ends ("Sat … Sat"). Use a numeric date so the
|
||||
// two ends are distinguishable.
|
||||
f.setLocalizedDateFormatFromTemplate("Md")
|
||||
} else {
|
||||
f.dateStyle = .none
|
||||
f.timeStyle = .short
|
||||
}
|
||||
return f.string(from: date)
|
||||
}
|
||||
|
||||
func burnProviderName(_ provider: UsageProvider) -> String {
|
||||
ProviderDefaults.metadata[provider]?.displayName ?? provider.rawValue.capitalized
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
import SwiftUI
|
||||
import WidgetKit
|
||||
|
||||
@main
|
||||
struct CodexBarWidgetBundle: WidgetBundle {
|
||||
var body: some Widget {
|
||||
CodexBarSwitcherWidget()
|
||||
CodexBarUsageWidget()
|
||||
CodexBarHistoryWidget()
|
||||
CodexBarCompactWidget()
|
||||
CodexBarBurnDownWidget()
|
||||
CodexBarCombinedBurnDownWidget()
|
||||
}
|
||||
}
|
||||
|
||||
struct CodexBarSwitcherWidget: Widget {
|
||||
private let kind = "CodexBarSwitcherWidget"
|
||||
|
||||
var body: some WidgetConfiguration {
|
||||
StaticConfiguration(
|
||||
kind: self.kind,
|
||||
provider: CodexBarSwitcherTimelineProvider())
|
||||
{ entry in
|
||||
CodexBarSwitcherWidgetView(entry: entry)
|
||||
}
|
||||
.configurationDisplayName("CodexBar Switcher")
|
||||
.description("Usage widget with a provider switcher.")
|
||||
.supportedFamilies([.systemSmall, .systemMedium, .systemLarge])
|
||||
}
|
||||
}
|
||||
|
||||
struct CodexBarUsageWidget: Widget {
|
||||
private let kind = "CodexBarUsageWidget"
|
||||
|
||||
var body: some WidgetConfiguration {
|
||||
AppIntentConfiguration(
|
||||
kind: self.kind,
|
||||
intent: ProviderSelectionIntent.self,
|
||||
provider: CodexBarTimelineProvider())
|
||||
{ entry in
|
||||
CodexBarUsageWidgetView(entry: entry)
|
||||
}
|
||||
.configurationDisplayName("CodexBar Usage")
|
||||
.description("Session and weekly usage with credits and costs.")
|
||||
.supportedFamilies([.systemSmall, .systemMedium, .systemLarge])
|
||||
}
|
||||
}
|
||||
|
||||
struct CodexBarHistoryWidget: Widget {
|
||||
private let kind = "CodexBarHistoryWidget"
|
||||
|
||||
var body: some WidgetConfiguration {
|
||||
AppIntentConfiguration(
|
||||
kind: self.kind,
|
||||
intent: ProviderSelectionIntent.self,
|
||||
provider: CodexBarTimelineProvider())
|
||||
{ entry in
|
||||
CodexBarHistoryWidgetView(entry: entry)
|
||||
}
|
||||
.configurationDisplayName("CodexBar History")
|
||||
.description("Usage history chart with recent totals.")
|
||||
.supportedFamilies([.systemMedium, .systemLarge])
|
||||
}
|
||||
}
|
||||
|
||||
struct CodexBarCompactWidget: Widget {
|
||||
private let kind = "CodexBarCompactWidget"
|
||||
|
||||
var body: some WidgetConfiguration {
|
||||
AppIntentConfiguration(
|
||||
kind: self.kind,
|
||||
intent: CompactMetricSelectionIntent.self,
|
||||
provider: CodexBarCompactTimelineProvider())
|
||||
{ entry in
|
||||
CodexBarCompactWidgetView(entry: entry)
|
||||
}
|
||||
.configurationDisplayName("CodexBar Metric")
|
||||
.description("Compact widget for credits or cost.")
|
||||
.supportedFamilies([.systemSmall])
|
||||
}
|
||||
}
|
||||
|
||||
struct CodexBarBurnDownWidget: Widget {
|
||||
private let kind = "CodexBarBurnDownWidget"
|
||||
|
||||
var body: some WidgetConfiguration {
|
||||
AppIntentConfiguration(
|
||||
kind: self.kind,
|
||||
intent: BurnDownSelectionIntent.self,
|
||||
provider: BurnDownTimelineProvider())
|
||||
{ entry in
|
||||
BurnDownWidgetView(entry: entry)
|
||||
}
|
||||
.configurationDisplayName("CodexBar Burn Down")
|
||||
.description("Remaining budget compared with an ideal steady burn rate.")
|
||||
.supportedFamilies([.systemMedium])
|
||||
}
|
||||
}
|
||||
|
||||
struct CodexBarCombinedBurnDownWidget: Widget {
|
||||
private let kind = "CodexBarCombinedBurnDownWidget"
|
||||
|
||||
var body: some WidgetConfiguration {
|
||||
AppIntentConfiguration(
|
||||
kind: self.kind,
|
||||
intent: BurnProviderSelectionIntent.self,
|
||||
provider: CombinedBurnDownTimelineProvider())
|
||||
{ entry in
|
||||
CombinedBurnDownWidgetView(entry: entry)
|
||||
}
|
||||
.configurationDisplayName("CodexBar Burn Down (Combined)")
|
||||
.description("Session and weekly burn-down charts in one tile.")
|
||||
.supportedFamilies([.systemMedium])
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,366 @@
|
||||
import AppIntents
|
||||
import CodexBarCore
|
||||
import SwiftUI
|
||||
import WidgetKit
|
||||
|
||||
enum ProviderChoice: String, AppEnum {
|
||||
case codex
|
||||
case claude
|
||||
case gemini
|
||||
case alibaba
|
||||
case alibabatokenplan
|
||||
case antigravity
|
||||
case cursor
|
||||
case zai
|
||||
case copilot
|
||||
case devin
|
||||
case minimax
|
||||
case kilo
|
||||
case opencode
|
||||
case opencodego
|
||||
case mistral
|
||||
case kimi
|
||||
|
||||
static let typeDisplayRepresentation = TypeDisplayRepresentation(name: "Provider")
|
||||
|
||||
static let caseDisplayRepresentations: [ProviderChoice: DisplayRepresentation] = [
|
||||
.codex: DisplayRepresentation(title: "Codex"),
|
||||
.claude: DisplayRepresentation(title: "Claude"),
|
||||
.gemini: DisplayRepresentation(title: "Gemini"),
|
||||
.alibaba: DisplayRepresentation(title: "Alibaba"),
|
||||
.alibabatokenplan: DisplayRepresentation(title: "Alibaba Token Plan"),
|
||||
.antigravity: DisplayRepresentation(title: "Antigravity"),
|
||||
.cursor: DisplayRepresentation(title: "Cursor"),
|
||||
.zai: DisplayRepresentation(title: "z.ai"),
|
||||
.copilot: DisplayRepresentation(title: "Copilot"),
|
||||
.devin: DisplayRepresentation(title: "Devin"),
|
||||
.minimax: DisplayRepresentation(title: "MiniMax"),
|
||||
.kilo: DisplayRepresentation(title: "Kilo"),
|
||||
.opencode: DisplayRepresentation(title: "OpenCode"),
|
||||
.opencodego: DisplayRepresentation(title: "OpenCode Go"),
|
||||
.mistral: DisplayRepresentation(title: "Mistral"),
|
||||
.kimi: DisplayRepresentation(title: "Kimi"),
|
||||
]
|
||||
|
||||
var provider: UsageProvider {
|
||||
switch self {
|
||||
case .codex: .codex
|
||||
case .claude: .claude
|
||||
case .gemini: .gemini
|
||||
case .alibaba: .alibaba
|
||||
case .alibabatokenplan: .alibabatokenplan
|
||||
case .antigravity: .antigravity
|
||||
case .cursor: .cursor
|
||||
case .zai: .zai
|
||||
case .copilot: .copilot
|
||||
case .devin: .devin
|
||||
case .minimax: .minimax
|
||||
case .kilo: .kilo
|
||||
case .opencode: .opencode
|
||||
case .opencodego: .opencodego
|
||||
case .mistral: .mistral
|
||||
case .kimi: .kimi
|
||||
}
|
||||
}
|
||||
|
||||
// swiftlint:disable:next cyclomatic_complexity
|
||||
init?(provider: UsageProvider) {
|
||||
switch provider {
|
||||
case .codex: self = .codex
|
||||
case .openai: return nil // OpenAI not yet supported in widgets
|
||||
case .azureopenai: return nil // Azure OpenAI not yet supported in widgets
|
||||
case .claude: self = .claude
|
||||
case .gemini: self = .gemini
|
||||
case .alibaba: self = .alibaba
|
||||
case .alibabatokenplan: self = .alibabatokenplan
|
||||
case .antigravity: self = .antigravity
|
||||
case .cursor: self = .cursor
|
||||
case .opencode: self = .opencode
|
||||
case .opencodego: self = .opencodego
|
||||
case .zai: self = .zai
|
||||
case .factory: return nil // Factory not yet supported in widgets
|
||||
case .copilot: self = .copilot
|
||||
case .devin: self = .devin
|
||||
case .minimax: self = .minimax
|
||||
case .manus: return nil // Manus not yet supported in widgets
|
||||
case .vertexai: return nil // Vertex AI not yet supported in widgets
|
||||
case .kilo: self = .kilo
|
||||
case .kiro: return nil // Kiro not yet supported in widgets
|
||||
case .augment: return nil // Augment not yet supported in widgets
|
||||
case .jetbrains: return nil // JetBrains not yet supported in widgets
|
||||
case .kimi: self = .kimi
|
||||
case .kimik2: return nil // Kimi K2 not yet supported in widgets
|
||||
case .moonshot: return nil // Moonshot not yet supported in widgets
|
||||
case .amp: return nil // Amp not yet supported in widgets
|
||||
case .t3chat: return nil // T3 Chat not yet supported in widgets
|
||||
case .ollama: return nil // Ollama not yet supported in widgets
|
||||
case .synthetic: return nil // Synthetic not yet supported in widgets
|
||||
case .openrouter: return nil // OpenRouter not yet supported in widgets
|
||||
case .crossmodel: return nil // CrossModel not yet supported in widgets
|
||||
case .clawrouter: return nil // ClawRouter not yet supported in widgets
|
||||
case .sub2api: return nil // sub2api not yet supported in widgets
|
||||
case .wayfinder: return nil // Wayfinder not yet supported in widgets
|
||||
case .elevenlabs: return nil // ElevenLabs not yet supported in widgets
|
||||
case .warp: return nil // Warp not yet supported in widgets
|
||||
case .windsurf: return nil // Windsurf not yet supported in widgets
|
||||
case .perplexity: return nil // Perplexity not yet supported in widgets
|
||||
case .mimo: return nil // Xiaomi MiMo not yet supported in widgets
|
||||
case .doubao: return nil // Doubao not yet supported in widgets
|
||||
case .sakana: return nil // Sakana AI not yet supported in widgets
|
||||
case .abacus: return nil // Abacus AI not yet supported in widgets
|
||||
case .mistral: self = .mistral
|
||||
case .deepseek: return nil // DeepSeek not yet supported in widgets
|
||||
case .codebuff: return nil // Codebuff not yet supported in widgets
|
||||
case .crof: return nil // Crof not yet supported in widgets
|
||||
case .venice: return nil // Venice not yet supported in widgets
|
||||
case .commandcode: return nil // CommandCode not yet supported in widgets
|
||||
case .qoder: return nil // Qoder not yet supported in widgets
|
||||
case .stepfun: return nil // StepFun not yet supported in widgets
|
||||
case .bedrock: return nil // Bedrock not yet supported in widgets
|
||||
case .grok: return nil // Grok not yet supported in widgets
|
||||
case .groq: return nil // Groq not yet supported in widgets
|
||||
case .llmproxy: return nil // LLM Proxy not yet supported in widgets
|
||||
case .litellm: return nil // LiteLLM not yet supported in widgets
|
||||
case .deepgram: return nil // Deepgram not yet supported in widgets
|
||||
case .poe: return nil // Poe not yet supported in widgets
|
||||
case .chutes: return nil // Chutes not yet supported in widgets
|
||||
case .zed: return nil // Zed not yet supported in widgets
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
enum CompactMetric: String, AppEnum {
|
||||
case credits
|
||||
case todayCost
|
||||
case last30DaysCost
|
||||
|
||||
static let typeDisplayRepresentation = TypeDisplayRepresentation(name: "Metric")
|
||||
|
||||
static let caseDisplayRepresentations: [CompactMetric: DisplayRepresentation] = [
|
||||
.credits: DisplayRepresentation(title: "Credits left"),
|
||||
.todayCost: DisplayRepresentation(title: "Today cost"),
|
||||
.last30DaysCost: DisplayRepresentation(title: "30d cost"),
|
||||
]
|
||||
}
|
||||
|
||||
struct ProviderSelectionIntent: AppIntent, WidgetConfigurationIntent {
|
||||
static let title: LocalizedStringResource = "Provider"
|
||||
static let description = IntentDescription("Select the provider to display in the widget.")
|
||||
|
||||
@Parameter(title: "Provider", default: .codex)
|
||||
var provider: ProviderChoice
|
||||
|
||||
init() {
|
||||
self.provider = .codex
|
||||
}
|
||||
}
|
||||
|
||||
struct SwitchWidgetProviderIntent: AppIntent {
|
||||
static let title: LocalizedStringResource = "Switch Provider"
|
||||
static let description = IntentDescription("Switch the provider shown in the widget.")
|
||||
|
||||
@Parameter(title: "Provider")
|
||||
var provider: ProviderChoice
|
||||
|
||||
init() {}
|
||||
|
||||
init(provider: ProviderChoice) {
|
||||
self.provider = provider
|
||||
}
|
||||
|
||||
func perform() async throws -> some IntentResult {
|
||||
WidgetSelectionStore.saveSelectedProvider(self.provider.provider)
|
||||
WidgetCenter.shared.reloadAllTimelines()
|
||||
return .result()
|
||||
}
|
||||
}
|
||||
|
||||
struct CompactMetricSelectionIntent: AppIntent, WidgetConfigurationIntent {
|
||||
static let title: LocalizedStringResource = "Provider + Metric"
|
||||
static let description = IntentDescription("Select the provider and metric to display.")
|
||||
|
||||
@Parameter(title: "Provider", default: .codex)
|
||||
var provider: ProviderChoice
|
||||
|
||||
@Parameter(title: "Metric", default: .credits)
|
||||
var metric: CompactMetric
|
||||
|
||||
init() {
|
||||
self.provider = .codex
|
||||
self.metric = .credits
|
||||
}
|
||||
}
|
||||
|
||||
struct CodexBarWidgetEntry: TimelineEntry {
|
||||
let date: Date
|
||||
let provider: UsageProvider
|
||||
let snapshot: WidgetSnapshot
|
||||
}
|
||||
|
||||
struct CodexBarCompactEntry: TimelineEntry {
|
||||
let date: Date
|
||||
let provider: UsageProvider
|
||||
let metric: CompactMetric
|
||||
let snapshot: WidgetSnapshot
|
||||
}
|
||||
|
||||
struct CodexBarSwitcherEntry: TimelineEntry {
|
||||
let date: Date
|
||||
let provider: UsageProvider
|
||||
let availableProviders: [UsageProvider]
|
||||
let snapshot: WidgetSnapshot
|
||||
}
|
||||
|
||||
struct CodexBarTimelineProvider: AppIntentTimelineProvider {
|
||||
func placeholder(in context: Context) -> CodexBarWidgetEntry {
|
||||
CodexBarWidgetEntry(
|
||||
date: Date(),
|
||||
provider: .codex,
|
||||
snapshot: WidgetPreviewData.snapshot())
|
||||
}
|
||||
|
||||
func snapshot(for configuration: ProviderSelectionIntent, in context: Context) async -> CodexBarWidgetEntry {
|
||||
let provider = configuration.provider.provider
|
||||
return CodexBarWidgetEntry(
|
||||
date: Date(),
|
||||
provider: provider,
|
||||
snapshot: WidgetSnapshotStore.load() ?? WidgetPreviewData.snapshot())
|
||||
}
|
||||
|
||||
func timeline(
|
||||
for configuration: ProviderSelectionIntent,
|
||||
in context: Context) async -> Timeline<CodexBarWidgetEntry>
|
||||
{
|
||||
let provider = configuration.provider.provider
|
||||
let snapshot = WidgetSnapshotStore.load() ?? WidgetPreviewData.emptySnapshot()
|
||||
let now = Date()
|
||||
let entry = CodexBarWidgetEntry(date: now, provider: provider, snapshot: snapshot)
|
||||
let refresh = BurnDownRefreshSchedule.nextRefresh(snapshot: snapshot, provider: provider, now: now)
|
||||
return Timeline(entries: [entry], policy: .after(refresh))
|
||||
}
|
||||
}
|
||||
|
||||
struct CodexBarSwitcherTimelineProvider: TimelineProvider {
|
||||
func placeholder(in context: Context) -> CodexBarSwitcherEntry {
|
||||
let snapshot = WidgetPreviewData.snapshot()
|
||||
let providers = self.availableProviders(from: snapshot)
|
||||
return CodexBarSwitcherEntry(
|
||||
date: Date(),
|
||||
provider: providers.first ?? .codex,
|
||||
availableProviders: providers,
|
||||
snapshot: snapshot)
|
||||
}
|
||||
|
||||
func getSnapshot(in context: Context, completion: @escaping (CodexBarSwitcherEntry) -> Void) {
|
||||
completion(self.makeEntry())
|
||||
}
|
||||
|
||||
func getTimeline(in context: Context, completion: @escaping (Timeline<CodexBarSwitcherEntry>) -> Void) {
|
||||
let entry = self.makeEntry()
|
||||
let refresh = BurnDownRefreshSchedule.nextRefresh(
|
||||
snapshot: entry.snapshot,
|
||||
provider: entry.provider,
|
||||
now: entry.date)
|
||||
completion(Timeline(entries: [entry], policy: .after(refresh)))
|
||||
}
|
||||
|
||||
private func makeEntry() -> CodexBarSwitcherEntry {
|
||||
let snapshot = WidgetSnapshotStore.load() ?? WidgetPreviewData.emptySnapshot()
|
||||
let providers = self.availableProviders(from: snapshot)
|
||||
let stored = WidgetSelectionStore.loadSelectedProvider()
|
||||
let selected = providers.first { $0 == stored } ?? providers.first ?? .codex
|
||||
if selected != stored {
|
||||
WidgetSelectionStore.saveSelectedProvider(selected)
|
||||
}
|
||||
return CodexBarSwitcherEntry(
|
||||
date: Date(),
|
||||
provider: selected,
|
||||
availableProviders: providers,
|
||||
snapshot: snapshot)
|
||||
}
|
||||
|
||||
private func availableProviders(from snapshot: WidgetSnapshot) -> [UsageProvider] {
|
||||
Self.supportedProviders(from: snapshot)
|
||||
}
|
||||
|
||||
static func supportedProviders(from snapshot: WidgetSnapshot) -> [UsageProvider] {
|
||||
let enabled = snapshot.enabledProviders
|
||||
let providers = enabled.isEmpty ? snapshot.entries.map(\.provider) : enabled
|
||||
let supported = providers.filter { ProviderChoice(provider: $0) != nil }
|
||||
return supported.isEmpty ? [.codex] : supported
|
||||
}
|
||||
}
|
||||
|
||||
struct CodexBarCompactTimelineProvider: AppIntentTimelineProvider {
|
||||
func placeholder(in context: Context) -> CodexBarCompactEntry {
|
||||
CodexBarCompactEntry(
|
||||
date: Date(),
|
||||
provider: .codex,
|
||||
metric: .credits,
|
||||
snapshot: WidgetPreviewData.snapshot())
|
||||
}
|
||||
|
||||
func snapshot(for configuration: CompactMetricSelectionIntent, in context: Context) async -> CodexBarCompactEntry {
|
||||
let provider = configuration.provider.provider
|
||||
let metric = configuration.metric
|
||||
return CodexBarCompactEntry(
|
||||
date: Date(),
|
||||
provider: provider,
|
||||
metric: metric,
|
||||
snapshot: WidgetSnapshotStore.load() ?? WidgetPreviewData.snapshot())
|
||||
}
|
||||
|
||||
func timeline(
|
||||
for configuration: CompactMetricSelectionIntent,
|
||||
in context: Context) async -> Timeline<CodexBarCompactEntry>
|
||||
{
|
||||
let provider = configuration.provider.provider
|
||||
let metric = configuration.metric
|
||||
let snapshot = WidgetSnapshotStore.load() ?? WidgetPreviewData.emptySnapshot()
|
||||
let entry = CodexBarCompactEntry(
|
||||
date: Date(),
|
||||
provider: provider,
|
||||
metric: metric,
|
||||
snapshot: snapshot)
|
||||
let refresh = Date().addingTimeInterval(30 * 60)
|
||||
return Timeline(entries: [entry], policy: .after(refresh))
|
||||
}
|
||||
}
|
||||
|
||||
enum WidgetPreviewData {
|
||||
static func emptySnapshot() -> WidgetSnapshot {
|
||||
WidgetSnapshot(entries: [], enabledProviders: [], generatedAt: Date())
|
||||
}
|
||||
|
||||
static func snapshot() -> WidgetSnapshot {
|
||||
let primary = RateWindow(usedPercent: 35, windowMinutes: 300, resetsAt: nil, resetDescription: "Resets in 4h")
|
||||
let secondary = RateWindow(
|
||||
usedPercent: 60,
|
||||
windowMinutes: 10080,
|
||||
resetsAt: nil,
|
||||
resetDescription: "Resets in 3d")
|
||||
let entry = WidgetSnapshot.ProviderEntry(
|
||||
provider: .codex,
|
||||
updatedAt: Date(),
|
||||
primary: primary,
|
||||
secondary: secondary,
|
||||
tertiary: nil,
|
||||
creditsRemaining: 1243.4,
|
||||
codeReviewRemainingPercent: 78,
|
||||
tokenUsage: WidgetSnapshot.TokenUsageSummary(
|
||||
sessionCostUSD: 12.4,
|
||||
sessionTokens: 420_000,
|
||||
last30DaysCostUSD: 923.8,
|
||||
last30DaysTokens: 12_400_000),
|
||||
dailyUsage: [
|
||||
WidgetSnapshot.DailyUsagePoint(dayKey: "2025-12-01", totalTokens: 120_000, costUSD: 15.2),
|
||||
WidgetSnapshot.DailyUsagePoint(dayKey: "2025-12-02", totalTokens: 80000, costUSD: 10.1),
|
||||
WidgetSnapshot.DailyUsagePoint(dayKey: "2025-12-03", totalTokens: 140_000, costUSD: 17.9),
|
||||
WidgetSnapshot.DailyUsagePoint(dayKey: "2025-12-04", totalTokens: 90000, costUSD: 11.4),
|
||||
WidgetSnapshot.DailyUsagePoint(dayKey: "2025-12-05", totalTokens: 160_000, costUSD: 19.8),
|
||||
WidgetSnapshot.DailyUsagePoint(dayKey: "2025-12-06", totalTokens: 70000, costUSD: 8.9),
|
||||
WidgetSnapshot.DailyUsagePoint(dayKey: "2025-12-07", totalTokens: 110_000, costUSD: 13.7),
|
||||
])
|
||||
return WidgetSnapshot(entries: [entry], generatedAt: Date())
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,480 @@
|
||||
import AppKit
|
||||
import CodexBarCore
|
||||
import SwiftUI
|
||||
import WidgetKit
|
||||
|
||||
// MARK: - Entry View
|
||||
|
||||
struct CombinedBurnDownWidgetView: View {
|
||||
let entry: CombinedBurnDownEntry
|
||||
|
||||
var body: some View {
|
||||
let state = BurnDownState(
|
||||
snapshot: self.entry.snapshot,
|
||||
provider: self.entry.provider,
|
||||
selection: .session)
|
||||
|
||||
Group {
|
||||
if let state {
|
||||
CombinedBurnDownLayout(state: state, provider: self.entry.provider)
|
||||
} else {
|
||||
self.emptyState
|
||||
}
|
||||
}
|
||||
.containerBackground(for: .widget) {
|
||||
BurnWidgetBackground()
|
||||
}
|
||||
}
|
||||
|
||||
private var emptyState: some View {
|
||||
VStack(spacing: 6) {
|
||||
Text("Open CodexBar")
|
||||
.font(.body)
|
||||
.fontWeight(.semibold)
|
||||
Text("Usage data will appear once the app refreshes.")
|
||||
.font(.caption)
|
||||
.multilineTextAlignment(.center)
|
||||
.opacity(0.55)
|
||||
}
|
||||
.padding(12)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Layout
|
||||
|
||||
private struct CombinedBurnDownLayout: View {
|
||||
@Environment(\.widgetRenderingMode) private var renderingMode
|
||||
@Environment(\.colorScheme) private var colorScheme
|
||||
|
||||
let state: BurnDownState
|
||||
let provider: UsageProvider
|
||||
|
||||
var body: some View {
|
||||
let dark = self.colorScheme == .dark
|
||||
let isMonochrome = self.renderingMode != .fullColor
|
||||
|
||||
let sessionWindow = self.state.primaryWindow
|
||||
let weeklyWindow = self.state.secondaryWindow
|
||||
|
||||
let sessionGeom = sessionWindow.map { BurnGeom(window: $0) }
|
||||
let weeklyGeom = weeklyWindow.map { BurnGeom(window: $0) }
|
||||
|
||||
// Use a neutral baseline theme for the header/hairline colors
|
||||
let baseGeom = sessionGeom ?? weeklyGeom ?? BurnGeom(
|
||||
window: RateWindow(
|
||||
usedPercent: 50,
|
||||
windowMinutes: 300,
|
||||
resetsAt: Date().addingTimeInterval(2.5 * 3600),
|
||||
resetDescription: nil))
|
||||
let baseTheme = BurnTheme(
|
||||
provider: self.provider,
|
||||
geom: baseGeom,
|
||||
dark: dark,
|
||||
isMonochrome: isMonochrome)
|
||||
|
||||
VStack(spacing: 0) {
|
||||
// Header
|
||||
HStack(alignment: .center) {
|
||||
HStack(spacing: 6) {
|
||||
Circle()
|
||||
.fill(baseTheme.brandDot)
|
||||
.frame(width: 7, height: 7)
|
||||
.shadow(color: baseTheme.brandDot.opacity(0.7), radius: 3.5)
|
||||
Text(burnProviderName(self.provider))
|
||||
.font(.system(size: 14, weight: .semibold))
|
||||
.foregroundStyle(baseTheme.text)
|
||||
.lineLimit(1)
|
||||
}
|
||||
Spacer()
|
||||
Text("Session & weekly limits")
|
||||
.font(.system(size: 10))
|
||||
.foregroundStyle(baseTheme.sub)
|
||||
.kerning(0.3)
|
||||
}
|
||||
|
||||
// Two rows
|
||||
VStack(spacing: 0) {
|
||||
// 5H row — shows % remaining by default
|
||||
if let win = sessionWindow, let geom = sessionGeom {
|
||||
CombinedBurnRow(
|
||||
window: win,
|
||||
geom: geom,
|
||||
theme: BurnTheme(
|
||||
provider: self.provider,
|
||||
geom: geom,
|
||||
dark: dark,
|
||||
isMonochrome: isMonochrome),
|
||||
tag: burnCompactWindowLabel(win.windowMinutes, fallback: "S"),
|
||||
periods: 5,
|
||||
metric: .remaining,
|
||||
dark: dark,
|
||||
blankChart: self.state.blankPrimaryChart,
|
||||
resetsAtOverride: self.state.selectedResetOverride)
|
||||
} else {
|
||||
CombinedEmptyRow(tag: "S", theme: baseTheme)
|
||||
}
|
||||
|
||||
Rectangle()
|
||||
.fill(baseTheme.hair)
|
||||
.frame(height: 1)
|
||||
|
||||
// 7D row — shows % off pace by default
|
||||
if let win = weeklyWindow, let geom = weeklyGeom {
|
||||
CombinedBurnRow(
|
||||
window: win,
|
||||
geom: geom,
|
||||
theme: BurnTheme(
|
||||
provider: self.provider,
|
||||
geom: geom,
|
||||
dark: dark,
|
||||
isMonochrome: isMonochrome),
|
||||
tag: burnCompactWindowLabel(win.windowMinutes, fallback: "W"),
|
||||
periods: 7,
|
||||
metric: .pace,
|
||||
dark: dark)
|
||||
} else {
|
||||
CombinedEmptyRow(tag: "W", theme: baseTheme)
|
||||
}
|
||||
}
|
||||
.frame(maxHeight: .infinity)
|
||||
.padding(.top, 6)
|
||||
}
|
||||
.padding(.horizontal, 15)
|
||||
.padding(.top, 12)
|
||||
.padding(.bottom, 11)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Metric
|
||||
|
||||
private enum CombinedMetric {
|
||||
case remaining // % left (default for 5H)
|
||||
case pace // % off ideal pace (default for 7D)
|
||||
case used // % consumed
|
||||
}
|
||||
|
||||
// MARK: - Row
|
||||
|
||||
private struct CombinedBurnRow: View {
|
||||
let window: RateWindow
|
||||
let geom: BurnGeom
|
||||
let theme: BurnTheme
|
||||
let tag: String
|
||||
let periods: Int
|
||||
let metric: CombinedMetric
|
||||
let dark: Bool
|
||||
var blankChart = false
|
||||
var resetsAtOverride: Date?
|
||||
|
||||
var body: some View {
|
||||
let windowMins = self.window.windowMinutes ?? 300
|
||||
let isDailyWindow = windowMins >= 1440
|
||||
|
||||
let heroNum = self.metric == .pace ? abs(Int(self.geom.margin.rounded()))
|
||||
: self.metric == .used ? Int((100 - self.geom.vNow).rounded())
|
||||
: Int(self.geom.vNow.rounded())
|
||||
let suffix = self.metric == .remaining ? "left" : self.metric == .used ? "used" : ""
|
||||
let prefixArrow = self.metric == .pace
|
||||
|
||||
let paceWord: String = self.geom.depleted ? "spent" : self.geom.fresh ? "full"
|
||||
: self.geom.status == .ahead ? "under pace"
|
||||
: self.geom.status == .behind ? "over pace" : "on pace"
|
||||
let arrow: String = self.geom.depleted ? "■" : self.geom.fresh ? "◆"
|
||||
: self.geom.status == .ahead ? "▲" : self.geom.status == .behind ? "▼" : "●"
|
||||
|
||||
let explicitReset = self.blankChart
|
||||
? self.resetsAtOverride
|
||||
: self.resetsAtOverride ?? self.window.resetsAt
|
||||
let now = Date()
|
||||
let estimatedResetMinutes = self.blankChart || self.geom.tNow >= 1
|
||||
? nil
|
||||
: (1 - self.geom.tNow) * Double(windowMins)
|
||||
let effectiveResetDate = burnEffectiveResetDate(
|
||||
explicitResetAt: explicitReset,
|
||||
estimatedResetMinutes: estimatedResetMinutes,
|
||||
now: now)
|
||||
let heroColor = self.geom.depleted ? self.theme.danger : self.theme.statusColor
|
||||
|
||||
HStack(alignment: .center, spacing: 12) {
|
||||
// Label column
|
||||
VStack(alignment: .leading, spacing: 0) {
|
||||
// Line 1: tag + (arrow for non-pace metrics) + pace word
|
||||
// For remaining/used: "5H ▼ over pace". For pace: "7D on pace" (arrow is on hero line).
|
||||
HStack(alignment: .firstTextBaseline, spacing: 5) {
|
||||
Text(self.tag)
|
||||
.font(.system(size: 9.5, weight: .heavy))
|
||||
.foregroundStyle(self.theme.sub)
|
||||
.kerning(1)
|
||||
HStack(alignment: .firstTextBaseline, spacing: 2) {
|
||||
if !prefixArrow {
|
||||
Text(arrow)
|
||||
.font(.system(size: 8))
|
||||
.foregroundStyle(self.theme.statusColor)
|
||||
}
|
||||
Text(paceWord)
|
||||
.font(.system(size: 10, weight: .semibold))
|
||||
.foregroundStyle(self.theme.statusColor)
|
||||
}
|
||||
.lineLimit(1)
|
||||
}
|
||||
|
||||
// Line 2: hero number. Pace metric prefixes an arrow glyph.
|
||||
HStack(alignment: .lastTextBaseline, spacing: 2) {
|
||||
if prefixArrow {
|
||||
Text(arrow)
|
||||
.font(.system(size: 13, weight: .bold))
|
||||
.foregroundStyle(heroColor)
|
||||
}
|
||||
Text("\(heroNum)")
|
||||
.font(.system(size: 27, weight: .semibold))
|
||||
.foregroundStyle(heroColor)
|
||||
.monospacedDigit()
|
||||
.lineLimit(1)
|
||||
.minimumScaleFactor(0.8)
|
||||
Text("%")
|
||||
.font(.system(size: 13, weight: .medium))
|
||||
.foregroundStyle(self.theme.sub)
|
||||
if !suffix.isEmpty {
|
||||
Text(suffix)
|
||||
.font(.system(size: 10))
|
||||
.foregroundStyle(self.theme.sub)
|
||||
}
|
||||
}
|
||||
.padding(.top, 1)
|
||||
|
||||
// Line 3: reset line — refresh glyph + countdown + compact time
|
||||
HStack(spacing: 4) {
|
||||
Image(systemName: "arrow.clockwise")
|
||||
.font(.system(size: 9))
|
||||
.foregroundStyle(self.theme.sub.opacity(0.85))
|
||||
if let effectiveResetDate {
|
||||
Text(effectiveResetDate, style: .relative)
|
||||
.font(.system(size: 9.5, weight: .medium))
|
||||
.foregroundStyle(self.theme.text)
|
||||
.monospacedDigit()
|
||||
Text("· \(combinedCompactResetTime(effectiveResetDate, isDailyWindow: isDailyWindow))")
|
||||
.font(.system(size: 9.5, weight: .medium))
|
||||
.foregroundStyle(self.theme.text)
|
||||
} else {
|
||||
Text("—")
|
||||
.font(.system(size: 9.5, weight: .medium))
|
||||
.foregroundStyle(self.theme.text)
|
||||
}
|
||||
}
|
||||
.padding(.top, 2)
|
||||
.lineLimit(1)
|
||||
}
|
||||
.frame(width: 112, alignment: .leading)
|
||||
|
||||
// Chart column — blanked when the session window is blocked by an exhausted
|
||||
// weekly cap; there is no session burn to chart until the weekly resets.
|
||||
if self.blankChart {
|
||||
Color.clear
|
||||
.frame(maxWidth: .infinity)
|
||||
.frame(height: 50)
|
||||
} else {
|
||||
CombinedBurnChartCanvas(geom: self.geom, theme: self.theme, periods: self.periods, dark: self.dark)
|
||||
.frame(maxWidth: .infinity)
|
||||
.frame(height: 50)
|
||||
}
|
||||
}
|
||||
.frame(maxHeight: .infinity)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Empty Row
|
||||
|
||||
private struct CombinedEmptyRow: View {
|
||||
let tag: String
|
||||
let theme: BurnTheme
|
||||
|
||||
var body: some View {
|
||||
HStack {
|
||||
Text(self.tag)
|
||||
.font(.system(size: 9.5, weight: .heavy))
|
||||
.foregroundStyle(self.theme.sub)
|
||||
.kerning(1)
|
||||
Text("No data")
|
||||
.font(.system(size: 10))
|
||||
.foregroundStyle(self.theme.sub)
|
||||
Spacer()
|
||||
}
|
||||
.frame(maxHeight: .infinity)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Mini Chart Canvas
|
||||
|
||||
private struct CombinedBurnChartCanvas: View {
|
||||
let geom: BurnGeom
|
||||
let theme: BurnTheme
|
||||
let periods: Int
|
||||
let dark: Bool
|
||||
|
||||
var body: some View {
|
||||
Canvas { context, size in
|
||||
let w = size.width
|
||||
let h = size.height
|
||||
let padT: CGFloat = 5
|
||||
let padB: CGFloat = 2
|
||||
let padL: CGFloat = 1
|
||||
let padR: CGFloat = 1
|
||||
|
||||
func X(_ t: Double) -> CGFloat {
|
||||
padL + CGFloat(t) * (w - padL - padR)
|
||||
}
|
||||
func Y(_ v: Double) -> CGFloat {
|
||||
padT + CGFloat(1 - v / 100) * (h - padT - padB)
|
||||
}
|
||||
|
||||
let tNow = self.geom.tNow
|
||||
let vNow = self.geom.vNow
|
||||
let barColor = self.dark ? Color.white : Color.black
|
||||
|
||||
// --- Usage bars (background texture) ---
|
||||
// Drawn first so the actual line renders on top.
|
||||
// Heights are relative-to-ideal: idealPerPeriod maps to ~46% of plot height.
|
||||
let plotH = h - padT - padB
|
||||
let refH = 0.46 * plotH // reference height = ideal-pace bar height
|
||||
let idealPerPeriod = 100.0 / Double(self.periods)
|
||||
let burnRate = tNow > 0.001 ? (100.0 - vNow) / tNow : 0.0 // %/unit-t
|
||||
let slotW = (w - padL - padR) / CGFloat(self.periods)
|
||||
|
||||
for i in 0..<self.periods {
|
||||
let slotStart = Double(i) / Double(self.periods)
|
||||
let slotEnd = Double(i + 1) / Double(self.periods)
|
||||
let slotX = padL + CGFloat(slotStart) * (w - padL - padR)
|
||||
|
||||
if slotEnd <= tNow {
|
||||
// Completed period — full-width bar
|
||||
let consumed = burnRate * (slotEnd - slotStart)
|
||||
let ratio = consumed / idealPerPeriod
|
||||
let totalBarH = CGFloat(ratio) * refH
|
||||
let baseH = min(totalBarH, refH)
|
||||
|
||||
if baseH > 0 {
|
||||
let rect = CGRect(
|
||||
x: slotX,
|
||||
y: h - padB - baseH,
|
||||
width: slotW - 1,
|
||||
height: baseH)
|
||||
context.fill(Path(rect), with: .color(barColor.opacity(0.17)))
|
||||
}
|
||||
// Overage segment — above ideal reference line
|
||||
if totalBarH > refH {
|
||||
let overH = totalBarH - refH
|
||||
let rect = CGRect(
|
||||
x: slotX,
|
||||
y: h - padB - totalBarH,
|
||||
width: slotW - 1,
|
||||
height: overH)
|
||||
context.fill(Path(rect), with: .color(barColor.opacity(0.34)))
|
||||
}
|
||||
} else if slotStart < tNow {
|
||||
// Current (partial) period — narrower bar ending at tNow
|
||||
let partialFrac = (tNow - slotStart) / (slotEnd - slotStart)
|
||||
let consumed = burnRate * (tNow - slotStart)
|
||||
let ratio = consumed / idealPerPeriod
|
||||
let totalBarH = CGFloat(ratio) * refH
|
||||
let baseH = min(totalBarH, refH)
|
||||
let barW = CGFloat(partialFrac) * (slotW - 1)
|
||||
|
||||
if baseH > 0 {
|
||||
let rect = CGRect(
|
||||
x: slotX,
|
||||
y: h - padB - baseH,
|
||||
width: barW,
|
||||
height: baseH)
|
||||
context.fill(Path(rect), with: .color(barColor.opacity(0.13)))
|
||||
}
|
||||
if totalBarH > refH {
|
||||
let overH = totalBarH - refH
|
||||
let rect = CGRect(
|
||||
x: slotX,
|
||||
y: h - padB - totalBarH,
|
||||
width: barW,
|
||||
height: overH)
|
||||
context.fill(Path(rect), with: .color(barColor.opacity(0.26)))
|
||||
}
|
||||
} else {
|
||||
// Future period — faint full-height placeholder
|
||||
let rect = CGRect(
|
||||
x: slotX,
|
||||
y: h - padB - refH,
|
||||
width: slotW - 1,
|
||||
height: refH)
|
||||
context.fill(Path(rect), with: .color(barColor.opacity(0.045)))
|
||||
}
|
||||
}
|
||||
|
||||
// --- Now vertical hairline ---
|
||||
do {
|
||||
var p = Path()
|
||||
p.move(to: CGPoint(x: X(tNow), y: Y(100)))
|
||||
p.addLine(to: CGPoint(x: X(tNow), y: Y(0)))
|
||||
context.stroke(p, with: .color(self.theme.chartGrid), lineWidth: 1)
|
||||
}
|
||||
|
||||
// --- Baseline ---
|
||||
do {
|
||||
var p = Path()
|
||||
p.move(to: CGPoint(x: X(0), y: Y(0)))
|
||||
p.addLine(to: CGPoint(x: X(1), y: Y(0)))
|
||||
context.stroke(p, with: .color(self.theme.chartGrid), lineWidth: 1)
|
||||
}
|
||||
|
||||
// --- Ideal line (dashed, recedes) ---
|
||||
do {
|
||||
var p = Path()
|
||||
p.move(to: CGPoint(x: X(0), y: Y(100)))
|
||||
p.addLine(to: CGPoint(x: X(1), y: Y(0)))
|
||||
context.stroke(
|
||||
p,
|
||||
with: .color(self.theme.chartIdeal),
|
||||
style: StrokeStyle(lineWidth: 1.4, lineCap: .round, dash: [2.5, 3]))
|
||||
}
|
||||
|
||||
// --- Projection (fine dotted) ---
|
||||
if self.geom.slope < -0.01 {
|
||||
var p = Path()
|
||||
p.move(to: CGPoint(x: X(tNow), y: Y(vNow)))
|
||||
p.addLine(to: CGPoint(x: X(self.geom.projT), y: Y(self.geom.projV)))
|
||||
context.stroke(
|
||||
p,
|
||||
with: .color(self.theme.chartProj.opacity(0.95)),
|
||||
style: StrokeStyle(lineWidth: 1.6, lineCap: .round, dash: [0.5, 3.5]))
|
||||
}
|
||||
|
||||
// --- Actual line (solid, dominant) ---
|
||||
do {
|
||||
var p = Path()
|
||||
p.move(to: CGPoint(x: X(0), y: Y(100)))
|
||||
p.addLine(to: CGPoint(x: X(tNow), y: Y(vNow)))
|
||||
context.stroke(
|
||||
p,
|
||||
with: .color(self.theme.chartLine),
|
||||
style: StrokeStyle(lineWidth: 2.4, lineCap: .round, lineJoin: .round))
|
||||
}
|
||||
|
||||
// --- Now dot (filled, ringed) ---
|
||||
let dotCenter = CGPoint(x: X(tNow), y: Y(vNow))
|
||||
let ringPath = Path(ellipseIn: CGRect(
|
||||
x: dotCenter.x - 5.4, y: dotCenter.y - 5.4, width: 10.8, height: 10.8))
|
||||
context.fill(ringPath, with: .color(self.theme.chartNowRing))
|
||||
let dotPath = Path(ellipseIn: CGRect(
|
||||
x: dotCenter.x - 3.4, y: dotCenter.y - 3.4, width: 6.8, height: 6.8))
|
||||
context.fill(dotPath, with: .color(self.theme.chartNowDot))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Compact reset time helper
|
||||
|
||||
/// Formats a reset date compactly: "4:30p", "5p", "Sun 9a".
|
||||
/// Weekday prefix is added for the 7-day window or when the reset is ≥20h away.
|
||||
private func combinedCompactResetTime(_ date: Date, isDailyWindow: Bool) -> String {
|
||||
let includeDay = isDailyWindow || date.timeIntervalSinceNow >= 20 * 3600
|
||||
let formatter = DateFormatter()
|
||||
formatter.setLocalizedDateFormatFromTemplate(includeDay ? "EEEjm" : "jm")
|
||||
return formatter.string(from: date)
|
||||
}
|
||||
Reference in New Issue
Block a user