| 1 | import { HStack, ProgressView, Spacer, Text, VStack } from "@expo/ui/swift-ui"; |
| 2 | import { |
| 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"; |
| 15 | import { createWidget, type WidgetEnvironment } from "expo-widgets"; |
| 16 | |
| 17 | import type { SubscriptionUsageSnapshot as SubscriptionUsageProps } from "./subscriptionUsageSnapshot"; |
| 18 | |
| 19 | type UsageConfiguration = { |
| 20 | codexPeriod?: "auto" | "session" | "weekly"; |
| 21 | claudePeriod?: "auto" | "session" | "weekly"; |
| 22 | }; |
| 23 | |
| 24 | function 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 | |
| 265 | export default createWidget("SubscriptionUsage", SubscriptionUsage); |