3dc0448942
Two root causes: - Alarms don't survive reboot — BootReceiver now restarts alarm + triggers an immediate fetch on BOOT_COMPLETED - onUpdate() drew from cached prefs but never fetched fresh data — now triggers an immediate refresh so the widget is live on every launcher redraw Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
258 lines
13 KiB
Kotlin
258 lines
13 KiB
Kotlin
package me.khodak.claudeusage
|
|
|
|
import android.app.PendingIntent
|
|
import android.appwidget.AppWidgetManager
|
|
import android.appwidget.AppWidgetProvider
|
|
import android.content.Context
|
|
import android.content.Intent
|
|
import android.os.Build
|
|
import android.util.SizeF
|
|
import android.widget.RemoteViews
|
|
import me.khodak.claudeusage.data.PreferencesManager
|
|
import me.khodak.claudeusage.data.UsageData
|
|
import java.text.SimpleDateFormat
|
|
import java.util.*
|
|
import java.util.concurrent.TimeUnit
|
|
|
|
class ClaudeUsageWidget : AppWidgetProvider() {
|
|
|
|
override fun onUpdate(context: Context, manager: AppWidgetManager, ids: IntArray) {
|
|
ids.forEach { updateWidget(context, manager, it) }
|
|
if (PreferencesManager(context).isLoggedIn()) {
|
|
UsageUpdateWorker.schedulePeriodicRefresh(context)
|
|
UsageUpdateWorker.triggerImmediateRefresh(context)
|
|
}
|
|
}
|
|
|
|
override fun onReceive(context: Context, intent: Intent) {
|
|
super.onReceive(context, intent)
|
|
if (intent.action == ACTION_REFRESH) {
|
|
val manager = AppWidgetManager.getInstance(context)
|
|
val ids = manager.getAppWidgetIds(
|
|
android.content.ComponentName(context, ClaudeUsageWidget::class.java)
|
|
)
|
|
isRefreshing = true
|
|
ids.forEach { updateWidget(context, manager, it) }
|
|
UsageUpdateWorker.triggerImmediateRefresh(context)
|
|
}
|
|
}
|
|
|
|
companion object {
|
|
const val ACTION_REFRESH = "me.khodak.claudeusage.ACTION_REFRESH"
|
|
@Volatile internal var isRefreshing = false
|
|
@Volatile internal var currentRotation = 0f
|
|
|
|
fun updateWidget(context: Context, manager: AppWidgetManager, widgetId: Int) {
|
|
val prefs = PreferencesManager(context)
|
|
val apiData = prefs.getUsageData()
|
|
val views = responsiveViews(context, prefs, apiData, widgetId)
|
|
manager.updateAppWidget(widgetId, views)
|
|
}
|
|
|
|
fun updateWidget(context: Context, manager: AppWidgetManager, widgetId: Int, data: UsageData?) {
|
|
val prefs = PreferencesManager(context)
|
|
val views = responsiveViews(context, prefs, data, widgetId)
|
|
manager.updateAppWidget(widgetId, views)
|
|
}
|
|
|
|
private fun responsiveViews(
|
|
context: Context, prefs: PreferencesManager, apiData: UsageData?, widgetId: Int
|
|
): RemoteViews {
|
|
val refreshPi = PendingIntent.getBroadcast(
|
|
context, widgetId,
|
|
Intent(context, ClaudeUsageWidget::class.java).apply { action = ACTION_REFRESH },
|
|
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
|
|
)
|
|
val openPi = PendingIntent.getActivity(
|
|
context, widgetId + 1000,
|
|
Intent(context, MainActivity::class.java),
|
|
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
|
|
)
|
|
|
|
fun attach(v: RemoteViews): RemoteViews {
|
|
v.setOnClickPendingIntent(R.id.btn_refresh, refreshPi)
|
|
v.setOnClickPendingIntent(R.id.widget_root, openPi)
|
|
return v
|
|
}
|
|
|
|
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))
|
|
))
|
|
} else {
|
|
attach(buildViews(context, prefs, apiData))
|
|
}
|
|
}
|
|
|
|
private fun buildSmallViews(context: Context, prefs: PreferencesManager, apiData: UsageData?): RemoteViews {
|
|
val v = RemoteViews(context.packageName, R.layout.widget_layout_small)
|
|
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)
|
|
return v
|
|
}
|
|
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.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))
|
|
}
|
|
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.setTextViewText(R.id.tv_session_label, formatReset(apiData.resetAtEpoch))
|
|
}
|
|
sessionStart > 0 -> {
|
|
val elapsedMs = System.currentTimeMillis() - sessionStart
|
|
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.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.setTextViewText(R.id.tv_session_label, "")
|
|
}
|
|
}
|
|
val hasWeekly = apiData != null && apiData.weeklyUtilization >= 0f
|
|
if (hasWeekly) {
|
|
val wPct = apiData!!.weeklyUtilization.toInt()
|
|
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))
|
|
} 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.setTextViewText(R.id.tv_weekly_label, "")
|
|
}
|
|
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 fun buildViews(context: Context, prefs: PreferencesManager, apiData: UsageData?): RemoteViews {
|
|
val v = RemoteViews(context.packageName, R.layout.widget_layout)
|
|
|
|
// ── Not logged in ────────────────────────────────────────────────
|
|
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, "Open app to sign in")
|
|
v.setTextViewText(R.id.tv_status, "")
|
|
v.setProgressBar(R.id.progress_bar, 100, 0, false)
|
|
return v
|
|
}
|
|
|
|
// ── 5-hour window bar ────────────────────────────────────────────
|
|
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.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))
|
|
}
|
|
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.setTextViewText(R.id.tv_session_label, formatReset(apiData.resetAtEpoch))
|
|
}
|
|
sessionStart > 0 -> {
|
|
val elapsedMs = System.currentTimeMillis() - sessionStart
|
|
val h = TimeUnit.MILLISECONDS.toHours(elapsedMs)
|
|
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.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.setTextViewText(R.id.tv_session_label, "")
|
|
}
|
|
}
|
|
|
|
// ── 7-day usage bar ──────────────────────────────────────────────
|
|
val hasWeekly = apiData != null && apiData.weeklyUtilization >= 0f
|
|
if (hasWeekly) {
|
|
val wPct = apiData!!.weeklyUtilization.toInt()
|
|
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))
|
|
} 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.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 fun formatReset(epochMs: Long): String {
|
|
if (epochMs <= 0) return ""
|
|
val now = System.currentTimeMillis()
|
|
if (epochMs <= now) return "Resets soon"
|
|
val diffH = TimeUnit.MILLISECONDS.toHours(epochMs - now)
|
|
val timeStr = SimpleDateFormat("h:mm a", Locale.US).format(Date(epochMs))
|
|
return when {
|
|
diffH < 24 -> "Resets at $timeStr"
|
|
diffH < 48 -> "Resets tomorrow $timeStr"
|
|
else -> "Resets ${SimpleDateFormat("EEE", Locale.US).format(Date(epochMs))} $timeStr"
|
|
}
|
|
}
|
|
|
|
private fun formatTime(ms: Long) =
|
|
SimpleDateFormat("h:mm a", Locale.US).format(Date(ms))
|
|
}
|
|
}
|