682 lines · 25.9 KB · Swift
RawDownload
import AppKitimport CodexBarCoreimport SwiftUIimport 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

Lines 1–500 of 682.