From a95e8ad318ed20ff7dae54846e0172494938a963 Mon Sep 17 00:00:00 2001 From: Koushik Date: Wed, 11 Nov 2026 20:37:08 +0530 Subject: [PATCH 1/2] feat(mobile): redesign the Android usage limits widget Port the Usage > Limits card to the home-screen widget: per-account segments, pace ticks, and refill hatching, with thin bars when the widget is too short for full rows. --- .../drawable-xxhdpi/t3_widget_hatch_tile.png | Bin 0 -> 151 bytes .../src/main/res/drawable/t3_widget_hatch.xml | 4 + .../src/features/usage/providerColors.ts | 14 + .../src/features/usage/usageProviders.ts | 13 +- .../src/widgets/SubscriptionUsage.android.tsx | 346 ++++++++++++++---- .../widgets/subscriptionUsageSnapshot.test.ts | 43 +++ .../src/widgets/subscriptionUsageSnapshot.ts | 70 +++- apps/mobile/src/widgets/widgetTheme.ts | 5 + patches/expo-widgets@58.0.27.patch | 88 ++++- pnpm-lock.yaml | 8 +- 10 files changed, 489 insertions(+), 102 deletions(-) create mode 100644 apps/mobile/modules/t3-widget-expiry/android/src/main/res/drawable-xxhdpi/t3_widget_hatch_tile.png create mode 100644 apps/mobile/modules/t3-widget-expiry/android/src/main/res/drawable/t3_widget_hatch.xml create mode 100644 apps/mobile/src/features/usage/providerColors.ts diff --git a/apps/mobile/modules/t3-widget-expiry/android/src/main/res/drawable-xxhdpi/t3_widget_hatch_tile.png b/apps/mobile/modules/t3-widget-expiry/android/src/main/res/drawable-xxhdpi/t3_widget_hatch_tile.png new file mode 100644 index 0000000000000000000000000000000000000000..1e55bbd87a57ff9ecd17fffc9f72799562e4d87e GIT binary patch literal 151 zcmeAS@N?(olHy`uVBq!ia0vp^LLkh+1|-AI^@RheI8PVHkcwMtuXuAYC~&YIc(?!k zo@rAAr#|zq;ATx<&gyn`-Xo8>zgKfAK48&f`7OfoT(d(oNswDbDe6UchJxs)<|`d6 zGX?mJl=V-v&Y2*6bD@A!fP|t(&xNnf$BMJJN(<|+d}EOfw28sf)z4*}Q$iB}Z*Ve? literal 0 HcmV?d00001 diff --git a/apps/mobile/modules/t3-widget-expiry/android/src/main/res/drawable/t3_widget_hatch.xml b/apps/mobile/modules/t3-widget-expiry/android/src/main/res/drawable/t3_widget_hatch.xml new file mode 100644 index 000000000000..8125e99ad560 --- /dev/null +++ b/apps/mobile/modules/t3-widget-expiry/android/src/main/res/drawable/t3_widget_hatch.xml @@ -0,0 +1,4 @@ + + diff --git a/apps/mobile/src/features/usage/providerColors.ts b/apps/mobile/src/features/usage/providerColors.ts new file mode 100644 index 000000000000..683601e20815 --- /dev/null +++ b/apps/mobile/src/features/usage/providerColors.ts @@ -0,0 +1,14 @@ +import type { UsageProviderKind } from "@t3tools/contracts"; + +export function providerColors(scheme: "light" | "dark"): Record { + return { + claude: "#d97757", + codex: scheme === "dark" ? "#e6e6e6" : "#3c3c43", + grok: scheme === "dark" ? "#a1a1aa" : "#52525b", + cursor: "#8b8b8b", + opencode: "#5b9bbd", + antigravity: "#8c7bd1", + pi: "#4d9f8a", + muse: "#0866ff", + }; +} diff --git a/apps/mobile/src/features/usage/usageProviders.ts b/apps/mobile/src/features/usage/usageProviders.ts index ce9c778a7776..93825830f66b 100644 --- a/apps/mobile/src/features/usage/usageProviders.ts +++ b/apps/mobile/src/features/usage/usageProviders.ts @@ -1,5 +1,6 @@ import type { UsageProviderKind } from "@t3tools/contracts"; import { useAppearancePreferences } from "../settings/appearance/AppearancePreferencesProvider"; +import { providerColors } from "./providerColors"; export const PROVIDER_ORDER: readonly UsageProviderKind[] = [ "codex", @@ -29,17 +30,7 @@ export const PROVIDER_LABEL: Record = { }; export function useProviderColors(): Record { - const { themeAppearance: scheme } = useAppearancePreferences(); - return { - claude: "#d97757", - codex: scheme === "dark" ? "#e6e6e6" : "#3c3c43", - grok: scheme === "dark" ? "#a1a1aa" : "#52525b", - cursor: "#8b8b8b", - opencode: "#5b9bbd", - antigravity: "#8c7bd1", - pi: "#4d9f8a", - muse: "#0866ff", - }; + return providerColors(useAppearancePreferences().themeAppearance); } export function useUsageMixColors() { diff --git a/apps/mobile/src/widgets/SubscriptionUsage.android.tsx b/apps/mobile/src/widgets/SubscriptionUsage.android.tsx index 9e9fcbe3e6ef..3e5097fff017 100644 --- a/apps/mobile/src/widgets/SubscriptionUsage.android.tsx +++ b/apps/mobile/src/widgets/SubscriptionUsage.android.tsx @@ -1,26 +1,35 @@ import { + Box, Button, Column, getMaterialColors, - LinearProgressIndicator, + Image, + Row, + Spacer, Text, } from "@expo/ui/jetpack-compose"; import { + background, + cornerRadius, fillMaxSize, fillMaxWidth, height, padding, paddingAll, + size, + width, } from "@expo/ui/jetpack-compose/modifiers"; import { createWidget, type WidgetEnvironment } from "expo-widgets"; -import type { SubscriptionUsageSnapshot as SubscriptionUsageProps } from "./subscriptionUsageSnapshot"; +import type { + SubscriptionUsageSnapshot as SubscriptionUsageProps, + WidgetLimitAccount, +} from "./subscriptionUsageSnapshot"; export function SubscriptionUsage(props: SubscriptionUsageProps, environment: WidgetEnvironment) { "widget"; // The widget runtime evaluates this function without the app's module scope. const now = Date.now(); - const limit = 2; const mode = props.theme?.mode; const appearance = mode === "light" || mode === "dark" @@ -29,21 +38,273 @@ export function SubscriptionUsage(props: SubscriptionUsageProps, environment: Wi ? "dark" : "light"; const palette = props.theme?.[appearance]; + const material = getMaterialColors({ scheme: appearance }); const colors = palette ? { surface: palette.background, onSurface: palette.foreground, - onSurfaceVariant: palette.secondary, - surfaceVariant: palette.track, - primary: palette.accent, + muted: palette.secondary, + track: palette.subtle ?? palette.track, error: palette.danger, } - : getMaterialColors({ scheme: appearance }); - const muted = colors.onSurfaceVariant; + : { + surface: material.surface, + onSurface: material.onSurface, + muted: material.onSurfaceVariant, + track: material.surfaceVariant, + error: material.error, + }; const providers = props.providers ?? [ { name: "Codex", detail: "Open T3 to connect", windows: [], expiresAt: 0, totalWindows: 0 }, { name: "Claude", detail: "Open T3 to connect", windows: [], expiresAt: 0, totalWindows: 0 }, ]; + + const pad = 14; + const labelWidth = 92; + const footer = 16; + const widgetWidth = environment.width ?? 320; + const contentWidth = widgetWidth - pad * 2; + const contentHeight = (environment.height ?? 200) - pad * 2 - footer; + const kindOrder: Record = { session: 0, weekly: 1 }; + + type Line = + | { + type: "window"; + provider: string; + color: string; + window: SubscriptionUsageProps["providers"][number]["windows"][number]; + } + | { type: "note"; provider: string; text: string }; + const lines: Line[] = providers.flatMap((provider): Line[] => { + const stale = + provider.windows.length > 0 && provider.expiresAt > 0 && now >= provider.expiresAt; + if (stale || provider.windows.length === 0) + return [ + { + type: "note", + provider: provider.name, + text: stale ? "Open T3 to refresh" : provider.detail, + }, + ]; + return [...provider.windows] + .sort((a, b) => (kindOrder[a.kind ?? ""] ?? 2) - (kindOrder[b.kind ?? ""] ?? 2)) + .map((window) => ({ + type: "window", + provider: provider.name, + color: provider.colors?.[appearance] ?? colors.onSurface, + window, + })); + }); + + // Roomy rows when every limit fits, thin bars when they would not. + const fullRow = 34; + const compactRow = 20; + const full = lines.length * fullRow <= contentHeight; + // Glance columns hold at most ten children, the overflow note included. + const capacity = Math.max( + 1, + Math.min(9, Math.floor(contentHeight / (full ? fullRow : compactRow))), + ); + const shown = lines.length > capacity ? lines.slice(0, capacity - 1) : lines; + const hidden = lines.length - shown.length; + + const low = (remaining: number) => remaining <= 10; + const timeLeft = (account: WidgetLimitAccount) => + account.resetsAt && account.durationMs + ? Math.max(0, Math.min(1, (account.resetsAt - now) / account.durationMs)) + : null; + const paceArrow = (accounts: WidgetLimitAccount[]) => { + const timed = accounts.flatMap((account) => { + const left = timeLeft(account); + return left === null || account.remaining === undefined + ? [] + : [{ used: 100 - account.remaining, elapsed: 1 - left }]; + }); + if (timed.length === 0) return ""; + const gap = timed.reduce((sum, t) => sum + t.used - t.elapsed * 100, 0) / timed.length; + return gap > 5 ? " ↗" : gap < -5 ? " ↘" : " →"; + }; + + const segment = ( + account: WidgetLimitAccount, + color: string, + segmentWidth: number, + barHeight: number, + labels: { name: boolean; percent: boolean; reset: boolean }, + ) => { + if (account.remaining === undefined) + return ; + const fill = (segmentWidth * account.remaining) / 100; + const refill = (segmentWidth * Math.min(100 - account.remaining, account.refill ?? 0)) / 100; + const left = timeLeft(account); + const tick = + left === null ? null : Math.max(0, Math.min(segmentWidth - 5, segmentWidth * left - 2.5)); + return ( + + {fill > 0 ? ( + + ) : null} + {refill > 0 ? ( + + + + + ) : null} + {tick === null ? null : ( + + + + + + + )} + {labels.percent ? ( + + {labels.name ? ( + + {`${account.name} `} + + ) : null} + + {`${account.remaining}%`} + + + ) : null} + {labels.reset && account.reset ? ( + + + + {`↻ ${account.reset}`} + + + + ) : null} + + ); + }; + + const bar = (line: Extract, barWidth: number, barHeight: number) => { + const fallback: WidgetLimitAccount = { name: line.provider, remaining: line.window.remaining }; + const listed = line.window.accounts ?? []; + // Glance rows hold at most ten children: five segments and their gaps. + const accounts = listed.length > 0 && listed.length <= 5 ? listed : [fallback]; + const gap = 3; + const segmentWidth = (barWidth - gap * (accounts.length - 1)) / accounts.length; + const multi = accounts.length > 1; + return ( + + {accounts.flatMap((account, index) => [ + index > 0 ? : null, + segment(account, account.color ?? line.color, segmentWidth, barHeight, { + percent: barHeight >= 16 && multi && segmentWidth >= 34, + name: multi && segmentWidth >= 76, + reset: barHeight >= 16 && !multi && segmentWidth >= 120, + }), + ])} + + ); + }; + + const percentText = (remaining: number, fontSize: number) => ( + + {`${remaining}%`} + + ); + + const row = (line: Line) => { + const label = `${line.provider} ${line.type === "window" ? line.window.label : ""}`.trim(); + if (line.type === "note") + return ( + + + {`${line.provider} `} + + + {line.text} + + + ); + if (!full) + return ( + + + {label} + + {bar(line, contentWidth - labelWidth - 40, 6)} + + {percentText(line.window.remaining, 10)} + + + ); + return ( + + + + {label} + + + {percentText(line.window.remaining, 18)} + + {`left${paceArrow(line.window.accounts ?? [])}`} + + + + + {bar(line, contentWidth - labelWidth - 8, 20)} + + ); + }; + return ( // Android lacks widgetURL; one card Button routes taps to the app listener opening props.url.