diff --git a/README.md b/README.md index e3193b7..deda8ff 100644 --- a/README.md +++ b/README.md @@ -10,6 +10,11 @@ Android home screen widget that shows your Claude Pro usage at a glance. - **SESSION** bar — current 5-hour window utilization 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. - Tap the widget to open the app; tap ⟳ to force-refresh - Responsive: works as 4×1 (compact) or 4×2 (full) - Auto-refreshes every 5 minutes in the background diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 1c9e69a..8b67400 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -11,8 +11,8 @@ android { applicationId = "me.khodak.claudeusage" minSdk = 26 targetSdk = 34 - versionCode = 11 - versionName = "1.10" + versionCode = 12 + versionName = "1.11" } signingConfigs { diff --git a/app/src/main/java/me/khodak/claudeusage/BarRenderer.kt b/app/src/main/java/me/khodak/claudeusage/BarRenderer.kt new file mode 100644 index 0000000..080a1b7 --- /dev/null +++ b/app/src/main/java/me/khodak/claudeusage/BarRenderer.kt @@ -0,0 +1,57 @@ +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, + tierColor: 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 tick — "where you should be right now" + if (markerPct != null && tierColor != null) { + val m = markerPct.coerceIn(0, 100) + val tickW = (wPx * 0.012f).coerceIn(3f, 7f) + var x = wPx * m / 100f + x = x.coerceIn(tickW / 2f, wPx - tickW / 2f) + paint.color = tierColor + canvas.drawRect(x - tickW / 2f, 0f, x + tickW / 2f, hPx.toFloat(), paint) + } + + return bmp + } +} diff --git a/app/src/main/java/me/khodak/claudeusage/ClaudeUsageWidget.kt b/app/src/main/java/me/khodak/claudeusage/ClaudeUsageWidget.kt index 67c2924..b3e1c20 100644 --- a/app/src/main/java/me/khodak/claudeusage/ClaudeUsageWidget.kt +++ b/app/src/main/java/me/khodak/claudeusage/ClaudeUsageWidget.kt @@ -87,14 +87,15 @@ class ClaudeUsageWidget : AppWidgetProvider() { private fun buildSmallViews(context: Context, prefs: PreferencesManager, apiData: UsageData?): RemoteViews { val v = RemoteViews(context.packageName, R.layout.widget_layout_small) + applyPeak(v, showText = false) if (!prefs.isLoggedIn()) { v.setTextViewText(R.id.tv_session_value, "—") v.setTextViewText(R.id.tv_session_label, "Not signed in") v.setTextViewText(R.id.tv_weekly_value, "—") v.setTextViewText(R.id.tv_weekly_label, "") v.setTextViewText(R.id.tv_status, "") - v.setProgressBar(R.id.progress_bar, 100, 0, false) - v.setProgressBar(R.id.progress_bar_weekly, 100, 0, false) + v.setImageViewBitmap(R.id.bar_session, BarRenderer.render(0, null, SESSION_FILL, null)) + v.setImageViewBitmap(R.id.bar_weekly, BarRenderer.render(0, null, WEEKLY_FILL, null)) return v } val hasUtilization = apiData != null && apiData.fiveHourUtilization >= 0f @@ -103,15 +104,17 @@ class ClaudeUsageWidget : AppWidgetProvider() { when { hasUtilization -> { val pct = apiData!!.fiveHourUtilization.toInt() + val pace = PaceCalc.compute(apiData.fiveHourUtilization, apiData.utilizationResetAtEpoch, PaceCalc.SESSION_WINDOW_MS) v.setTextViewText(R.id.tv_session_value, "$pct%") - v.setProgressBar(R.id.progress_bar, 100, pct, false) - v.setTextViewText(R.id.tv_session_label, formatReset(apiData.utilizationResetAtEpoch)) + v.setImageViewBitmap(R.id.bar_session, BarRenderer.render(pct, pace?.markerPct, SESSION_FILL, pace?.tierColor)) + v.setTextViewText(R.id.tv_session_label, + if (pace != null) PaceCalc.shortTag(apiData.fiveHourUtilization, pace) else formatReset(apiData.utilizationResetAtEpoch)) } hasApiMessages -> { val rem = apiData!!.effectiveRemaining val lim = apiData.messagesLimit v.setTextViewText(R.id.tv_session_value, "$rem/$lim") - v.setProgressBar(R.id.progress_bar, 100, apiData.progressPercent, false) + v.setImageViewBitmap(R.id.bar_session, BarRenderer.render(apiData.progressPercent, null, SESSION_FILL, null)) v.setTextViewText(R.id.tv_session_label, formatReset(apiData.resetAtEpoch)) } sessionStart > 0 -> { @@ -119,25 +122,27 @@ class ClaudeUsageWidget : AppWidgetProvider() { val h = TimeUnit.MILLISECONDS.toHours(elapsedMs) 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.setProgressBar(R.id.progress_bar, 100, 0, false) + v.setImageViewBitmap(R.id.bar_session, BarRenderer.render(0, null, SESSION_FILL, null)) v.setTextViewText(R.id.tv_session_label, "active") } else -> { v.setTextViewText(R.id.tv_session_value, "—") - v.setProgressBar(R.id.progress_bar, 100, 0, false) + v.setImageViewBitmap(R.id.bar_session, BarRenderer.render(0, null, SESSION_FILL, null)) v.setTextViewText(R.id.tv_session_label, "") } } 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.setTextViewText(R.id.tv_weekly_value, "$wPct%") - v.setProgressBar(R.id.progress_bar_weekly, 100, wPct, false) - v.setTextViewText(R.id.tv_weekly_label, formatReset(apiData.weeklyResetAtEpoch)) + v.setImageViewBitmap(R.id.bar_weekly, BarRenderer.render(wPct, pace?.markerPct, WEEKLY_FILL, pace?.tierColor)) + v.setTextViewText(R.id.tv_weekly_label, + if (pace != null) PaceCalc.shortTag(apiData.weeklyUtilization, pace) else formatReset(apiData.weeklyResetAtEpoch)) } else { val weeklyDays = Integer.bitCount(prefs.getWeeklyMask()) v.setTextViewText(R.id.tv_weekly_value, "${weeklyDays}d") - v.setProgressBar(R.id.progress_bar_weekly, 100, 0, false) + v.setImageViewBitmap(R.id.bar_weekly, BarRenderer.render(0, null, WEEKLY_FILL, null)) v.setTextViewText(R.id.tv_weekly_label, "") } val resetEpoch = apiData?.effectiveResetEpoch ?: -1L @@ -157,6 +162,7 @@ class ClaudeUsageWidget : AppWidgetProvider() { private fun buildViews(context: Context, prefs: PreferencesManager, apiData: UsageData?): RemoteViews { val v = RemoteViews(context.packageName, R.layout.widget_layout) + applyPeak(v, showText = true) // ── Not logged in ──────────────────────────────────────────────── if (!prefs.isLoggedIn()) { @@ -165,7 +171,8 @@ class ClaudeUsageWidget : AppWidgetProvider() { v.setTextViewText(R.id.tv_weekly_value, "—") v.setTextViewText(R.id.tv_weekly_label, "Open app to sign in") v.setTextViewText(R.id.tv_status, "") - v.setProgressBar(R.id.progress_bar, 100, 0, false) + v.setImageViewBitmap(R.id.bar_session, BarRenderer.render(0, null, SESSION_FILL, null)) + v.setImageViewBitmap(R.id.bar_weekly, BarRenderer.render(0, null, WEEKLY_FILL, null)) return v } @@ -177,15 +184,16 @@ class ClaudeUsageWidget : AppWidgetProvider() { when { hasUtilization -> { val pct = apiData!!.fiveHourUtilization.toInt() + val pace = PaceCalc.compute(apiData.fiveHourUtilization, apiData.utilizationResetAtEpoch, PaceCalc.SESSION_WINDOW_MS) v.setTextViewText(R.id.tv_session_value, "$pct%") - v.setProgressBar(R.id.progress_bar, 100, pct, false) - v.setTextViewText(R.id.tv_session_label, formatReset(apiData.utilizationResetAtEpoch)) + v.setImageViewBitmap(R.id.bar_session, BarRenderer.render(pct, pace?.markerPct, SESSION_FILL, pace?.tierColor)) + v.setTextViewText(R.id.tv_session_label, resetWithPace(apiData.utilizationResetAtEpoch, apiData.fiveHourUtilization, pace)) } hasApiMessages -> { val rem = apiData!!.effectiveRemaining val lim = apiData.messagesLimit v.setTextViewText(R.id.tv_session_value, "$rem / $lim") - v.setProgressBar(R.id.progress_bar, 100, apiData.progressPercent, false) + v.setImageViewBitmap(R.id.bar_session, BarRenderer.render(apiData.progressPercent, null, SESSION_FILL, null)) v.setTextViewText(R.id.tv_session_label, formatReset(apiData.resetAtEpoch)) } sessionStart > 0 -> { @@ -194,12 +202,12 @@ class ClaudeUsageWidget : AppWidgetProvider() { 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" v.setTextViewText(R.id.tv_session_value, display) - v.setProgressBar(R.id.progress_bar, 100, 0, false) + v.setImageViewBitmap(R.id.bar_session, BarRenderer.render(0, null, SESSION_FILL, null)) v.setTextViewText(R.id.tv_session_label, "session active") } else -> { v.setTextViewText(R.id.tv_session_value, "—") - v.setProgressBar(R.id.progress_bar, 100, 0, false) + v.setImageViewBitmap(R.id.bar_session, BarRenderer.render(0, null, SESSION_FILL, null)) v.setTextViewText(R.id.tv_session_label, "") } } @@ -208,13 +216,14 @@ class ClaudeUsageWidget : AppWidgetProvider() { 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.setTextViewText(R.id.tv_weekly_value, "$wPct%") - v.setProgressBar(R.id.progress_bar_weekly, 100, wPct, false) - v.setTextViewText(R.id.tv_weekly_label, formatReset(apiData.weeklyResetAtEpoch)) + v.setImageViewBitmap(R.id.bar_weekly, BarRenderer.render(wPct, pace?.markerPct, WEEKLY_FILL, pace?.tierColor)) + v.setTextViewText(R.id.tv_weekly_label, resetWithPace(apiData.weeklyResetAtEpoch, apiData.weeklyUtilization, pace)) } else { val weeklyDays = Integer.bitCount(prefs.getWeeklyMask()) v.setTextViewText(R.id.tv_weekly_value, "$weeklyDays d") - v.setProgressBar(R.id.progress_bar_weekly, 100, 0, false) + v.setImageViewBitmap(R.id.bar_weekly, BarRenderer.render(0, null, WEEKLY_FILL, null)) v.setTextViewText(R.id.tv_weekly_label, "active this week") } @@ -238,6 +247,27 @@ class ClaudeUsageWidget : AppWidgetProvider() { return v } + private const val SESSION_FILL = 0xFFCC785C.toInt() + private const val WEEKLY_FILL = 0xFF7B8FCC.toInt() + + /** 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 "") + } + } + + /** "Resets at 3:00 PM · 8% under pace" — reset line with the pace tag appended. */ + private fun resetWithPace(resetEpoch: Long, usedPct: Float, pace: PaceCalc.Pace?): CharSequence { + val reset = formatReset(resetEpoch) + if (pace == null) return reset + val tag = PaceCalc.shortTag(usedPct, pace) + return if (reset.isBlank()) tag else "$reset · $tag" + } + private fun formatReset(epochMs: Long): String { if (epochMs <= 0) return "" val now = System.currentTimeMillis() diff --git a/app/src/main/java/me/khodak/claudeusage/MainActivity.kt b/app/src/main/java/me/khodak/claudeusage/MainActivity.kt index b9dc91f..7ec81cc 100644 --- a/app/src/main/java/me/khodak/claudeusage/MainActivity.kt +++ b/app/src/main/java/me/khodak/claudeusage/MainActivity.kt @@ -104,15 +104,38 @@ class MainActivity : AppCompatActivity() { if (!loggedIn || data == null) return - binding.progressBar.progress = data.progressPercent + // ── Peak-hours row ─────────────────────────────────────────────────── + 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 + pace ────────────────────────────────────── + val sessionPct = data.progressPercent + val sessionPace = if (data.fiveHourUtilization >= 0f) + PaceCalc.compute(data.fiveHourUtilization, data.utilizationResetAtEpoch, PaceCalc.SESSION_WINDOW_MS) + else null + binding.barSession.setImageBitmap( + BarRenderer.render(sessionPct, sessionPace?.markerPct, SESSION_FILL, sessionPace?.tierColor) + ) + binding.tvSessionPace.text = paceSentence(data.fiveHourUtilization, sessionPace) + + // ── Weekly (7-day) bar + pace ──────────────────────────────────────── if (data.weeklyUtilization >= 0f) { val wPct = data.weeklyUtilization.toInt() - binding.progressBarWeekly.progress = wPct + val weeklyPace = PaceCalc.compute(data.weeklyUtilization, data.weeklyResetAtEpoch, PaceCalc.WEEKLY_WINDOW_MS) + binding.barWeekly.setImageBitmap( + BarRenderer.render(wPct, weeklyPace?.markerPct, WEEKLY_FILL, weeklyPace?.tierColor) + ) binding.tvWeeklyUsage.text = "$wPct% this week" + binding.tvWeeklyPace.text = paceSentence(data.weeklyUtilization, weeklyPace) } else { - binding.progressBarWeekly.progress = 0 + binding.barWeekly.setImageBitmap(BarRenderer.render(0, null, WEEKLY_FILL, null)) binding.tvWeeklyUsage.text = "—" + binding.tvWeeklyPace.text = "" } binding.tvUsage.text = when { @@ -139,6 +162,12 @@ class MainActivity : AppCompatActivity() { binding.tvError.visibility = if (data.errorMessage.isNotBlank()) View.VISIBLE else View.GONE } + /** "20% over pace · will likely hit limit" — empty when no projection is available yet. */ + private fun paceSentence(usedPct: Float, pace: PaceCalc.Pace?): String { + if (pace == null) return "" + return "${PaceCalc.shortTag(usedPct, pace)} · ${pace.label}" + } + private fun formatReset(epochMs: Long): String { if (epochMs <= 0) return "" val now = System.currentTimeMillis() @@ -151,4 +180,11 @@ class MainActivity : AppCompatActivity() { else -> "Resets ${SimpleDateFormat("EEE", Locale.US).format(Date(epochMs))} $timeStr" } } + + companion object { + private const val SESSION_FILL = 0xFFCC785C.toInt() + private const val WEEKLY_FILL = 0xFF7B8FCC.toInt() + private const val PEAK_ON = 0xFFCC785C.toInt() + private const val PEAK_OFF = 0xFF666666.toInt() + } } diff --git a/app/src/main/java/me/khodak/claudeusage/PaceCalc.kt b/app/src/main/java/me/khodak/claudeusage/PaceCalc.kt new file mode 100644 index 0000000..4f3dfa3 --- /dev/null +++ b/app/src/main/java/me/khodak/claudeusage/PaceCalc.kt @@ -0,0 +1,75 @@ +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) + } + + /** Short tag for the widget reset line, e.g. "8% under pace" / "20% over pace" / "on pace". */ + fun shortTag(usedPct: Float, pace: Pace): String { + val delta = usedPct.toInt() - pace.markerPct + return when { + delta >= 2 -> "$delta% over pace" + delta <= -2 -> "${-delta}% under pace" + else -> "on pace" + } + } +} diff --git a/app/src/main/java/me/khodak/claudeusage/PeakHours.kt b/app/src/main/java/me/khodak/claudeusage/PeakHours.kt new file mode 100644 index 0000000..cffa8a4 --- /dev/null +++ b/app/src/main/java/me/khodak/claudeusage/PeakHours.kt @@ -0,0 +1,80 @@ +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 + } +} diff --git a/app/src/main/res/drawable/ic_claude_burst.xml b/app/src/main/res/drawable/ic_claude_burst.xml new file mode 100644 index 0000000..d70d468 --- /dev/null +++ b/app/src/main/res/drawable/ic_claude_burst.xml @@ -0,0 +1,29 @@ + + + + + + diff --git a/app/src/main/res/layout/activity_main.xml b/app/src/main/res/layout/activity_main.xml index eb22b44..03e26dd 100644 --- a/app/src/main/res/layout/activity_main.xml +++ b/app/src/main/res/layout/activity_main.xml @@ -92,6 +92,33 @@ android:background="@drawable/widget_background" android:padding="20dp"> + + + + + + + + + - + android:scaleType="fitXY" + android:adjustViewBounds="false" + android:contentDescription="Session usage bar" /> + + - + android:scaleType="fitXY" + android:adjustViewBounds="false" + android:contentDescription="Weekly usage bar" /> + + + + + + - + android:scaleType="fitXY" + android:adjustViewBounds="false" + android:contentDescription="Session usage bar" /> - + android:scaleType="fitXY" + android:adjustViewBounds="false" + android:contentDescription="Weekly usage bar" /> + + - + android:scaleType="fitXY" + android:adjustViewBounds="false" + android:contentDescription="Session usage bar" /> - + android:scaleType="fitXY" + android:adjustViewBounds="false" + android:contentDescription="Weekly usage bar" />