Files
nucleic-remote-ios/NucleicRemote/NucleicRemote/Views/StatusAndQuota.swift
T

259 lines
10 KiB
Swift

import SwiftUI
import NucleicProtocol
/// The Mac's usage gauges and provider-status pill, rendered from the dashboard's wire
/// projections (`DashboardSnapshot.usage` / `.statusFeeds`) — same thresholds, icons, and
/// copy as `QuotaIndicator` / `StatusFeedIndicator` on desktop.
// MARK: - Shared formatting (mirrors the Mac's QuotaFormat)
enum QuotaFormat {
static let fiveHourIcon = "clock"
static let weeklyIcon = "calendar"
static func percent(_ value: Double) -> String { "\(Int(value.rounded()))%" }
/// "6d 23h" / "2h 14m" / "14m" — the Mac's countdown formatting.
static func duration(_ interval: TimeInterval) -> String {
let total = Int(interval)
let days = total / 86_400
let hours = (total % 86_400) / 3600
let minutes = (total % 3600) / 60
if days > 0 { return "\(days)d \(hours)h" }
if hours > 0 { return "\(hours)h \(minutes)m" }
if minutes > 0 { return "\(minutes)m" }
return "<1m"
}
static func resetCaption(_ window: WireUsageWindow, now: Date) -> String? {
resetCaption(window.resetsAt, now: now)
}
static func resetCaption(_ resetsAt: Date?, now: Date) -> String? {
guard let resetsAt else { return nil }
let remaining = resetsAt.timeIntervalSince(now)
guard remaining > 0 else { return "resetting…" }
return "resets in \(duration(remaining))"
}
/// Calm until 75%, amber to 90%, red beyond — the Mac's escalation.
static func color(forPercent value: Double) -> Color {
switch value {
case ..<75: return Palette.accent
case ..<90: return Palette.attention
default: return Palette.danger
}
}
}
// MARK: - Quota card (the Mac's QuotaCard, dashboard form)
/// Labeled progress bars for the 5-hour and weekly windows with reset countdowns.
/// Renders nothing when the host hasn't projected usage (older host, logged out) so the
/// home screen doesn't carry a permanently empty card.
struct QuotaCard: View {
let usage: WireSubscriptionUsage?
var body: some View {
if let usage, usage.peakUtilization != nil {
TimelineView(.periodic(from: .now, by: 30)) { context in
VStack(alignment: .leading, spacing: 12) {
HStack(spacing: 6) {
Image(systemName: "gauge.with.dots.needle.67percent").foregroundStyle(.secondary)
Text("Usage").font(.headline)
}
windowRow("5-hour limit", icon: QuotaFormat.fiveHourIcon, usage.fiveHour, now: context.date)
windowRow("Weekly limit", icon: QuotaFormat.weeklyIcon, usage.sevenDay, now: context.date)
windowRow("Weekly · Fable", icon: "sparkles", usage.sevenDayFable, now: context.date)
}
.frame(maxWidth: .infinity, alignment: .leading)
}
.card()
}
}
@ViewBuilder
private func windowRow(_ label: String, icon: String, _ window: WireUsageWindow?, now: Date) -> some View {
if let window {
let utilization = window.utilization(at: now)
let color = QuotaFormat.color(forPercent: utilization)
VStack(alignment: .leading, spacing: 4) {
HStack(spacing: 5) {
Image(systemName: icon).font(.caption).foregroundStyle(.secondary)
Text(label).font(.subheadline)
Spacer()
Text(QuotaFormat.percent(utilization))
.font(.subheadline.monospacedDigit().weight(.semibold))
.foregroundStyle(color)
}
bar(fraction: utilization / 100, color: color)
if let caption = QuotaFormat.resetCaption(window, now: now) {
Text(caption).font(.caption2).foregroundStyle(.secondary)
}
}
}
}
private func bar(fraction: Double, color: Color) -> some View {
GeometryReader { geo in
ZStack(alignment: .leading) {
Capsule().fill(Color.secondary.opacity(0.18))
Capsule().fill(color)
.frame(width: max(3, geo.size.width * min(1, max(0, fraction))))
}
}
.frame(height: 6)
}
}
// MARK: - Codex quota card (the Mac's CodexUsageCard, dashboard form)
/// The Codex counterpart to `QuotaCard`, rendered from `DashboardSnapshot.codexUsage` — the
/// rolling weekly window the host's `codex` agent reported (its short ~5-hour window has been
/// retired). Renders nothing until a Codex chat has reported usage, so Claude-only users never
/// carry an empty card. Same thresholds, icons, and copy as the Claude card.
struct CodexQuotaCard: View {
let usage: CodexUsage?
var body: some View {
if let usage, usage.hasData {
TimelineView(.periodic(from: .now, by: 30)) { context in
VStack(alignment: .leading, spacing: 12) {
HStack(spacing: 6) {
Image(systemName: "gauge.with.dots.needle.67percent").foregroundStyle(.secondary)
Text("Codex").font(.headline)
}
windowRow(usage.weekly, fallbackLabel: "Weekly limit",
icon: QuotaFormat.weeklyIcon, now: context.date)
}
.frame(maxWidth: .infinity, alignment: .leading)
}
.card()
}
}
@ViewBuilder
private func windowRow(
_ window: CodexUsageWindow?, fallbackLabel: String, icon: String, now: Date
) -> some View {
if let window {
let utilization = window.utilization(at: now)
let color = QuotaFormat.color(forPercent: utilization)
VStack(alignment: .leading, spacing: 4) {
HStack(spacing: 5) {
Image(systemName: icon).font(.caption).foregroundStyle(.secondary)
Text(window.label ?? fallbackLabel).font(.subheadline)
Spacer()
Text(QuotaFormat.percent(utilization))
.font(.subheadline.monospacedDigit().weight(.semibold))
.foregroundStyle(color)
}
bar(fraction: utilization / 100, color: color)
if let caption = QuotaFormat.resetCaption(window.resetsAt, now: now) {
Text(caption).font(.caption2).foregroundStyle(.secondary)
}
}
}
}
private func bar(fraction: Double, color: Color) -> some View {
GeometryReader { geo in
ZStack(alignment: .leading) {
Capsule().fill(Color.secondary.opacity(0.18))
Capsule().fill(color)
.frame(width: max(3, geo.size.width * min(1, max(0, fraction))))
}
}
.frame(height: 6)
}
}
// MARK: - Status pill (the Mac's StatusFeedIndicator)
/// A compact service-status pill: a calm green check while every watched provider is
/// operational, amber naming the affected providers when any has an active incident.
/// Tapping opens a sheet listing current incidents with links to the status pages.
/// Renders nothing before the host projects any feed (older host / first poll pending).
struct StatusFeedPill: View {
let feeds: [WireStatusFeed]
@State private var showingSheet = false
private var active: [WireStatusIncident] { feeds.flatMap(\.activeIncidents) }
private var affected: [String] { feeds.filter(\.hasActiveIncident).map(\.providerName) }
var body: some View {
if !feeds.isEmpty {
Button { showingSheet = true } label: {
if active.isEmpty {
Label("Operational", systemImage: "checkmark.circle.fill")
.foregroundStyle(Palette.success)
} else {
Label(affected.joined(separator: ", "), systemImage: "exclamationmark.triangle.fill")
.foregroundStyle(Palette.attention)
}
}
.buttonStyle(.plain)
.font(.caption)
.sheet(isPresented: $showingSheet) { StatusFeedSheet(feeds: feeds) }
}
}
}
/// The pill's detail: current incidents grouped by provider (the Mac popover, sheet-form).
private struct StatusFeedSheet: View {
let feeds: [WireStatusFeed]
@Environment(\.dismiss) private var dismiss
var body: some View {
NavigationStack {
List {
ForEach(feeds) { feed in
Section(feed.providerName) {
if feed.activeIncidents.isEmpty {
Label("Operational", systemImage: "checkmark.circle.fill")
.foregroundStyle(Palette.success)
.font(.subheadline)
} else {
ForEach(feed.activeIncidents) { incident in
incidentRow(incident)
}
}
}
}
}
.navigationTitle("Service status")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .topBarTrailing) {
Button("Done") { dismiss() }
}
}
}
.presentationDetents([.medium, .large])
}
@ViewBuilder
private func incidentRow(_ incident: WireStatusIncident) -> some View {
VStack(alignment: .leading, spacing: 3) {
Text(incident.title).font(.subheadline.weight(.medium))
HStack(spacing: 6) {
if let state = incident.state {
Text(state).font(.caption).foregroundStyle(Palette.attention)
}
if !incident.components.isEmpty {
Text(incident.components.joined(separator: " · "))
.font(.caption).foregroundStyle(.secondary)
}
if let updated = incident.updatedAt {
Text(updated.formatted(date: .omitted, time: .shortened))
.font(.caption).foregroundStyle(.tertiary)
}
}
if let url = incident.url {
Link("Status page", destination: url).font(.caption)
}
}
.padding(.vertical, 2)
}
}