feat: Material You colors, settings backup/restore, add LICENSE
Build APK / build (push) Successful in 1m33s
Build APK / build (push) Successful in 1m33s
- Material You toggle: bars/rings use wallpaper-derived system accents on Android 12+ (ThemeColors.sessionFill/weeklyFill), Claude brand colors otherwise - Settings backup/restore via SAF (export/import JSON); session cookie never included so backups are safe to store anywhere - Add source-available LICENSE (mirrors SyncFlow's), with Anthropic trademark note Built + installed on emulator, launches clean.
This commit is contained in:
@@ -0,0 +1,45 @@
|
|||||||
|
Claude Usage Widget Source-Available License
|
||||||
|
Copyright (c) 2026 Amir Khodak. All rights reserved.
|
||||||
|
|
||||||
|
This is NOT an OSI-approved open-source license. The source code is made
|
||||||
|
publicly viewable ("source-available"), but the rights granted are limited as
|
||||||
|
described below. Where this license is silent, all rights are reserved.
|
||||||
|
|
||||||
|
1. DEFINITIONS
|
||||||
|
"Software" means the Claude Usage Widget source code, assets, and
|
||||||
|
documentation in this repository. "You" means anyone other than the
|
||||||
|
copyright holder.
|
||||||
|
|
||||||
|
2. WHAT YOU MAY DO
|
||||||
|
a. View, read, and study the Software.
|
||||||
|
b. Clone or fork the repository for your own private, personal,
|
||||||
|
non-commercial use and experimentation.
|
||||||
|
c. Build the Software from source and run it on devices you personally own.
|
||||||
|
d. Submit contributions (pull requests) back to this repository; by doing so
|
||||||
|
you license your contribution to the copyright holder under these terms.
|
||||||
|
|
||||||
|
3. WHAT YOU MAY NOT DO (without the copyright holder's prior written permission)
|
||||||
|
a. Redistribute, publish, or make available the Software or any derivative
|
||||||
|
work — in source or binary/APK form — to any third party or app store
|
||||||
|
(including but not limited to Google Play, F-Droid, Amazon Appstore,
|
||||||
|
Gitea/GitHub releases, or any website).
|
||||||
|
b. Use the Software, in whole or in part, for any commercial purpose.
|
||||||
|
c. Sell, sublicense, rent, or offer the Software as a service.
|
||||||
|
d. Use the names, app identity ("Claude Usage Widget"), package identifier
|
||||||
|
("me.khodak.claudeusage"), logos, or signing keys of the original work.
|
||||||
|
e. Remove or alter this license or the copyright notice.
|
||||||
|
|
||||||
|
4. RESERVED RIGHTS
|
||||||
|
All publishing and distribution rights are reserved exclusively to the
|
||||||
|
copyright holder. Only the copyright holder may publish official builds.
|
||||||
|
|
||||||
|
5. TRADEMARK NOTE
|
||||||
|
"Claude" is a trademark of Anthropic. This is an unofficial, independent
|
||||||
|
utility and is not affiliated with or endorsed by Anthropic.
|
||||||
|
|
||||||
|
6. NO WARRANTY
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
IMPLIED. IN NO EVENT SHALL THE COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM,
|
||||||
|
DAMAGES, OR OTHER LIABILITY ARISING FROM THE SOFTWARE OR ITS USE.
|
||||||
|
|
||||||
|
To request permission for anything in section 3, contact the copyright holder.
|
||||||
@@ -102,6 +102,8 @@ class ClaudeUsageWidget : AppWidgetProvider() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private fun buildSmallViews(context: Context, prefs: PreferencesManager, apiData: UsageData?): RemoteViews {
|
private fun buildSmallViews(context: Context, prefs: PreferencesManager, apiData: UsageData?): RemoteViews {
|
||||||
|
val SESSION_FILL = ThemeColors.sessionFill(context, prefs)
|
||||||
|
val WEEKLY_FILL = ThemeColors.weeklyFill(context, prefs)
|
||||||
val v = RemoteViews(context.packageName, R.layout.widget_layout_small)
|
val v = RemoteViews(context.packageName, R.layout.widget_layout_small)
|
||||||
applyPeak(v, showText = false)
|
applyPeak(v, showText = false)
|
||||||
if (!prefs.isLoggedIn()) {
|
if (!prefs.isLoggedIn()) {
|
||||||
@@ -174,6 +176,8 @@ class ClaudeUsageWidget : AppWidgetProvider() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private fun buildViews(context: Context, prefs: PreferencesManager, apiData: UsageData?): RemoteViews {
|
private fun buildViews(context: Context, prefs: PreferencesManager, apiData: UsageData?): RemoteViews {
|
||||||
|
val SESSION_FILL = ThemeColors.sessionFill(context, prefs)
|
||||||
|
val WEEKLY_FILL = ThemeColors.weeklyFill(context, prefs)
|
||||||
val v = RemoteViews(context.packageName, R.layout.widget_layout)
|
val v = RemoteViews(context.packageName, R.layout.widget_layout)
|
||||||
applyPeak(v, showText = true)
|
applyPeak(v, showText = true)
|
||||||
|
|
||||||
@@ -264,6 +268,8 @@ class ClaudeUsageWidget : AppWidgetProvider() {
|
|||||||
* circular [RingRenderer] gauge (percentage drawn in the center) instead of a bar.
|
* circular [RingRenderer] gauge (percentage drawn in the center) instead of a bar.
|
||||||
*/
|
*/
|
||||||
private fun buildRingViews(context: Context, prefs: PreferencesManager, apiData: UsageData?): RemoteViews {
|
private fun buildRingViews(context: Context, prefs: PreferencesManager, apiData: UsageData?): RemoteViews {
|
||||||
|
val SESSION_FILL = ThemeColors.sessionFill(context, prefs)
|
||||||
|
val WEEKLY_FILL = ThemeColors.weeklyFill(context, prefs)
|
||||||
val v = RemoteViews(context.packageName, R.layout.widget_layout_rings)
|
val v = RemoteViews(context.packageName, R.layout.widget_layout_rings)
|
||||||
applyPeak(v, showText = true)
|
applyPeak(v, showText = true)
|
||||||
|
|
||||||
|
|||||||
@@ -30,6 +30,33 @@ class MainActivity : AppCompatActivity() {
|
|||||||
private val notifPermLauncher =
|
private val notifPermLauncher =
|
||||||
registerForActivityResult(ActivityResultContracts.RequestPermission()) { /* result handled silently */ }
|
registerForActivityResult(ActivityResultContracts.RequestPermission()) { /* result handled silently */ }
|
||||||
|
|
||||||
|
/** SAF: pick a destination file and write the current settings JSON to it. */
|
||||||
|
private val exportLauncher =
|
||||||
|
registerForActivityResult(ActivityResultContracts.CreateDocument("application/json")) { uri ->
|
||||||
|
if (uri == null) return@registerForActivityResult
|
||||||
|
try {
|
||||||
|
contentResolver.openOutputStream(uri)?.use { it.write(prefs.exportSettings().toByteArray()) }
|
||||||
|
toast("Settings exported")
|
||||||
|
} catch (_: Exception) { toast("Export failed") }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** SAF: pick a backup file and apply it. */
|
||||||
|
private val importLauncher =
|
||||||
|
registerForActivityResult(ActivityResultContracts.OpenDocument()) { uri ->
|
||||||
|
if (uri == null) return@registerForActivityResult
|
||||||
|
try {
|
||||||
|
val json = contentResolver.openInputStream(uri)?.bufferedReader()?.use { it.readText() } ?: ""
|
||||||
|
if (prefs.importSettings(json)) {
|
||||||
|
toast("Settings restored")
|
||||||
|
// Reflect restored prefs immediately: re-sync switches and redraw any widgets.
|
||||||
|
binding.switchNotify.isChecked = prefs.isNotifyEnabled()
|
||||||
|
binding.switchRingStyle.isChecked = prefs.getWidgetStyle() == PreferencesManager.STYLE_RINGS
|
||||||
|
binding.switchMaterialYou.isChecked = prefs.isMaterialYou()
|
||||||
|
ClaudeUsageWidget.notifyDataChanged(this)
|
||||||
|
} else toast("Not a valid settings file")
|
||||||
|
} catch (_: Exception) { toast("Import failed") }
|
||||||
|
}
|
||||||
|
|
||||||
/** Live refresh loop that runs only while the app is in the foreground. */
|
/** Live refresh loop that runs only while the app is in the foreground. */
|
||||||
private var autoRefreshJob: Job? = null
|
private var autoRefreshJob: Job? = null
|
||||||
|
|
||||||
@@ -67,6 +94,8 @@ class MainActivity : AppCompatActivity() {
|
|||||||
|
|
||||||
setupNotificationSettings()
|
setupNotificationSettings()
|
||||||
setupWidgetStyleSetting()
|
setupWidgetStyleSetting()
|
||||||
|
setupMaterialYouSetting()
|
||||||
|
setupBackupRestore()
|
||||||
|
|
||||||
binding.btnDebug.setOnClickListener {
|
binding.btnDebug.setOnClickListener {
|
||||||
if (binding.tvDebugInfo.visibility == android.view.View.GONE) {
|
if (binding.tvDebugInfo.visibility == android.view.View.GONE) {
|
||||||
@@ -122,6 +151,28 @@ class MainActivity : AppCompatActivity() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Material You toggle — switches the bars/rings to wallpaper-derived accents (Android 12+). */
|
||||||
|
private fun setupMaterialYouSetting() {
|
||||||
|
binding.switchMaterialYou.isChecked = prefs.isMaterialYou()
|
||||||
|
binding.switchMaterialYou.setOnCheckedChangeListener { _, checked ->
|
||||||
|
prefs.setMaterialYou(checked)
|
||||||
|
ClaudeUsageWidget.notifyDataChanged(this)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Export/Import settings via the Storage Access Framework (no extra permissions needed). */
|
||||||
|
private fun setupBackupRestore() {
|
||||||
|
binding.btnExportSettings.setOnClickListener {
|
||||||
|
exportLauncher.launch("claude-usage-settings.json")
|
||||||
|
}
|
||||||
|
binding.btnImportSettings.setOnClickListener {
|
||||||
|
importLauncher.launch(arrayOf("application/json", "text/*", "*/*"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun toast(msg: String) =
|
||||||
|
android.widget.Toast.makeText(this, msg, android.widget.Toast.LENGTH_SHORT).show()
|
||||||
|
|
||||||
private fun requestNotificationPermission() {
|
private fun requestNotificationPermission() {
|
||||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) return
|
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) return
|
||||||
if (ContextCompat.checkSelfPermission(this, Manifest.permission.POST_NOTIFICATIONS)
|
if (ContextCompat.checkSelfPermission(this, Manifest.permission.POST_NOTIFICATIONS)
|
||||||
|
|||||||
@@ -0,0 +1,310 @@
|
|||||||
|
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 */ }
|
||||||
|
|
||||||
|
/** SAF: pick a destination file and write the current settings JSON to it. */
|
||||||
|
private val exportLauncher =
|
||||||
|
registerForActivityResult(ActivityResultContracts.CreateDocument("application/json")) { uri ->
|
||||||
|
if (uri == null) return@registerForActivityResult
|
||||||
|
try {
|
||||||
|
contentResolver.openOutputStream(uri)?.use { it.write(prefs.exportSettings().toByteArray()) }
|
||||||
|
toast("Settings exported")
|
||||||
|
} catch (_: Exception) { toast("Export failed") }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** SAF: pick a backup file and apply it. */
|
||||||
|
private val importLauncher =
|
||||||
|
registerForActivityResult(ActivityResultContracts.OpenDocument()) { uri ->
|
||||||
|
if (uri == null) return@registerForActivityResult
|
||||||
|
try {
|
||||||
|
val json = contentResolver.openInputStream(uri)?.bufferedReader()?.use { it.readText() } ?: ""
|
||||||
|
if (prefs.importSettings(json)) {
|
||||||
|
toast("Settings restored")
|
||||||
|
// Reflect restored prefs immediately: re-sync switches and redraw any widgets.
|
||||||
|
binding.switchNotify.isChecked = prefs.isNotifyEnabled()
|
||||||
|
binding.switchRingStyle.isChecked = prefs.getWidgetStyle() == PreferencesManager.STYLE_RINGS
|
||||||
|
binding.switchMaterialYou.isChecked = prefs.isMaterialYou()
|
||||||
|
ClaudeUsageWidget.notifyDataChanged(this)
|
||||||
|
} else toast("Not a valid settings file")
|
||||||
|
} catch (_: Exception) { toast("Import failed") }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 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()
|
||||||
|
setupMaterialYouSetting()
|
||||||
|
setupBackupRestore()
|
||||||
|
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Material You toggle — redraws placed widgets so the new accent applies immediately. */
|
||||||
|
private fun setupMaterialYouSetting() {
|
||||||
|
binding.switchMaterialYou.isChecked = prefs.isMaterialYou()
|
||||||
|
binding.switchMaterialYou.setOnCheckedChangeListener { _, checked ->
|
||||||
|
prefs.setMaterialYou(checked)
|
||||||
|
ClaudeUsageWidget.notifyDataChanged(this)
|
||||||
|
updateUI(prefs.getUsageData()) // refresh the in-app bars too
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun setupBackupRestore() {
|
||||||
|
binding.btnExportSettings.setOnClickListener { exportLauncher.launch("claude-usage-settings.json") }
|
||||||
|
binding.btnImportSettings.setOnClickListener { importLauncher.launch(arrayOf("application/json", "text/plain", "*/*")) }
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun toast(msg: String) =
|
||||||
|
android.widget.Toast.makeText(this, msg, android.widget.Toast.LENGTH_SHORT).show()
|
||||||
|
|
||||||
|
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()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
package me.khodak.claudeusage
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.os.Build
|
||||||
|
import me.khodak.claudeusage.data.PreferencesManager
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Central source of the two accent colors used by the bars/rings (session + weekly).
|
||||||
|
*
|
||||||
|
* Default = the Claude brand palette. When the user enables Material You *and* the device is
|
||||||
|
* Android 12+ (where the wallpaper-derived `system_accent*` palette exists), the fills switch to
|
||||||
|
* the dynamic system accents so the widget matches the rest of the home screen. On older devices
|
||||||
|
* the toggle is a no-op and the brand colors are always used.
|
||||||
|
*/
|
||||||
|
object ThemeColors {
|
||||||
|
|
||||||
|
const val SESSION_DEFAULT = 0xFFCC785C.toInt() // Claude orange
|
||||||
|
const val WEEKLY_DEFAULT = 0xFF7B8FCC.toInt() // Claude periwinkle
|
||||||
|
|
||||||
|
private fun dynamicAvailable(prefs: PreferencesManager): Boolean =
|
||||||
|
prefs.isMaterialYou() && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S
|
||||||
|
|
||||||
|
fun sessionFill(context: Context, prefs: PreferencesManager): Int =
|
||||||
|
if (dynamicAvailable(prefs)) context.getColor(android.R.color.system_accent1_300)
|
||||||
|
else SESSION_DEFAULT
|
||||||
|
|
||||||
|
fun weeklyFill(context: Context, prefs: PreferencesManager): Int =
|
||||||
|
if (dynamicAvailable(prefs)) context.getColor(android.R.color.system_accent2_300)
|
||||||
|
else WEEKLY_DEFAULT
|
||||||
|
}
|
||||||
@@ -143,6 +143,42 @@ class PreferencesManager(context: Context) {
|
|||||||
fun isNotifyEnabled(): Boolean = prefs.getBoolean(KEY_NOTIFY_ENABLED, true)
|
fun isNotifyEnabled(): Boolean = prefs.getBoolean(KEY_NOTIFY_ENABLED, true)
|
||||||
fun setNotifyEnabled(v: Boolean) = prefs.edit().putBoolean(KEY_NOTIFY_ENABLED, v).apply()
|
fun setNotifyEnabled(v: Boolean) = prefs.edit().putBoolean(KEY_NOTIFY_ENABLED, v).apply()
|
||||||
|
|
||||||
|
// ── Material You (dynamic color) ─────────────────────────────────────────
|
||||||
|
|
||||||
|
/** When on (and Android 12+), bars/rings use the wallpaper-derived system accents. */
|
||||||
|
fun isMaterialYou(): Boolean = prefs.getBoolean(KEY_MATERIAL_YOU, false)
|
||||||
|
fun setMaterialYou(v: Boolean) = prefs.edit().putBoolean(KEY_MATERIAL_YOU, v).apply()
|
||||||
|
|
||||||
|
// ── Settings backup / restore ────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Serialize the user-tunable settings (NOT the session cookie or usage data) to JSON, so a
|
||||||
|
* reinstall doesn't lose preferences. Cookies are deliberately excluded — they're sensitive and
|
||||||
|
* device-bound, so a backup file stays safe to store anywhere.
|
||||||
|
*/
|
||||||
|
fun exportSettings(): String = gson.toJson(
|
||||||
|
mapOf(
|
||||||
|
"version" to 1,
|
||||||
|
KEY_NOTIFY_ENABLED to isNotifyEnabled(),
|
||||||
|
KEY_WIDGET_STYLE to getWidgetStyle(),
|
||||||
|
KEY_MATERIAL_YOU to isMaterialYou()
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
/** Apply a settings JSON produced by [exportSettings]. Returns true if it parsed and applied. */
|
||||||
|
fun importSettings(json: String): Boolean = try {
|
||||||
|
@Suppress("UNCHECKED_CAST")
|
||||||
|
val map = gson.fromJson(json, Map::class.java) as Map<String, Any?>
|
||||||
|
val e = prefs.edit()
|
||||||
|
(map[KEY_NOTIFY_ENABLED] as? Boolean)?.let { e.putBoolean(KEY_NOTIFY_ENABLED, it) }
|
||||||
|
(map[KEY_MATERIAL_YOU] as? Boolean)?.let { e.putBoolean(KEY_MATERIAL_YOU, it) }
|
||||||
|
(map[KEY_WIDGET_STYLE] as? String)?.let {
|
||||||
|
if (it == STYLE_BARS || it == STYLE_RINGS) e.putString(KEY_WIDGET_STYLE, it)
|
||||||
|
}
|
||||||
|
e.apply()
|
||||||
|
true
|
||||||
|
} catch (_: Exception) { false }
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Per-level "already alerted" flag (e.g. "session_90"). Set when the level is crossed,
|
* Per-level "already alerted" flag (e.g. "session_90"). Set when the level is crossed,
|
||||||
* cleared when usage drops back below it — so each level fires once per window.
|
* cleared when usage drops back below it — so each level fires once per window.
|
||||||
@@ -162,6 +198,7 @@ class PreferencesManager(context: Context) {
|
|||||||
private const val KEY_NOTIFY_ENABLED = "notify_enabled"
|
private const val KEY_NOTIFY_ENABLED = "notify_enabled"
|
||||||
private const val KEY_AUTH_FAILS = "auth_fail_count"
|
private const val KEY_AUTH_FAILS = "auth_fail_count"
|
||||||
private const val KEY_WIDGET_STYLE = "widget_style"
|
private const val KEY_WIDGET_STYLE = "widget_style"
|
||||||
|
private const val KEY_MATERIAL_YOU = "material_you"
|
||||||
|
|
||||||
const val STYLE_BARS = "bars"
|
const val STYLE_BARS = "bars"
|
||||||
const val STYLE_RINGS = "rings"
|
const val STYLE_RINGS = "rings"
|
||||||
|
|||||||
@@ -333,6 +333,48 @@
|
|||||||
|
|
||||||
</LinearLayout>
|
</LinearLayout>
|
||||||
|
|
||||||
|
<!-- Material You card -->
|
||||||
|
<LinearLayout
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:orientation="vertical"
|
||||||
|
android:background="@drawable/widget_background"
|
||||||
|
android:padding="20dp"
|
||||||
|
android:layout_marginTop="16dp">
|
||||||
|
|
||||||
|
<LinearLayout
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:orientation="horizontal"
|
||||||
|
android:gravity="center_vertical">
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:layout_width="0dp"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_weight="1"
|
||||||
|
android:text="MATERIAL YOU COLORS"
|
||||||
|
android:textColor="#888888"
|
||||||
|
android:textSize="11sp"
|
||||||
|
android:letterSpacing="0.1" />
|
||||||
|
|
||||||
|
<com.google.android.material.switchmaterial.SwitchMaterial
|
||||||
|
android:id="@+id/switchMaterialYou"
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content" />
|
||||||
|
|
||||||
|
</LinearLayout>
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginTop="12dp"
|
||||||
|
android:text="Use your wallpaper-derived system accent colors for the bars/rings (Android 12+). Off = Claude brand colors."
|
||||||
|
android:textColor="#AAAAAA"
|
||||||
|
android:textSize="13sp"
|
||||||
|
android:lineSpacingExtra="3dp" />
|
||||||
|
|
||||||
|
</LinearLayout>
|
||||||
|
|
||||||
<!-- Notifications card -->
|
<!-- Notifications card -->
|
||||||
<LinearLayout
|
<LinearLayout
|
||||||
android:layout_width="match_parent"
|
android:layout_width="match_parent"
|
||||||
@@ -375,6 +417,60 @@
|
|||||||
|
|
||||||
</LinearLayout>
|
</LinearLayout>
|
||||||
|
|
||||||
|
<!-- Backup & Restore card -->
|
||||||
|
<LinearLayout
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:orientation="vertical"
|
||||||
|
android:background="@drawable/widget_background"
|
||||||
|
android:padding="20dp"
|
||||||
|
android:layout_marginTop="16dp">
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:text="BACKUP & RESTORE"
|
||||||
|
android:textColor="#888888"
|
||||||
|
android:textSize="11sp"
|
||||||
|
android:letterSpacing="0.1" />
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginTop="12dp"
|
||||||
|
android:text="Save your settings (widget style, Material You, alerts) to a file so a reinstall doesn't lose them. The Claude session cookie is never included."
|
||||||
|
android:textColor="#AAAAAA"
|
||||||
|
android:textSize="13sp"
|
||||||
|
android:lineSpacingExtra="3dp" />
|
||||||
|
|
||||||
|
<LinearLayout
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:orientation="horizontal"
|
||||||
|
android:layout_marginTop="14dp">
|
||||||
|
|
||||||
|
<Button
|
||||||
|
android:id="@+id/btnExportSettings"
|
||||||
|
android:layout_width="0dp"
|
||||||
|
android:layout_weight="1"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginEnd="6dp"
|
||||||
|
android:text="Export"
|
||||||
|
style="?attr/materialButtonOutlinedStyle" />
|
||||||
|
|
||||||
|
<Button
|
||||||
|
android:id="@+id/btnImportSettings"
|
||||||
|
android:layout_width="0dp"
|
||||||
|
android:layout_weight="1"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginStart="6dp"
|
||||||
|
android:text="Import"
|
||||||
|
style="?attr/materialButtonOutlinedStyle" />
|
||||||
|
|
||||||
|
</LinearLayout>
|
||||||
|
|
||||||
|
</LinearLayout>
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
android:id="@+id/btnRefresh"
|
android:id="@+id/btnRefresh"
|
||||||
android:layout_width="match_parent"
|
android:layout_width="match_parent"
|
||||||
|
|||||||
Reference in New Issue
Block a user