Files
steipete--codexbar/Sources/CodexBarWidget/CombinedBurnDownWidgetViews.swift
2026-07-13 12:22:33 +08:00

481 lines
18 KiB
Swift

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)
}