diff --git a/app/build.gradle.kts b/app/build.gradle.kts
index e03353e..a99bf86 100644
--- a/app/build.gradle.kts
+++ b/app/build.gradle.kts
@@ -24,8 +24,8 @@ android {
applicationId = "me.khodak.claudeusage"
minSdk = 26
targetSdk = 34
- versionCode = 20
- versionName = "1.19"
+ versionCode = 21
+ versionName = "1.20"
}
val releaseStorePassword = signingCred("KEYSTORE_PASSWORD", "storePassword")
diff --git a/app/src/main/java/me/khodak/claudeusage/ClaudeUsageWidget.kt b/app/src/main/java/me/khodak/claudeusage/ClaudeUsageWidget.kt
index 6a4464d..d7aee78 100644
--- a/app/src/main/java/me/khodak/claudeusage/ClaudeUsageWidget.kt
+++ b/app/src/main/java/me/khodak/claudeusage/ClaudeUsageWidget.kt
@@ -84,13 +84,20 @@ class ClaudeUsageWidget : AppWidgetProvider() {
return v
}
+ // Full-size builder follows the user's chosen style; the compact (1-cell-tall) size
+ // always uses bars, since two rings don't fit that height.
+ val rings = prefs.getWidgetStyle() == PreferencesManager.STYLE_RINGS
+ fun buildFull() =
+ if (rings) buildRingViews(context, prefs, apiData)
+ else buildViews(context, prefs, apiData)
+
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
RemoteViews(mapOf(
SizeF(50f, 50f) to attach(buildSmallViews(context, prefs, apiData)),
- SizeF(50f, 120f) to attach(buildViews(context, prefs, apiData))
+ SizeF(50f, 120f) to attach(buildFull())
))
} else {
- attach(buildViews(context, prefs, apiData))
+ attach(buildFull())
}
}
@@ -252,6 +259,80 @@ class ClaudeUsageWidget : AppWidgetProvider() {
return v
}
+ /**
+ * Ring style of the full widget — same data/branching as [buildViews] but each metric is a
+ * circular [RingRenderer] gauge (percentage drawn in the center) instead of a bar.
+ */
+ private fun buildRingViews(context: Context, prefs: PreferencesManager, apiData: UsageData?): RemoteViews {
+ val v = RemoteViews(context.packageName, R.layout.widget_layout_rings)
+ applyPeak(v, showText = true)
+
+ if (!prefs.isLoggedIn()) {
+ v.setImageViewBitmap(R.id.ring_session, RingRenderer.render(0, null, SESSION_FILL, null, "—", "SESSION"))
+ v.setImageViewBitmap(R.id.ring_weekly, RingRenderer.render(0, null, WEEKLY_FILL, null, "—", "WEEKLY"))
+ v.setTextViewText(R.id.tv_session_label, "Not signed in")
+ v.setTextViewText(R.id.tv_weekly_label, "Open app to sign in")
+ v.setTextViewText(R.id.tv_status, "")
+ return v
+ }
+
+ // ── 5-hour session ring ──────────────────────────────────────────
+ val hasUtilization = apiData != null && apiData.fiveHourUtilization >= 0f
+ val hasApiMessages = apiData != null && apiData.effectiveRemaining >= 0 && apiData.messagesLimit > 0
+ val sessionStart = prefs.getSessionStart()
+ when {
+ hasUtilization -> {
+ val pct = apiData!!.fiveHourUtilization.toInt()
+ v.setImageViewBitmap(R.id.ring_session, RingRenderer.render(pct, null, SESSION_FILL, null, "$pct%", "SESSION"))
+ v.setTextViewText(R.id.tv_session_label, formatReset(apiData.utilizationResetAtEpoch))
+ }
+ hasApiMessages -> {
+ val pct = apiData!!.progressPercent
+ v.setImageViewBitmap(R.id.ring_session, RingRenderer.render(pct, null, SESSION_FILL, null, "$pct%", "SESSION"))
+ v.setTextViewText(R.id.tv_session_label, formatReset(apiData.resetAtEpoch))
+ }
+ sessionStart > 0 -> {
+ v.setImageViewBitmap(R.id.ring_session, RingRenderer.render(0, null, SESSION_FILL, null, "·", "SESSION"))
+ v.setTextViewText(R.id.tv_session_label, "session active")
+ }
+ else -> {
+ v.setImageViewBitmap(R.id.ring_session, RingRenderer.render(0, null, SESSION_FILL, null, "—", "SESSION"))
+ v.setTextViewText(R.id.tv_session_label, "")
+ }
+ }
+
+ // ── 7-day weekly ring (with pace tick) ───────────────────────────
+ val hasWeekly = apiData != null && apiData.weeklyUtilization >= 0f
+ if (hasWeekly) {
+ val wPct = apiData!!.weeklyUtilization.toInt()
+ val pace = PaceCalc.compute(apiData.weeklyUtilization, apiData.weeklyResetAtEpoch, PaceCalc.WEEKLY_WINDOW_MS)
+ v.setImageViewBitmap(
+ R.id.ring_weekly,
+ RingRenderer.render(wPct, pace?.markerPct, WEEKLY_FILL, if (pace != null) MARKER_COLOR else null, "$wPct%", "WEEKLY")
+ )
+ v.setTextViewText(R.id.tv_weekly_label, formatResetDay(apiData.weeklyResetAtEpoch))
+ } else {
+ val weeklyDays = Integer.bitCount(prefs.getWeeklyMask())
+ v.setImageViewBitmap(R.id.ring_weekly, RingRenderer.render(0, null, WEEKLY_FILL, null, "${weeklyDays}d", "WEEKLY"))
+ v.setTextViewText(R.id.tv_weekly_label, "active this week")
+ }
+
+ // ── Footer ───────────────────────────────────────────────────────
+ val resetEpoch = apiData?.effectiveResetEpoch ?: -1L
+ val status = when {
+ isRefreshing -> "Refreshing…"
+ apiData?.isRateLimited == true -> "Rate limited · ${formatReset(resetEpoch)}"
+ else -> ""
+ }
+ val updatedMs = apiData?.lastUpdated ?: 0L
+ v.setTextViewText(R.id.tv_status,
+ if (status.isNotBlank()) status else if (updatedMs > 0) formatTime(updatedMs) else "")
+ v.setInt(R.id.btn_refresh, "setColorFilter",
+ if (isRefreshing) 0xFFCC785C.toInt() else 0xFF999999.toInt())
+ v.setFloat(R.id.btn_refresh, "setRotation", currentRotation)
+ 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)
diff --git a/app/src/main/java/me/khodak/claudeusage/MainActivity.kt b/app/src/main/java/me/khodak/claudeusage/MainActivity.kt
index 912329f..23059eb 100644
--- a/app/src/main/java/me/khodak/claudeusage/MainActivity.kt
+++ b/app/src/main/java/me/khodak/claudeusage/MainActivity.kt
@@ -66,6 +66,7 @@ class MainActivity : AppCompatActivity() {
}
setupNotificationSettings()
+ setupWidgetStyleSetting()
binding.btnDebug.setOnClickListener {
if (binding.tvDebugInfo.visibility == android.view.View.GONE) {
@@ -112,6 +113,15 @@ class MainActivity : AppCompatActivity() {
if (prefs.isNotifyEnabled()) requestNotificationPermission()
}
+ /** Bars ⇄ Rings switch for the home-screen widget; redraws any placed widgets immediately. */
+ private fun setupWidgetStyleSetting() {
+ binding.switchRingStyle.isChecked = prefs.getWidgetStyle() == PreferencesManager.STYLE_RINGS
+ binding.switchRingStyle.setOnCheckedChangeListener { _, checked ->
+ prefs.setWidgetStyle(if (checked) PreferencesManager.STYLE_RINGS else PreferencesManager.STYLE_BARS)
+ ClaudeUsageWidget.notifyDataChanged(this)
+ }
+ }
+
private fun requestNotificationPermission() {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) return
if (ContextCompat.checkSelfPermission(this, Manifest.permission.POST_NOTIFICATIONS)
diff --git a/app/src/main/java/me/khodak/claudeusage/RingRenderer.kt b/app/src/main/java/me/khodak/claudeusage/RingRenderer.kt
new file mode 100644
index 0000000..e641042
--- /dev/null
+++ b/app/src/main/java/me/khodak/claudeusage/RingRenderer.kt
@@ -0,0 +1,120 @@
+package me.khodak.claudeusage
+
+import android.graphics.Bitmap
+import android.graphics.Canvas
+import android.graphics.Paint
+import android.graphics.RectF
+import android.graphics.Typeface
+import kotlin.math.cos
+import kotlin.math.sin
+
+/**
+ * Draws a usage gauge as a circular ring Bitmap: rounded track ring + colored progress arc
+ * (sweeping clockwise from 12 o'clock) + an optional radial pace tick + center percentage and
+ * label. The ring counterpart to [BarRenderer] — same Bitmap contract, so it drops into both the
+ * home-screen widget (setImageViewBitmap) and the in-app card and renders identically.
+ *
+ * Design mirrors hamed-elfayome/Claude-Usage-Tracker's ring gauges, using this app's existing
+ * palette (SESSION_FILL clay / WEEKLY_FILL periwinkle, #252525 track, white pace marker).
+ */
+object RingRenderer {
+
+ private const val TRACK_COLOR = 0xFF252525.toInt()
+ private const val CENTER_TEXT_COLOR = 0xFFFFFFFF.toInt()
+ private const val LABEL_TEXT_COLOR = 0xFF999999.toInt()
+
+ /**
+ * @param usedPct 0..100 fill of the ring
+ * @param markerPct where you "should be" right now (pace), or null for no tick
+ * @param fillColor ARGB progress-arc color (e.g. SESSION_FILL / WEEKLY_FILL)
+ * @param markerColor ARGB pace-tick color, or null
+ * @param centerText big text in the middle, e.g. "47%" (caller formats it)
+ * @param labelText small caption under the number, e.g. "SESSION" (null hides it)
+ * @param sizePx square bitmap edge; ring scales to fit
+ * @param strokePx ring thickness
+ */
+ fun render(
+ usedPct: Int,
+ markerPct: Int?,
+ fillColor: Int,
+ markerColor: Int?,
+ centerText: String,
+ labelText: String? = null,
+ sizePx: Int = 360,
+ strokePx: Float = 36f
+ ): Bitmap {
+ val bmp = Bitmap.createBitmap(sizePx, sizePx, Bitmap.Config.ARGB_8888)
+ val canvas = Canvas(bmp)
+ val cx = sizePx / 2f
+ val cy = sizePx / 2f
+ val pad = strokePx / 2f + 2f
+ val arcRect = RectF(pad, pad, sizePx - pad, sizePx - pad)
+ val radius = (sizePx - strokePx) / 2f - 2f
+
+ // Track ring (full circle).
+ val track = Paint(Paint.ANTI_ALIAS_FLAG).apply {
+ style = Paint.Style.STROKE
+ strokeWidth = strokePx
+ strokeCap = Paint.Cap.ROUND
+ color = TRACK_COLOR
+ }
+ canvas.drawArc(arcRect, 0f, 360f, false, track)
+
+ // Progress arc — clockwise from 12 o'clock (-90°).
+ val pct = usedPct.coerceIn(0, 100)
+ if (pct > 0) {
+ val fill = Paint(Paint.ANTI_ALIAS_FLAG).apply {
+ style = Paint.Style.STROKE
+ strokeWidth = strokePx
+ strokeCap = Paint.Cap.ROUND
+ color = fillColor
+ }
+ canvas.drawArc(arcRect, -90f, pct / 100f * 360f, false, fill)
+ }
+
+ // Pace tick — a short radial mark across the ring at the "should be here" angle.
+ if (markerPct != null && markerColor != null) {
+ val m = markerPct.coerceIn(0, 100)
+ val ang = Math.toRadians((-90f + m / 100f * 360f).toDouble())
+ val rInner = radius - strokePx / 2f - 1f
+ val rOuter = radius + strokePx / 2f + 1f
+ val tick = Paint(Paint.ANTI_ALIAS_FLAG).apply {
+ style = Paint.Style.STROKE
+ strokeWidth = strokePx * 0.18f
+ strokeCap = Paint.Cap.ROUND
+ color = markerColor
+ }
+ canvas.drawLine(
+ cx + (rInner * cos(ang)).toFloat(), cy + (rInner * sin(ang)).toFloat(),
+ cx + (rOuter * cos(ang)).toFloat(), cy + (rOuter * sin(ang)).toFloat(),
+ tick
+ )
+ }
+
+ // Center percentage.
+ val hasLabel = !labelText.isNullOrEmpty()
+ val valuePaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
+ color = CENTER_TEXT_COLOR
+ textAlign = Paint.Align.CENTER
+ textSize = sizePx * 0.27f
+ typeface = Typeface.create(Typeface.DEFAULT, Typeface.BOLD)
+ }
+ val vfm = valuePaint.fontMetrics
+ val valueBaselineShift = -(vfm.ascent + vfm.descent) / 2f
+ val valueY = cy + valueBaselineShift - (if (hasLabel) sizePx * 0.07f else 0f)
+ canvas.drawText(centerText, cx, valueY, valuePaint)
+
+ if (hasLabel) {
+ val labelPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
+ color = LABEL_TEXT_COLOR
+ textAlign = Paint.Align.CENTER
+ textSize = sizePx * 0.105f
+ letterSpacing = 0.12f
+ typeface = Typeface.create(Typeface.DEFAULT, Typeface.BOLD)
+ }
+ canvas.drawText(labelText!!.uppercase(), cx, cy + sizePx * 0.17f, labelPaint)
+ }
+
+ return bmp
+ }
+}
diff --git a/app/src/main/java/me/khodak/claudeusage/data/PreferencesManager.kt b/app/src/main/java/me/khodak/claudeusage/data/PreferencesManager.kt
index c851271..947eab3 100644
--- a/app/src/main/java/me/khodak/claudeusage/data/PreferencesManager.kt
+++ b/app/src/main/java/me/khodak/claudeusage/data/PreferencesManager.kt
@@ -132,6 +132,12 @@ class PreferencesManager(context: Context) {
} catch (e: Exception) { emptyList() }
}
+ // ── Widget style ─────────────────────────────────────────────────────────
+
+ /** Home-screen widget visual style: [STYLE_BARS] (default) or [STYLE_RINGS]. */
+ fun getWidgetStyle(): String = prefs.getString(KEY_WIDGET_STYLE, STYLE_BARS) ?: STYLE_BARS
+ fun setWidgetStyle(style: String) = prefs.edit().putString(KEY_WIDGET_STYLE, style).apply()
+
// ── Notification settings ────────────────────────────────────────────────
fun isNotifyEnabled(): Boolean = prefs.getBoolean(KEY_NOTIFY_ENABLED, true)
@@ -155,6 +161,10 @@ class PreferencesManager(context: Context) {
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 KEY_WIDGET_STYLE = "widget_style"
+
+ const val STYLE_BARS = "bars"
+ const val STYLE_RINGS = "rings"
internal 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
diff --git a/app/src/main/res/layout/activity_main.xml b/app/src/main/res/layout/activity_main.xml
index ce99b8a..55af20e 100644
--- a/app/src/main/res/layout/activity_main.xml
+++ b/app/src/main/res/layout/activity_main.xml
@@ -291,6 +291,48 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+