1import { HStack, ProgressView, Spacer, Text, VStack } from "@expo/ui/swift-ui";
2import {
3 accessibilityElement,
4 accessibilityLabel,
5 fixedSize,
6 font,
7 foregroundStyle,
8 frame,
9 layoutPriority,
10 lineLimit,
11 progressViewStyle,
12 tint,
13 widgetURL,
14} from "@expo/ui/swift-ui/modifiers";
15import { createWidget, type WidgetEnvironment } from "expo-widgets";
16
17import type { SubscriptionUsageSnapshot as SubscriptionUsageProps } from "./subscriptionUsageSnapshot";
18
19type UsageConfiguration = {
20 codexPeriod?: "auto" | "session" | "weekly";
21 claudePeriod?: "auto" | "session" | "weekly";
22};
23
24function SubscriptionUsage(
25 props: SubscriptionUsageProps,
26 environment: WidgetEnvironment<UsageConfiguration>,
27) {
28 "widget";
29 // The extension evaluates this function without the app's module scope.
30 const family = environment.widgetFamily;
31 // Gallery snapshots can render an old timeline entry after it has expired.
32 const now = Math.max(environment.date?.getTime() ?? 0, Date.now());
33 const accessory = family === "accessoryRectangular";
34 const compact =
35 family === "systemSmall" || accessory || environment.levelOfDetail === "simplified";
36 // Budget short cards for two quotas per provider, including their secondary text.
37 const dense = family === "systemSmall" || family === "systemMedium";
38 const limit = family === "systemExtraLarge" ? 6 : family === "systemLarge" ? 4 : 2;
39 const monochrome =
40 environment.widgetRenderingMode !== "fullColor" || environment.isLuminanceReduced;
41 const providers = props.providers ?? [
42 { name: "Codex", detail: "Open The Knife to connect", windows: [], expiresAt: 0 },
43 { name: "Claude", detail: "Open The Knife to connect", windows: [], expiresAt: 0 },
44 ];
45 const columns = providers.map((provider) => {
46 const stale = provider.windows.length > 0 && now >= provider.expiresAt;
47 const period =
48 environment.configuration?.[provider.name === "Claude" ? "claudePeriod" : "codexPeriod"] ??
49 "auto";
50 const windows = stale
51 ? []
52 : provider.windows.filter((window) => period === "auto" || window.kind === period);
53 // Lock Screen widgets surface the tightest selected limit.
54 const tightest = windows.reduce<(typeof windows)[number] | undefined>(
55 (result, window) => (!result || window.remaining < result.remaining ? window : result),
56 undefined,
57 );
58 const compactWindows = [
59 windows.find((window) => window.kind === "session"),
60 windows.find((window) => window.kind === "weekly"),
61 ].filter((window) => window !== undefined);
62 const shown =
63 accessory || environment.levelOfDetail === "simplified"
64 ? tightest
65 ? [tightest]
66 : []
67 : family === "systemSmall" && compactWindows.length > 0
68 ? compactWindows
69 : period === "auto" && compactWindows.length > 0
70 ? [
71 ...compactWindows,
72 ...windows.filter((window) => !compactWindows.includes(window)),
73 ].slice(0, limit)
74 : windows.slice(0, limit);
75 const detail = stale
76 ? "Open The Knife to refresh"
77 : period !== "auto" && windows.length === 0 && provider.windows.length > 0
78 ? `No ${period} limit reported`
79 : provider.detail;
80 const barModifiers = [
81 progressViewStyle("linear"),
82 frame({ height: 4 }),
83 ...(monochrome ? [] : [tint(provider.name === "Claude" ? "#d97757" : "#8e8e93")]),
84 ];
85 if (accessory) {
86 return (
87 <VStack
88 key={provider.name}
89 alignment="leading"
90 spacing={2}
91 modifiers={[
92 accessibilityElement("ignore"),
93 accessibilityLabel(
94 tightest
95 ? `${provider.name}, ${tightest.label}, ${tightest.remaining} percent remaining. ${tightest.reset}. ${provider.detail}.`
96 : `${provider.name}. ${detail}.`,
97 ),
98 ]}
99 >
100 <HStack spacing={4}>
101 <Text
102 modifiers={[
103 font({ textStyle: "caption", weight: "semibold" }),
104 lineLimit(1),
105 foregroundStyle("primary"),
106 ]}
107 >
108 {provider.name}
109 {tightest ? ` · ${tightest.label}` : ""}
110 </Text>
111 <Spacer />
112 <Text
113 modifiers={[
114 font({ textStyle: "caption", weight: "semibold" }),
115 lineLimit(1),
116 layoutPriority(1),
117 foregroundStyle("primary"),
118 ]}
119 >
120 {tightest
121 ? `${tightest.remaining}% left`
122 : period !== "auto" && !stale && provider.windows.length > 0
123 ? "N/A"
124 : "Open The Knife"}
125 </Text>
126 </HStack>
127 {tightest ? (
128 <ProgressView value={tightest.remaining / 100} modifiers={barModifiers} />
129 ) : null}
130 </VStack>
131 );
132 }
133 return (
134 <VStack
135 key={provider.name}
136 alignment="leading"
137 spacing={dense ? 1 : compact ? 2 : 4}
138 modifiers={[
139 frame({ maxWidth: Infinity, alignment: "leading" }),
140 fixedSize({ horizontal: false, vertical: true }),
141 ]}
142 >
143 <Text
144 modifiers={[
145 font({ textStyle: compact ? "caption" : "headline", weight: "bold" }),
146 lineLimit(1),
147 foregroundStyle("primary"),
148 ]}
149 >
150 {provider.name}
151 </Text>
152 {!compact || shown.length === 0 ? (
153 <Text
154 modifiers={[
155 font({ textStyle: "caption2" }),
156 foregroundStyle("secondary"),
157 lineLimit(1),
158 ]}
159 >
160 {detail === "Subscription remaining" ? " " : detail}
161 </Text>
162 ) : null}
163 {shown.map((window) => (
164 <VStack
165 key={window.label}
166 alignment="leading"
167 spacing={dense ? 1 : 2}
168 modifiers={[
169 accessibilityElement("ignore"),
170 accessibilityLabel(
171 `${provider.name}, ${window.label}, ${window.remaining} percent remaining. ${window.reset}. ${provider.detail}.`,
172 ),
173 ]}
174 >
175 <HStack spacing={4}>
176 <Text
177 modifiers={[
178 font({ textStyle: compact || dense ? "caption2" : "caption" }),
179 foregroundStyle("secondary"),
180 lineLimit(1),
181 ]}
182 >
183 {window.label}
184 </Text>
185 <Spacer />
186 <Text
187 modifiers={[
188 font({
189 textStyle: compact || dense ? "caption2" : "caption",
190 weight: "semibold",
191 }),
192 lineLimit(1),
193 layoutPriority(1),
194 foregroundStyle(
195 window.remaining <= 10 && !monochrome
196 ? environment.colorScheme === "light"
197 ? "#dc2626"
198 : "#fca5a5"
199 : "primary",
200 ),
201 ]}
202 >
203 {window.remaining}% left
204 </Text>
205 </HStack>
206 <ProgressView value={window.remaining / 100} modifiers={barModifiers} />
207 {!compact ? (
208 <Text modifiers={[font({ size: 10 }), foregroundStyle("secondary"), lineLimit(1)]}>
209 {window.reset}
210 </Text>
211 ) : null}
212 </VStack>
213 ))}
214 {!compact &&
215 !stale &&
216 (period === "auto" ? (provider.totalWindows ?? windows.length) : windows.length) > limit ? (
217 <Text
218 modifiers={[
219 font({ textStyle: "caption2" }),
220 foregroundStyle("secondary"),
221 lineLimit(1),
222 ]}
223 >
224 {(period === "auto" ? (provider.totalWindows ?? windows.length) : windows.length) -
225 limit}{" "}
226 more in The Knife
227 </Text>
228 ) : null}
229 </VStack>
230 );
231 });
232 return (
233 <VStack
234 alignment="leading"
235 spacing={accessory || dense ? 2 : 6}
236 modifiers={props.url ? [widgetURL(props.url)] : []}
237 >
238 {providers.length === 0 ? (
239 <Text modifiers={[font({ textStyle: "caption" }), foregroundStyle("secondary")]}>
240 No subscription limits available.
241 </Text>
242 ) : compact ? (
243 <VStack alignment="leading" spacing={accessory || dense ? 4 : 8}>
244 {columns}
245 </VStack>
246 ) : (
247 <HStack alignment="top" spacing={16}>
248 {columns}
249 </HStack>
250 )}
251 {!accessory ? <Spacer /> : null}
252 {!accessory ? (
253 <Text
254 modifiers={[font({ textStyle: "caption2" }), foregroundStyle("secondary"), lineLimit(1)]}
255 >
256 {props.checkedAt
257 ? `As of ${new Date(props.checkedAt).toLocaleString(undefined, { hour: "numeric", minute: "2-digit", month: "short", day: "numeric" })}`
258 : "Tap to connect in The Knife"}
259 </Text>
260 ) : null}
261 </VStack>
262 );
263}
264
265export default createWidget("SubscriptionUsage", SubscriptionUsage);