Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 6801a60183 |
@@ -1,103 +0,0 @@
|
|||||||
name: Build APK
|
|
||||||
|
|
||||||
# Push to master / open a PR → builds a DEBUG apk (smoke test, no secrets needed).
|
|
||||||
# Push a tag like v1.14 → builds a SIGNED RELEASE apk and attaches it to the
|
|
||||||
# Gitea release for that tag.
|
|
||||||
on:
|
|
||||||
push:
|
|
||||||
branches: [master]
|
|
||||||
tags: ['v*']
|
|
||||||
pull_request:
|
|
||||||
branches: [master]
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
build:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- name: Checkout
|
|
||||||
uses: actions/checkout@v4
|
|
||||||
|
|
||||||
- name: Set up JDK 17
|
|
||||||
uses: actions/setup-java@v4
|
|
||||||
with:
|
|
||||||
distribution: temurin
|
|
||||||
java-version: '17'
|
|
||||||
|
|
||||||
- name: Install Android SDK
|
|
||||||
run: |
|
|
||||||
set -e
|
|
||||||
SDK="$GITHUB_WORKSPACE/android-sdk"
|
|
||||||
mkdir -p "$SDK/cmdline-tools"
|
|
||||||
curl -sSL -o /tmp/cmdtools.zip \
|
|
||||||
https://dl.google.com/android/repository/commandlinetools-linux-9862592_latest.zip
|
|
||||||
unzip -q /tmp/cmdtools.zip -d "$SDK/cmdline-tools"
|
|
||||||
mv "$SDK/cmdline-tools/cmdline-tools" "$SDK/cmdline-tools/latest"
|
|
||||||
# Feed "y" via process substitution, not a pipe: `yes |` triggers SIGPIPE (exit 141)
|
|
||||||
# once sdkmanager stops reading, and the step shell runs with `-eo pipefail`.
|
|
||||||
"$SDK/cmdline-tools/latest/bin/sdkmanager" --sdk_root="$SDK" --licenses >/dev/null < <(yes)
|
|
||||||
"$SDK/cmdline-tools/latest/bin/sdkmanager" --sdk_root="$SDK" \
|
|
||||||
"platform-tools" "platforms;android-34" "build-tools;34.0.0" >/dev/null
|
|
||||||
|
|
||||||
- name: Point Gradle at the SDK
|
|
||||||
run: echo "sdk.dir=$GITHUB_WORKSPACE/android-sdk" > local.properties
|
|
||||||
|
|
||||||
# ── Debug build: every push/PR that is NOT a tag ───────────────────────
|
|
||||||
- name: Build debug APK
|
|
||||||
if: ${{ !startsWith(github.ref, 'refs/tags/') }}
|
|
||||||
run: ./gradlew :app:assembleDebug --no-daemon
|
|
||||||
|
|
||||||
# ── Release build: tags only (needs the KEYSTORE_BASE64 secret) ────────
|
|
||||||
- name: Decode signing keystore
|
|
||||||
if: startsWith(github.ref, 'refs/tags/')
|
|
||||||
run: |
|
|
||||||
if [ -z "${{ secrets.KEYSTORE_BASE64 }}" ]; then
|
|
||||||
echo "::error::KEYSTORE_BASE64 secret is not set — cannot build a signed release."
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
echo "${{ secrets.KEYSTORE_BASE64 }}" | base64 -d > app/claude-widget-release.keystore
|
|
||||||
|
|
||||||
- name: Build release APK
|
|
||||||
if: startsWith(github.ref, 'refs/tags/')
|
|
||||||
run: ./gradlew :app:assembleRelease --no-daemon
|
|
||||||
|
|
||||||
# ── Stage whichever APK was produced ───────────────────────────────────
|
|
||||||
- name: Stage APK
|
|
||||||
run: |
|
|
||||||
mkdir -p out
|
|
||||||
if [ -f app/build/outputs/apk/release/app-release.apk ]; then
|
|
||||||
cp app/build/outputs/apk/release/app-release.apk out/claude-usage-widget.apk
|
|
||||||
else
|
|
||||||
cp app/build/outputs/apk/debug/app-debug.apk out/claude-usage-widget-debug.apk
|
|
||||||
fi
|
|
||||||
ls -la out/
|
|
||||||
|
|
||||||
- name: Upload APK artifact
|
|
||||||
uses: actions/upload-artifact@v3
|
|
||||||
continue-on-error: true
|
|
||||||
with:
|
|
||||||
name: apk
|
|
||||||
path: out/*.apk
|
|
||||||
|
|
||||||
# ── Attach the signed APK to the Gitea release on tag ──────────────────
|
|
||||||
- name: Publish APK to Gitea release
|
|
||||||
if: startsWith(github.ref, 'refs/tags/')
|
|
||||||
env:
|
|
||||||
TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
|
||||||
run: |
|
|
||||||
set -e
|
|
||||||
TAG="${GITHUB_REF#refs/tags/}"
|
|
||||||
API="${GITHUB_SERVER_URL}/api/v1/repos/${GITHUB_REPOSITORY}"
|
|
||||||
# Create the release if it doesn't exist yet (ignore "already exists").
|
|
||||||
curl -s -X POST "$API/releases" \
|
|
||||||
-H "Authorization: token $TOKEN" -H "Content-Type: application/json" \
|
|
||||||
-d "{\"tag_name\":\"$TAG\",\"name\":\"$TAG\",\"body\":\"Automated build of $TAG\"}" >/dev/null || true
|
|
||||||
RID=$(curl -s "$API/releases/tags/$TAG" -H "Authorization: token $TOKEN" \
|
|
||||||
| python3 -c "import sys,json;print(json.load(sys.stdin)['id'])")
|
|
||||||
# Replace any existing asset of the same name, then upload.
|
|
||||||
curl -s "$API/releases/$RID/assets" -H "Authorization: token $TOKEN" \
|
|
||||||
| python3 -c "import sys,json;[print(a['id']) for a in json.load(sys.stdin) if a['name']=='claude-usage-widget.apk']" \
|
|
||||||
| while read AID; do curl -s -X DELETE "$API/releases/$RID/assets/$AID" -H "Authorization: token $TOKEN"; done
|
|
||||||
curl -s -X POST "$API/releases/$RID/assets?name=claude-usage-widget.apk" \
|
|
||||||
-H "Authorization: token $TOKEN" \
|
|
||||||
-F "attachment=@out/claude-usage-widget.apk" >/dev/null
|
|
||||||
echo "Attached claude-usage-widget.apk to release $TAG"
|
|
||||||
@@ -1,7 +1,3 @@
|
|||||||
<p align="center">
|
|
||||||
<img src="icon.png" width="108" alt="Claude Usage Widget">
|
|
||||||
</p>
|
|
||||||
|
|
||||||
# Claude Usage Widget
|
# Claude Usage Widget
|
||||||
|
|
||||||
Android home screen widget that shows your Claude Pro usage at a glance.
|
Android home screen widget that shows your Claude Pro usage at a glance.
|
||||||
@@ -10,15 +6,6 @@ Android home screen widget that shows your Claude Pro usage at a glance.
|
|||||||
|
|
||||||
- **SESSION** bar — current 5-hour window utilization with reset time
|
- **SESSION** bar — current 5-hour window utilization with reset time
|
||||||
- **WEEKLY** bar — 7-day rolling usage with reset time
|
- **WEEKLY** bar — 7-day rolling usage with reset time
|
||||||
- **Pace marker** — a colored tick on each bar shows where you *should be* right now to finish at
|
|
||||||
exactly 100% by reset. Tick color grades your projection: green (way under budget) → teal →
|
|
||||||
yellow → orange → red → purple (burning way too fast), with an "X% over/under pace" label.
|
|
||||||
- **Peak-hours indicator** — a Claude burst icon that lights up 🔥 during Anthropic's peak window
|
|
||||||
(5–11 AM Pacific, Mon–Fri), when tokens burn faster, with a countdown to the window close.
|
|
||||||
- **Usage history chart** — the app plots your session and weekly utilization over the past 7 days,
|
|
||||||
so you can see your consumption trend, not just the current snapshot.
|
|
||||||
- **Usage alerts** — opt-in notifications when session or weekly usage crosses a threshold you set
|
|
||||||
(sliders, 50–100%). Each alert fires at most once per limit window, so you're never spammed.
|
|
||||||
- Tap the widget to open the app; tap ⟳ to force-refresh
|
- Tap the widget to open the app; tap ⟳ to force-refresh
|
||||||
- Responsive: works as 4×1 (compact) or 4×2 (full)
|
- Responsive: works as 4×1 (compact) or 4×2 (full)
|
||||||
- Auto-refreshes every 5 minutes in the background
|
- Auto-refreshes every 5 minutes in the background
|
||||||
|
|||||||
@@ -11,8 +11,8 @@ android {
|
|||||||
applicationId = "me.khodak.claudeusage"
|
applicationId = "me.khodak.claudeusage"
|
||||||
minSdk = 26
|
minSdk = 26
|
||||||
targetSdk = 34
|
targetSdk = 34
|
||||||
versionCode = 19
|
versionCode = 10
|
||||||
versionName = "1.18"
|
versionName = "1.9"
|
||||||
}
|
}
|
||||||
|
|
||||||
signingConfigs {
|
signingConfigs {
|
||||||
|
|||||||
@@ -3,7 +3,6 @@
|
|||||||
|
|
||||||
<uses-permission android:name="android.permission.INTERNET" />
|
<uses-permission android:name="android.permission.INTERNET" />
|
||||||
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
|
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
|
||||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
|
||||||
|
|
||||||
<application
|
<application
|
||||||
android:allowBackup="false"
|
android:allowBackup="false"
|
||||||
@@ -12,7 +11,6 @@
|
|||||||
android:roundIcon="@mipmap/ic_launcher_round"
|
android:roundIcon="@mipmap/ic_launcher_round"
|
||||||
android:supportsRtl="true"
|
android:supportsRtl="true"
|
||||||
android:theme="@style/Theme.ClaudeUsage"
|
android:theme="@style/Theme.ClaudeUsage"
|
||||||
android:networkSecurityConfig="@xml/network_security_config"
|
|
||||||
android:usesCleartextTraffic="false">
|
android:usesCleartextTraffic="false">
|
||||||
|
|
||||||
<activity
|
<activity
|
||||||
|
|||||||
@@ -1,59 +0,0 @@
|
|||||||
package me.khodak.claudeusage
|
|
||||||
|
|
||||||
import android.graphics.Bitmap
|
|
||||||
import android.graphics.Canvas
|
|
||||||
import android.graphics.Paint
|
|
||||||
import android.graphics.RectF
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Draws a usage bar as a Bitmap: rounded track + rounded fill + an optional vertical pace tick.
|
|
||||||
* Used both in the home-screen widget (setImageViewBitmap) and the in-app card, so the two render
|
|
||||||
* identically. Bitmaps are rendered at a fixed width and stretched horizontally via ImageView
|
|
||||||
* scaleType=fitXY; height is fixed so there is no vertical distortion.
|
|
||||||
*/
|
|
||||||
object BarRenderer {
|
|
||||||
|
|
||||||
private const val TRACK_COLOR = 0xFF252525.toInt()
|
|
||||||
|
|
||||||
fun render(
|
|
||||||
usedPct: Int,
|
|
||||||
markerPct: Int?,
|
|
||||||
fillColor: Int,
|
|
||||||
markerColor: Int?,
|
|
||||||
wPx: Int = 500,
|
|
||||||
hPx: Int = 14,
|
|
||||||
cornerPx: Float = 7f
|
|
||||||
): Bitmap {
|
|
||||||
val bmp = Bitmap.createBitmap(wPx, hPx, Bitmap.Config.ARGB_8888)
|
|
||||||
val canvas = Canvas(bmp)
|
|
||||||
val paint = Paint(Paint.ANTI_ALIAS_FLAG)
|
|
||||||
|
|
||||||
// Track
|
|
||||||
paint.color = TRACK_COLOR
|
|
||||||
val full = RectF(0f, 0f, wPx.toFloat(), hPx.toFloat())
|
|
||||||
canvas.drawRoundRect(full, cornerPx, cornerPx, paint)
|
|
||||||
|
|
||||||
// Fill
|
|
||||||
val pct = usedPct.coerceIn(0, 100)
|
|
||||||
if (pct > 0) {
|
|
||||||
paint.color = fillColor
|
|
||||||
val fillW = (wPx * pct / 100f).coerceAtLeast(cornerPx * 2)
|
|
||||||
val fill = RectF(0f, 0f, fillW, hPx.toFloat())
|
|
||||||
canvas.drawRoundRect(fill, cornerPx, cornerPx, paint)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Pace marker — a single clean tick showing "where you should be right now".
|
|
||||||
// One color (no tiers); rounded ends to match the bar.
|
|
||||||
if (markerPct != null && markerColor != null) {
|
|
||||||
val m = markerPct.coerceIn(0, 100)
|
|
||||||
val tickW = (wPx * 0.016f).coerceIn(6f, 10f)
|
|
||||||
var x = wPx * m / 100f
|
|
||||||
x = x.coerceIn(tickW / 2f, wPx - tickW / 2f)
|
|
||||||
paint.color = markerColor
|
|
||||||
val tick = RectF(x - tickW / 2f, 0f, x + tickW / 2f, hPx.toFloat())
|
|
||||||
canvas.drawRoundRect(tick, tickW / 2f, tickW / 2f, paint)
|
|
||||||
}
|
|
||||||
|
|
||||||
return bmp
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -42,15 +42,6 @@ class ClaudeUsageWidget : AppWidgetProvider() {
|
|||||||
@Volatile internal var isRefreshing = false
|
@Volatile internal var isRefreshing = false
|
||||||
@Volatile internal var currentRotation = 0f
|
@Volatile internal var currentRotation = 0f
|
||||||
|
|
||||||
/** Redraw every placed widget from the current cached data (call after a refresh). */
|
|
||||||
fun notifyDataChanged(context: Context) {
|
|
||||||
val manager = AppWidgetManager.getInstance(context)
|
|
||||||
val ids = manager.getAppWidgetIds(
|
|
||||||
android.content.ComponentName(context, ClaudeUsageWidget::class.java)
|
|
||||||
)
|
|
||||||
ids.forEach { updateWidget(context, manager, it) }
|
|
||||||
}
|
|
||||||
|
|
||||||
fun updateWidget(context: Context, manager: AppWidgetManager, widgetId: Int) {
|
fun updateWidget(context: Context, manager: AppWidgetManager, widgetId: Int) {
|
||||||
val prefs = PreferencesManager(context)
|
val prefs = PreferencesManager(context)
|
||||||
val apiData = prefs.getUsageData()
|
val apiData = prefs.getUsageData()
|
||||||
@@ -96,15 +87,14 @@ class ClaudeUsageWidget : AppWidgetProvider() {
|
|||||||
|
|
||||||
private fun buildSmallViews(context: Context, prefs: PreferencesManager, apiData: UsageData?): RemoteViews {
|
private fun buildSmallViews(context: Context, prefs: PreferencesManager, apiData: UsageData?): RemoteViews {
|
||||||
val v = RemoteViews(context.packageName, R.layout.widget_layout_small)
|
val v = RemoteViews(context.packageName, R.layout.widget_layout_small)
|
||||||
applyPeak(v, showText = false)
|
|
||||||
if (!prefs.isLoggedIn()) {
|
if (!prefs.isLoggedIn()) {
|
||||||
v.setTextViewText(R.id.tv_session_value, "—")
|
v.setTextViewText(R.id.tv_session_value, "—")
|
||||||
v.setTextViewText(R.id.tv_session_label, "Not signed in")
|
v.setTextViewText(R.id.tv_session_label, "Not signed in")
|
||||||
v.setTextViewText(R.id.tv_weekly_value, "—")
|
v.setTextViewText(R.id.tv_weekly_value, "—")
|
||||||
v.setTextViewText(R.id.tv_weekly_label, "")
|
v.setTextViewText(R.id.tv_weekly_label, "")
|
||||||
v.setTextViewText(R.id.tv_status, "")
|
v.setTextViewText(R.id.tv_status, "")
|
||||||
v.setImageViewBitmap(R.id.bar_session, BarRenderer.render(0, null, SESSION_FILL, null))
|
v.setProgressBar(R.id.progress_bar, 100, 0, false)
|
||||||
v.setImageViewBitmap(R.id.bar_weekly, BarRenderer.render(0, null, WEEKLY_FILL, null))
|
v.setProgressBar(R.id.progress_bar_weekly, 100, 0, false)
|
||||||
return v
|
return v
|
||||||
}
|
}
|
||||||
val hasUtilization = apiData != null && apiData.fiveHourUtilization >= 0f
|
val hasUtilization = apiData != null && apiData.fiveHourUtilization >= 0f
|
||||||
@@ -114,14 +104,14 @@ class ClaudeUsageWidget : AppWidgetProvider() {
|
|||||||
hasUtilization -> {
|
hasUtilization -> {
|
||||||
val pct = apiData!!.fiveHourUtilization.toInt()
|
val pct = apiData!!.fiveHourUtilization.toInt()
|
||||||
v.setTextViewText(R.id.tv_session_value, "$pct%")
|
v.setTextViewText(R.id.tv_session_value, "$pct%")
|
||||||
v.setImageViewBitmap(R.id.bar_session, BarRenderer.render(pct, null, SESSION_FILL, null))
|
v.setProgressBar(R.id.progress_bar, 100, pct, false)
|
||||||
v.setTextViewText(R.id.tv_session_label, formatReset(apiData.utilizationResetAtEpoch))
|
v.setTextViewText(R.id.tv_session_label, formatReset(apiData.utilizationResetAtEpoch))
|
||||||
}
|
}
|
||||||
hasApiMessages -> {
|
hasApiMessages -> {
|
||||||
val rem = apiData!!.effectiveRemaining
|
val rem = apiData!!.effectiveRemaining
|
||||||
val lim = apiData.messagesLimit
|
val lim = apiData.messagesLimit
|
||||||
v.setTextViewText(R.id.tv_session_value, "$rem/$lim")
|
v.setTextViewText(R.id.tv_session_value, "$rem/$lim")
|
||||||
v.setImageViewBitmap(R.id.bar_session, BarRenderer.render(apiData.progressPercent, null, SESSION_FILL, null))
|
v.setProgressBar(R.id.progress_bar, 100, apiData.progressPercent, false)
|
||||||
v.setTextViewText(R.id.tv_session_label, formatReset(apiData.resetAtEpoch))
|
v.setTextViewText(R.id.tv_session_label, formatReset(apiData.resetAtEpoch))
|
||||||
}
|
}
|
||||||
sessionStart > 0 -> {
|
sessionStart > 0 -> {
|
||||||
@@ -129,26 +119,25 @@ class ClaudeUsageWidget : AppWidgetProvider() {
|
|||||||
val h = TimeUnit.MILLISECONDS.toHours(elapsedMs)
|
val h = TimeUnit.MILLISECONDS.toHours(elapsedMs)
|
||||||
val m = TimeUnit.MILLISECONDS.toMinutes(elapsedMs) % 60
|
val m = TimeUnit.MILLISECONDS.toMinutes(elapsedMs) % 60
|
||||||
v.setTextViewText(R.id.tv_session_value, if (h > 0) "${h}h${m}m" else "${m}m")
|
v.setTextViewText(R.id.tv_session_value, if (h > 0) "${h}h${m}m" else "${m}m")
|
||||||
v.setImageViewBitmap(R.id.bar_session, BarRenderer.render(0, null, SESSION_FILL, null))
|
v.setProgressBar(R.id.progress_bar, 100, 0, false)
|
||||||
v.setTextViewText(R.id.tv_session_label, "active")
|
v.setTextViewText(R.id.tv_session_label, "active")
|
||||||
}
|
}
|
||||||
else -> {
|
else -> {
|
||||||
v.setTextViewText(R.id.tv_session_value, "—")
|
v.setTextViewText(R.id.tv_session_value, "—")
|
||||||
v.setImageViewBitmap(R.id.bar_session, BarRenderer.render(0, null, SESSION_FILL, null))
|
v.setProgressBar(R.id.progress_bar, 100, 0, false)
|
||||||
v.setTextViewText(R.id.tv_session_label, "")
|
v.setTextViewText(R.id.tv_session_label, "")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
val hasWeekly = apiData != null && apiData.weeklyUtilization >= 0f
|
val hasWeekly = apiData != null && apiData.weeklyUtilization >= 0f
|
||||||
if (hasWeekly) {
|
if (hasWeekly) {
|
||||||
val wPct = apiData!!.weeklyUtilization.toInt()
|
val wPct = apiData!!.weeklyUtilization.toInt()
|
||||||
val pace = PaceCalc.compute(apiData.weeklyUtilization, apiData.weeklyResetAtEpoch, PaceCalc.WEEKLY_WINDOW_MS)
|
|
||||||
v.setTextViewText(R.id.tv_weekly_value, "$wPct%")
|
v.setTextViewText(R.id.tv_weekly_value, "$wPct%")
|
||||||
v.setImageViewBitmap(R.id.bar_weekly, BarRenderer.render(wPct, pace?.markerPct, WEEKLY_FILL, if (pace != null) MARKER_COLOR else null))
|
v.setProgressBar(R.id.progress_bar_weekly, 100, wPct, false)
|
||||||
v.setTextViewText(R.id.tv_weekly_label, formatResetShort(apiData.weeklyResetAtEpoch))
|
v.setTextViewText(R.id.tv_weekly_label, formatReset(apiData.weeklyResetAtEpoch))
|
||||||
} else {
|
} else {
|
||||||
val weeklyDays = Integer.bitCount(prefs.getWeeklyMask())
|
val weeklyDays = Integer.bitCount(prefs.getWeeklyMask())
|
||||||
v.setTextViewText(R.id.tv_weekly_value, "${weeklyDays}d")
|
v.setTextViewText(R.id.tv_weekly_value, "${weeklyDays}d")
|
||||||
v.setImageViewBitmap(R.id.bar_weekly, BarRenderer.render(0, null, WEEKLY_FILL, null))
|
v.setProgressBar(R.id.progress_bar_weekly, 100, 0, false)
|
||||||
v.setTextViewText(R.id.tv_weekly_label, "")
|
v.setTextViewText(R.id.tv_weekly_label, "")
|
||||||
}
|
}
|
||||||
val resetEpoch = apiData?.effectiveResetEpoch ?: -1L
|
val resetEpoch = apiData?.effectiveResetEpoch ?: -1L
|
||||||
@@ -168,7 +157,6 @@ class ClaudeUsageWidget : AppWidgetProvider() {
|
|||||||
|
|
||||||
private fun buildViews(context: Context, prefs: PreferencesManager, apiData: UsageData?): RemoteViews {
|
private fun buildViews(context: Context, prefs: PreferencesManager, apiData: UsageData?): RemoteViews {
|
||||||
val v = RemoteViews(context.packageName, R.layout.widget_layout)
|
val v = RemoteViews(context.packageName, R.layout.widget_layout)
|
||||||
applyPeak(v, showText = true)
|
|
||||||
|
|
||||||
// ── Not logged in ────────────────────────────────────────────────
|
// ── Not logged in ────────────────────────────────────────────────
|
||||||
if (!prefs.isLoggedIn()) {
|
if (!prefs.isLoggedIn()) {
|
||||||
@@ -177,8 +165,7 @@ class ClaudeUsageWidget : AppWidgetProvider() {
|
|||||||
v.setTextViewText(R.id.tv_weekly_value, "—")
|
v.setTextViewText(R.id.tv_weekly_value, "—")
|
||||||
v.setTextViewText(R.id.tv_weekly_label, "Open app to sign in")
|
v.setTextViewText(R.id.tv_weekly_label, "Open app to sign in")
|
||||||
v.setTextViewText(R.id.tv_status, "")
|
v.setTextViewText(R.id.tv_status, "")
|
||||||
v.setImageViewBitmap(R.id.bar_session, BarRenderer.render(0, null, SESSION_FILL, null))
|
v.setProgressBar(R.id.progress_bar, 100, 0, false)
|
||||||
v.setImageViewBitmap(R.id.bar_weekly, BarRenderer.render(0, null, WEEKLY_FILL, null))
|
|
||||||
return v
|
return v
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -191,14 +178,14 @@ class ClaudeUsageWidget : AppWidgetProvider() {
|
|||||||
hasUtilization -> {
|
hasUtilization -> {
|
||||||
val pct = apiData!!.fiveHourUtilization.toInt()
|
val pct = apiData!!.fiveHourUtilization.toInt()
|
||||||
v.setTextViewText(R.id.tv_session_value, "$pct%")
|
v.setTextViewText(R.id.tv_session_value, "$pct%")
|
||||||
v.setImageViewBitmap(R.id.bar_session, BarRenderer.render(pct, null, SESSION_FILL, null))
|
v.setProgressBar(R.id.progress_bar, 100, pct, false)
|
||||||
v.setTextViewText(R.id.tv_session_label, formatReset(apiData.utilizationResetAtEpoch))
|
v.setTextViewText(R.id.tv_session_label, formatReset(apiData.utilizationResetAtEpoch))
|
||||||
}
|
}
|
||||||
hasApiMessages -> {
|
hasApiMessages -> {
|
||||||
val rem = apiData!!.effectiveRemaining
|
val rem = apiData!!.effectiveRemaining
|
||||||
val lim = apiData.messagesLimit
|
val lim = apiData.messagesLimit
|
||||||
v.setTextViewText(R.id.tv_session_value, "$rem / $lim")
|
v.setTextViewText(R.id.tv_session_value, "$rem / $lim")
|
||||||
v.setImageViewBitmap(R.id.bar_session, BarRenderer.render(apiData.progressPercent, null, SESSION_FILL, null))
|
v.setProgressBar(R.id.progress_bar, 100, apiData.progressPercent, false)
|
||||||
v.setTextViewText(R.id.tv_session_label, formatReset(apiData.resetAtEpoch))
|
v.setTextViewText(R.id.tv_session_label, formatReset(apiData.resetAtEpoch))
|
||||||
}
|
}
|
||||||
sessionStart > 0 -> {
|
sessionStart > 0 -> {
|
||||||
@@ -207,12 +194,12 @@ class ClaudeUsageWidget : AppWidgetProvider() {
|
|||||||
val m = TimeUnit.MILLISECONDS.toMinutes(elapsedMs) % 60
|
val m = TimeUnit.MILLISECONDS.toMinutes(elapsedMs) % 60
|
||||||
val display = if (h > 0) "${h}h ${m}m" else if (m > 0) "${m}m" else "Just started"
|
val display = if (h > 0) "${h}h ${m}m" else if (m > 0) "${m}m" else "Just started"
|
||||||
v.setTextViewText(R.id.tv_session_value, display)
|
v.setTextViewText(R.id.tv_session_value, display)
|
||||||
v.setImageViewBitmap(R.id.bar_session, BarRenderer.render(0, null, SESSION_FILL, null))
|
v.setProgressBar(R.id.progress_bar, 100, 0, false)
|
||||||
v.setTextViewText(R.id.tv_session_label, "session active")
|
v.setTextViewText(R.id.tv_session_label, "session active")
|
||||||
}
|
}
|
||||||
else -> {
|
else -> {
|
||||||
v.setTextViewText(R.id.tv_session_value, "—")
|
v.setTextViewText(R.id.tv_session_value, "—")
|
||||||
v.setImageViewBitmap(R.id.bar_session, BarRenderer.render(0, null, SESSION_FILL, null))
|
v.setProgressBar(R.id.progress_bar, 100, 0, false)
|
||||||
v.setTextViewText(R.id.tv_session_label, "")
|
v.setTextViewText(R.id.tv_session_label, "")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -221,14 +208,13 @@ class ClaudeUsageWidget : AppWidgetProvider() {
|
|||||||
val hasWeekly = apiData != null && apiData.weeklyUtilization >= 0f
|
val hasWeekly = apiData != null && apiData.weeklyUtilization >= 0f
|
||||||
if (hasWeekly) {
|
if (hasWeekly) {
|
||||||
val wPct = apiData!!.weeklyUtilization.toInt()
|
val wPct = apiData!!.weeklyUtilization.toInt()
|
||||||
val pace = PaceCalc.compute(apiData.weeklyUtilization, apiData.weeklyResetAtEpoch, PaceCalc.WEEKLY_WINDOW_MS)
|
|
||||||
v.setTextViewText(R.id.tv_weekly_value, "$wPct%")
|
v.setTextViewText(R.id.tv_weekly_value, "$wPct%")
|
||||||
v.setImageViewBitmap(R.id.bar_weekly, BarRenderer.render(wPct, pace?.markerPct, WEEKLY_FILL, if (pace != null) MARKER_COLOR else null))
|
v.setProgressBar(R.id.progress_bar_weekly, 100, wPct, false)
|
||||||
v.setTextViewText(R.id.tv_weekly_label, formatResetDay(apiData.weeklyResetAtEpoch))
|
v.setTextViewText(R.id.tv_weekly_label, formatReset(apiData.weeklyResetAtEpoch))
|
||||||
} else {
|
} else {
|
||||||
val weeklyDays = Integer.bitCount(prefs.getWeeklyMask())
|
val weeklyDays = Integer.bitCount(prefs.getWeeklyMask())
|
||||||
v.setTextViewText(R.id.tv_weekly_value, "$weeklyDays d")
|
v.setTextViewText(R.id.tv_weekly_value, "$weeklyDays d")
|
||||||
v.setImageViewBitmap(R.id.bar_weekly, BarRenderer.render(0, null, WEEKLY_FILL, null))
|
v.setProgressBar(R.id.progress_bar_weekly, 100, 0, false)
|
||||||
v.setTextViewText(R.id.tv_weekly_label, "active this week")
|
v.setTextViewText(R.id.tv_weekly_label, "active this week")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -252,20 +238,6 @@ class ClaudeUsageWidget : AppWidgetProvider() {
|
|||||||
return v
|
return v
|
||||||
}
|
}
|
||||||
|
|
||||||
private const val SESSION_FILL = 0xFFCC785C.toInt()
|
|
||||||
private const val WEEKLY_FILL = 0xFF7B8FCC.toInt()
|
|
||||||
private const val MARKER_COLOR = 0xFFFFFFFF.toInt() // single-color pace marker (weekly only)
|
|
||||||
|
|
||||||
/** Tints the header burst icon and (optionally) the PEAK text by current peak state. */
|
|
||||||
private fun applyPeak(v: RemoteViews, showText: Boolean) {
|
|
||||||
val peak = PeakHours.isPeak()
|
|
||||||
v.setInt(R.id.img_peak, "setColorFilter",
|
|
||||||
if (peak) 0xFFCC785C.toInt() else 0xFF666666.toInt())
|
|
||||||
if (showText) {
|
|
||||||
v.setTextViewText(R.id.tv_peak, if (peak) "🔥 PEAK" else "")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun formatReset(epochMs: Long): String {
|
private fun formatReset(epochMs: Long): String {
|
||||||
if (epochMs <= 0) return ""
|
if (epochMs <= 0) return ""
|
||||||
val now = System.currentTimeMillis()
|
val now = System.currentTimeMillis()
|
||||||
@@ -279,23 +251,6 @@ class ClaudeUsageWidget : AppWidgetProvider() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Weekly reset with weekday + date ("Resets Friday, Jun 6 · 3:00 PM"), never "tomorrow". */
|
|
||||||
private fun formatResetDay(epochMs: Long): String {
|
|
||||||
if (epochMs <= 0) return ""
|
|
||||||
if (epochMs <= System.currentTimeMillis()) return "Resets soon"
|
|
||||||
val day = SimpleDateFormat("EEEE", Locale.US).format(Date(epochMs))
|
|
||||||
val date = SimpleDateFormat("MMM d", Locale.US).format(Date(epochMs))
|
|
||||||
val timeStr = SimpleDateFormat("h:mm a", Locale.US).format(Date(epochMs))
|
|
||||||
return "Resets $day, $date · $timeStr"
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Compact weekly reset for the space-tight small widget: "Fri, Jun 6". */
|
|
||||||
private fun formatResetShort(epochMs: Long): String {
|
|
||||||
if (epochMs <= 0) return ""
|
|
||||||
if (epochMs <= System.currentTimeMillis()) return "soon"
|
|
||||||
return SimpleDateFormat("EEE, MMM d", Locale.US).format(Date(epochMs))
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun formatTime(ms: Long) =
|
private fun formatTime(ms: Long) =
|
||||||
SimpleDateFormat("h:mm a", Locale.US).format(Date(ms))
|
SimpleDateFormat("h:mm a", Locale.US).format(Date(ms))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,131 +0,0 @@
|
|||||||
package me.khodak.claudeusage
|
|
||||||
|
|
||||||
import android.content.Context
|
|
||||||
import android.graphics.Canvas
|
|
||||||
import android.graphics.Paint
|
|
||||||
import android.graphics.Path
|
|
||||||
import android.util.AttributeSet
|
|
||||||
import android.view.View
|
|
||||||
import me.khodak.claudeusage.data.UsageSnapshot
|
|
||||||
import java.text.SimpleDateFormat
|
|
||||||
import java.util.Date
|
|
||||||
import java.util.Locale
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Lightweight line chart for usage history, hand-drawn on Canvas to stay dependency-free
|
|
||||||
* and consistent with [BarRenderer]. Plots session (orange) and weekly (blue) utilization
|
|
||||||
* 0-100% over time. Gaps longer than [GAP_MS] are not connected, so an offline stretch
|
|
||||||
* shows as a break rather than a misleading straight line.
|
|
||||||
*/
|
|
||||||
class HistoryChartView @JvmOverloads constructor(
|
|
||||||
context: Context,
|
|
||||||
attrs: AttributeSet? = null,
|
|
||||||
defStyle: Int = 0
|
|
||||||
) : View(context, attrs, defStyle) {
|
|
||||||
|
|
||||||
private var points: List<UsageSnapshot> = emptyList()
|
|
||||||
|
|
||||||
private val gridPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
|
|
||||||
color = 0xFF2A2A2A.toInt(); strokeWidth = dp(1f); style = Paint.Style.STROKE
|
|
||||||
}
|
|
||||||
private val labelPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
|
|
||||||
color = 0xFF666666.toInt(); textSize = sp(10f)
|
|
||||||
}
|
|
||||||
private val sessionPaint = linePaint(0xFFCC785C.toInt())
|
|
||||||
private val weeklyPaint = linePaint(0xFF7B8FCC.toInt())
|
|
||||||
private val emptyPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
|
|
||||||
color = 0xFF666666.toInt(); textSize = sp(13f); textAlign = Paint.Align.CENTER
|
|
||||||
}
|
|
||||||
|
|
||||||
private val padL = dp(28f)
|
|
||||||
private val padR = dp(8f)
|
|
||||||
private val padT = dp(10f)
|
|
||||||
private val padB = dp(18f)
|
|
||||||
|
|
||||||
fun setData(data: List<UsageSnapshot>) {
|
|
||||||
points = data
|
|
||||||
invalidate()
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onDraw(canvas: Canvas) {
|
|
||||||
super.onDraw(canvas)
|
|
||||||
val left = padL
|
|
||||||
val top = padT
|
|
||||||
val right = width - padR
|
|
||||||
val bottom = height - padB
|
|
||||||
if (right <= left || bottom <= top) return
|
|
||||||
|
|
||||||
// Y gridlines + labels at 0 / 50 / 100%
|
|
||||||
for (pct in intArrayOf(0, 50, 100)) {
|
|
||||||
val y = bottom - (pct / 100f) * (bottom - top)
|
|
||||||
canvas.drawLine(left, y, right, y, gridPaint)
|
|
||||||
canvas.drawText("$pct", dp(4f), y + sp(3.5f), labelPaint)
|
|
||||||
}
|
|
||||||
|
|
||||||
val plottable = points.filter { it.sessionPct >= 0f || it.weeklyPct >= 0f }
|
|
||||||
if (plottable.size < 2) {
|
|
||||||
canvas.drawText(
|
|
||||||
"Collecting history… check back later",
|
|
||||||
(left + right) / 2f, (top + bottom) / 2f, emptyPaint
|
|
||||||
)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
val tMin = plottable.first().epochMs
|
|
||||||
val tMax = plottable.last().epochMs
|
|
||||||
val tSpan = (tMax - tMin).coerceAtLeast(1L)
|
|
||||||
|
|
||||||
fun x(ms: Long) = left + (ms - tMin).toFloat() / tSpan * (right - left)
|
|
||||||
fun y(pct: Float) = bottom - (pct / 100f) * (bottom - top)
|
|
||||||
|
|
||||||
drawSeries(canvas, plottable, sessionPaint, ::x, ::y) { it.sessionPct }
|
|
||||||
drawSeries(canvas, plottable, weeklyPaint, ::x, ::y) { it.weeklyPct }
|
|
||||||
|
|
||||||
// X axis time labels (start … end)
|
|
||||||
val fmt = SimpleDateFormat("MMM d, h a", Locale.US)
|
|
||||||
labelPaint.textAlign = Paint.Align.LEFT
|
|
||||||
canvas.drawText(fmt.format(Date(tMin)), left, height - dp(4f), labelPaint)
|
|
||||||
labelPaint.textAlign = Paint.Align.RIGHT
|
|
||||||
canvas.drawText(fmt.format(Date(tMax)), right, height - dp(4f), labelPaint)
|
|
||||||
labelPaint.textAlign = Paint.Align.LEFT
|
|
||||||
}
|
|
||||||
|
|
||||||
private inline fun drawSeries(
|
|
||||||
canvas: Canvas,
|
|
||||||
data: List<UsageSnapshot>,
|
|
||||||
paint: Paint,
|
|
||||||
x: (Long) -> Float,
|
|
||||||
y: (Float) -> Float,
|
|
||||||
value: (UsageSnapshot) -> Float
|
|
||||||
) {
|
|
||||||
val path = Path()
|
|
||||||
var penDown = false
|
|
||||||
var prevMs = 0L
|
|
||||||
for (p in data) {
|
|
||||||
val v = value(p)
|
|
||||||
if (v < 0f) { penDown = false; continue }
|
|
||||||
val px = x(p.epochMs); val py = y(v)
|
|
||||||
if (!penDown || p.epochMs - prevMs > GAP_MS) {
|
|
||||||
path.moveTo(px, py)
|
|
||||||
} else {
|
|
||||||
path.lineTo(px, py)
|
|
||||||
}
|
|
||||||
penDown = true
|
|
||||||
prevMs = p.epochMs
|
|
||||||
}
|
|
||||||
canvas.drawPath(path, paint)
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun linePaint(c: Int) = Paint(Paint.ANTI_ALIAS_FLAG).apply {
|
|
||||||
color = c; strokeWidth = dp(2f); style = Paint.Style.STROKE
|
|
||||||
strokeJoin = Paint.Join.ROUND; strokeCap = Paint.Cap.ROUND
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun dp(v: Float) = v * resources.displayMetrics.density
|
|
||||||
private fun sp(v: Float) = v * resources.displayMetrics.scaledDensity
|
|
||||||
|
|
||||||
companion object {
|
|
||||||
// Don't connect points separated by more than ~35 min (a missed refresh cycle or two).
|
|
||||||
private const val GAP_MS = 35 * 60 * 1000L
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -72,8 +72,8 @@ class LoginActivity : AppCompatActivity() {
|
|||||||
settings.apply {
|
settings.apply {
|
||||||
javaScriptEnabled = true
|
javaScriptEnabled = true
|
||||||
domStorageEnabled = true
|
domStorageEnabled = true
|
||||||
databaseEnabled = false
|
databaseEnabled = true
|
||||||
javaScriptCanOpenWindowsAutomatically = false
|
javaScriptCanOpenWindowsAutomatically = true
|
||||||
setSupportMultipleWindows(false)
|
setSupportMultipleWindows(false)
|
||||||
// Standard Android Chrome UA — less suspicious than desktop
|
// Standard Android Chrome UA — less suspicious than desktop
|
||||||
userAgentString = "Mozilla/5.0 (Linux; Android 13; Pixel 7) " +
|
userAgentString = "Mozilla/5.0 (Linux; Android 13; Pixel 7) " +
|
||||||
|
|||||||
@@ -1,18 +1,10 @@
|
|||||||
package me.khodak.claudeusage
|
package me.khodak.claudeusage
|
||||||
|
|
||||||
import android.Manifest
|
|
||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
import android.content.pm.PackageManager
|
|
||||||
import android.os.Build
|
|
||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
import android.view.View
|
import android.view.View
|
||||||
import androidx.activity.result.contract.ActivityResultContracts
|
|
||||||
import androidx.appcompat.app.AppCompatActivity
|
import androidx.appcompat.app.AppCompatActivity
|
||||||
import androidx.core.content.ContextCompat
|
|
||||||
import androidx.lifecycle.lifecycleScope
|
import androidx.lifecycle.lifecycleScope
|
||||||
import kotlinx.coroutines.Job
|
|
||||||
import kotlinx.coroutines.delay
|
|
||||||
import kotlinx.coroutines.isActive
|
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import me.khodak.claudeusage.data.PreferencesManager
|
import me.khodak.claudeusage.data.PreferencesManager
|
||||||
import me.khodak.claudeusage.data.UsageData
|
import me.khodak.claudeusage.data.UsageData
|
||||||
@@ -27,12 +19,6 @@ class MainActivity : AppCompatActivity() {
|
|||||||
private lateinit var prefs: PreferencesManager
|
private lateinit var prefs: PreferencesManager
|
||||||
private lateinit var repo: UsageRepository
|
private lateinit var repo: UsageRepository
|
||||||
|
|
||||||
private val notifPermLauncher =
|
|
||||||
registerForActivityResult(ActivityResultContracts.RequestPermission()) { /* result handled silently */ }
|
|
||||||
|
|
||||||
/** Live refresh loop that runs only while the app is in the foreground. */
|
|
||||||
private var autoRefreshJob: Job? = null
|
|
||||||
|
|
||||||
override fun onCreate(savedInstanceState: Bundle?) {
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
super.onCreate(savedInstanceState)
|
super.onCreate(savedInstanceState)
|
||||||
binding = ActivityMainBinding.inflate(layoutInflater)
|
binding = ActivityMainBinding.inflate(layoutInflater)
|
||||||
@@ -65,8 +51,6 @@ class MainActivity : AppCompatActivity() {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
setupNotificationSettings()
|
|
||||||
|
|
||||||
binding.btnDebug.setOnClickListener {
|
binding.btnDebug.setOnClickListener {
|
||||||
if (binding.tvDebugInfo.visibility == android.view.View.GONE) {
|
if (binding.tvDebugInfo.visibility == android.view.View.GONE) {
|
||||||
binding.tvDebugInfo.text = repo.lastDebugInfo.ifBlank { "No debug info yet — tap Refresh first" }
|
binding.tvDebugInfo.text = repo.lastDebugInfo.ifBlank { "No debug info yet — tap Refresh first" }
|
||||||
@@ -81,74 +65,32 @@ class MainActivity : AppCompatActivity() {
|
|||||||
|
|
||||||
override fun onResume() {
|
override fun onResume() {
|
||||||
super.onResume()
|
super.onResume()
|
||||||
updateUI(prefs.getUsageData()) // show cached instantly — never a blank screen
|
val cached = prefs.getUsageData()
|
||||||
if (prefs.isLoggedIn()) startAutoRefresh()
|
updateUI(cached)
|
||||||
}
|
if (prefs.isLoggedIn()) {
|
||||||
|
val staleMs = 5 * 60 * 1000L
|
||||||
override fun onPause() {
|
if ((cached?.lastUpdated ?: 0L) < System.currentTimeMillis() - staleMs) {
|
||||||
super.onPause()
|
refreshUsage()
|
||||||
autoRefreshJob?.cancel()
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Refresh immediately on open, then every [REFRESH_INTERVAL_MS] while foregrounded. */
|
|
||||||
private fun startAutoRefresh() {
|
|
||||||
autoRefreshJob?.cancel()
|
|
||||||
autoRefreshJob = lifecycleScope.launch {
|
|
||||||
while (isActive) {
|
|
||||||
doRefresh(silent = true)
|
|
||||||
delay(REFRESH_INTERVAL_MS)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun setupNotificationSettings() {
|
|
||||||
binding.switchNotify.isChecked = prefs.isNotifyEnabled()
|
|
||||||
binding.switchNotify.setOnCheckedChangeListener { _, checked ->
|
|
||||||
prefs.setNotifyEnabled(checked)
|
|
||||||
if (checked) requestNotificationPermission()
|
|
||||||
}
|
|
||||||
// Alerts default on, so prompt for the runtime permission once on first launch
|
|
||||||
// (a user who never toggles the switch would otherwise never be asked).
|
|
||||||
if (prefs.isNotifyEnabled()) requestNotificationPermission()
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun requestNotificationPermission() {
|
|
||||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) return
|
|
||||||
if (ContextCompat.checkSelfPermission(this, Manifest.permission.POST_NOTIFICATIONS)
|
|
||||||
!= PackageManager.PERMISSION_GRANTED
|
|
||||||
) {
|
|
||||||
notifPermLauncher.launch(Manifest.permission.POST_NOTIFICATIONS)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Manual "Refresh Now" button — shows the spinner. */
|
|
||||||
private fun refreshUsage() {
|
private fun refreshUsage() {
|
||||||
lifecycleScope.launch { doRefresh(silent = false) }
|
binding.btnRefresh.isEnabled = false
|
||||||
}
|
binding.progressIndicator.visibility = View.VISIBLE
|
||||||
|
lifecycleScope.launch {
|
||||||
private suspend fun doRefresh(silent: Boolean) {
|
val data = try {
|
||||||
if (!silent) {
|
repo.fetchUsage()
|
||||||
binding.btnRefresh.isEnabled = false
|
} catch (e: Exception) {
|
||||||
binding.progressIndicator.visibility = View.VISIBLE
|
if (e is kotlinx.coroutines.CancellationException) throw e
|
||||||
}
|
prefs.getUsageData()?.copy(errorMessage = "Network error")
|
||||||
val fresh = try {
|
?: UsageData(errorMessage = "Network error")
|
||||||
repo.fetchUsage()
|
}
|
||||||
} catch (e: Exception) {
|
prefs.saveUsageData(data)
|
||||||
if (e is kotlinx.coroutines.CancellationException) throw e
|
updateUI(data)
|
||||||
UsageData(errorMessage = "Network error")
|
if (binding.tvDebugInfo.visibility == View.VISIBLE) {
|
||||||
}
|
binding.tvDebugInfo.text = repo.lastDebugInfo
|
||||||
// Preserve last-good data so a failed/partial fetch never blanks the UI or widget.
|
}
|
||||||
val merged = fresh.mergedWith(prefs.getUsageData())
|
|
||||||
prefs.saveUsageData(merged)
|
|
||||||
prefs.recordHistory(fresh)
|
|
||||||
// Note: alerts fire only from the background worker, not here — no point pinging you
|
|
||||||
// with a notification while you're already looking at the app.
|
|
||||||
updateUI(merged)
|
|
||||||
ClaudeUsageWidget.notifyDataChanged(this) // opening the app refreshes the widget too
|
|
||||||
if (binding.tvDebugInfo.visibility == View.VISIBLE) {
|
|
||||||
binding.tvDebugInfo.text = repo.lastDebugInfo
|
|
||||||
}
|
|
||||||
if (!silent) {
|
|
||||||
binding.btnRefresh.isEnabled = true
|
binding.btnRefresh.isEnabled = true
|
||||||
binding.progressIndicator.visibility = View.GONE
|
binding.progressIndicator.visibility = View.GONE
|
||||||
}
|
}
|
||||||
@@ -162,37 +104,17 @@ class MainActivity : AppCompatActivity() {
|
|||||||
|
|
||||||
if (!loggedIn || data == null) return
|
if (!loggedIn || data == null) return
|
||||||
|
|
||||||
// ── Peak-hours row ───────────────────────────────────────────────────
|
binding.progressBar.progress = data.progressPercent
|
||||||
val peak = PeakHours.state()
|
|
||||||
binding.imgPeak.setColorFilter(if (peak.active) PEAK_ON else PEAK_OFF)
|
|
||||||
binding.tvPeak.setTextColor(if (peak.active) PEAK_ON else 0xFFAAAAAA.toInt())
|
|
||||||
binding.tvPeak.text = if (peak.active)
|
|
||||||
"🔥 Peak hours — ${peak.endsInLabel} · ${peak.windowLabel}"
|
|
||||||
else
|
|
||||||
"Off-peak · ${peak.windowLabel}"
|
|
||||||
|
|
||||||
// ── Session (5-hour) bar — no pace marker ────────────────────────────
|
|
||||||
binding.barSession.setImageBitmap(
|
|
||||||
BarRenderer.render(data.progressPercent, null, SESSION_FILL, null)
|
|
||||||
)
|
|
||||||
|
|
||||||
// ── Weekly (7-day) bar — single-color pace marker ────────────────────
|
|
||||||
if (data.weeklyUtilization >= 0f) {
|
if (data.weeklyUtilization >= 0f) {
|
||||||
val wPct = data.weeklyUtilization.toInt()
|
val wPct = data.weeklyUtilization.toInt()
|
||||||
val weeklyPace = PaceCalc.compute(data.weeklyUtilization, data.weeklyResetAtEpoch, PaceCalc.WEEKLY_WINDOW_MS)
|
binding.progressBarWeekly.progress = wPct
|
||||||
binding.barWeekly.setImageBitmap(
|
|
||||||
BarRenderer.render(wPct, weeklyPace?.markerPct, WEEKLY_FILL, if (weeklyPace != null) MARKER_COLOR else null)
|
|
||||||
)
|
|
||||||
binding.tvWeeklyUsage.text = "$wPct% this week"
|
binding.tvWeeklyUsage.text = "$wPct% this week"
|
||||||
} else {
|
} else {
|
||||||
binding.barWeekly.setImageBitmap(BarRenderer.render(0, null, WEEKLY_FILL, null))
|
binding.progressBarWeekly.progress = 0
|
||||||
binding.tvWeeklyUsage.text = "—"
|
binding.tvWeeklyUsage.text = "—"
|
||||||
}
|
}
|
||||||
|
|
||||||
// Pace text removed per design — bars carry the signal.
|
|
||||||
binding.tvSessionPace.visibility = View.GONE
|
|
||||||
binding.tvWeeklyPace.visibility = View.GONE
|
|
||||||
|
|
||||||
binding.tvUsage.text = when {
|
binding.tvUsage.text = when {
|
||||||
data.fiveHourUtilization >= 0f -> {
|
data.fiveHourUtilization >= 0f -> {
|
||||||
val pct = data.fiveHourUtilization.toInt()
|
val pct = data.fiveHourUtilization.toInt()
|
||||||
@@ -208,15 +130,13 @@ class MainActivity : AppCompatActivity() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
binding.tvReset.text = formatReset(data.effectiveResetEpoch)
|
binding.tvReset.text = formatReset(data.effectiveResetEpoch)
|
||||||
binding.tvWeeklyReset.text = formatResetDay(data.weeklyResetAtEpoch)
|
binding.tvWeeklyReset.text = formatReset(data.weeklyResetAtEpoch)
|
||||||
binding.tvUpdated.text = if (data.lastUpdated > 0)
|
binding.tvUpdated.text = if (data.lastUpdated > 0)
|
||||||
"Last updated: ${SimpleDateFormat("h:mm a, MMM d", Locale.US).format(Date(data.lastUpdated))}"
|
"Last updated: ${SimpleDateFormat("h:mm a, MMM d", Locale.US).format(Date(data.lastUpdated))}"
|
||||||
else ""
|
else ""
|
||||||
|
|
||||||
binding.tvError.text = data.errorMessage
|
binding.tvError.text = data.errorMessage
|
||||||
binding.tvError.visibility = if (data.errorMessage.isNotBlank()) View.VISIBLE else View.GONE
|
binding.tvError.visibility = if (data.errorMessage.isNotBlank()) View.VISIBLE else View.GONE
|
||||||
|
|
||||||
binding.historyChart.setData(prefs.getHistory())
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun formatReset(epochMs: Long): String {
|
private fun formatReset(epochMs: Long): String {
|
||||||
@@ -231,23 +151,4 @@ class MainActivity : AppCompatActivity() {
|
|||||||
else -> "Resets ${SimpleDateFormat("EEE", Locale.US).format(Date(epochMs))} $timeStr"
|
else -> "Resets ${SimpleDateFormat("EEE", Locale.US).format(Date(epochMs))} $timeStr"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Weekly reset shown with the weekday name ("Resets Friday 3:00 PM"), never "tomorrow". */
|
|
||||||
private fun formatResetDay(epochMs: Long): String {
|
|
||||||
if (epochMs <= 0) return ""
|
|
||||||
if (epochMs <= System.currentTimeMillis()) return "Resets soon"
|
|
||||||
val day = SimpleDateFormat("EEEE", Locale.US).format(Date(epochMs))
|
|
||||||
val date = SimpleDateFormat("MMM d", Locale.US).format(Date(epochMs))
|
|
||||||
val timeStr = SimpleDateFormat("h:mm a", Locale.US).format(Date(epochMs))
|
|
||||||
return "Resets $day, $date · $timeStr"
|
|
||||||
}
|
|
||||||
|
|
||||||
companion object {
|
|
||||||
private const val REFRESH_INTERVAL_MS = 30_000L // live refresh cadence while app is open
|
|
||||||
private const val SESSION_FILL = 0xFFCC785C.toInt()
|
|
||||||
private const val WEEKLY_FILL = 0xFF7B8FCC.toInt()
|
|
||||||
private const val MARKER_COLOR = 0xFFFFFFFF.toInt() // single-color weekly pace marker
|
|
||||||
private const val PEAK_ON = 0xFFCC785C.toInt()
|
|
||||||
private const val PEAK_OFF = 0xFF666666.toInt()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,116 +0,0 @@
|
|||||||
package me.khodak.claudeusage
|
|
||||||
|
|
||||||
import android.app.NotificationChannel
|
|
||||||
import android.app.NotificationManager
|
|
||||||
import android.app.PendingIntent
|
|
||||||
import android.content.Context
|
|
||||||
import android.content.Intent
|
|
||||||
import android.os.Build
|
|
||||||
import androidx.core.app.NotificationCompat
|
|
||||||
import androidx.core.app.NotificationManagerCompat
|
|
||||||
import me.khodak.claudeusage.data.PreferencesManager
|
|
||||||
import me.khodak.claudeusage.data.UsageData
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Fires exactly two alerts per metric — one at 90% and one at 100% — and no more.
|
|
||||||
*
|
|
||||||
* Uses hysteresis, not the API's reset timestamp: each level fires once when usage first
|
|
||||||
* crosses it, and only re-arms after usage drops back below that level (i.e. a new window /
|
|
||||||
* usage reset). That keeps it quiet even if the reset time drifts between fetches. Only the
|
|
||||||
* background worker calls this — never the in-app refresh loop — so you're not pinged while
|
|
||||||
* you're already looking at the app.
|
|
||||||
*/
|
|
||||||
object Notifier {
|
|
||||||
|
|
||||||
private const val CHANNEL_ID = "usage_alerts"
|
|
||||||
private val LEVELS = intArrayOf(90, 100)
|
|
||||||
|
|
||||||
fun checkAndNotify(context: Context, prefs: PreferencesManager, data: UsageData) {
|
|
||||||
if (!prefs.isNotifyEnabled()) return
|
|
||||||
val mgr = NotificationManagerCompat.from(context)
|
|
||||||
if (!mgr.areNotificationsEnabled()) return // OS-level or runtime permission off
|
|
||||||
ensureChannel(context)
|
|
||||||
|
|
||||||
evaluate(context, mgr, prefs, "session", data.fiveHourUtilization, "5-hour session")
|
|
||||||
evaluate(context, mgr, prefs, "weekly", data.weeklyUtilization, "weekly")
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun evaluate(
|
|
||||||
context: Context,
|
|
||||||
mgr: NotificationManagerCompat,
|
|
||||||
prefs: PreferencesManager,
|
|
||||||
metric: String,
|
|
||||||
utilization: Float,
|
|
||||||
label: String
|
|
||||||
) {
|
|
||||||
if (utilization < 0f) return
|
|
||||||
val util = utilization.toInt()
|
|
||||||
for ((i, level) in LEVELS.withIndex()) {
|
|
||||||
val key = "${metric}_$level"
|
|
||||||
if (util >= level) {
|
|
||||||
if (!prefs.wasNotified(key)) {
|
|
||||||
fire(context, mgr, notifId(metric, i), level, label, util)
|
|
||||||
prefs.setNotified(key, true)
|
|
||||||
}
|
|
||||||
} else if (prefs.wasNotified(key)) {
|
|
||||||
prefs.setNotified(key, false) // dropped below the line → re-arm for next window
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun fire(
|
|
||||||
context: Context,
|
|
||||||
mgr: NotificationManagerCompat,
|
|
||||||
notifId: Int,
|
|
||||||
level: Int,
|
|
||||||
label: String,
|
|
||||||
util: Int
|
|
||||||
) {
|
|
||||||
val title: String
|
|
||||||
val body: String
|
|
||||||
if (level >= 100) {
|
|
||||||
title = "${label.replaceFirstChar { it.uppercase() }} limit reached"
|
|
||||||
body = "You've hit 100% of your $label limit."
|
|
||||||
} else {
|
|
||||||
title = "${label.replaceFirstChar { it.uppercase() }} at $util%"
|
|
||||||
body = "You're at $level% of your $label limit."
|
|
||||||
}
|
|
||||||
val notif = NotificationCompat.Builder(context, CHANNEL_ID)
|
|
||||||
.setSmallIcon(R.drawable.ic_claude_burst)
|
|
||||||
.setContentTitle(title)
|
|
||||||
.setContentText(body)
|
|
||||||
.setPriority(NotificationCompat.PRIORITY_DEFAULT)
|
|
||||||
.setAutoCancel(true)
|
|
||||||
.setContentIntent(openAppIntent(context))
|
|
||||||
.build()
|
|
||||||
try {
|
|
||||||
mgr.notify(notifId, notif)
|
|
||||||
} catch (_: SecurityException) {
|
|
||||||
// Notifications revoked between the check and post — nothing to do.
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Stable id per (metric, level) so re-posts replace rather than stack.
|
|
||||||
private fun notifId(metric: String, levelIndex: Int) =
|
|
||||||
2000 + (if (metric == "weekly") 10 else 0) + levelIndex
|
|
||||||
|
|
||||||
private fun ensureChannel(context: Context) {
|
|
||||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return
|
|
||||||
val mgr = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
|
|
||||||
if (mgr.getNotificationChannel(CHANNEL_ID) != null) return
|
|
||||||
mgr.createNotificationChannel(
|
|
||||||
NotificationChannel(
|
|
||||||
CHANNEL_ID, "Usage alerts", NotificationManager.IMPORTANCE_DEFAULT
|
|
||||||
).apply { description = "Alerts at 90% and 100% of your Claude usage limits" }
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun openAppIntent(context: Context): PendingIntent {
|
|
||||||
val intent = Intent(context, MainActivity::class.java)
|
|
||||||
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP)
|
|
||||||
return PendingIntent.getActivity(
|
|
||||||
context, 0, intent,
|
|
||||||
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,65 +0,0 @@
|
|||||||
package me.khodak.claudeusage
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Pace projection mirroring hamed-elfayome/Claude-Usage-Tracker's PaceStatus.
|
|
||||||
*
|
|
||||||
* Idea: if you keep your current burn rate, where do you land at reset?
|
|
||||||
* projected = (usedPct / 100) / elapsedFraction
|
|
||||||
* The "where you should be right now" marker sits at elapsedFraction*100 on the bar —
|
|
||||||
* i.e. the position that finishes at exactly 100% by reset.
|
|
||||||
*/
|
|
||||||
object PaceCalc {
|
|
||||||
|
|
||||||
const val SESSION_WINDOW_MS = 5L * 60 * 60 * 1000 // 5 hours
|
|
||||||
const val WEEKLY_WINDOW_MS = 7L * 24 * 60 * 60 * 1000 // 7 days
|
|
||||||
|
|
||||||
// System-style tier colors (ARGB).
|
|
||||||
private const val GREEN = 0xFF34C759.toInt()
|
|
||||||
private const val TEAL = 0xFF30B0C7.toInt()
|
|
||||||
private const val YELLOW = 0xFFFFCC00.toInt()
|
|
||||||
private const val ORANGE = 0xFFFF9500.toInt()
|
|
||||||
private const val RED = 0xFFFF3B30.toInt()
|
|
||||||
private const val PURPLE = 0xFFAF52DE.toInt()
|
|
||||||
|
|
||||||
data class Pace(
|
|
||||||
val markerPct: Int, // where you "should be" now (0..100)
|
|
||||||
val projected: Float, // projected end-of-period fraction (1.0 == exactly 100%)
|
|
||||||
val tierColor: Int, // ARGB color for the tier
|
|
||||||
val label: String // short interpretation, e.g. "sustainable pace"
|
|
||||||
)
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param usedPct current utilization 0..100
|
|
||||||
* @param resetEpoch epoch millis when this window resets (period end)
|
|
||||||
* @param windowMs total length of the window
|
|
||||||
* @return Pace, or null if we can't meaningfully project yet
|
|
||||||
*/
|
|
||||||
fun compute(
|
|
||||||
usedPct: Float,
|
|
||||||
resetEpoch: Long,
|
|
||||||
windowMs: Long,
|
|
||||||
now: Long = System.currentTimeMillis()
|
|
||||||
): Pace? {
|
|
||||||
if (resetEpoch <= 0L || usedPct < 0f) return null
|
|
||||||
val start = resetEpoch - windowMs
|
|
||||||
val elapsed = now - start
|
|
||||||
val elapsedFraction = elapsed.toFloat() / windowMs.toFloat()
|
|
||||||
// Match reference: need >=3% elapsed and period not complete.
|
|
||||||
if (elapsedFraction < 0.03f || elapsedFraction >= 1.0f) return null
|
|
||||||
|
|
||||||
val markerPct = (elapsedFraction * 100f).toInt().coerceIn(0, 100)
|
|
||||||
if (usedPct == 0f) {
|
|
||||||
return Pace(markerPct, 0f, GREEN, "way under budget")
|
|
||||||
}
|
|
||||||
val projected = (usedPct / 100f) / elapsedFraction
|
|
||||||
val (color, label) = when {
|
|
||||||
projected < 0.50f -> GREEN to "way under budget"
|
|
||||||
projected < 0.75f -> TEAL to "sustainable pace"
|
|
||||||
projected < 0.90f -> YELLOW to "starting to push it"
|
|
||||||
projected < 1.00f -> ORANGE to "will likely hit limit"
|
|
||||||
projected < 1.20f -> RED to "on track to exceed"
|
|
||||||
else -> PURPLE to "burning way too fast"
|
|
||||||
}
|
|
||||||
return Pace(markerPct, projected, color, label)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,80 +0,0 @@
|
|||||||
package me.khodak.claudeusage
|
|
||||||
|
|
||||||
import java.text.SimpleDateFormat
|
|
||||||
import java.util.Calendar
|
|
||||||
import java.util.Date
|
|
||||||
import java.util.GregorianCalendar
|
|
||||||
import java.util.Locale
|
|
||||||
import java.util.TimeZone
|
|
||||||
import java.util.concurrent.TimeUnit
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Anthropic peak usage window, mirroring hamed-elfayome/Claude-Usage-Tracker's PeakHoursService:
|
|
||||||
* 5:00–11:00 AM America/Los_Angeles, Monday–Friday. Tokens burn faster during this window.
|
|
||||||
*/
|
|
||||||
object PeakHours {
|
|
||||||
|
|
||||||
private const val PEAK_START_HOUR = 5 // 5 AM PT inclusive
|
|
||||||
private const val PEAK_END_HOUR = 11 // 11 AM PT exclusive
|
|
||||||
private val PT: TimeZone = TimeZone.getTimeZone("America/Los_Angeles")
|
|
||||||
|
|
||||||
data class PeakState(
|
|
||||||
val active: Boolean,
|
|
||||||
val endsInLabel: String, // e.g. "ends in 2h 14m" (only meaningful when active)
|
|
||||||
val windowLabel: String // e.g. "5–11 AM PT · 8 AM–2 PM your time"
|
|
||||||
)
|
|
||||||
|
|
||||||
fun isPeak(now: Long = System.currentTimeMillis()): Boolean {
|
|
||||||
val cal = GregorianCalendar(PT).apply { timeInMillis = now }
|
|
||||||
val dow = cal.get(Calendar.DAY_OF_WEEK) // Sun=1 .. Sat=7
|
|
||||||
val weekday = dow in Calendar.MONDAY..Calendar.FRIDAY
|
|
||||||
val hour = cal.get(Calendar.HOUR_OF_DAY)
|
|
||||||
return weekday && hour in PEAK_START_HOUR until PEAK_END_HOUR
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Epoch millis of 11:00 AM PT on the current PT day (peak window close). */
|
|
||||||
fun peakEndEpoch(now: Long = System.currentTimeMillis()): Long {
|
|
||||||
val cal = GregorianCalendar(PT).apply {
|
|
||||||
timeInMillis = now
|
|
||||||
set(Calendar.HOUR_OF_DAY, PEAK_END_HOUR)
|
|
||||||
set(Calendar.MINUTE, 0)
|
|
||||||
set(Calendar.SECOND, 0)
|
|
||||||
set(Calendar.MILLISECOND, 0)
|
|
||||||
}
|
|
||||||
return cal.timeInMillis
|
|
||||||
}
|
|
||||||
|
|
||||||
fun state(now: Long = System.currentTimeMillis()): PeakState {
|
|
||||||
val active = isPeak(now)
|
|
||||||
val endsIn = if (active) {
|
|
||||||
val rem = peakEndEpoch(now) - now
|
|
||||||
val h = TimeUnit.MILLISECONDS.toHours(rem)
|
|
||||||
val m = TimeUnit.MILLISECONDS.toMinutes(rem) % 60
|
|
||||||
if (h > 0) "ends in ${h}h ${m}m" else "ends in ${m}m"
|
|
||||||
} else ""
|
|
||||||
return PeakState(active, endsIn, localWindowString(now))
|
|
||||||
}
|
|
||||||
|
|
||||||
/** "5–11 AM PT · 8 AM–2 PM your time" — peak window translated to the device timezone. */
|
|
||||||
fun localWindowString(now: Long = System.currentTimeMillis()): String {
|
|
||||||
val local = TimeZone.getDefault()
|
|
||||||
if (local.id == PT.id) return "5–11 AM PT"
|
|
||||||
val startLocal = ptHourToLocal(PEAK_START_HOUR, now, local)
|
|
||||||
val endLocal = ptHourToLocal(PEAK_END_HOUR, now, local)
|
|
||||||
val fmt = SimpleDateFormat("h a", Locale.US).apply { timeZone = local }
|
|
||||||
val s = fmt.format(Date(startLocal)).replace(" ", " ")
|
|
||||||
val e = fmt.format(Date(endLocal)).replace(" ", " ")
|
|
||||||
return "5–11 AM PT · $s–$e your time"
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun ptHourToLocal(ptHour: Int, now: Long, local: TimeZone): Long {
|
|
||||||
val cal = GregorianCalendar(PT).apply {
|
|
||||||
timeInMillis = now
|
|
||||||
set(Calendar.HOUR_OF_DAY, ptHour)
|
|
||||||
set(Calendar.MINUTE, 0)
|
|
||||||
set(Calendar.SECOND, 0)
|
|
||||||
set(Calendar.MILLISECOND, 0)
|
|
||||||
}
|
|
||||||
return cal.timeInMillis // absolute instant; formatting in `local` renders local wall time
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -55,7 +55,6 @@ class UsageRepository(private val prefs: PreferencesManager) {
|
|||||||
if (orgId == null) return@withContext base.copy(errorMessage = "Can't reach claude.ai")
|
if (orgId == null) return@withContext base.copy(errorMessage = "Can't reach claude.ai")
|
||||||
|
|
||||||
if (orgUsageData?.hasRateLimitData == true) {
|
if (orgUsageData?.hasRateLimitData == true) {
|
||||||
prefs.resetAuthFailCount()
|
|
||||||
return@withContext base.copy(
|
return@withContext base.copy(
|
||||||
messagesUsed = orgUsageData.messagesUsed,
|
messagesUsed = orgUsageData.messagesUsed,
|
||||||
messagesLimit = orgUsageData.messagesLimit,
|
messagesLimit = orgUsageData.messagesLimit,
|
||||||
@@ -78,7 +77,6 @@ class UsageRepository(private val prefs: PreferencesManager) {
|
|||||||
if (BuildConfig.DEBUG) debugBuf.append("$usageUrl\n→ $code: ${body.take(400)}\n\n")
|
if (BuildConfig.DEBUG) debugBuf.append("$usageUrl\n→ $code: ${body.take(400)}\n\n")
|
||||||
val utilData = tryParseUtilizationBody(body)
|
val utilData = tryParseUtilizationBody(body)
|
||||||
if (utilData != null) {
|
if (utilData != null) {
|
||||||
prefs.resetAuthFailCount()
|
|
||||||
return@withContext base.copy(
|
return@withContext base.copy(
|
||||||
fiveHourUtilization = utilData.fiveHourUtilization,
|
fiveHourUtilization = utilData.fiveHourUtilization,
|
||||||
weeklyUtilization = utilData.weeklyUtilization,
|
weeklyUtilization = utilData.weeklyUtilization,
|
||||||
@@ -104,13 +102,8 @@ class UsageRepository(private val prefs: PreferencesManager) {
|
|||||||
if (BuildConfig.DEBUG) Log.d(TAG, "GET $url → $code")
|
if (BuildConfig.DEBUG) Log.d(TAG, "GET $url → $code")
|
||||||
|
|
||||||
if (code == 401 || code == 403) {
|
if (code == 401 || code == 403) {
|
||||||
if (prefs.incAuthFailCount() >= AUTH_FAIL_LIMIT) {
|
prefs.clearSession()
|
||||||
prefs.clearSession()
|
return@withContext UsageData(errorMessage = "Session expired — please sign in again")
|
||||||
prefs.resetAuthFailCount()
|
|
||||||
return@withContext UsageData(errorMessage = "Session expired — please sign in again")
|
|
||||||
}
|
|
||||||
// Transient auth failure — keep showing last-good data instead of logging out.
|
|
||||||
return@withContext base
|
|
||||||
}
|
}
|
||||||
|
|
||||||
val rateLimitData = extractRateLimitHeaders(resp.headers)
|
val rateLimitData = extractRateLimitHeaders(resp.headers)
|
||||||
@@ -348,8 +341,5 @@ class UsageRepository(private val prefs: PreferencesManager) {
|
|||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
private const val TAG = "UsageRepo"
|
private const val TAG = "UsageRepo"
|
||||||
// Clear the session only after this many consecutive 401/403s, so one transient
|
|
||||||
// auth failure (Cloudflare challenge, brief edge hiccup) doesn't sign the user out.
|
|
||||||
private const val AUTH_FAIL_LIMIT = 3
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -31,10 +31,7 @@ class UsageUpdateWorker(
|
|||||||
val animJob = launch { rotateRefreshIcon() }
|
val animJob = launch { rotateRefreshIcon() }
|
||||||
try {
|
try {
|
||||||
val data = UsageRepository(prefs).fetchUsage()
|
val data = UsageRepository(prefs).fetchUsage()
|
||||||
// Preserve last-good data so a failed/partial fetch never blanks the widget.
|
prefs.saveUsageData(data)
|
||||||
prefs.saveUsageData(data.mergedWith(prefs.getUsageData()))
|
|
||||||
prefs.recordHistory(data) // history records only fresh readings
|
|
||||||
Notifier.checkAndNotify(context, prefs, data)
|
|
||||||
} catch (_: Exception) {}
|
} catch (_: Exception) {}
|
||||||
animJob.cancel()
|
animJob.cancel()
|
||||||
animJob.join()
|
animJob.join()
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ import android.content.Context
|
|||||||
import androidx.security.crypto.EncryptedSharedPreferences
|
import androidx.security.crypto.EncryptedSharedPreferences
|
||||||
import androidx.security.crypto.MasterKey
|
import androidx.security.crypto.MasterKey
|
||||||
import com.google.gson.Gson
|
import com.google.gson.Gson
|
||||||
import com.google.gson.reflect.TypeToken
|
|
||||||
import java.util.Calendar
|
import java.util.Calendar
|
||||||
|
|
||||||
class PreferencesManager(context: Context) {
|
class PreferencesManager(context: Context) {
|
||||||
@@ -74,67 +73,6 @@ class PreferencesManager(context: Context) {
|
|||||||
|
|
||||||
fun isLoggedIn(): Boolean = !getCookies().isNullOrBlank()
|
fun isLoggedIn(): Boolean = !getCookies().isNullOrBlank()
|
||||||
|
|
||||||
// Consecutive 401/403 counter — we only clear the session after several in a row, so a
|
|
||||||
// single transient auth failure (e.g. a Cloudflare challenge) doesn't log the user out.
|
|
||||||
fun getAuthFailCount(): Int = prefs.getInt(KEY_AUTH_FAILS, 0)
|
|
||||||
fun incAuthFailCount(): Int {
|
|
||||||
val n = getAuthFailCount() + 1
|
|
||||||
prefs.edit().putInt(KEY_AUTH_FAILS, n).apply()
|
|
||||||
return n
|
|
||||||
}
|
|
||||||
fun resetAuthFailCount() {
|
|
||||||
if (getAuthFailCount() != 0) prefs.edit().putInt(KEY_AUTH_FAILS, 0).apply()
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Usage history (for the in-app chart) ─────────────────────────────────
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Append a history point if [data] carries a real utilization reading.
|
|
||||||
* De-duplicates rapid double-fires (manual refresh + background worker landing
|
|
||||||
* together) by skipping points within [MIN_HISTORY_GAP_MS] of the last one, and
|
|
||||||
* prunes anything older than [HISTORY_RETENTION_MS] / beyond [MAX_HISTORY_POINTS].
|
|
||||||
*/
|
|
||||||
fun recordHistory(data: UsageData) {
|
|
||||||
if (data.fiveHourUtilization < 0f && data.weeklyUtilization < 0f) return
|
|
||||||
val now = System.currentTimeMillis()
|
|
||||||
val history = getHistory().toMutableList()
|
|
||||||
if (history.isNotEmpty() && now - history.last().epochMs < MIN_HISTORY_GAP_MS) {
|
|
||||||
history.removeAt(history.size - 1) // collapse near-simultaneous readings
|
|
||||||
}
|
|
||||||
history.add(
|
|
||||||
UsageSnapshot(
|
|
||||||
epochMs = now,
|
|
||||||
sessionPct = data.fiveHourUtilization,
|
|
||||||
weeklyPct = data.weeklyUtilization
|
|
||||||
)
|
|
||||||
)
|
|
||||||
val cutoff = now - HISTORY_RETENTION_MS
|
|
||||||
val pruned = history.filter { it.epochMs >= cutoff }
|
|
||||||
.takeLast(MAX_HISTORY_POINTS)
|
|
||||||
prefs.edit().putString(KEY_HISTORY, gson.toJson(pruned)).apply()
|
|
||||||
}
|
|
||||||
|
|
||||||
fun getHistory(): List<UsageSnapshot> {
|
|
||||||
val json = prefs.getString(KEY_HISTORY, null) ?: return emptyList()
|
|
||||||
return try {
|
|
||||||
val type = object : TypeToken<List<UsageSnapshot>>() {}.type
|
|
||||||
gson.fromJson<List<UsageSnapshot>>(json, type) ?: emptyList()
|
|
||||||
} catch (e: Exception) { emptyList() }
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Notification settings ────────────────────────────────────────────────
|
|
||||||
|
|
||||||
fun isNotifyEnabled(): Boolean = prefs.getBoolean(KEY_NOTIFY_ENABLED, true)
|
|
||||||
fun setNotifyEnabled(v: Boolean) = prefs.edit().putBoolean(KEY_NOTIFY_ENABLED, v).apply()
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Per-level "already alerted" flag (e.g. "session_90"). Set when the level is crossed,
|
|
||||||
* cleared when usage drops back below it — so each level fires once per window.
|
|
||||||
*/
|
|
||||||
fun wasNotified(key: String): Boolean = prefs.getBoolean("notified_$key", false)
|
|
||||||
fun setNotified(key: String, v: Boolean) =
|
|
||||||
prefs.edit().putBoolean("notified_$key", v).apply()
|
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
private const val KEY_COOKIES = "session_cookies"
|
private const val KEY_COOKIES = "session_cookies"
|
||||||
private const val KEY_ORG_ID = "org_id"
|
private const val KEY_ORG_ID = "org_id"
|
||||||
@@ -142,13 +80,6 @@ class PreferencesManager(context: Context) {
|
|||||||
private const val KEY_USAGE_DATA = "usage_data"
|
private const val KEY_USAGE_DATA = "usage_data"
|
||||||
private const val KEY_ACTIVE_WEEK = "active_week"
|
private const val KEY_ACTIVE_WEEK = "active_week"
|
||||||
private const val KEY_ACTIVE_MASK = "active_mask"
|
private const val KEY_ACTIVE_MASK = "active_mask"
|
||||||
private const val KEY_HISTORY = "usage_history"
|
|
||||||
private const val KEY_NOTIFY_ENABLED = "notify_enabled"
|
|
||||||
private const val KEY_AUTH_FAILS = "auth_fail_count"
|
|
||||||
|
|
||||||
private const val MIN_HISTORY_GAP_MS = 2 * 60 * 1000L // collapse readings <2 min apart
|
|
||||||
private const val HISTORY_RETENTION_MS = 7 * 24 * 60 * 60 * 1000L // keep 7 days
|
|
||||||
private const val MAX_HISTORY_POINTS = 600
|
|
||||||
|
|
||||||
fun createSecurePrefs(context: Context, onFallback: (() -> Unit)? = null): android.content.SharedPreferences {
|
fun createSecurePrefs(context: Context, onFallback: (() -> Unit)? = null): android.content.SharedPreferences {
|
||||||
return try {
|
return try {
|
||||||
|
|||||||
@@ -49,36 +49,4 @@ data class UsageData(
|
|||||||
resetAtEpoch > 0 -> resetAtEpoch
|
resetAtEpoch > 0 -> resetAtEpoch
|
||||||
else -> -1L
|
else -> -1L
|
||||||
}
|
}
|
||||||
|
|
||||||
/** True if this fetch produced any usable usage reading at all. */
|
|
||||||
val hasAnyReading: Boolean get() =
|
|
||||||
fiveHourUtilization >= 0f || weeklyUtilization >= 0f || hasRateLimitData
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Merge a fresh fetch over the last cached reading so a failed or partial refresh never
|
|
||||||
* blanks the widget. If this fetch got nothing usable, the whole previous snapshot is kept
|
|
||||||
* (with its original timestamp, so the footer shows the data's true age). Otherwise each
|
|
||||||
* metric this fetch didn't return falls back to the previous value.
|
|
||||||
*/
|
|
||||||
fun mergedWith(previous: UsageData?): UsageData {
|
|
||||||
if (previous == null || !previous.hasAnyReading) return this
|
|
||||||
if (!hasAnyReading) {
|
|
||||||
// Keep last-good data; only carry this attempt's login/session context forward.
|
|
||||||
return previous.copy(
|
|
||||||
isLoggedIn = isLoggedIn,
|
|
||||||
sessionStartEpoch = if (sessionStartEpoch > 0) sessionStartEpoch else previous.sessionStartEpoch,
|
|
||||||
weeklyActiveDaysMask = if (weeklyActiveDaysMask != 0) weeklyActiveDaysMask else previous.weeklyActiveDaysMask
|
|
||||||
)
|
|
||||||
}
|
|
||||||
return copy(
|
|
||||||
fiveHourUtilization = if (fiveHourUtilization >= 0f) fiveHourUtilization else previous.fiveHourUtilization,
|
|
||||||
utilizationResetAtEpoch = if (fiveHourUtilization >= 0f) utilizationResetAtEpoch else previous.utilizationResetAtEpoch,
|
|
||||||
weeklyUtilization = if (weeklyUtilization >= 0f) weeklyUtilization else previous.weeklyUtilization,
|
|
||||||
weeklyResetAtEpoch = if (weeklyUtilization >= 0f) weeklyResetAtEpoch else previous.weeklyResetAtEpoch,
|
|
||||||
messagesLimit = if (messagesLimit > 0) messagesLimit else previous.messagesLimit,
|
|
||||||
messagesUsed = if (messagesUsed >= 0) messagesUsed else previous.messagesUsed,
|
|
||||||
messagesRemaining = if (messagesRemaining >= 0) messagesRemaining else previous.messagesRemaining,
|
|
||||||
resetAtEpoch = if (resetAtEpoch > 0) resetAtEpoch else previous.resetAtEpoch
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,11 +0,0 @@
|
|||||||
package me.khodak.claudeusage.data
|
|
||||||
|
|
||||||
/**
|
|
||||||
* A single point-in-time reading of usage, stored for the in-app history chart.
|
|
||||||
* Percentages are 0-100; -1 means "no reading for this metric at this time".
|
|
||||||
*/
|
|
||||||
data class UsageSnapshot(
|
|
||||||
val epochMs: Long = 0L,
|
|
||||||
val sessionPct: Float = -1f,
|
|
||||||
val weeklyPct: Float = -1f
|
|
||||||
)
|
|
||||||
@@ -1,29 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
|
||||||
<!-- Claude "sunburst" mark. Drawn in white; tinted at runtime via setColorFilter
|
|
||||||
(calm #666666, peak #CC785C). -->
|
|
||||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
|
||||||
android:width="24dp"
|
|
||||||
android:height="24dp"
|
|
||||||
android:viewportWidth="24"
|
|
||||||
android:viewportHeight="24">
|
|
||||||
<path
|
|
||||||
android:strokeColor="#FFFFFF"
|
|
||||||
android:strokeWidth="1.7"
|
|
||||||
android:strokeLineCap="round"
|
|
||||||
android:pathData="
|
|
||||||
M12,3 L12,7
|
|
||||||
M12,17 L12,21
|
|
||||||
M3,12 L7,12
|
|
||||||
M17,12 L21,12
|
|
||||||
M5.6,5.6 L8.4,8.4
|
|
||||||
M15.6,15.6 L18.4,18.4
|
|
||||||
M18.4,5.6 L15.6,8.4
|
|
||||||
M8.4,15.6 L5.6,18.4
|
|
||||||
M12,4.5 L12,6.2
|
|
||||||
M12,17.8 L12,19.5
|
|
||||||
M4.5,12 L6.2,12
|
|
||||||
M17.8,12 L19.5,12" />
|
|
||||||
<path
|
|
||||||
android:fillColor="#FFFFFF"
|
|
||||||
android:pathData="M12,9.6 a2.4,2.4 0 1,0 0.01,0 z" />
|
|
||||||
</vector>
|
|
||||||
@@ -92,33 +92,6 @@
|
|||||||
android:background="@drawable/widget_background"
|
android:background="@drawable/widget_background"
|
||||||
android:padding="20dp">
|
android:padding="20dp">
|
||||||
|
|
||||||
<!-- Peak-hours row -->
|
|
||||||
<LinearLayout
|
|
||||||
android:layout_width="match_parent"
|
|
||||||
android:layout_height="wrap_content"
|
|
||||||
android:orientation="horizontal"
|
|
||||||
android:gravity="center_vertical"
|
|
||||||
android:layout_marginBottom="14dp">
|
|
||||||
|
|
||||||
<ImageView
|
|
||||||
android:id="@+id/imgPeak"
|
|
||||||
android:layout_width="20dp"
|
|
||||||
android:layout_height="20dp"
|
|
||||||
android:layout_marginEnd="8dp"
|
|
||||||
android:src="@drawable/ic_claude_burst"
|
|
||||||
android:contentDescription="Peak hours" />
|
|
||||||
|
|
||||||
<TextView
|
|
||||||
android:id="@+id/tvPeak"
|
|
||||||
android:layout_width="0dp"
|
|
||||||
android:layout_height="wrap_content"
|
|
||||||
android:layout_weight="1"
|
|
||||||
android:text=""
|
|
||||||
android:textColor="#AAAAAA"
|
|
||||||
android:textSize="12sp" />
|
|
||||||
|
|
||||||
</LinearLayout>
|
|
||||||
|
|
||||||
<TextView
|
<TextView
|
||||||
android:layout_width="wrap_content"
|
android:layout_width="wrap_content"
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
@@ -137,24 +110,16 @@
|
|||||||
android:textSize="20sp"
|
android:textSize="20sp"
|
||||||
android:textStyle="bold" />
|
android:textStyle="bold" />
|
||||||
|
|
||||||
<ImageView
|
<ProgressBar
|
||||||
android:id="@+id/barSession"
|
android:id="@+id/progressBar"
|
||||||
|
style="@android:style/Widget.ProgressBar.Horizontal"
|
||||||
android:layout_width="match_parent"
|
android:layout_width="match_parent"
|
||||||
android:layout_height="9dp"
|
android:layout_height="8dp"
|
||||||
android:layout_marginTop="12dp"
|
android:layout_marginTop="12dp"
|
||||||
android:scaleType="fitXY"
|
android:max="100"
|
||||||
android:adjustViewBounds="false"
|
android:progress="0"
|
||||||
android:contentDescription="Session usage bar" />
|
android:progressTint="#CC785C"
|
||||||
|
android:progressBackgroundTint="#3A3A3A" />
|
||||||
<TextView
|
|
||||||
android:id="@+id/tvSessionPace"
|
|
||||||
android:layout_width="wrap_content"
|
|
||||||
android:layout_height="wrap_content"
|
|
||||||
android:layout_marginTop="6dp"
|
|
||||||
android:text=""
|
|
||||||
android:textColor="#AAAAAA"
|
|
||||||
android:textSize="13sp"
|
|
||||||
android:textStyle="bold" />
|
|
||||||
|
|
||||||
<TextView
|
<TextView
|
||||||
android:id="@+id/tvReset"
|
android:id="@+id/tvReset"
|
||||||
@@ -183,24 +148,16 @@
|
|||||||
android:textSize="16sp"
|
android:textSize="16sp"
|
||||||
android:textStyle="bold" />
|
android:textStyle="bold" />
|
||||||
|
|
||||||
<ImageView
|
<ProgressBar
|
||||||
android:id="@+id/barWeekly"
|
android:id="@+id/progressBarWeekly"
|
||||||
|
style="@android:style/Widget.ProgressBar.Horizontal"
|
||||||
android:layout_width="match_parent"
|
android:layout_width="match_parent"
|
||||||
android:layout_height="9dp"
|
android:layout_height="8dp"
|
||||||
android:layout_marginTop="8dp"
|
android:layout_marginTop="8dp"
|
||||||
android:scaleType="fitXY"
|
android:max="100"
|
||||||
android:adjustViewBounds="false"
|
android:progress="0"
|
||||||
android:contentDescription="Weekly usage bar" />
|
android:progressTint="#7B8FCC"
|
||||||
|
android:progressBackgroundTint="#3A3A3A" />
|
||||||
<TextView
|
|
||||||
android:id="@+id/tvWeeklyPace"
|
|
||||||
android:layout_width="wrap_content"
|
|
||||||
android:layout_height="wrap_content"
|
|
||||||
android:layout_marginTop="6dp"
|
|
||||||
android:text=""
|
|
||||||
android:textColor="#AAAAAA"
|
|
||||||
android:textSize="13sp"
|
|
||||||
android:textStyle="bold" />
|
|
||||||
|
|
||||||
<TextView
|
<TextView
|
||||||
android:id="@+id/tvWeeklyReset"
|
android:id="@+id/tvWeeklyReset"
|
||||||
@@ -229,110 +186,6 @@
|
|||||||
|
|
||||||
</LinearLayout>
|
</LinearLayout>
|
||||||
|
|
||||||
<!-- History card -->
|
|
||||||
<LinearLayout
|
|
||||||
android:layout_width="match_parent"
|
|
||||||
android:layout_height="wrap_content"
|
|
||||||
android:orientation="vertical"
|
|
||||||
android:background="@drawable/widget_background"
|
|
||||||
android:padding="20dp"
|
|
||||||
android:layout_marginTop="16dp">
|
|
||||||
|
|
||||||
<TextView
|
|
||||||
android:layout_width="wrap_content"
|
|
||||||
android:layout_height="wrap_content"
|
|
||||||
android:text="HISTORY"
|
|
||||||
android:textColor="#888888"
|
|
||||||
android:textSize="11sp"
|
|
||||||
android:letterSpacing="0.1" />
|
|
||||||
|
|
||||||
<me.khodak.claudeusage.HistoryChartView
|
|
||||||
android:id="@+id/historyChart"
|
|
||||||
android:layout_width="match_parent"
|
|
||||||
android:layout_height="140dp"
|
|
||||||
android:layout_marginTop="12dp" />
|
|
||||||
|
|
||||||
<!-- Legend -->
|
|
||||||
<LinearLayout
|
|
||||||
android:layout_width="wrap_content"
|
|
||||||
android:layout_height="wrap_content"
|
|
||||||
android:orientation="horizontal"
|
|
||||||
android:layout_marginTop="10dp"
|
|
||||||
android:gravity="center_vertical">
|
|
||||||
|
|
||||||
<View
|
|
||||||
android:layout_width="12dp"
|
|
||||||
android:layout_height="3dp"
|
|
||||||
android:background="#CC785C" />
|
|
||||||
|
|
||||||
<TextView
|
|
||||||
android:layout_width="wrap_content"
|
|
||||||
android:layout_height="wrap_content"
|
|
||||||
android:layout_marginStart="6dp"
|
|
||||||
android:layout_marginEnd="16dp"
|
|
||||||
android:text="Session"
|
|
||||||
android:textColor="#AAAAAA"
|
|
||||||
android:textSize="12sp" />
|
|
||||||
|
|
||||||
<View
|
|
||||||
android:layout_width="12dp"
|
|
||||||
android:layout_height="3dp"
|
|
||||||
android:background="#7B8FCC" />
|
|
||||||
|
|
||||||
<TextView
|
|
||||||
android:layout_width="wrap_content"
|
|
||||||
android:layout_height="wrap_content"
|
|
||||||
android:layout_marginStart="6dp"
|
|
||||||
android:text="Weekly"
|
|
||||||
android:textColor="#AAAAAA"
|
|
||||||
android:textSize="12sp" />
|
|
||||||
|
|
||||||
</LinearLayout>
|
|
||||||
|
|
||||||
</LinearLayout>
|
|
||||||
|
|
||||||
<!-- Notifications card -->
|
|
||||||
<LinearLayout
|
|
||||||
android:layout_width="match_parent"
|
|
||||||
android:layout_height="wrap_content"
|
|
||||||
android:orientation="vertical"
|
|
||||||
android:background="@drawable/widget_background"
|
|
||||||
android:padding="20dp"
|
|
||||||
android:layout_marginTop="16dp">
|
|
||||||
|
|
||||||
<LinearLayout
|
|
||||||
android:layout_width="match_parent"
|
|
||||||
android:layout_height="wrap_content"
|
|
||||||
android:orientation="horizontal"
|
|
||||||
android:gravity="center_vertical">
|
|
||||||
|
|
||||||
<TextView
|
|
||||||
android:layout_width="0dp"
|
|
||||||
android:layout_height="wrap_content"
|
|
||||||
android:layout_weight="1"
|
|
||||||
android:text="USAGE ALERTS"
|
|
||||||
android:textColor="#888888"
|
|
||||||
android:textSize="11sp"
|
|
||||||
android:letterSpacing="0.1" />
|
|
||||||
|
|
||||||
<com.google.android.material.switchmaterial.SwitchMaterial
|
|
||||||
android:id="@+id/switchNotify"
|
|
||||||
android:layout_width="wrap_content"
|
|
||||||
android:layout_height="wrap_content" />
|
|
||||||
|
|
||||||
</LinearLayout>
|
|
||||||
|
|
||||||
<TextView
|
|
||||||
android:layout_width="match_parent"
|
|
||||||
android:layout_height="wrap_content"
|
|
||||||
android:layout_marginTop="12dp"
|
|
||||||
android:text="One alert at 90% and one at 100%, for both your session and weekly limits. Each fires once until usage resets."
|
|
||||||
android:textColor="#AAAAAA"
|
|
||||||
android:textSize="13sp"
|
|
||||||
android:lineSpacingExtra="3dp" />
|
|
||||||
|
|
||||||
</LinearLayout>
|
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
android:id="@+id/btnRefresh"
|
android:id="@+id/btnRefresh"
|
||||||
android:layout_width="match_parent"
|
android:layout_width="match_parent"
|
||||||
|
|||||||
@@ -23,24 +23,6 @@
|
|||||||
android:textSize="13sp"
|
android:textSize="13sp"
|
||||||
android:textStyle="bold" />
|
android:textStyle="bold" />
|
||||||
|
|
||||||
<ImageView
|
|
||||||
android:id="@+id/img_peak"
|
|
||||||
android:layout_width="16dp"
|
|
||||||
android:layout_height="16dp"
|
|
||||||
android:layout_marginEnd="4dp"
|
|
||||||
android:src="@drawable/ic_claude_burst"
|
|
||||||
android:contentDescription="Peak hours" />
|
|
||||||
|
|
||||||
<TextView
|
|
||||||
android:id="@+id/tv_peak"
|
|
||||||
android:layout_width="wrap_content"
|
|
||||||
android:layout_height="wrap_content"
|
|
||||||
android:layout_marginEnd="6dp"
|
|
||||||
android:text=""
|
|
||||||
android:textColor="#CC785C"
|
|
||||||
android:textSize="9sp"
|
|
||||||
android:textStyle="bold" />
|
|
||||||
|
|
||||||
<ImageButton
|
<ImageButton
|
||||||
android:id="@+id/btn_refresh"
|
android:id="@+id/btn_refresh"
|
||||||
android:layout_width="32dp"
|
android:layout_width="32dp"
|
||||||
@@ -72,7 +54,7 @@
|
|||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
android:layout_weight="1"
|
android:layout_weight="1"
|
||||||
android:text="SESSION"
|
android:text="SESSION"
|
||||||
android:textColor="#FFFFFF"
|
android:textColor="#555555"
|
||||||
android:textSize="9sp"
|
android:textSize="9sp"
|
||||||
android:textStyle="bold" />
|
android:textStyle="bold" />
|
||||||
|
|
||||||
@@ -87,14 +69,16 @@
|
|||||||
|
|
||||||
</LinearLayout>
|
</LinearLayout>
|
||||||
|
|
||||||
<ImageView
|
<ProgressBar
|
||||||
android:id="@+id/bar_session"
|
android:id="@+id/progress_bar"
|
||||||
|
style="@android:style/Widget.ProgressBar.Horizontal"
|
||||||
android:layout_width="match_parent"
|
android:layout_width="match_parent"
|
||||||
android:layout_height="6dp"
|
android:layout_height="5dp"
|
||||||
android:layout_marginTop="5dp"
|
android:layout_marginTop="5dp"
|
||||||
android:scaleType="fitXY"
|
android:max="100"
|
||||||
android:adjustViewBounds="false"
|
android:progress="0"
|
||||||
android:contentDescription="Session usage bar" />
|
android:progressTint="#CC785C"
|
||||||
|
android:progressBackgroundTint="#252525" />
|
||||||
|
|
||||||
<TextView
|
<TextView
|
||||||
android:id="@+id/tv_session_label"
|
android:id="@+id/tv_session_label"
|
||||||
@@ -102,9 +86,8 @@
|
|||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
android:layout_marginTop="3dp"
|
android:layout_marginTop="3dp"
|
||||||
android:text=""
|
android:text=""
|
||||||
android:textColor="#FFFFFF"
|
android:textColor="#666666"
|
||||||
android:textSize="11sp"
|
android:textSize="11sp" />
|
||||||
android:textStyle="bold" />
|
|
||||||
|
|
||||||
<!-- 7-day window bar -->
|
<!-- 7-day window bar -->
|
||||||
<LinearLayout
|
<LinearLayout
|
||||||
@@ -119,7 +102,7 @@
|
|||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
android:layout_weight="1"
|
android:layout_weight="1"
|
||||||
android:text="WEEKLY"
|
android:text="WEEKLY"
|
||||||
android:textColor="#FFFFFF"
|
android:textColor="#555555"
|
||||||
android:textSize="9sp"
|
android:textSize="9sp"
|
||||||
android:textStyle="bold" />
|
android:textStyle="bold" />
|
||||||
|
|
||||||
@@ -134,14 +117,16 @@
|
|||||||
|
|
||||||
</LinearLayout>
|
</LinearLayout>
|
||||||
|
|
||||||
<ImageView
|
<ProgressBar
|
||||||
android:id="@+id/bar_weekly"
|
android:id="@+id/progress_bar_weekly"
|
||||||
|
style="@android:style/Widget.ProgressBar.Horizontal"
|
||||||
android:layout_width="match_parent"
|
android:layout_width="match_parent"
|
||||||
android:layout_height="6dp"
|
android:layout_height="5dp"
|
||||||
android:layout_marginTop="5dp"
|
android:layout_marginTop="5dp"
|
||||||
android:scaleType="fitXY"
|
android:max="100"
|
||||||
android:adjustViewBounds="false"
|
android:progress="0"
|
||||||
android:contentDescription="Weekly usage bar" />
|
android:progressTint="#7B8FCC"
|
||||||
|
android:progressBackgroundTint="#252525" />
|
||||||
|
|
||||||
<TextView
|
<TextView
|
||||||
android:id="@+id/tv_weekly_label"
|
android:id="@+id/tv_weekly_label"
|
||||||
@@ -149,9 +134,8 @@
|
|||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
android:layout_marginTop="3dp"
|
android:layout_marginTop="3dp"
|
||||||
android:text=""
|
android:text=""
|
||||||
android:textColor="#FFFFFF"
|
android:textColor="#666666"
|
||||||
android:textSize="11sp"
|
android:textSize="11sp" />
|
||||||
android:textStyle="bold" />
|
|
||||||
|
|
||||||
<!-- Footer -->
|
<!-- Footer -->
|
||||||
<LinearLayout
|
<LinearLayout
|
||||||
@@ -167,9 +151,8 @@
|
|||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
android:layout_weight="1"
|
android:layout_weight="1"
|
||||||
android:text=""
|
android:text=""
|
||||||
android:textColor="#FFFFFF"
|
android:textColor="#CC785C"
|
||||||
android:textSize="9sp"
|
android:textSize="9sp"
|
||||||
android:textStyle="bold"
|
|
||||||
android:singleLine="true"
|
android:singleLine="true"
|
||||||
android:ellipsize="end" />
|
android:ellipsize="end" />
|
||||||
|
|
||||||
@@ -178,9 +161,8 @@
|
|||||||
android:layout_width="wrap_content"
|
android:layout_width="wrap_content"
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
android:text=""
|
android:text=""
|
||||||
android:textColor="#FFFFFF"
|
android:textColor="#444444"
|
||||||
android:textSize="9sp"
|
android:textSize="9sp" />
|
||||||
android:textStyle="bold" />
|
|
||||||
|
|
||||||
</LinearLayout>
|
</LinearLayout>
|
||||||
|
|
||||||
|
|||||||
@@ -23,14 +23,6 @@
|
|||||||
android:textSize="11sp"
|
android:textSize="11sp"
|
||||||
android:textStyle="bold" />
|
android:textStyle="bold" />
|
||||||
|
|
||||||
<ImageView
|
|
||||||
android:id="@+id/img_peak"
|
|
||||||
android:layout_width="14dp"
|
|
||||||
android:layout_height="14dp"
|
|
||||||
android:layout_marginEnd="6dp"
|
|
||||||
android:src="@drawable/ic_claude_burst"
|
|
||||||
android:contentDescription="Peak hours" />
|
|
||||||
|
|
||||||
<ImageButton
|
<ImageButton
|
||||||
android:id="@+id/btn_refresh"
|
android:id="@+id/btn_refresh"
|
||||||
android:layout_width="28dp"
|
android:layout_width="28dp"
|
||||||
@@ -54,7 +46,7 @@
|
|||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
android:layout_weight="1"
|
android:layout_weight="1"
|
||||||
android:text="SESSION"
|
android:text="SESSION"
|
||||||
android:textColor="#FFFFFF"
|
android:textColor="#555555"
|
||||||
android:textSize="8sp"
|
android:textSize="8sp"
|
||||||
android:textStyle="bold" />
|
android:textStyle="bold" />
|
||||||
|
|
||||||
@@ -73,20 +65,21 @@
|
|||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
android:layout_marginStart="6dp"
|
android:layout_marginStart="6dp"
|
||||||
android:text=""
|
android:text=""
|
||||||
android:textColor="#FFFFFF"
|
android:textColor="#555555"
|
||||||
android:textSize="10sp"
|
android:textSize="10sp" />
|
||||||
android:textStyle="bold" />
|
|
||||||
|
|
||||||
</LinearLayout>
|
</LinearLayout>
|
||||||
|
|
||||||
<ImageView
|
<ProgressBar
|
||||||
android:id="@+id/bar_session"
|
android:id="@+id/progress_bar"
|
||||||
|
style="@android:style/Widget.ProgressBar.Horizontal"
|
||||||
android:layout_width="match_parent"
|
android:layout_width="match_parent"
|
||||||
android:layout_height="5dp"
|
android:layout_height="4dp"
|
||||||
android:layout_marginTop="3dp"
|
android:layout_marginTop="3dp"
|
||||||
android:scaleType="fitXY"
|
android:max="100"
|
||||||
android:adjustViewBounds="false"
|
android:progress="0"
|
||||||
android:contentDescription="Session usage bar" />
|
android:progressTint="#CC785C"
|
||||||
|
android:progressBackgroundTint="#252525" />
|
||||||
|
|
||||||
<!-- 7-DAY row -->
|
<!-- 7-DAY row -->
|
||||||
<LinearLayout
|
<LinearLayout
|
||||||
@@ -101,7 +94,7 @@
|
|||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
android:layout_weight="1"
|
android:layout_weight="1"
|
||||||
android:text="WEEKLY"
|
android:text="WEEKLY"
|
||||||
android:textColor="#FFFFFF"
|
android:textColor="#555555"
|
||||||
android:textSize="8sp"
|
android:textSize="8sp"
|
||||||
android:textStyle="bold" />
|
android:textStyle="bold" />
|
||||||
|
|
||||||
@@ -120,20 +113,21 @@
|
|||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
android:layout_marginStart="6dp"
|
android:layout_marginStart="6dp"
|
||||||
android:text=""
|
android:text=""
|
||||||
android:textColor="#FFFFFF"
|
android:textColor="#555555"
|
||||||
android:textSize="10sp"
|
android:textSize="10sp" />
|
||||||
android:textStyle="bold" />
|
|
||||||
|
|
||||||
</LinearLayout>
|
</LinearLayout>
|
||||||
|
|
||||||
<ImageView
|
<ProgressBar
|
||||||
android:id="@+id/bar_weekly"
|
android:id="@+id/progress_bar_weekly"
|
||||||
|
style="@android:style/Widget.ProgressBar.Horizontal"
|
||||||
android:layout_width="match_parent"
|
android:layout_width="match_parent"
|
||||||
android:layout_height="5dp"
|
android:layout_height="4dp"
|
||||||
android:layout_marginTop="3dp"
|
android:layout_marginTop="3dp"
|
||||||
android:scaleType="fitXY"
|
android:max="100"
|
||||||
android:adjustViewBounds="false"
|
android:progress="0"
|
||||||
android:contentDescription="Weekly usage bar" />
|
android:progressTint="#7B8FCC"
|
||||||
|
android:progressBackgroundTint="#252525" />
|
||||||
|
|
||||||
<TextView
|
<TextView
|
||||||
android:id="@+id/tv_status"
|
android:id="@+id/tv_status"
|
||||||
@@ -141,9 +135,8 @@
|
|||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
android:layout_marginTop="4dp"
|
android:layout_marginTop="4dp"
|
||||||
android:text=""
|
android:text=""
|
||||||
android:textColor="#FFFFFF"
|
android:textColor="#CC785C"
|
||||||
android:textSize="8sp"
|
android:textSize="8sp"
|
||||||
android:textStyle="bold"
|
|
||||||
android:maxLines="1" />
|
android:maxLines="1" />
|
||||||
|
|
||||||
</LinearLayout>
|
</LinearLayout>
|
||||||
|
|||||||
@@ -1,5 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
|
||||||
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
|
||||||
<background android:drawable="@color/ic_launcher_bg" />
|
|
||||||
<foreground android:drawable="@mipmap/ic_launcher_foreground" />
|
|
||||||
</adaptive-icon>
|
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
|
||||||
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
|
||||||
<background android:drawable="@color/ic_launcher_bg" />
|
|
||||||
<foreground android:drawable="@mipmap/ic_launcher_foreground" />
|
|
||||||
</adaptive-icon>
|
|
||||||
|
Before Width: | Height: | Size: 5.8 KiB |
@@ -0,0 +1,10 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
<background android:drawable="@color/claude_orange" />
|
||||||
|
<foreground>
|
||||||
|
<inset
|
||||||
|
xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:drawable="@drawable/ic_launcher_fg"
|
||||||
|
android:inset="25%" />
|
||||||
|
</foreground>
|
||||||
|
</adaptive-icon>
|
||||||
|
Before Width: | Height: | Size: 21 KiB |
|
Before Width: | Height: | Size: 5.8 KiB |
@@ -0,0 +1,10 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
<background android:drawable="@color/claude_orange" />
|
||||||
|
<foreground>
|
||||||
|
<inset
|
||||||
|
xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:drawable="@drawable/ic_launcher_fg"
|
||||||
|
android:inset="25%" />
|
||||||
|
</foreground>
|
||||||
|
</adaptive-icon>
|
||||||
|
Before Width: | Height: | Size: 3.0 KiB |
@@ -0,0 +1,10 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
<background android:drawable="@color/claude_orange" />
|
||||||
|
<foreground>
|
||||||
|
<inset
|
||||||
|
xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:drawable="@drawable/ic_launcher_fg"
|
||||||
|
android:inset="25%" />
|
||||||
|
</foreground>
|
||||||
|
</adaptive-icon>
|
||||||
|
Before Width: | Height: | Size: 11 KiB |
|
Before Width: | Height: | Size: 3.0 KiB |
@@ -0,0 +1,10 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
<background android:drawable="@color/claude_orange" />
|
||||||
|
<foreground>
|
||||||
|
<inset
|
||||||
|
xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:drawable="@drawable/ic_launcher_fg"
|
||||||
|
android:inset="25%" />
|
||||||
|
</foreground>
|
||||||
|
</adaptive-icon>
|
||||||
|
Before Width: | Height: | Size: 9.1 KiB |
@@ -0,0 +1,10 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
<background android:drawable="@color/claude_orange" />
|
||||||
|
<foreground>
|
||||||
|
<inset
|
||||||
|
xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:drawable="@drawable/ic_launcher_fg"
|
||||||
|
android:inset="25%" />
|
||||||
|
</foreground>
|
||||||
|
</adaptive-icon>
|
||||||
|
Before Width: | Height: | Size: 34 KiB |
|
Before Width: | Height: | Size: 9.1 KiB |
@@ -0,0 +1,10 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
<background android:drawable="@color/claude_orange" />
|
||||||
|
<foreground>
|
||||||
|
<inset
|
||||||
|
xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:drawable="@drawable/ic_launcher_fg"
|
||||||
|
android:inset="25%" />
|
||||||
|
</foreground>
|
||||||
|
</adaptive-icon>
|
||||||
|
Before Width: | Height: | Size: 17 KiB |
@@ -0,0 +1,10 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
<background android:drawable="@color/claude_orange" />
|
||||||
|
<foreground>
|
||||||
|
<inset
|
||||||
|
xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:drawable="@drawable/ic_launcher_fg"
|
||||||
|
android:inset="25%" />
|
||||||
|
</foreground>
|
||||||
|
</adaptive-icon>
|
||||||
|
Before Width: | Height: | Size: 68 KiB |
|
Before Width: | Height: | Size: 17 KiB |
@@ -0,0 +1,10 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
<background android:drawable="@color/claude_orange" />
|
||||||
|
<foreground>
|
||||||
|
<inset
|
||||||
|
xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:drawable="@drawable/ic_launcher_fg"
|
||||||
|
android:inset="25%" />
|
||||||
|
</foreground>
|
||||||
|
</adaptive-icon>
|
||||||
|
Before Width: | Height: | Size: 28 KiB |
@@ -0,0 +1,10 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
<background android:drawable="@color/claude_orange" />
|
||||||
|
<foreground>
|
||||||
|
<inset
|
||||||
|
xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:drawable="@drawable/ic_launcher_fg"
|
||||||
|
android:inset="25%" />
|
||||||
|
</foreground>
|
||||||
|
</adaptive-icon>
|
||||||
|
Before Width: | Height: | Size: 116 KiB |
|
Before Width: | Height: | Size: 28 KiB |
@@ -0,0 +1,10 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
<background android:drawable="@color/claude_orange" />
|
||||||
|
<foreground>
|
||||||
|
<inset
|
||||||
|
xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:drawable="@drawable/ic_launcher_fg"
|
||||||
|
android:inset="25%" />
|
||||||
|
</foreground>
|
||||||
|
</adaptive-icon>
|
||||||
@@ -5,5 +5,4 @@
|
|||||||
<color name="surface_dark">#1E1E1E</color>
|
<color name="surface_dark">#1E1E1E</color>
|
||||||
<color name="text_primary">#FFFFFF</color>
|
<color name="text_primary">#FFFFFF</color>
|
||||||
<color name="text_secondary">#888888</color>
|
<color name="text_secondary">#888888</color>
|
||||||
<color name="ic_launcher_bg">#0B1D27</color>
|
|
||||||
</resources>
|
</resources>
|
||||||
|
|||||||
@@ -1,8 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
|
||||||
<network-security-config>
|
|
||||||
<base-config cleartextTrafficPermitted="false">
|
|
||||||
<trust-anchors>
|
|
||||||
<certificates src="system" />
|
|
||||||
</trust-anchors>
|
|
||||||
</base-config>
|
|
||||||
</network-security-config>
|
|
||||||