952c8261e9
Build APK / build (pull_request) Successful in 1m34s
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.
264 lines
11 KiB
Kotlin
264 lines
11 KiB
Kotlin
package me.khodak.claudeusage
|
|
|
|
import android.Manifest
|
|
import android.content.Intent
|
|
import android.content.pm.PackageManager
|
|
import android.os.Build
|
|
import android.os.Bundle
|
|
import android.view.View
|
|
import androidx.activity.result.contract.ActivityResultContracts
|
|
import androidx.appcompat.app.AppCompatActivity
|
|
import androidx.core.content.ContextCompat
|
|
import androidx.lifecycle.lifecycleScope
|
|
import kotlinx.coroutines.Job
|
|
import kotlinx.coroutines.delay
|
|
import kotlinx.coroutines.isActive
|
|
import kotlinx.coroutines.launch
|
|
import me.khodak.claudeusage.data.PreferencesManager
|
|
import me.khodak.claudeusage.data.UsageData
|
|
import me.khodak.claudeusage.databinding.ActivityMainBinding
|
|
import java.text.SimpleDateFormat
|
|
import java.util.*
|
|
import java.util.concurrent.TimeUnit
|
|
|
|
class MainActivity : AppCompatActivity() {
|
|
|
|
private lateinit var binding: ActivityMainBinding
|
|
private lateinit var prefs: PreferencesManager
|
|
private lateinit var repo: UsageRepository
|
|
|
|
private val notifPermLauncher =
|
|
registerForActivityResult(ActivityResultContracts.RequestPermission()) { /* result handled silently */ }
|
|
|
|
/** Live refresh loop that runs only while the app is in the foreground. */
|
|
private var autoRefreshJob: Job? = null
|
|
|
|
override fun onCreate(savedInstanceState: Bundle?) {
|
|
super.onCreate(savedInstanceState)
|
|
binding = ActivityMainBinding.inflate(layoutInflater)
|
|
setContentView(binding.root)
|
|
|
|
prefs = PreferencesManager(this)
|
|
repo = UsageRepository(prefs)
|
|
|
|
binding.btnLogin.setOnClickListener {
|
|
startActivity(Intent(this, LoginActivity::class.java))
|
|
}
|
|
|
|
// If already logged in, go straight to logged-in state without re-opening login
|
|
if (prefs.isLoggedIn()) {
|
|
updateUI(prefs.getUsageData())
|
|
}
|
|
|
|
binding.btnLogout.setOnClickListener {
|
|
prefs.clearSession()
|
|
updateUI(null)
|
|
}
|
|
|
|
binding.btnRefresh.setOnClickListener {
|
|
refreshUsage()
|
|
}
|
|
|
|
binding.tvWidgetHint.setOnClickListener {
|
|
startActivity(Intent(Intent.ACTION_MAIN).apply {
|
|
addCategory(Intent.CATEGORY_HOME)
|
|
})
|
|
}
|
|
|
|
setupNotificationSettings()
|
|
setupWidgetStyleSetting()
|
|
|
|
binding.btnDebug.setOnClickListener {
|
|
if (binding.tvDebugInfo.visibility == android.view.View.GONE) {
|
|
binding.tvDebugInfo.text = repo.lastDebugInfo.ifBlank { "No debug info yet — tap Refresh first" }
|
|
binding.tvDebugInfo.visibility = android.view.View.VISIBLE
|
|
binding.btnDebug.text = "Hide API Debug"
|
|
} else {
|
|
binding.tvDebugInfo.visibility = android.view.View.GONE
|
|
binding.btnDebug.text = "Show API Debug"
|
|
}
|
|
}
|
|
}
|
|
|
|
override fun onResume() {
|
|
super.onResume()
|
|
updateUI(prefs.getUsageData()) // show cached instantly — never a blank screen
|
|
if (prefs.isLoggedIn()) startAutoRefresh()
|
|
}
|
|
|
|
override fun onPause() {
|
|
super.onPause()
|
|
autoRefreshJob?.cancel()
|
|
}
|
|
|
|
/** Refresh immediately on open, then every [REFRESH_INTERVAL_MS] while foregrounded. */
|
|
private fun startAutoRefresh() {
|
|
autoRefreshJob?.cancel()
|
|
autoRefreshJob = lifecycleScope.launch {
|
|
while (isActive) {
|
|
doRefresh(silent = true)
|
|
delay(REFRESH_INTERVAL_MS)
|
|
}
|
|
}
|
|
}
|
|
|
|
private fun setupNotificationSettings() {
|
|
binding.switchNotify.isChecked = prefs.isNotifyEnabled()
|
|
binding.switchNotify.setOnCheckedChangeListener { _, checked ->
|
|
prefs.setNotifyEnabled(checked)
|
|
if (checked) requestNotificationPermission()
|
|
}
|
|
// Alerts default on, so prompt for the runtime permission once on first launch
|
|
// (a user who never toggles the switch would otherwise never be asked).
|
|
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)
|
|
!= PackageManager.PERMISSION_GRANTED
|
|
) {
|
|
notifPermLauncher.launch(Manifest.permission.POST_NOTIFICATIONS)
|
|
}
|
|
}
|
|
|
|
/** Manual "Refresh Now" button — shows the spinner. */
|
|
private fun refreshUsage() {
|
|
lifecycleScope.launch { doRefresh(silent = false) }
|
|
}
|
|
|
|
private suspend fun doRefresh(silent: Boolean) {
|
|
if (!silent) {
|
|
binding.btnRefresh.isEnabled = false
|
|
binding.progressIndicator.visibility = View.VISIBLE
|
|
}
|
|
val fresh = try {
|
|
repo.fetchUsage()
|
|
} catch (e: Exception) {
|
|
if (e is kotlinx.coroutines.CancellationException) throw e
|
|
UsageData(errorMessage = "Network error")
|
|
}
|
|
// Preserve last-good data so a failed/partial fetch never blanks the UI or widget.
|
|
val merged = fresh.mergedWith(prefs.getUsageData())
|
|
prefs.saveUsageData(merged)
|
|
prefs.recordHistory(fresh)
|
|
// Note: alerts fire only from the background worker, not here — no point pinging you
|
|
// with a notification while you're already looking at the app.
|
|
updateUI(merged)
|
|
ClaudeUsageWidget.notifyDataChanged(this) // opening the app refreshes the widget too
|
|
if (binding.tvDebugInfo.visibility == View.VISIBLE) {
|
|
binding.tvDebugInfo.text = repo.lastDebugInfo
|
|
}
|
|
if (!silent) {
|
|
binding.btnRefresh.isEnabled = true
|
|
binding.progressIndicator.visibility = View.GONE
|
|
}
|
|
}
|
|
|
|
private fun updateUI(data: UsageData?) {
|
|
val loggedIn = prefs.isLoggedIn()
|
|
|
|
binding.layoutLoggedOut.visibility = if (loggedIn) View.GONE else View.VISIBLE
|
|
binding.layoutLoggedIn.visibility = if (loggedIn) View.VISIBLE else View.GONE
|
|
|
|
if (!loggedIn || data == null) return
|
|
|
|
// ── 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 — no pace marker ────────────────────────────
|
|
binding.barSession.setImageBitmap(
|
|
BarRenderer.render(data.progressPercent, null, SESSION_FILL, null)
|
|
)
|
|
|
|
// ── Weekly (7-day) bar — single-color pace marker ────────────────────
|
|
if (data.weeklyUtilization >= 0f) {
|
|
val wPct = data.weeklyUtilization.toInt()
|
|
val weeklyPace = PaceCalc.compute(data.weeklyUtilization, data.weeklyResetAtEpoch, PaceCalc.WEEKLY_WINDOW_MS)
|
|
binding.barWeekly.setImageBitmap(
|
|
BarRenderer.render(wPct, weeklyPace?.markerPct, WEEKLY_FILL, if (weeklyPace != null) MARKER_COLOR else null)
|
|
)
|
|
binding.tvWeeklyUsage.text = "$wPct% this week"
|
|
} else {
|
|
binding.barWeekly.setImageBitmap(BarRenderer.render(0, null, WEEKLY_FILL, null))
|
|
binding.tvWeeklyUsage.text = "—"
|
|
}
|
|
|
|
// Pace text removed per design — bars carry the signal.
|
|
binding.tvSessionPace.visibility = View.GONE
|
|
binding.tvWeeklyPace.visibility = View.GONE
|
|
|
|
binding.tvUsage.text = when {
|
|
data.fiveHourUtilization >= 0f -> {
|
|
val pct = data.fiveHourUtilization.toInt()
|
|
"$pct% of limit used"
|
|
}
|
|
data.messagesLimit > 0 && data.effectiveUsed >= 0 ->
|
|
"${data.effectiveUsed} of ${data.messagesLimit} messages used"
|
|
data.effectiveRemaining >= 0 && data.messagesLimit > 0 ->
|
|
"${data.effectiveRemaining} of ${data.messagesLimit} remaining"
|
|
data.effectiveRemaining >= 0 ->
|
|
"${data.effectiveRemaining} messages remaining"
|
|
else -> "Usage data unavailable"
|
|
}
|
|
|
|
binding.tvReset.text = formatReset(data.effectiveResetEpoch)
|
|
binding.tvWeeklyReset.text = formatResetDay(data.weeklyResetAtEpoch)
|
|
binding.tvUpdated.text = if (data.lastUpdated > 0)
|
|
"Last updated: ${SimpleDateFormat("h:mm a, MMM d", Locale.US).format(Date(data.lastUpdated))}"
|
|
else ""
|
|
|
|
binding.tvError.text = data.errorMessage
|
|
binding.tvError.visibility = if (data.errorMessage.isNotBlank()) View.VISIBLE else View.GONE
|
|
|
|
binding.historyChart.setData(prefs.getHistory())
|
|
}
|
|
|
|
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 date = SimpleDateFormat("MMM d", Locale.US).format(Date(epochMs))
|
|
val timeStr = SimpleDateFormat("h:mm a", Locale.US).format(Date(epochMs))
|
|
return "Resets $day, $date · $timeStr"
|
|
}
|
|
|
|
companion object {
|
|
private const val REFRESH_INTERVAL_MS = 30_000L // live refresh cadence while app is open
|
|
private const val SESSION_FILL = 0xFFCC785C.toInt()
|
|
private const val WEEKLY_FILL = 0xFF7B8FCC.toInt()
|
|
private const val MARKER_COLOR = 0xFFFFFFFF.toInt() // single-color weekly pace marker
|
|
private const val PEAK_ON = 0xFFCC785C.toInt()
|
|
private const val PEAK_OFF = 0xFF666666.toInt()
|
|
}
|
|
}
|