forked from BasedHardware/omi
-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathChatQuotaBannerView.swift
More file actions
144 lines (129 loc) · 5.32 KB
/
Copy pathChatQuotaBannerView.swift
File metadata and controls
144 lines (129 loc) · 5.32 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
import OmiTheme
import SwiftUI
/// The chat-quota warning card that sits directly above the composer.
///
/// One quiet line in `ChatErrorCard`'s idiom — tinted wash, tinted hairline,
/// severity glyph, one CTA pill — so the warning costs the composer a single
/// row and never pushes the conversation around. Severity lives in one hue:
/// outline orange at 90%, filled orange at the allowance, and only a
/// hard-capped plan, where sends are actually refused, borrows the error red.
/// `PageGlass.warning` and not a hand-mixed yellow — the palette's "something
/// to do about it, not yet an error" tone.
struct ChatQuotaBannerView: View {
let banner: ChatQuotaBanner
let onViewPlan: () -> Void
let onDismiss: () -> Void
/// The inline meter's fixed track width. Narrow enough to stay decoration,
/// wide enough that 90% vs 100% is legible at a glance.
private let meterWidth: CGFloat = 72
/// Mounted directly above a composer. Both desktop shells show the same
/// warning on the same surface (INV-NAV-1), so the wiring lives here once
/// rather than being restated at each mount point.
struct Slot: View {
@ObservedObject private var usageLimiter = FloatingBarUsageLimiter.shared
@ObservedObject private var dismissals = ChatQuotaBannerDismissals.shared
private var banner: ChatQuotaBanner? {
ChatQuotaBanner.current(
quota: usageLimiter.serverQuota,
optimisticDelta: usageLimiter.optimisticDelta,
dismissed: dismissals.dismissed)
}
var body: some View {
content
.onAppear { ChatQuotaBannerPresentation.shared.record(banner) }
.onChange(of: banner) { _, shown in
ChatQuotaBannerPresentation.shared.record(shown)
}
}
@ViewBuilder
private var content: some View {
if let banner {
ChatQuotaBannerView(
banner: banner,
onViewPlan: {
NotificationCenter.default.post(name: .navigateToPlanSettings, object: nil)
},
onDismiss: {
dismissals.dismiss(threshold: banner.threshold, cycleID: banner.cycleID)
}
)
}
}
}
var body: some View {
// One line: what happened, the count, the meter inline, and the two
// actions. The full detail (plan name, reset date, overage copy) stays a
// glance away on the plan page, where "View plan" lands.
HStack(alignment: .center, spacing: OmiSpacing.sm) {
Image(systemName: severityGlyph)
.scaledFont(size: OmiType.body, weight: .semibold)
.foregroundColor(severityColor)
.accessibilityHidden(true)
HStack(alignment: .firstTextBaseline, spacing: OmiSpacing.xs) {
Text(banner.title)
.scaledFont(size: OmiType.body, weight: .semibold)
.foregroundColor(Ink.primary)
.lineLimit(1)
Text(banner.summary)
.scaledFont(size: OmiType.caption)
.foregroundColor(Ink.secondary)
.lineLimit(1)
}
.layoutPriority(1)
meter
.frame(width: meterWidth)
Spacer(minLength: OmiSpacing.md)
Button(action: onViewPlan) {
Text("View plan")
}
.buttonStyle(OmiButtonStyle(.secondary, size: .compact))
.accessibilityIdentifier("chat-quota-banner-view-plan")
Button(action: onDismiss) {
Image(systemName: "xmark")
.scaledFont(size: OmiType.caption)
.foregroundColor(Ink.secondary)
}
.buttonStyle(.plain)
.help("Dismiss")
.accessibilityLabel("Dismiss")
.accessibilityIdentifier("chat-quota-banner-dismiss")
}
.padding(.horizontal, OmiSpacing.md)
.padding(.vertical, OmiSpacing.sm)
.background(Ink.rowFill)
.overlay(
RoundedRectangle(cornerRadius: PageGlass.rowRadius, style: .continuous)
.strokeBorder(severityColor.opacity(0.35), lineWidth: 1)
)
.clipShape(RoundedRectangle(cornerRadius: PageGlass.rowRadius, style: .continuous))
.accessibilityElement(children: .contain)
.accessibilityLabel("\(banner.title). \(banner.message)")
.accessibilityIdentifier("chat-quota-banner")
}
/// A hard-capped plan at its allowance is stopped, not just warned — the
/// server refuses its sends — so that state alone speaks in the error voice.
/// Everything else (the 90% approach, an overage plan being billed) warns.
private var severityColor: Color {
banner.threshold >= 100 && !banner.isBillingOverage ? Ink.errorRed : PageGlass.warning
}
/// One glyph shape throughout, escalated by fill: the outline says "getting
/// close", the filled triangle says "there".
private var severityGlyph: String {
banner.threshold >= 100 ? "exclamationmark.triangle.fill" : "exclamationmark.triangle"
}
/// The allowance meter, inline: a hairline track with the used share filled.
/// The glanceable fact prose cannot be — how close the end is. Decorative to
/// accessibility, which the banner label carries in words.
private var meter: some View {
Capsule(style: .continuous)
.fill(Ink.rowFillHover)
.overlay(alignment: .leading) {
Capsule(style: .continuous)
.fill(severityColor)
// A nonzero reading stays a visible nub; a zero one is empty.
.frame(width: meterWidth * CGFloat(max(banner.percent > 0 ? 3 : 0, banner.percent)) / 100)
}
.frame(height: 4)
.accessibilityHidden(true)
}
}