Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions apps/mobile/app.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -130,6 +130,12 @@ const widgetsPlugin: NonNullable<ExpoConfig["plugins"]>[number] = [
// frequent-updates entitlement iOS throttles the update budget sooner.
frequentUpdates: true,
widgets: [
{
name: "SubscriptionUsage",
displayName: "Subscription usage",
description: "Subscription quotas from your connected T3 Code environments.",
supportedFamilies: ["systemSmall", "systemMedium", "systemLarge"],
},
{
name: "AgentActivity",
displayName: "Agent Activity",
Expand Down
18 changes: 18 additions & 0 deletions apps/mobile/modules/t3-subscription-widget/android/build.gradle
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"] }
Comment thread
saphid marked this conversation as resolved.
}
3 changes: 3 additions & 0 deletions apps/mobile/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,8 @@ import { OverlayPortalHost } from "./components/OverlayPortal";
import { appBlurTargetRef } from "./lib/appBlurTarget";
import { useMobileNavigationTheme } from "./lib/useMobileNavigationTheme";

import { SubscriptionUsageCoordinator } from "./widgets/SubscriptionUsageCoordinator";

import "../global.css";

if (process.env.EXPO_PUBLIC_SHOWCASE === "1") {
Expand Down Expand Up @@ -77,6 +79,7 @@ function AppContent() {
return (
<>
<SplashScreenCoordinator />
<SubscriptionUsageCoordinator />
<GestureHandlerRootView className="flex-1">
<KeyboardProvider statusBarTranslucent>
<SafeAreaProvider>
Expand Down
16 changes: 14 additions & 2 deletions apps/mobile/src/features/usage/UsageRouteScreen.tsx
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,
Expand Down Expand Up @@ -65,9 +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();
const [tab, setTab] = useState<UsageTab>("usage");
const [selection, setSelection] = useState(() => ({
params: route.params,
tab: (route.params?.tab === "limits" ? "limits" : "usage") as UsageTab,
}));
if (selection.params !== route.params) {
setSelection({
params: route.params,
tab: route.params?.tab === "limits" ? "limits" : "usage",
});
}
const { tab } = selection;
const setTab = (tab: UsageTab) => setSelection({ params: route.params, tab });
const [windowSelection, setWindowSelection] = useState(() => ({
days: 30,
window: makeWindow(30),
Expand Down
96 changes: 96 additions & 0 deletions apps/mobile/src/widgets/SubscriptionUsage.test.ts
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");
});
});
Loading
Loading