1import {
2 Button,
3 Column,
4 getMaterialColors,
5 LinearProgressIndicator,
6 Text,
7} from "@expo/ui/jetpack-compose";
8import {
9 fillMaxSize,
10 fillMaxWidth,
11 height,
12 padding,
13 paddingAll,
14} from "@expo/ui/jetpack-compose/modifiers";
15import { createWidget, type WidgetEnvironment } from "expo-widgets";
16
17import type { SubscriptionUsageSnapshot as SubscriptionUsageProps } from "./subscriptionUsageSnapshot";
18
19export function SubscriptionUsage(props: SubscriptionUsageProps, environment: WidgetEnvironment) {
20 "widget";
21 // The widget runtime evaluates this function without the app's module scope.
22 // Android has no timeline, so freshness is decided on every render; the
23 // expiry alarm and each tap trigger one while the app is closed.
24 const now = Date.now();
25 // The 4x3 default cell fits two quotas per provider with their reset text.
26 const limit = 2;
27 const colors = getMaterialColors({
28 scheme: environment.colorScheme === "dark" ? "dark" : "light",
29 });
30 const muted = colors.onSurfaceVariant;
31 const providers = props.providers ?? [
32 {
33 name: "Codex",
34 detail: "Open The Knife to connect",
35 windows: [],
36 expiresAt: 0,
37 totalWindows: 0,
38 },
39 {
40 name: "Claude",
41 detail: "Open The Knife to connect",
42 windows: [],
43 expiresAt: 0,
44 totalWindows: 0,
45 },
46 ];
47 return (
48 // The card is one Button so a tap reaches the app's interaction listener,
49 // which opens props.url. expo-widgets has no Android counterpart to widgetURL.
50 <Button
51 colors={{ containerColor: colors.surface }}
52 modifiers={[fillMaxSize()]}
53 onClick={() => {}}
54 >
55 <Column modifiers={[fillMaxSize(), paddingAll(16)]}>
56 {providers.map((provider, index) => {
57 const stale =
58 provider.windows.length > 0 && provider.expiresAt > 0 && now >= provider.expiresAt;
59 const shown = stale ? [] : provider.windows.slice(0, limit);
60 const hidden = stale ? 0 : (provider.totalWindows ?? provider.windows.length) - limit;
61 return (
62 <Column
63 key={provider.name}
64 modifiers={[fillMaxWidth(), padding(0, index === 0 ? 0 : 10, 0, 0)]}
65 >
66 <Text
67 color={colors.onSurface}
68 maxLines={1}
69 style={{ fontSize: 13, fontWeight: "bold" }}
70 >
71 {provider.name}
72 </Text>
73 {shown.length === 0 ? (
74 <Text color={muted} maxLines={1} style={{ fontSize: 11 }}>
75 {stale ? "Open The Knife to refresh" : provider.detail}
76 </Text>
77 ) : null}
78 {shown.map((window) => {
79 const low = window.remaining <= 10;
80 return (
81 <Column key={window.label} modifiers={[fillMaxWidth(), padding(0, 4, 0, 0)]}>
82 <Text
83 color={low ? colors.error : colors.onSurface}
84 maxLines={1}
85 style={{ fontSize: 11 }}
86 >
87 {`${window.label} · ${window.remaining}% left`}
88 </Text>
89 <Column modifiers={[fillMaxWidth(), padding(0, 3, 0, 3)]}>
90 <LinearProgressIndicator
91 progress={window.remaining / 100}
92 color={low ? colors.error : colors.primary}
93 trackColor={colors.surfaceVariant}
94 modifiers={[fillMaxWidth(), height(6)]}
95 />
96 </Column>
97 <Text color={muted} maxLines={1} style={{ fontSize: 10 }}>
98 {window.reset}
99 </Text>
100 </Column>
101 );
102 })}
103 {hidden > 0 ? (
104 <Text color={muted} maxLines={1} style={{ fontSize: 10 }}>
105 {`${hidden} more in The Knife`}
106 </Text>
107 ) : null}
108 </Column>
109 );
110 })}
111 <Text
112 color={muted}
113 maxLines={1}
114 style={{ fontSize: 10 }}
115 modifiers={[padding(0, 10, 0, 0)]}
116 >
117 {props.checkedAt
118 ? `As of ${new Date(props.checkedAt).toLocaleString(undefined, { hour: "numeric", minute: "2-digit", month: "short", day: "numeric" })}`
119 : "Tap to connect in The Knife"}
120 </Text>
121 </Column>
122 </Button>
123 );
124}
125
126export default createWidget("SubscriptionUsage", SubscriptionUsage);