v1.14: usage history chart + threshold notifications

Add an in-app 7-day history chart and opt-in usage alerts, the two
features requested from the macOS Claude-Usage-Tracker that map cleanly
to an Android widget app.

History:
- UsageSnapshot model; PreferencesManager records session/weekly
  utilization on every refresh (7-day retention, <=600 points, collapses
  readings under 2 min apart to avoid worker+manual double-logging).
- HistoryChartView: dependency-free Canvas line chart (session/weekly,
  0/50/100% gridlines), breaks the line across >35-min gaps.
- New HISTORY card with chart + legend.

Notifications:
- Notifier posts when session/weekly crosses a user threshold, at most
  once per limit window (keyed on reset-epoch, re-arms on rollover).
- USAGE ALERTS card: enable switch + session/weekly sliders (50-100%,
  defaults 90/85). POST_NOTIFICATIONS permission + runtime request.
- Wired into the existing 5-min background worker.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-04 11:49:47 +00:00
parent ae0f466f50
commit 0520f0dc5e
10 changed files with 523 additions and 2 deletions
@@ -1,9 +1,14 @@
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.launch
import me.khodak.claudeusage.data.PreferencesManager
@@ -19,6 +24,9 @@ class MainActivity : AppCompatActivity() {
private lateinit var prefs: PreferencesManager
private lateinit var repo: UsageRepository
private val notifPermLauncher =
registerForActivityResult(ActivityResultContracts.RequestPermission()) { /* result handled silently */ }
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
binding = ActivityMainBinding.inflate(layoutInflater)
@@ -51,6 +59,8 @@ class MainActivity : AppCompatActivity() {
})
}
setupNotificationSettings()
binding.btnDebug.setOnClickListener {
if (binding.tvDebugInfo.visibility == android.view.View.GONE) {
binding.tvDebugInfo.text = repo.lastDebugInfo.ifBlank { "No debug info yet — tap Refresh first" }
@@ -75,6 +85,54 @@ class MainActivity : AppCompatActivity() {
}
}
private fun setupNotificationSettings() {
binding.switchNotify.isChecked = prefs.isNotifyEnabled()
binding.sliderSession.value = prefs.getSessionThreshold().toFloat().coerceIn(50f, 100f)
binding.sliderWeekly.value = prefs.getWeeklyThreshold().toFloat().coerceIn(50f, 100f)
applyThresholdLabels()
applyNotifyControlsEnabled(prefs.isNotifyEnabled())
binding.switchNotify.setOnCheckedChangeListener { _, checked ->
prefs.setNotifyEnabled(checked)
applyNotifyControlsEnabled(checked)
if (checked) requestNotificationPermission()
}
binding.sliderSession.addOnChangeListener { _, value, _ ->
prefs.setSessionThreshold(value.toInt())
applyThresholdLabels()
}
binding.sliderWeekly.addOnChangeListener { _, value, _ ->
prefs.setWeeklyThreshold(value.toInt())
applyThresholdLabels()
}
// 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()
}
private fun applyThresholdLabels() {
binding.tvSessionThreshLabel.text = "Session alert at ${prefs.getSessionThreshold()}%"
binding.tvWeeklyThreshLabel.text = "Weekly alert at ${prefs.getWeeklyThreshold()}%"
}
private fun applyNotifyControlsEnabled(enabled: Boolean) {
binding.sliderSession.isEnabled = enabled
binding.sliderWeekly.isEnabled = enabled
val alpha = if (enabled) 1f else 0.4f
binding.tvSessionThreshLabel.alpha = alpha
binding.tvWeeklyThreshLabel.alpha = alpha
}
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)
}
}
private fun refreshUsage() {
binding.btnRefresh.isEnabled = false
binding.progressIndicator.visibility = View.VISIBLE
@@ -87,6 +145,8 @@ class MainActivity : AppCompatActivity() {
?: UsageData(errorMessage = "Network error")
}
prefs.saveUsageData(data)
prefs.recordHistory(data)
Notifier.checkAndNotify(this@MainActivity, prefs, data)
updateUI(data)
if (binding.tvDebugInfo.visibility == View.VISIBLE) {
binding.tvDebugInfo.text = repo.lastDebugInfo
@@ -157,6 +217,8 @@ class MainActivity : AppCompatActivity() {
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 {