From 952c8261e964b81aee09a819a755f0a8ec9256a5 Mon Sep 17 00:00:00 2001 From: "Amir (via Friday)" Date: Fri, 12 Jun 2026 07:45:45 +0000 Subject: [PATCH 1/3] feat: ring widget style (selectable bars/rings) Add RingRenderer (circular gauge mirror of BarRenderer), a widget_layout_rings layout, a Bars/Rings preference + in-app toggle, and ring rendering branch in ClaudeUsageWidget. Full (4x2) widget honors the chosen style; compact size stays bars. Phase 1 of porting hamed-elfayome/Claude-Usage-Tracker features. --- .../khodak/claudeusage/ClaudeUsageWidget.kt | 85 ++++++++- .../me/khodak/claudeusage/MainActivity.kt | 10 ++ .../me/khodak/claudeusage/RingRenderer.kt | 120 +++++++++++++ .../claudeusage/data/PreferencesManager.kt | 10 ++ app/src/main/res/layout/activity_main.xml | 42 +++++ .../main/res/layout/widget_layout_rings.xml | 166 ++++++++++++++++++ 6 files changed, 431 insertions(+), 2 deletions(-) create mode 100644 app/src/main/java/me/khodak/claudeusage/RingRenderer.kt create mode 100644 app/src/main/res/layout/widget_layout_rings.xml 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..3b39631 --- /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 = 240, + strokePx: Float = 24f + ): 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 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + From 10cc064f1f26e66f4f95ab5e6a4ad70d46043f03 Mon Sep 17 00:00:00 2001 From: "Amir (via Friday)" Date: Fri, 12 Jun 2026 08:02:11 +0000 Subject: [PATCH 2/3] fix(rings): make widget rings fill available space + higher render res Rings were capped at a fixed 86dp; now each gauge fills its half of the widget (weighted ImageView) so they grow with the placed widget size. Bumped RingRenderer default bitmap to 360px/36 stroke to stay crisp at the larger display size. --- .../me/khodak/claudeusage/RingRenderer.kt | 4 +-- .../main/res/layout/widget_layout_rings.xml | 25 +++++++++++-------- 2 files changed, 16 insertions(+), 13 deletions(-) diff --git a/app/src/main/java/me/khodak/claudeusage/RingRenderer.kt b/app/src/main/java/me/khodak/claudeusage/RingRenderer.kt index 3b39631..e641042 100644 --- a/app/src/main/java/me/khodak/claudeusage/RingRenderer.kt +++ b/app/src/main/java/me/khodak/claudeusage/RingRenderer.kt @@ -40,8 +40,8 @@ object RingRenderer { markerColor: Int?, centerText: String, labelText: String? = null, - sizePx: Int = 240, - strokePx: Float = 24f + sizePx: Int = 360, + strokePx: Float = 36f ): Bitmap { val bmp = Bitmap.createBitmap(sizePx, sizePx, Bitmap.Config.ARGB_8888) val canvas = Canvas(bmp) diff --git a/app/src/main/res/layout/widget_layout_rings.xml b/app/src/main/res/layout/widget_layout_rings.xml index dc8d42d..9b5d03d 100644 --- a/app/src/main/res/layout/widget_layout_rings.xml +++ b/app/src/main/res/layout/widget_layout_rings.xml @@ -65,7 +65,8 @@ android:layout_marginBottom="6dp" android:background="#2A2A2A" /> - + Date: Fri, 12 Jun 2026 08:13:24 +0000 Subject: [PATCH 3/3] chore: bump to 1.20 (rings release) --- app/build.gradle.kts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) 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")