Repository navigation
feat(mobile): add subscription usage home screen widgets #10055
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
b69c790
29484cc
e6578aa
480aa83
bfd493f
0c07b93
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,18 @@ | ||
| apply plugin: 'com.android.library' | ||
| apply plugin: 'org.jetbrains.kotlin.android' | ||
|
|
||
| group = 'com.t3tools.subscriptionwidget' | ||
| version = '0.0.0' | ||
|
|
||
| android { | ||
| namespace 'expo.modules.t3subscriptionwidget' | ||
| compileSdk rootProject.ext.compileSdkVersion | ||
| defaultConfig { | ||
| minSdkVersion rootProject.ext.minSdkVersion | ||
| targetSdkVersion rootProject.ext.targetSdkVersion | ||
| } | ||
| } | ||
|
|
||
| dependencies { | ||
| implementation project(':expo-modules-core') | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,8 @@ | ||
| <manifest xmlns:android="http://schemas.android.com/apk/res/android"> | ||
| <application> | ||
| <receiver android:name="expo.modules.t3subscriptionwidget.SubscriptionUsageWidget" android:exported="false" android:label="@string/t3_subscription_widget_name"> | ||
| <intent-filter><action android:name="android.appwidget.action.APPWIDGET_UPDATE" /></intent-filter> | ||
| <meta-data android:name="android.appwidget.provider" android:resource="@xml/t3_subscription_widget_info" /> | ||
| </receiver> | ||
| </application> | ||
| </manifest> |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,124 @@ | ||
| package expo.modules.t3subscriptionwidget | ||
|
|
||
| import android.app.PendingIntent | ||
| import android.appwidget.AppWidgetManager | ||
| import android.appwidget.AppWidgetProvider | ||
| import android.content.ComponentName | ||
| import android.content.Context | ||
| import android.content.Intent | ||
| import android.net.Uri | ||
| import android.os.Bundle | ||
| import android.view.View | ||
| import android.widget.RemoteViews | ||
| import org.json.JSONObject | ||
| import java.text.DateFormat | ||
| import java.util.Date | ||
|
|
||
| class SubscriptionUsageWidget : AppWidgetProvider() { | ||
| override fun onUpdate(context: Context, manager: AppWidgetManager, ids: IntArray) { | ||
| ids.forEach { update(context, manager, it) } | ||
| } | ||
|
|
||
| override fun onAppWidgetOptionsChanged( | ||
| context: Context, | ||
| manager: AppWidgetManager, | ||
| id: Int, | ||
| options: Bundle | ||
| ) { | ||
| update(context, manager, id) | ||
| } | ||
|
|
||
| companion object { | ||
| const val PREFERENCES = "t3_subscription_widget" | ||
|
|
||
| fun updateAll(context: Context) { | ||
| val manager = AppWidgetManager.getInstance(context) | ||
| manager.getAppWidgetIds(ComponentName(context, SubscriptionUsageWidget::class.java)) | ||
| .forEach { update(context, manager, it) } | ||
| } | ||
|
|
||
| private fun update(context: Context, manager: AppWidgetManager, id: Int) { | ||
| val saved = context.getSharedPreferences(PREFERENCES, 0).getString("snapshot", null) | ||
| val snapshot = try { | ||
| saved?.let { JSONObject(it) } | ||
| } catch (_: Exception) { | ||
| null | ||
| } | ||
| val views = RemoteViews(context.packageName, R.layout.t3_subscription_widget) | ||
| openAppIntent(context, id, snapshot)?.let { | ||
| views.setOnClickPendingIntent(R.id.t3_widget_root, it) | ||
| } | ||
| val rows = snapshot?.optJSONArray("rows") | ||
| if (rows != null && rows.length() > 0) { | ||
| views.removeAllViews(R.id.t3_widget_rows) | ||
| val options = manager.getAppWidgetOptions(id) | ||
| val height = options.getInt(AppWidgetManager.OPTION_APPWIDGET_MIN_HEIGHT, 180) | ||
| val count = ((height - 64) / 66).coerceIn(1, 8).coerceAtMost(rows.length()) | ||
| var oldest = Long.MAX_VALUE | ||
| for (index in 0 until count) { | ||
| val row = rows.optJSONObject(index) ?: continue | ||
| val child = rowView(context, row) | ||
| oldest = minOf(oldest, row.optLong("checkedAt")) | ||
| views.addView(R.id.t3_widget_rows, child) | ||
| } | ||
| val remaining = (snapshot?.optInt("totalRows", rows.length()) ?: rows.length()) - count | ||
| val formatted = DateFormat.getDateTimeInstance( | ||
| DateFormat.SHORT, | ||
| DateFormat.SHORT | ||
| ).format(Date(oldest)) | ||
| val more = if (remaining > 0) { | ||
| context.getString(R.string.t3_subscription_widget_more, remaining) | ||
| } else { | ||
| "" | ||
| } | ||
| views.setTextViewText( | ||
| R.id.t3_widget_footer, | ||
| ( | ||
| if (oldest > 0) { | ||
| context.getString(R.string.t3_subscription_widget_as_of, formatted) | ||
| } else { | ||
| context.getString(R.string.t3_subscription_widget_unknown_check) | ||
| } | ||
| ) + more | ||
| ) | ||
| } | ||
| manager.updateAppWidget(id, views) | ||
| } | ||
|
|
||
| private fun openAppIntent(context: Context, id: Int, snapshot: JSONObject?): PendingIntent? { | ||
| // Target this variant's launcher so co-installed builds cannot steal the tap. | ||
| val intent = | ||
| context.packageManager.getLaunchIntentForPackage(context.packageName) ?: return null | ||
| intent.action = Intent.ACTION_VIEW | ||
| val deepLink = snapshot?.optString("deepLink")?.takeIf { it.isNotBlank() } | ||
| ?: "t3code://settings/usage?tab=limits" | ||
| intent.data = Uri.parse(deepLink) | ||
| intent.flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP | ||
| return PendingIntent.getActivity( | ||
| context, | ||
| id, | ||
| intent, | ||
| PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE | ||
| ) | ||
| } | ||
|
|
||
| private fun rowView(context: Context, row: JSONObject): RemoteViews { | ||
| val child = RemoteViews(context.packageName, R.layout.t3_subscription_widget_row) | ||
| val used = if (row.isNull("usedPercent")) null else row.optInt("usedPercent").coerceIn(0, 100) | ||
| child.setTextViewText(R.id.t3_widget_label, row.optString("label")) | ||
| child.setTextViewText(R.id.t3_widget_window, row.optString("window")) | ||
| val percent = used?.let { context.getString(R.string.t3_subscription_widget_used, it) } ?: "—" | ||
| child.setTextViewText(R.id.t3_widget_percent, percent) | ||
| val visibility = if (used == null) View.GONE else View.VISIBLE | ||
| child.setViewVisibility(R.id.t3_widget_progress, visibility) | ||
| if (used != null) child.setProgressBar(R.id.t3_widget_progress, 100, used, false) | ||
| val reset = if (row.optLong("expiresAt") <= System.currentTimeMillis()) { | ||
| context.getString(R.string.t3_subscription_widget_refresh) | ||
| } else { | ||
| row.optString("resetLabel") | ||
| } | ||
| child.setTextViewText(R.id.t3_widget_reset, reset) | ||
| return child | ||
| } | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,18 @@ | ||
| package expo.modules.t3subscriptionwidget | ||
|
|
||
| import expo.modules.kotlin.modules.Module | ||
| import expo.modules.kotlin.modules.ModuleDefinition | ||
| import org.json.JSONObject | ||
|
|
||
| class T3SubscriptionWidgetModule : Module() { | ||
| override fun definition() = ModuleDefinition { | ||
| Name("T3SubscriptionWidget") | ||
| Function("updateSnapshot") { snapshot: String -> | ||
| val context = appContext.reactContext ?: return@Function | ||
| JSONObject(snapshot) // Reject malformed writes before replacing the saved snapshot. | ||
| context.getSharedPreferences(SubscriptionUsageWidget.PREFERENCES, 0) | ||
| .edit().putString("snapshot", snapshot).apply() | ||
| SubscriptionUsageWidget.updateAll(context) | ||
| } | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,4 @@ | ||
| <shape xmlns:android="http://schemas.android.com/apk/res/android"> | ||
| <solid android:color="@color/t3_widget_background" /> | ||
| <corners android:radius="20dp" /> | ||
| </shape> |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,9 @@ | ||
| <LinearLayout xmlns:android="http://schemas.android.com/apk/res/android" | ||
| android:id="@+id/t3_widget_root" android:layout_width="match_parent" android:layout_height="match_parent" | ||
| android:orientation="vertical" android:padding="16dp" android:background="@drawable/t3_subscription_widget_background"> | ||
| <TextView android:layout_width="match_parent" android:layout_height="wrap_content" android:text="@string/t3_subscription_widget_name" android:textStyle="bold" android:textSize="16sp" android:textColor="@color/t3_widget_foreground" android:maxLines="1" android:ellipsize="end" /> | ||
| <LinearLayout android:id="@+id/t3_widget_rows" android:layout_width="match_parent" android:layout_height="0dp" android:layout_weight="1" android:orientation="vertical"> | ||
| <TextView android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_marginTop="8dp" android:text="@string/t3_subscription_widget_empty" android:textSize="13sp" android:textColor="@color/t3_widget_secondary" /> | ||
| </LinearLayout> | ||
| <TextView android:id="@+id/t3_widget_footer" android:layout_width="match_parent" android:layout_height="wrap_content" android:text="@string/t3_subscription_widget_open" android:textSize="10sp" android:textColor="@color/t3_widget_secondary" android:maxLines="1" android:ellipsize="end" /> | ||
| </LinearLayout> |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,10 @@ | ||
| <LinearLayout xmlns:android="http://schemas.android.com/apk/res/android" | ||
| android:layout_width="match_parent" android:layout_height="wrap_content" android:orientation="vertical" android:paddingTop="8dp"> | ||
| <LinearLayout android:layout_width="match_parent" android:layout_height="wrap_content" android:orientation="horizontal"> | ||
| <TextView android:id="@+id/t3_widget_label" android:layout_width="0dp" android:layout_weight="1" android:layout_height="wrap_content" android:textSize="12sp" android:textStyle="bold" android:textColor="@color/t3_widget_foreground" android:maxLines="1" android:ellipsize="end" /> | ||
| <TextView android:id="@+id/t3_widget_percent" android:layout_width="wrap_content" android:layout_height="wrap_content" android:paddingStart="8dp" android:textSize="12sp" android:textColor="@color/t3_widget_foreground" /> | ||
| </LinearLayout> | ||
| <TextView android:id="@+id/t3_widget_window" android:layout_width="match_parent" android:layout_height="wrap_content" android:textSize="10sp" android:textColor="@color/t3_widget_secondary" android:maxLines="1" android:ellipsize="end" /> | ||
| <ProgressBar android:id="@+id/t3_widget_progress" style="?android:attr/progressBarStyleHorizontal" android:layout_width="match_parent" android:layout_height="6dp" android:max="100" android:progressTint="#0284c7" /> | ||
| <TextView android:id="@+id/t3_widget_reset" android:layout_width="match_parent" android:layout_height="wrap_content" android:textSize="10sp" android:textColor="@color/t3_widget_secondary" android:maxLines="1" android:ellipsize="end" /> | ||
| </LinearLayout> |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,5 @@ | ||
| <resources> | ||
| <color name="t3_widget_background">#18181B</color> | ||
| <color name="t3_widget_foreground">#FAFAFA</color> | ||
| <color name="t3_widget_secondary">#A1A1AA</color> | ||
| </resources> |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,5 @@ | ||
| <resources> | ||
| <color name="t3_widget_background">#FAFAFA</color> | ||
| <color name="t3_widget_foreground">#18181B</color> | ||
| <color name="t3_widget_secondary">#52525B</color> | ||
| </resources> |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,11 @@ | ||
| <resources> | ||
| <string name="t3_subscription_widget_unknown_check">Last checked unavailable</string> | ||
| <string name="t3_subscription_widget_name">Subscription usage</string> | ||
| <string name="t3_subscription_widget_description">Saved subscription quotas from your T3 Code environments. Tap to refresh in the app.</string> | ||
| <string name="t3_subscription_widget_empty">Open T3 Code and connect an environment to see limits.</string> | ||
| <string name="t3_subscription_widget_open">Tap to open Usage</string> | ||
| <string name="t3_subscription_widget_refresh">Open app to refresh</string> | ||
| <string name="t3_subscription_widget_used">%1$d%% used</string> | ||
| <string name="t3_subscription_widget_as_of">As of %1$s</string> | ||
| <string name="t3_subscription_widget_more"> · +%1$d more</string> | ||
| </resources> |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,9 @@ | ||
| <appwidget-provider xmlns:android="http://schemas.android.com/apk/res/android" | ||
| android:minWidth="250dp" android:minHeight="180dp" | ||
| android:minResizeWidth="180dp" android:minResizeHeight="130dp" | ||
| android:targetCellWidth="4" android:targetCellHeight="3" | ||
| android:resizeMode="horizontal|vertical" android:widgetCategory="home_screen" | ||
| android:updatePeriodMillis="1800000" | ||
| android:description="@string/t3_subscription_widget_description" | ||
| android:initialLayout="@layout/t3_subscription_widget" | ||
| android:previewLayout="@layout/t3_subscription_widget" /> |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,4 @@ | ||
| { | ||
| "platforms": ["android"], | ||
| "android": { "modules": ["expo.modules.t3subscriptionwidget.T3SubscriptionWidgetModule"] } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,5 +1,5 @@ | ||
| import { EnvironmentId, USAGE_CONTRACT_VERSION } from "@t3tools/contracts"; | ||
| import { useNavigation } from "@react-navigation/native"; | ||
| import { type RouteProp, useNavigation, useRoute } from "@react-navigation/native"; | ||
| import { | ||
| isCompatibleUsageContractVersion, | ||
| isModelCostUnknown, | ||
|
|
@@ -65,11 +65,21 @@ const CHART_HEIGHT = 180; | |
| * pull to refresh, each refreshing its own data. | ||
| */ | ||
| export function UsageRouteScreen() { | ||
| const route = useRoute<RouteProp<{ Usage: { tab?: string } | undefined }, "Usage">>(); | ||
| const navigation = useNavigation(); | ||
| const insets = useSafeAreaInsets(); | ||
| // Limits first: remaining quota and reset time are what most people open | ||
| // the screen for. | ||
| const [tab, setTab] = useState<UsageTab>("limits"); | ||
| const [selection, setSelection] = useState(() => ({ | ||
| params: route.params, | ||
| tab: (route.params?.tab === "usage" ? "usage" : "limits") as UsageTab, | ||
| })); | ||
| if (selection.params !== route.params) { | ||
| setSelection({ | ||
| params: route.params, | ||
| tab: route.params?.tab === "usage" ? "usage" : "limits", | ||
|
Comment on lines
+68
to
+78
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win Default The Settings → Usage row navigates to 🤖 Prompt for AI Agents |
||
| }); | ||
| } | ||
| const { tab } = selection; | ||
| const setTab = (tab: UsageTab) => setSelection({ params: route.params, tab }); | ||
| const [windowSelection, setWindowSelection] = useState(() => ({ | ||
| days: 30, | ||
| window: makeWindow(30), | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,96 @@ | ||
| import { describe, expect, it, vi } from "vite-plus/test"; | ||
| vi.mock("@expo/ui/swift-ui", () => | ||
| Object.fromEntries( | ||
| ["HStack", "ProgressView", "Spacer", "Text", "VStack"].map((name) => [name, name]), | ||
| ), | ||
| ); | ||
| vi.mock("@expo/ui/swift-ui/modifiers", () => | ||
| Object.fromEntries( | ||
| ["font", "foregroundStyle", "lineLimit", "tint", "widgetURL"].map((name) => [ | ||
| name, | ||
| (value: unknown) => ({ [name]: value }), | ||
| ]), | ||
| ), | ||
| ); | ||
| vi.mock("expo-widgets", () => ({ createWidget: (name: string) => ({ name }) })); | ||
| import { SubscriptionUsage } from "./SubscriptionUsage"; | ||
| import type { SubscriptionUsageSnapshot } from "./subscriptionUsageSnapshot"; | ||
|
|
||
| const now = Date.parse("2026-09-05T12:00:00Z"); | ||
| const snapshot: SubscriptionUsageSnapshot = { | ||
| deepLink: "t3code-dev://settings/usage?tab=limits", | ||
| totalRows: 8, | ||
| rows: Array.from({ length: 8 }, (_, index) => ({ | ||
| label: `Provider ${index}`, | ||
| window: "Weekly", | ||
| usedPercent: 90, | ||
| resetLabel: "Resets tomorrow", | ||
| checkedAt: now, | ||
| expiresAt: now + 600_000, | ||
| })), | ||
| }; | ||
|
|
||
| describe("iOS subscription widget", () => { | ||
| it.each([ | ||
| ["systemSmall", 1], | ||
| ["systemMedium", 2], | ||
| ["systemLarge", 4], | ||
| ] as const)("bounds %s content and reports omitted windows", (widgetFamily, count) => { | ||
| const tree = JSON.stringify( | ||
| SubscriptionUsage(snapshot, { date: new Date(now), widgetFamily, configuration: undefined }), | ||
| ); | ||
| expect(tree).toContain(`Provider ${count - 1}`); | ||
| expect(tree).not.toContain(`Provider ${count}`); | ||
| expect(tree).toContain(`+${8 - count} more`); | ||
| expect(tree).toContain(snapshot.deepLink); | ||
| }); | ||
| it("renders gallery props without account data", () => { | ||
| const tree = JSON.stringify( | ||
| SubscriptionUsage({} as SubscriptionUsageSnapshot, { | ||
| date: new Date(now), | ||
| widgetFamily: "systemSmall", | ||
| configuration: undefined, | ||
| }), | ||
| ); | ||
| expect(tree).toContain("connect an environment"); | ||
| expect(tree).not.toContain("Invalid Date"); | ||
| }); | ||
| it("replaces reset copy when the snapshot expires", () => { | ||
| const tree = JSON.stringify( | ||
| SubscriptionUsage(snapshot, { | ||
| date: new Date(now + 600_000), | ||
| widgetFamily: "systemSmall", | ||
| configuration: undefined, | ||
| }), | ||
| ); | ||
| expect(tree).toContain("Open app to refresh"); | ||
| expect(tree).not.toContain("Resets tomorrow"); | ||
| expect(tree).toContain("90% used"); | ||
| }); | ||
| it("renders an omitted quota as unavailable rather than zero", () => { | ||
| const tree = JSON.stringify( | ||
| SubscriptionUsage( | ||
| { | ||
| ...snapshot, | ||
| rows: [ | ||
| { | ||
| label: "Claude", | ||
| window: "No subscription limits", | ||
| resetLabel: "Open app for details", | ||
| checkedAt: 0, | ||
| expiresAt: 0, | ||
| }, | ||
| ], | ||
| totalRows: 1, | ||
| }, | ||
| { date: new Date(now), widgetFamily: "systemSmall", configuration: undefined }, | ||
| ), | ||
| ); | ||
| expect(tree).toContain("—"); | ||
| expect(tree).toContain("No subscription limits"); | ||
| expect(tree).toContain("Last checked unavailable"); | ||
| expect(tree).not.toContain("1970"); | ||
| expect(tree).not.toContain("ProgressView"); | ||
| expect(tree).not.toContain("0% used"); | ||
| }); | ||
| }); |
Uh oh!
There was an error while loading. Please reload this page.