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) } } 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 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()) 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()) 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)) } }