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