| author | |
| committer | |
| log | e41755cf8a9b07a12fefee5134cadacb514d4ad9 |
| tree | ed04627f995d2b9ed6ec90d503640fd12e54645c |
| parent | 8dc07f199c2e3a4d8ec1a3997a2b9c2edf12457e |
| signature | Signed by PGP key B5690EEEBB952194 |
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>23 files changed, 483 insertions(+), 319 deletions(-)
apps/mobile/app.config.ts+47-32| ... | @@ -129,51 +129,66 @@ const widgetsPlugin: NonNullable<ExpoConfig["plugins"]>[number] = [ | ... | @@ -129,51 +129,66 @@ const widgetsPlugin: NonNullable<ExpoConfig["plugins"]>[number] = [ |
| 129 | // Agent activity can update many times an hour; without the | 129 | // Agent activity can update many times an hour; without the |
| 130 | // frequent-updates entitlement iOS throttles the update budget sooner. | 130 | // frequent-updates entitlement iOS throttles the update budget sooner. |
| 131 | frequentUpdates: true, | 131 | frequentUpdates: true, |
| 132 | enableAndroid: true, | ||
| 132 | widgets: [ | 133 | widgets: [ |
| 133 | { | 134 | { |
| 134 | name: "SubscriptionUsage", | 135 | name: "SubscriptionUsage", |
| 135 | displayName: "Subscription usage", | 136 | displayName: "Subscription usage", |
| 136 | description: "Subscription quotas from your connected T3 Code environments.", | 137 | description: "Subscription quotas from your connected T3 Code environments.", |
| 137 | configuration: { | 138 | ios: { |
| 138 | title: "Subscription usage", | 139 | configuration: { |
| 139 | description: | 140 | title: "Subscription usage", |
| 140 | "Both shows Session and Weekly when available. The Lock Screen shows the tightest selected limit.", | 141 | description: |
| 141 | parameters: { | 142 | "Both shows Session and Weekly when available. The Lock Screen shows the tightest selected limit.", |
| 142 | codexPeriod: { | 143 | parameters: { |
| 143 | title: "Codex limits", | 144 | codexPeriod: { |
| 144 | type: "enum", | 145 | title: "Codex limits", |
| 145 | default: "auto", | 146 | type: "enum", |
| 146 | values: [ | 147 | default: "auto", |
| 147 | { name: "Both", value: "auto" }, | 148 | values: [ |
| 148 | { name: "Session", value: "session" }, | 149 | { name: "Both", value: "auto" }, |
| 149 | { name: "Weekly", value: "weekly" }, | 150 | { name: "Session", value: "session" }, |
| 150 | ], | 151 | { name: "Weekly", value: "weekly" }, |
| 151 | }, | 152 | ], |
| 152 | claudePeriod: { | 153 | }, |
| 153 | title: "Claude limits", | 154 | claudePeriod: { |
| 154 | type: "enum", | 155 | title: "Claude limits", |
| 155 | default: "auto", | 156 | type: "enum", |
| 156 | values: [ | 157 | default: "auto", |
| 157 | { name: "Both", value: "auto" }, | 158 | values: [ |
| 158 | { name: "Session", value: "session" }, | 159 | { name: "Both", value: "auto" }, |
| 159 | { name: "Weekly", value: "weekly" }, | 160 | { name: "Session", value: "session" }, |
| 160 | ], | 161 | { name: "Weekly", value: "weekly" }, |
| 162 | ], | ||
| 163 | }, | ||
| 161 | }, | 164 | }, |
| 162 | }, | 165 | }, |
| 166 | supportedFamilies: [ | ||
| 167 | "systemSmall", | ||
| 168 | "systemMedium", | ||
| 169 | "systemLarge", | ||
| 170 | "systemExtraLarge", | ||
| 171 | "accessoryRectangular", | ||
| 172 | ], | ||
| 173 | }, | ||
| 174 | android: { | ||
| 175 | minWidth: 250, | ||
| 176 | minHeight: 180, | ||
| 177 | targetCellWidth: 4, | ||
| 178 | targetCellHeight: 3, | ||
| 179 | resizeMode: "both", | ||
| 180 | // Embeds the layout in the APK so the widget renders before the app | ||
| 181 | // has run once; the app replaces it with stored props on publish. | ||
| 182 | initialLayout: "./src/widgets/SubscriptionUsage.android.tsx", | ||
| 163 | }, | 183 | }, |
| 164 | supportedFamilies: [ | ||
| 165 | "systemSmall", | ||
| 166 | "systemMedium", | ||
| 167 | "systemLarge", | ||
| 168 | "systemExtraLarge", | ||
| 169 | "accessoryRectangular", | ||
| 170 | ], | ||
| 171 | }, | 184 | }, |
| 172 | { | 185 | { |
| 173 | name: "AgentActivity", | 186 | name: "AgentActivity", |
| 174 | displayName: "Agent Activity", | 187 | displayName: "Agent Activity", |
| 175 | description: "Shows the current state of active T3 Code agents.", | 188 | description: "Shows the current state of active T3 Code agents.", |
| 176 | supportedFamilies: ["systemSmall", "systemMedium", "accessoryRectangular"], | 189 | // Live Activity companion; there is no Android presentation for it. |
| 190 | android: null, | ||
| 191 | ios: { supportedFamilies: ["systemSmall", "systemMedium", "accessoryRectangular"] }, | ||
| 177 | }, | 192 | }, |
| 178 | ], | 193 | ], |
| 179 | }, | 194 | }, |
apps/mobile/modules/t3-subscription-widget/android/build.gradle deleted-18| ... | @@ -1,18 +0,0 @@ | ||
| 1 | apply plugin: 'com.android.library' | ||
| 2 | apply plugin: 'org.jetbrains.kotlin.android' | ||
| 3 | |||
| 4 | group = 'com.t3tools.subscriptionwidget' | ||
| 5 | version = '0.0.0' | ||
| 6 | |||
| 7 | android { | ||
| 8 | namespace 'expo.modules.t3subscriptionwidget' | ||
| 9 | compileSdk rootProject.ext.compileSdkVersion | ||
| 10 | defaultConfig { | ||
| 11 | minSdkVersion rootProject.ext.minSdkVersion | ||
| 12 | targetSdkVersion rootProject.ext.targetSdkVersion | ||
| 13 | } | ||
| 14 | } | ||
| 15 | |||
| 16 | dependencies { | ||
| 17 | implementation project(':expo-modules-core') | ||
| 18 | } | ||
apps/mobile/modules/t3-subscription-widget/android/src/main/AndroidManifest.xml deleted-8| ... | @@ -1,8 +0,0 @@ | ||
| 1 | <manifest xmlns:android="http://schemas.android.com/apk/res/android"> | ||
| 2 | <application> | ||
| 3 | <receiver android:name="expo.modules.t3subscriptionwidget.SubscriptionUsageWidget" android:enabled="@bool/t3_subscription_widget_enabled" android:exported="false" android:label="@string/t3_subscription_widget_name"> | ||
| 4 | <intent-filter><action android:name="android.appwidget.action.APPWIDGET_UPDATE" /></intent-filter> | ||
| 5 | <meta-data android:name="android.appwidget.provider" android:resource="@xml/t3_subscription_widget_info" /> | ||
| 6 | </receiver> | ||
| 7 | </application> | ||
| 8 | </manifest> | ||
apps/mobile/modules/t3-subscription-widget/android/src/main/java/expo/modules/t3subscriptionwidget/SubscriptionUsageWidget.kt deleted-174| ... | @@ -1,174 +0,0 @@ | ||
| 1 | package expo.modules.t3subscriptionwidget | ||
| 2 | |||
| 3 | import android.app.AlarmManager | ||
| 4 | import android.app.PendingIntent | ||
| 5 | import android.appwidget.AppWidgetManager | ||
| 6 | import android.appwidget.AppWidgetProvider | ||
| 7 | import android.content.ComponentName | ||
| 8 | import android.content.Context | ||
| 9 | import android.content.Intent | ||
| 10 | import android.net.Uri | ||
| 11 | import android.os.Build | ||
| 12 | import android.view.View | ||
| 13 | import android.widget.RemoteViews | ||
| 14 | import org.json.JSONObject | ||
| 15 | import java.text.DateFormat | ||
| 16 | import java.util.Date | ||
| 17 | |||
| 18 | class SubscriptionUsageWidget : AppWidgetProvider() { | ||
| 19 | override fun onUpdate(context: Context, manager: AppWidgetManager, ids: IntArray) { | ||
| 20 | ids.forEach { update(context, manager, it) } | ||
| 21 | } | ||
| 22 | |||
| 23 | override fun onReceive(context: Context, intent: Intent) { | ||
| 24 | super.onReceive(context, intent) | ||
| 25 | if (intent.action == EXPIRE) updateAll(context) | ||
| 26 | } | ||
| 27 | |||
| 28 | override fun onDisabled(context: Context) { | ||
| 29 | context.getSystemService(AlarmManager::class.java).cancel(expiryIntent(context)) | ||
| 30 | } | ||
| 31 | |||
| 32 | companion object { | ||
| 33 | const val PREFERENCES = "t3_subscription_widget" | ||
| 34 | private const val EXPIRE = "expo.modules.t3subscriptionwidget.EXPIRE" | ||
| 35 | |||
| 36 | private fun expiryIntent(context: Context): PendingIntent = PendingIntent.getBroadcast( | ||
| 37 | context, | ||
| 38 | 0, | ||
| 39 | Intent(context, SubscriptionUsageWidget::class.java).setAction(EXPIRE), | ||
| 40 | PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE | ||
| 41 | ) | ||
| 42 | |||
| 43 | fun updateAll(context: Context) { | ||
| 44 | val manager = AppWidgetManager.getInstance(context) | ||
| 45 | manager.getAppWidgetIds(ComponentName(context, SubscriptionUsageWidget::class.java)) | ||
| 46 | .forEach { update(context, manager, it) } | ||
| 47 | } | ||
| 48 | |||
| 49 | private fun update(context: Context, manager: AppWidgetManager, id: Int) { | ||
| 50 | // The receiver is disabled below 12L (values-v32/bools.xml), but the module still calls in. | ||
| 51 | if (Build.VERSION.SDK_INT < Build.VERSION_CODES.S_V2) return | ||
| 52 | val saved = context.getSharedPreferences(PREFERENCES, 0).getString("snapshot", null) | ||
| 53 | val snapshot = runCatching { JSONObject(saved.orEmpty()) }.getOrNull() | ||
| 54 | val openApp = openAppIntent(context, id, snapshot) | ||
| 55 | val providers = snapshot?.optJSONArray("providers") | ||
| 56 | val now = System.currentTimeMillis() | ||
| 57 | var nextExpiry = Long.MAX_VALUE | ||
| 58 | val groups = (0 until (providers?.length() ?: 0)).mapNotNull { index -> | ||
| 59 | val provider = providers?.optJSONObject(index) ?: return@mapNotNull null | ||
| 60 | val windows = provider.optJSONArray("windows") | ||
| 61 | val expiresAt = provider.optLong("expiresAt") | ||
| 62 | if (expiresAt > now && windows != null && windows.length() > 0) { | ||
| 63 | nextExpiry = minOf(nextExpiry, expiresAt) | ||
| 64 | (0 until windows.length()).map { provider to windows.optJSONObject(it) } | ||
| 65 | } else { | ||
| 66 | listOf(provider to null) | ||
| 67 | } | ||
| 68 | } | ||
| 69 | // Keep the first quota from each provider near the top of the list. | ||
| 70 | val rows = (0 until (groups.maxOfOrNull { it.size } ?: 0)).flatMap { index -> | ||
| 71 | groups.mapNotNull { it.getOrNull(index) } | ||
| 72 | } | ||
| 73 | val views = RemoteViews(context.packageName, R.layout.t3_subscription_widget) | ||
| 74 | // Count limits only; "Open app to refresh" placeholders are not entries. | ||
| 75 | val limits = rows.count { (_, window) -> window != null } | ||
| 76 | // Without limits the layout's plain title stays. | ||
| 77 | if (limits > 0) { | ||
| 78 | views.setTextViewText( | ||
| 79 | R.id.t3_widget_title, | ||
| 80 | context.getString(R.string.t3_subscription_widget_title_count, limits) | ||
| 81 | ) | ||
| 82 | views.setContentDescription( | ||
| 83 | R.id.t3_widget_title, | ||
| 84 | context.resources.getQuantityString( | ||
| 85 | R.plurals.t3_subscription_widget_title_description, | ||
| 86 | limits, | ||
| 87 | limits | ||
| 88 | ) | ||
| 89 | ) | ||
| 90 | } | ||
| 91 | openApp?.let { views.setOnClickPendingIntent(R.id.t3_widget_root, it) } | ||
| 92 | openAppIntent(context, id, snapshot, forCollection = true)?.let { | ||
| 93 | views.setPendingIntentTemplate(R.id.t3_widget_rows, it) | ||
| 94 | } | ||
| 95 | val items = RemoteViews.RemoteCollectionItems.Builder() | ||
| 96 | rows.forEachIndexed { index, (provider, window) -> | ||
| 97 | items.addItem(index.toLong(), rowView(context, provider, window)) | ||
| 98 | } | ||
| 99 | views.setRemoteAdapter(R.id.t3_widget_rows, items.build()) | ||
| 100 | views.setEmptyView(R.id.t3_widget_rows, R.id.t3_widget_empty) | ||
| 101 | val checkedAt = snapshot?.optLong("checkedAt") ?: 0 | ||
| 102 | val checked = if (checkedAt > 0) { | ||
| 103 | val formatted = DateFormat.getDateTimeInstance( | ||
| 104 | DateFormat.SHORT, | ||
| 105 | DateFormat.SHORT | ||
| 106 | ).format(Date(checkedAt)) | ||
| 107 | context.getString(R.string.t3_subscription_widget_last_checked, formatted) | ||
| 108 | } else { | ||
| 109 | context.getString(R.string.t3_subscription_widget_unknown_check) | ||
| 110 | } | ||
| 111 | views.setTextViewText(R.id.t3_widget_footer, checked) | ||
| 112 | val alarms = context.getSystemService(AlarmManager::class.java) | ||
| 113 | alarms.cancel(expiryIntent(context)) | ||
| 114 | // Inexact and non-wakeup: the timestamp remains visible if Android delays expiry. | ||
| 115 | if (nextExpiry != Long.MAX_VALUE) { | ||
| 116 | alarms.set(AlarmManager.RTC, nextExpiry, expiryIntent(context)) | ||
| 117 | } | ||
| 118 | manager.updateAppWidget(id, views) | ||
| 119 | } | ||
| 120 | |||
| 121 | private fun openAppIntent( | ||
| 122 | context: Context, | ||
| 123 | id: Int, | ||
| 124 | snapshot: JSONObject?, | ||
| 125 | forCollection: Boolean = false | ||
| 126 | ): PendingIntent? { | ||
| 127 | // Target this variant's launcher so co-installed builds cannot steal the tap. | ||
| 128 | val intent = | ||
| 129 | context.packageManager.getLaunchIntentForPackage(context.packageName) ?: return null | ||
| 130 | intent.action = Intent.ACTION_VIEW | ||
| 131 | val deepLink = snapshot?.optString("url")?.takeIf { it.isNotBlank() } | ||
| 132 | ?: "t3code://settings/usage?tab=limits" | ||
| 133 | intent.data = Uri.parse(deepLink) | ||
| 134 | intent.flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP | ||
| 135 | return PendingIntent.getActivity( | ||
| 136 | context, | ||
| 137 | id * 2 + if (forCollection) 1 else 0, | ||
| 138 | intent, | ||
| 139 | PendingIntent.FLAG_UPDATE_CURRENT or if (forCollection) { | ||
| 140 | // Collection rows use fill-in intents with an explicit app target. | ||
| 141 | PendingIntent.FLAG_MUTABLE | ||
| 142 | } else { | ||
| 143 | PendingIntent.FLAG_IMMUTABLE | ||
| 144 | } | ||
| 145 | ) | ||
| 146 | } | ||
| 147 | |||
| 148 | private fun rowView(context: Context, provider: JSONObject, window: JSONObject?): RemoteViews { | ||
| 149 | val child = RemoteViews(context.packageName, R.layout.t3_subscription_widget_row) | ||
| 150 | val remaining = window?.optInt("remaining")?.coerceIn(0, 100) | ||
| 151 | val detail = provider.optString("detail") | ||
| 152 | val label = provider.optString("name") | ||
| 153 | val windowLabel = window?.optString("label") ?: detail | ||
| 154 | child.setTextViewText(R.id.t3_widget_label, label) | ||
| 155 | child.setTextViewText(R.id.t3_widget_window, windowLabel) | ||
| 156 | val percent = remaining?.let { | ||
| 157 | context.getString(R.string.t3_subscription_widget_remaining, it) | ||
| 158 | } ?: "—" | ||
| 159 | child.setTextViewText(R.id.t3_widget_percent, percent) | ||
| 160 | val visibility = if (remaining == null) View.GONE else View.VISIBLE | ||
| 161 | child.setViewVisibility(R.id.t3_widget_progress, visibility) | ||
| 162 | if (remaining != null) child.setProgressBar(R.id.t3_widget_progress, 100, remaining, false) | ||
| 163 | val reset = window?.optString("reset") | ||
| 164 | ?: context.getString(R.string.t3_subscription_widget_refresh) | ||
| 165 | child.setTextViewText(R.id.t3_widget_reset, reset) | ||
| 166 | child.setOnClickFillInIntent(R.id.t3_widget_row, Intent()) | ||
| 167 | child.setContentDescription( | ||
| 168 | R.id.t3_widget_row, | ||
| 169 | "$label. $windowLabel. $percent. $reset. $detail" | ||
| 170 | ) | ||
| 171 | return child | ||
| 172 | } | ||
| 173 | } | ||
| 174 | } | ||
apps/mobile/modules/t3-subscription-widget/android/src/main/java/expo/modules/t3subscriptionwidget/T3SubscriptionWidgetModule.kt deleted-18| ... | @@ -1,18 +0,0 @@ | ||
| 1 | package expo.modules.t3subscriptionwidget | ||
| 2 | |||
| 3 | import expo.modules.kotlin.modules.Module | ||
| 4 | import expo.modules.kotlin.modules.ModuleDefinition | ||
| 5 | import org.json.JSONObject | ||
| 6 | |||
| 7 | class T3SubscriptionWidgetModule : Module() { | ||
| 8 | override fun definition() = ModuleDefinition { | ||
| 9 | Name("T3SubscriptionWidget") | ||
| 10 | Function("updateSnapshot") { snapshot: String -> | ||
| 11 | val context = appContext.reactContext ?: return@Function | ||
| 12 | JSONObject(snapshot) // Reject malformed writes before replacing the saved snapshot. | ||
| 13 | context.getSharedPreferences(SubscriptionUsageWidget.PREFERENCES, 0) | ||
| 14 | .edit().putString("snapshot", snapshot).apply() | ||
| 15 | SubscriptionUsageWidget.updateAll(context) | ||
| 16 | } | ||
| 17 | } | ||
| 18 | } | ||
apps/mobile/modules/t3-subscription-widget/android/src/main/res/drawable/t3_subscription_widget_background.xml deleted-4| ... | @@ -1,4 +0,0 @@ | ||
| 1 | <shape xmlns:android="http://schemas.android.com/apk/res/android"> | ||
| 2 | <solid android:color="@color/t3_widget_background" /> | ||
| 3 | <corners android:radius="20dp" /> | ||
| 4 | </shape> | ||
apps/mobile/modules/t3-subscription-widget/android/src/main/res/layout/t3_subscription_widget.xml deleted-10| ... | @@ -1,10 +0,0 @@ | ||
| 1 | <LinearLayout xmlns:android="http://schemas.android.com/apk/res/android" | ||
| 2 | android:id="@+id/t3_widget_root" android:layout_width="match_parent" android:layout_height="match_parent" | ||
| 3 | android:orientation="vertical" android:padding="16dp" android:background="@drawable/t3_subscription_widget_background"> | ||
| 4 | <TextView android:id="@+id/t3_widget_title" 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" /> | ||
| 5 | <FrameLayout android:layout_width="match_parent" android:layout_height="0dp" android:layout_weight="1"> | ||
| 6 | <ListView android:id="@+id/t3_widget_rows" android:layout_width="match_parent" android:layout_height="match_parent" android:divider="@null" android:scrollbars="vertical" /> | ||
| 7 | <TextView android:id="@+id/t3_widget_empty" 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" /> | ||
| 8 | </FrameLayout> | ||
| 9 | <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" /> | ||
| 10 | </LinearLayout> | ||
apps/mobile/modules/t3-subscription-widget/android/src/main/res/layout/t3_subscription_widget_row.xml deleted-10| ... | @@ -1,10 +0,0 @@ | ||
| 1 | <LinearLayout xmlns:android="http://schemas.android.com/apk/res/android" | ||
| 2 | android:id="@+id/t3_widget_row" android:layout_width="match_parent" android:layout_height="wrap_content" android:orientation="vertical" android:paddingTop="8dp"> | ||
| 3 | <LinearLayout android:layout_width="match_parent" android:layout_height="wrap_content" android:orientation="horizontal"> | ||
| 4 | <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" /> | ||
| 5 | <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" /> | ||
| 6 | </LinearLayout> | ||
| 7 | <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" /> | ||
| 8 | <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" /> | ||
| 9 | <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" /> | ||
| 10 | </LinearLayout> | ||
apps/mobile/modules/t3-subscription-widget/android/src/main/res/values-night/colors.xml deleted-5| ... | @@ -1,5 +0,0 @@ | ||
| 1 | <resources> | ||
| 2 | <color name="t3_widget_background">#18181B</color> | ||
| 3 | <color name="t3_widget_foreground">#FAFAFA</color> | ||
| 4 | <color name="t3_widget_secondary">#A1A1AA</color> | ||
| 5 | </resources> | ||
apps/mobile/modules/t3-subscription-widget/android/src/main/res/values/colors.xml deleted-5| ... | @@ -1,5 +0,0 @@ | ||
| 1 | <resources> | ||
| 2 | <color name="t3_widget_background">#FAFAFA</color> | ||
| 3 | <color name="t3_widget_foreground">#18181B</color> | ||
| 4 | <color name="t3_widget_secondary">#52525B</color> | ||
| 5 | </resources> | ||
apps/mobile/modules/t3-subscription-widget/android/src/main/res/values/strings.xml deleted-15| ... | @@ -1,15 +0,0 @@ | ||
| 1 | <resources> | ||
| 2 | <string name="t3_subscription_widget_unknown_check">Last checked unavailable</string> | ||
| 3 | <string name="t3_subscription_widget_name">Subscription usage</string> | ||
| 4 | <string name="t3_subscription_widget_title_count">Subscription usage (%1$d)</string> | ||
| 5 | <plurals name="t3_subscription_widget_title_description"> | ||
| 6 | <item quantity="one">Subscription usage, %1$d entry</item> | ||
| 7 | <item quantity="other">Subscription usage, %1$d entries</item> | ||
| 8 | </plurals> | ||
| 9 | <string name="t3_subscription_widget_description">Saved subscription quotas from your T3 Code environments. Tap to refresh in the app.</string> | ||
| 10 | <string name="t3_subscription_widget_empty">No subscription limits available. Open T3 Code to connect.</string> | ||
| 11 | <string name="t3_subscription_widget_open">Tap to open Usage</string> | ||
| 12 | <string name="t3_subscription_widget_refresh">Open app to refresh</string> | ||
| 13 | <string name="t3_subscription_widget_remaining">%1$d%% remaining</string> | ||
| 14 | <string name="t3_subscription_widget_last_checked">Last checked %1$s</string> | ||
| 15 | </resources> | ||
apps/mobile/modules/t3-subscription-widget/android/src/main/res/xml/t3_subscription_widget_info.xml deleted-9| ... | @@ -1,9 +0,0 @@ | ||
| 1 | <appwidget-provider xmlns:android="http://schemas.android.com/apk/res/android" | ||
| 2 | android:minWidth="250dp" android:minHeight="180dp" | ||
| 3 | android:minResizeWidth="180dp" android:minResizeHeight="130dp" | ||
| 4 | android:targetCellWidth="4" android:targetCellHeight="3" | ||
| 5 | android:resizeMode="horizontal|vertical" android:widgetCategory="home_screen" | ||
| 6 | android:updatePeriodMillis="1800000" | ||
| 7 | android:description="@string/t3_subscription_widget_description" | ||
| 8 | android:initialLayout="@layout/t3_subscription_widget" | ||
| 9 | android:previewLayout="@layout/t3_subscription_widget" /> | ||
apps/mobile/modules/t3-subscription-widget/expo-module.config.json deleted-4| ... | @@ -1,4 +0,0 @@ | ||
| 1 | { | ||
| 2 | "platforms": ["android"], | ||
| 3 | "android": { "modules": ["expo.modules.t3subscriptionwidget.T3SubscriptionWidgetModule"] } | ||
| 4 | } | ||
apps/mobile/modules/t3-widget-expiry/android/build.gradle created+29| ... | @@ -0,0 +1,29 @@ | ||
| 1 | apply plugin: 'com.android.library' | ||
| 2 | apply plugin: 'org.jetbrains.kotlin.android' | ||
| 3 | |||
| 4 | group = 'com.t3tools.widgetexpiry' | ||
| 5 | version = '0.0.0' | ||
| 6 | |||
| 7 | android { | ||
| 8 | namespace 'expo.modules.t3widgetexpiry' | ||
| 9 | compileSdk rootProject.ext.compileSdkVersion | ||
| 10 | defaultConfig { | ||
| 11 | minSdkVersion rootProject.ext.minSdkVersion | ||
| 12 | targetSdkVersion rootProject.ext.targetSdkVersion | ||
| 13 | } | ||
| 14 | testOptions { | ||
| 15 | unitTests.includeAndroidResources = true | ||
| 16 | } | ||
| 17 | } | ||
| 18 | |||
| 19 | dependencies { | ||
| 20 | implementation project(':expo-modules-core') | ||
| 21 | testImplementation 'junit:junit:4.13.2' | ||
| 22 | testImplementation 'org.robolectric:robolectric:4.16.1' | ||
| 23 | } | ||
| 24 | |||
| 25 | tasks.withType(Test).configureEach { | ||
| 26 | javaLauncher = javaToolchains.launcherFor { | ||
| 27 | languageVersion = JavaLanguageVersion.of(21) | ||
| 28 | } | ||
| 29 | } | ||
apps/mobile/modules/t3-widget-expiry/android/src/main/AndroidManifest.xml created+10| ... | @@ -0,0 +1,10 @@ | ||
| 1 | <manifest xmlns:android="http://schemas.android.com/apk/res/android"> | ||
| 2 | <uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" /> | ||
| 3 | <application> | ||
| 4 | <receiver android:name="expo.modules.t3widgetexpiry.WidgetExpiryReceiver" android:exported="false"> | ||
| 5 | <intent-filter> | ||
| 6 | <action android:name="android.intent.action.BOOT_COMPLETED" /> | ||
| 7 | </intent-filter> | ||
| 8 | </receiver> | ||
| 9 | </application> | ||
| 10 | </manifest> | ||
apps/mobile/modules/t3-widget-expiry/android/src/main/java/expo/modules/t3widgetexpiry/T3WidgetExpiryModule.kt created+14| ... | @@ -0,0 +1,14 @@ | ||
| 1 | package expo.modules.t3widgetexpiry | ||
| 2 | |||
| 3 | import expo.modules.kotlin.modules.Module | ||
| 4 | import expo.modules.kotlin.modules.ModuleDefinition | ||
| 5 | |||
| 6 | class T3WidgetExpiryModule : Module() { | ||
| 7 | override fun definition() = ModuleDefinition { | ||
| 8 | Name("T3WidgetExpiry") | ||
| 9 | Function("schedule") { name: String, deadlines: List<Double> -> | ||
| 10 | val context = appContext.reactContext ?: return@Function | ||
| 11 | WidgetExpiryReceiver.schedule(context, name, deadlines.map { it.toLong() }.toLongArray()) | ||
| 12 | } | ||
| 13 | } | ||
| 14 | } | ||
apps/mobile/modules/t3-widget-expiry/android/src/main/java/expo/modules/t3widgetexpiry/WidgetExpiryReceiver.kt created+82| ... | @@ -0,0 +1,82 @@ | ||
| 1 | package expo.modules.t3widgetexpiry | ||
| 2 | |||
| 3 | import android.app.AlarmManager | ||
| 4 | import android.app.PendingIntent | ||
| 5 | import android.appwidget.AppWidgetManager | ||
| 6 | import android.content.BroadcastReceiver | ||
| 7 | import android.content.ComponentName | ||
| 8 | import android.content.Context | ||
| 9 | import android.content.Intent | ||
| 10 | |||
| 11 | /** | ||
| 12 | * Re-renders an expo-widgets widget when a snapshot deadline passes while the app is closed. | ||
| 13 | * The generated `<package>.<name>Provider` receiver re-runs the JS layout with the stored | ||
| 14 | * snapshot on ACTION_APPWIDGET_UPDATE, so a deadline only needs to deliver that broadcast. | ||
| 15 | */ | ||
| 16 | class WidgetExpiryReceiver : BroadcastReceiver() { | ||
| 17 | override fun onReceive(context: Context, intent: Intent) { | ||
| 18 | if (intent.action == Intent.ACTION_BOOT_COMPLETED) { | ||
| 19 | val stored = context.getSharedPreferences(PREFERENCES, Context.MODE_PRIVATE) | ||
| 20 | for ((name, value) in stored.all) { | ||
| 21 | val deadlines = (value as? String)?.split(',')?.mapNotNull { it.toLongOrNull() } | ||
| 22 | ?.toLongArray() ?: continue | ||
| 23 | refresh(context, name) | ||
| 24 | schedule(context, name, deadlines) | ||
| 25 | } | ||
| 26 | return | ||
| 27 | } | ||
| 28 | val name = intent.getStringExtra(EXTRA_NAME) ?: return | ||
| 29 | val remaining = intent.getLongArrayExtra(EXTRA_DEADLINES) ?: LongArray(0) | ||
| 30 | refresh(context, name) | ||
| 31 | schedule(context, name, remaining) | ||
| 32 | } | ||
| 33 | |||
| 34 | private fun refresh(context: Context, name: String) { | ||
| 35 | val provider = providerComponent(context, name) | ||
| 36 | val ids = AppWidgetManager.getInstance(context).getAppWidgetIds(provider) | ||
| 37 | if (ids.isNotEmpty()) { | ||
| 38 | context.sendBroadcast( | ||
| 39 | Intent(AppWidgetManager.ACTION_APPWIDGET_UPDATE) | ||
| 40 | .setComponent(provider) | ||
| 41 | .putExtra(AppWidgetManager.EXTRA_APPWIDGET_IDS, ids) | ||
| 42 | ) | ||
| 43 | } | ||
| 44 | } | ||
| 45 | |||
| 46 | companion object { | ||
| 47 | private const val EXTRA_NAME = "expo.modules.t3widgetexpiry.NAME" | ||
| 48 | private const val EXTRA_DEADLINES = "expo.modules.t3widgetexpiry.DEADLINES" | ||
| 49 | private const val PREFERENCES = "expo.modules.t3widgetexpiry.DEADLINES" | ||
| 50 | |||
| 51 | /** Arms one inexact, non-wakeup alarm for the next future deadline and carries the rest. */ | ||
| 52 | fun schedule(context: Context, name: String, deadlines: LongArray) { | ||
| 53 | val alarms = context.getSystemService(AlarmManager::class.java) | ||
| 54 | val now = System.currentTimeMillis() | ||
| 55 | val pending = deadlines.filter { it > now }.sorted() | ||
| 56 | val stored = context.getSharedPreferences(PREFERENCES, Context.MODE_PRIVATE).edit() | ||
| 57 | if (pending.isEmpty()) { | ||
| 58 | stored.remove(name) | ||
| 59 | } else { | ||
| 60 | stored.putString(name, pending.joinToString(",")) | ||
| 61 | } | ||
| 62 | stored.apply() | ||
| 63 | val intent = Intent(context, WidgetExpiryReceiver::class.java) | ||
| 64 | .setAction("expo.modules.t3widgetexpiry.EXPIRE.$name") | ||
| 65 | .putExtra(EXTRA_NAME, name) | ||
| 66 | .putExtra(EXTRA_DEADLINES, pending.drop(1).toLongArray()) | ||
| 67 | val operation = PendingIntent.getBroadcast( | ||
| 68 | context, | ||
| 69 | 0, | ||
| 70 | intent, | ||
| 71 | PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE | ||
| 72 | ) | ||
| 73 | alarms.cancel(operation) | ||
| 74 | val next = pending.firstOrNull() ?: return | ||
| 75 | alarms.set(AlarmManager.RTC, next, operation) | ||
| 76 | } | ||
| 77 | |||
| 78 | // Mirrors expo-widgets' widgetProviderComponentName for the generated provider class. | ||
| 79 | private fun providerComponent(context: Context, name: String) = | ||
| 80 | ComponentName(context.packageName, "${context.packageName}.${name}Provider") | ||
| 81 | } | ||
| 82 | } | ||
apps/mobile/modules/t3-widget-expiry/android/src/test/java/expo/modules/t3widgetexpiry/WidgetExpiryReceiverTest.kt created+58| ... | @@ -0,0 +1,58 @@ | ||
| 1 | package expo.modules.t3widgetexpiry | ||
| 2 | |||
| 3 | import android.app.AlarmManager | ||
| 4 | import android.content.Context | ||
| 5 | import android.content.Intent | ||
| 6 | import org.junit.Assert.assertEquals | ||
| 7 | import org.junit.Assert.assertTrue | ||
| 8 | import org.junit.Test | ||
| 9 | import org.junit.runner.RunWith | ||
| 10 | import org.robolectric.RobolectricTestRunner | ||
| 11 | import org.robolectric.RuntimeEnvironment | ||
| 12 | import org.robolectric.Shadows.shadowOf | ||
| 13 | import org.robolectric.annotation.Config | ||
| 14 | |||
| 15 | @RunWith(RobolectricTestRunner::class) | ||
| 16 | @Config(sdk = [24, 36], manifest = Config.NONE) | ||
| 17 | class WidgetExpiryReceiverTest { | ||
| 18 | @Test | ||
| 19 | fun bootRestoresTheNextAlarmFromPersistedDeadlines() { | ||
| 20 | val context = RuntimeEnvironment.getApplication() | ||
| 21 | val alarms = context.getSystemService(AlarmManager::class.java) | ||
| 22 | val deadlines = longArrayOf( | ||
| 23 | System.currentTimeMillis() + 60000, | ||
| 24 | System.currentTimeMillis() + 120000 | ||
| 25 | ) | ||
| 26 | WidgetExpiryReceiver.schedule(context, "SubscriptionUsage", deadlines) | ||
| 27 | val scheduled = shadowOf(alarms).scheduledAlarms.single() | ||
| 28 | alarms.cancel(requireNotNull(scheduled.operation)) | ||
| 29 | assertTrue(shadowOf(alarms).scheduledAlarms.isEmpty()) | ||
| 30 | |||
| 31 | WidgetExpiryReceiver().onReceive(context, Intent(Intent.ACTION_BOOT_COMPLETED)) | ||
| 32 | |||
| 33 | assertEquals(deadlines[0], shadowOf(alarms).scheduledAlarms.single().triggerAtTime) | ||
| 34 | } | ||
| 35 | |||
| 36 | @Test | ||
| 37 | fun bootDoesNotRestoreCancelledDeadlines() { | ||
| 38 | val context = RuntimeEnvironment.getApplication() | ||
| 39 | WidgetExpiryReceiver.schedule( | ||
| 40 | context, | ||
| 41 | "SubscriptionUsage", | ||
| 42 | longArrayOf(System.currentTimeMillis() + 60000) | ||
| 43 | ) | ||
| 44 | WidgetExpiryReceiver.schedule(context, "SubscriptionUsage", longArrayOf()) | ||
| 45 | |||
| 46 | WidgetExpiryReceiver().onReceive(context, Intent(Intent.ACTION_BOOT_COMPLETED)) | ||
| 47 | |||
| 48 | assertTrue( | ||
| 49 | shadowOf(context.getSystemService(AlarmManager::class.java)).scheduledAlarms.isEmpty() | ||
| 50 | ) | ||
| 51 | assertTrue( | ||
| 52 | context.getSharedPreferences( | ||
| 53 | "expo.modules.t3widgetexpiry.DEADLINES", | ||
| 54 | Context.MODE_PRIVATE | ||
| 55 | ).all.isEmpty() | ||
| 56 | ) | ||
| 57 | } | ||
| 58 | } | ||
apps/mobile/modules/t3-widget-expiry/expo-module.config.json created+4| ... | @@ -0,0 +1,4 @@ | ||
| 1 | { | ||
| 2 | "platforms": ["android"], | ||
| 3 | "android": { "modules": ["expo.modules.t3widgetexpiry.T3WidgetExpiryModule"] } | ||
| 4 | } | ||
apps/mobile/src/widgets/SubscriptionUsage.android.test.ts created+93| ... | @@ -0,0 +1,93 @@ | ||
| 1 | import { describe, expect, it, vi } from "vite-plus/test"; | ||
| 2 | |||
| 3 | vi.mock("@expo/ui/jetpack-compose", () => ({ | ||
| 4 | Button: "Button", | ||
| 5 | Column: "Column", | ||
| 6 | LinearProgressIndicator: "LinearProgressIndicator", | ||
| 7 | Text: "Text", | ||
| 8 | getMaterialColors: ({ scheme }: { scheme: string }) => ({ | ||
| 9 | surface: `${scheme}-surface`, | ||
| 10 | onSurface: `${scheme}-onSurface`, | ||
| 11 | onSurfaceVariant: `${scheme}-onSurfaceVariant`, | ||
| 12 | surfaceVariant: `${scheme}-surfaceVariant`, | ||
| 13 | primary: `${scheme}-primary`, | ||
| 14 | error: `${scheme}-error`, | ||
| 15 | }), | ||
| 16 | })); | ||
| 17 | |||
| 18 | vi.mock("@expo/ui/jetpack-compose/modifiers", () => ({ | ||
| 19 | fillMaxSize: () => "fillMaxSize", | ||
| 20 | fillMaxWidth: () => "fillMaxWidth", | ||
| 21 | height: (value: number) => ({ height: value }), | ||
| 22 | padding: (...values: number[]) => ({ padding: values }), | ||
| 23 | paddingAll: (value: number) => ({ paddingAll: value }), | ||
| 24 | })); | ||
| 25 | |||
| 26 | vi.mock("expo-widgets", () => ({ | ||
| 27 | createWidget: vi.fn((name: string, layout: unknown) => ({ layout, name })), | ||
| 28 | })); | ||
| 29 | |||
| 30 | import { SubscriptionUsage } from "./SubscriptionUsage.android"; | ||
| 31 | import type { SubscriptionUsageSnapshot } from "./subscriptionUsageSnapshot"; | ||
| 32 | |||
| 33 | const now = Date.parse("2026-09-05T12:00:00.000Z"); | ||
| 34 | const provider = { | ||
| 35 | name: "Codex", | ||
| 36 | detail: "Subscription remaining", | ||
| 37 | windows: [ | ||
| 38 | { kind: "session", label: "5 hours", remaining: 60, reset: "Next reset Sep 5, 5:00 PM" }, | ||
| 39 | { kind: "weekly", label: "Weekly", remaining: 8, reset: "Next reset Sep 9, 9:00 AM" }, | ||
| 40 | ], | ||
| 41 | expiresAt: now + 60_000, | ||
| 42 | totalWindows: 2, | ||
| 43 | } satisfies SubscriptionUsageSnapshot["providers"][number]; | ||
| 44 | const snapshot = { | ||
| 45 | checkedAt: now, | ||
| 46 | url: "t3code-dev://settings/usage?tab=limits", | ||
| 47 | providers: [provider, { ...provider, name: "Claude" }], | ||
| 48 | } satisfies SubscriptionUsageSnapshot; | ||
| 49 | |||
| 50 | function render(props: SubscriptionUsageSnapshot, colorScheme: "light" | "dark" = "dark") { | ||
| 51 | vi.setSystemTime(now); | ||
| 52 | return JSON.stringify(SubscriptionUsage(props, { colorScheme, configuration: undefined })); | ||
| 53 | } | ||
| 54 | |||
| 55 | describe("SubscriptionUsage Android layout", () => { | ||
| 56 | it("renders each window with its remaining share, bar, and reset", () => { | ||
| 57 | const tree = render(snapshot); | ||
| 58 | expect(tree).toContain("5 hours · 60% left"); | ||
| 59 | expect(tree).toContain('"progress":0.6'); | ||
| 60 | expect(tree).toContain("Next reset Sep 5, 5:00 PM"); | ||
| 61 | expect(tree).toContain('"progress":0.08'); | ||
| 62 | expect(tree).toContain('"color":"dark-error"'); | ||
| 63 | expect(tree).toContain("As of "); | ||
| 64 | }); | ||
| 65 | |||
| 66 | it("drops the bars and asks for a refresh once the snapshot expires", () => { | ||
| 67 | const tree = render({ | ||
| 68 | ...snapshot, | ||
| 69 | providers: [{ ...provider, expiresAt: now - 1 }], | ||
| 70 | }); | ||
| 71 | expect(tree).toContain("Open T3 to refresh"); | ||
| 72 | expect(tree).not.toContain("LinearProgressIndicator"); | ||
| 73 | expect(tree).not.toContain("more in T3"); | ||
| 74 | }); | ||
| 75 | |||
| 76 | it("counts the quotas that did not fit", () => { | ||
| 77 | const tree = render({ ...snapshot, providers: [{ ...provider, totalWindows: 5 }] }); | ||
| 78 | expect(tree).toContain("3 more in T3"); | ||
| 79 | }); | ||
| 80 | |||
| 81 | it("keeps quotas without an expiry deadline visible", () => { | ||
| 82 | const tree = render({ ...snapshot, providers: [{ ...provider, expiresAt: 0 }] }); | ||
| 83 | expect(tree).toContain("5 hours · 60% left"); | ||
| 84 | expect(tree).not.toContain("Open T3 to refresh"); | ||
| 85 | }); | ||
| 86 | |||
| 87 | it("invites connecting when nothing has been checked", () => { | ||
| 88 | const tree = render({ checkedAt: 0, providers: [] }, "light"); | ||
| 89 | expect(tree).toContain("Tap to connect in T3"); | ||
| 90 | expect(tree).not.toContain("As of "); | ||
| 91 | expect(tree).toContain('"containerColor":"light-surface"'); | ||
| 92 | }); | ||
| 93 | }); | ||
apps/mobile/src/widgets/SubscriptionUsage.android.tsx created+114| ... | @@ -0,0 +1,114 @@ | ||
| 1 | import { | ||
| 2 | Button, | ||
| 3 | Column, | ||
| 4 | getMaterialColors, | ||
| 5 | LinearProgressIndicator, | ||
| 6 | Text, | ||
| 7 | } from "@expo/ui/jetpack-compose"; | ||
| 8 | import { | ||
| 9 | fillMaxSize, | ||
| 10 | fillMaxWidth, | ||
| 11 | height, | ||
| 12 | padding, | ||
| 13 | paddingAll, | ||
| 14 | } from "@expo/ui/jetpack-compose/modifiers"; | ||
| 15 | import { createWidget, type WidgetEnvironment } from "expo-widgets"; | ||
| 16 | |||
| 17 | import type { SubscriptionUsageSnapshot as SubscriptionUsageProps } from "./subscriptionUsageSnapshot"; | ||
| 18 | |||
| 19 | export 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 | { name: "Codex", detail: "Open T3 to connect", windows: [], expiresAt: 0, totalWindows: 0 }, | ||
| 33 | { name: "Claude", detail: "Open T3 to connect", windows: [], expiresAt: 0, totalWindows: 0 }, | ||
| 34 | ]; | ||
| 35 | return ( | ||
| 36 | // The card is one Button so a tap reaches the app's interaction listener, | ||
| 37 | // which opens props.url. expo-widgets has no Android counterpart to widgetURL. | ||
| 38 | <Button | ||
| 39 | colors={{ containerColor: colors.surface }} | ||
| 40 | modifiers={[fillMaxSize()]} | ||
| 41 | onClick={() => {}} | ||
| 42 | > | ||
| 43 | <Column modifiers={[fillMaxSize(), paddingAll(16)]}> | ||
| 44 | {providers.map((provider, index) => { | ||
| 45 | const stale = | ||
| 46 | provider.windows.length > 0 && provider.expiresAt > 0 && now >= provider.expiresAt; | ||
| 47 | const shown = stale ? [] : provider.windows.slice(0, limit); | ||
| 48 | const hidden = stale ? 0 : (provider.totalWindows ?? provider.windows.length) - limit; | ||
| 49 | return ( | ||
| 50 | <Column | ||
| 51 | key={provider.name} | ||
| 52 | modifiers={[fillMaxWidth(), padding(0, index === 0 ? 0 : 10, 0, 0)]} | ||
| 53 | > | ||
| 54 | <Text | ||
| 55 | color={colors.onSurface} | ||
| 56 | maxLines={1} | ||
| 57 | style={{ fontSize: 13, fontWeight: "bold" }} | ||
| 58 | > | ||
| 59 | {provider.name} | ||
| 60 | </Text> | ||
| 61 | {shown.length === 0 ? ( | ||
| 62 | <Text color={muted} maxLines={1} style={{ fontSize: 11 }}> | ||
| 63 | {stale ? "Open T3 to refresh" : provider.detail} | ||
| 64 | </Text> | ||
| 65 | ) : null} | ||
| 66 | {shown.map((window) => { | ||
| 67 | const low = window.remaining <= 10; | ||
| 68 | return ( | ||
| 69 | <Column key={window.label} modifiers={[fillMaxWidth(), padding(0, 4, 0, 0)]}> | ||
| 70 | <Text | ||
| 71 | color={low ? colors.error : colors.onSurface} | ||
| 72 | maxLines={1} | ||
| 73 | style={{ fontSize: 11 }} | ||
| 74 | > | ||
| 75 | {`${window.label} · ${window.remaining}% left`} | ||
| 76 | </Text> | ||
| 77 | <Column modifiers={[fillMaxWidth(), padding(0, 3, 0, 3)]}> | ||
| 78 | <LinearProgressIndicator | ||
| 79 | progress={window.remaining / 100} | ||
| 80 | color={low ? colors.error : colors.primary} | ||
| 81 | trackColor={colors.surfaceVariant} | ||
| 82 | modifiers={[fillMaxWidth(), height(6)]} | ||
| 83 | /> | ||
| 84 | </Column> | ||
| 85 | <Text color={muted} maxLines={1} style={{ fontSize: 10 }}> | ||
| 86 | {window.reset} | ||
| 87 | </Text> | ||
| 88 | </Column> | ||
| 89 | ); | ||
| 90 | })} | ||
| 91 | {hidden > 0 ? ( | ||
| 92 | <Text color={muted} maxLines={1} style={{ fontSize: 10 }}> | ||
| 93 | {`${hidden} more in T3`} | ||
| 94 | </Text> | ||
| 95 | ) : null} | ||
| 96 | </Column> | ||
| 97 | ); | ||
| 98 | })} | ||
| 99 | <Text | ||
| 100 | color={muted} | ||
| 101 | maxLines={1} | ||
| 102 | style={{ fontSize: 10 }} | ||
| 103 | modifiers={[padding(0, 10, 0, 0)]} | ||
| 104 | > | ||
| 105 | {props.checkedAt | ||
| 106 | ? `As of ${new Date(props.checkedAt).toLocaleString(undefined, { hour: "numeric", minute: "2-digit", month: "short", day: "numeric" })}` | ||
| 107 | : "Tap to connect in T3"} | ||
| 108 | </Text> | ||
| 109 | </Column> | ||
| 110 | </Button> | ||
| 111 | ); | ||
| 112 | } | ||
| 113 | |||
| 114 | export default createWidget("SubscriptionUsage", SubscriptionUsage); | ||
apps/mobile/src/widgets/publishSubscriptionUsage.android.ts+29-4| ... | @@ -1,8 +1,33 @@ | ... | @@ -1,8 +1,33 @@ |
| 1 | import { requireOptionalNativeModule } from "expo"; | 1 | import { requireOptionalNativeModule } from "expo"; |
| 2 | import * as Linking from "expo-linking"; | ||
| 2 | import type { SubscriptionUsageSnapshot } from "./subscriptionUsageSnapshot"; | 3 | import type { SubscriptionUsageSnapshot } from "./subscriptionUsageSnapshot"; |
| 3 | 4 | ||
| 4 | export function publishSubscriptionUsage(snapshot: SubscriptionUsageSnapshot) { | 5 | let tapUrl: string | undefined; |
| 5 | requireOptionalNativeModule<{ updateSnapshot: (snapshot: string) => void }>( | 6 | let listening = false; |
| 6 | "T3SubscriptionWidget", | 7 | |
| 7 | )?.updateSnapshot(JSON.stringify(snapshot)); | 8 | export async function publishSubscriptionUsage(snapshot: SubscriptionUsageSnapshot) { |
| 9 | if (!requireOptionalNativeModule("ExpoWidgets")) return; | ||
| 10 | const [{ default: widget }, { addUserInteractionListener }] = await Promise.all([ | ||
| 11 | import("./SubscriptionUsage"), | ||
| 12 | import("expo-widgets"), | ||
| 13 | ]); | ||
| 14 | tapUrl = snapshot.url; | ||
| 15 | if (!listening) { | ||
| 16 | // expo-widgets has no Android counterpart to widgetURL: the card is a | ||
| 17 | // button whose tap is delivered to this process, so opening the app needs | ||
| 18 | // a live JS runtime. | ||
| 19 | listening = true; | ||
| 20 | addUserInteractionListener((event) => { | ||
| 21 | if (event.source === "SubscriptionUsage" && tapUrl) void Linking.openURL(tapUrl); | ||
| 22 | }); | ||
| 23 | } | ||
| 24 | widget.updateSnapshot(snapshot); | ||
| 25 | // Android has no timeline; an alarm re-renders the stored snapshot at each | ||
| 26 | // deadline so stale readings flip to "Open T3 to refresh" unattended. | ||
| 27 | requireOptionalNativeModule<{ schedule: (name: string, deadlines: number[]) => void }>( | ||
| 28 | "T3WidgetExpiry", | ||
| 29 | )?.schedule( | ||
| 30 | "SubscriptionUsage", | ||
| 31 | snapshot.providers.map((provider) => provider.expiresAt).filter((at) => at > 0), | ||
| 32 | ); | ||
| 8 | } | 33 | } |
docs/user/usage.md+3-3| ... | @@ -120,9 +120,9 @@ settings section when you no longer need it. | ... | @@ -120,9 +120,9 @@ settings section when you no longer need it. |
| 120 | ## Subscription usage widget | 120 | ## Subscription usage widget |
| 121 | 121 | ||
| 122 | Add **Subscription usage** from your iOS or Android widget gallery to see remaining Codex and | 122 | Add **Subscription usage** from your iOS or Android widget gallery to see remaining Codex and |
| 123 | Claude quotas. Tap it to open **Usage → Limits**. On iOS, use **Edit Widget** to choose Session, | 123 | Claude quotas. Tap it to open **Usage → Limits**; on Android this works while T3 is running in |
| 124 | Weekly, or both for each provider. Reopen T3 to refresh expired readings. The Android widget | 124 | the background, otherwise open the app from the launcher. On iOS, use **Edit Widget** to choose |
| 125 | requires Android 12L or later. | 125 | Session, Weekly, or both for each provider. Reopen T3 to refresh expired readings. |
| 126 | 126 | ||
| 127 | ## Keyboard shortcuts | 127 | ## Keyboard shortcuts |
| 128 | 128 |