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) 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.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 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.setImageViewBitmap(R.id.bar_session, BarRenderer.render(pct, null, SESSION_FILL, null)) 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.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 -> { 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.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.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.setImageViewBitmap(R.id.bar_weekly, BarRenderer.render(wPct, pace?.markerPct, WEEKLY_FILL, if (pace != null) MARKER_COLOR else null)) v.setTextViewText(R.id.tv_weekly_label, formatResetDay(apiData.weeklyResetAtEpoch)) } else { val weeklyDays = Integer.bitCount(prefs.getWeeklyMask()) v.setTextViewText(R.id.tv_weekly_value, "${weeklyDays}d") 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 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) applyPeak(v, showText = true) // ── 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.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 } // ── 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.setImageViewBitmap(R.id.bar_session, BarRenderer.render(pct, null, SESSION_FILL, null)) 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.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 -> { 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.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.setImageViewBitmap(R.id.bar_session, BarRenderer.render(0, null, SESSION_FILL, null)) 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() val pace = PaceCalc.compute(apiData.weeklyUtilization, apiData.weeklyResetAtEpoch, PaceCalc.WEEKLY_WINDOW_MS) 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.setTextViewText(R.id.tv_weekly_label, formatResetDay(apiData.weeklyResetAtEpoch)) } else { val weeklyDays = Integer.bitCount(prefs.getWeeklyMask()) v.setTextViewText(R.id.tv_weekly_value, "$weeklyDays d") v.setImageViewBitmap(R.id.bar_weekly, BarRenderer.render(0, null, WEEKLY_FILL, null)) 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) /** 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 { 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" } } /** 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 timeStr = SimpleDateFormat("h:mm a", Locale.US).format(Date(epochMs)) return "Resets $day $timeStr" } private fun formatTime(ms: Long) = SimpleDateFormat("h:mm a", Locale.US).format(Date(ms)) } }