feat: Material You colors, settings backup/restore, add LICENSE
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:
2026-06-21 03:24:16 +00:00
parent 7ca0a1db4d
commit 8ae6183901
7 changed files with 575 additions and 0 deletions
+45
View File
@@ -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 {
val SESSION_FILL = ThemeColors.sessionFill(context, prefs)
val WEEKLY_FILL = ThemeColors.weeklyFill(context, prefs)
val v = RemoteViews(context.packageName, R.layout.widget_layout_small)
applyPeak(v, showText = false)
if (!prefs.isLoggedIn()) {
@@ -174,6 +176,8 @@ class ClaudeUsageWidget : AppWidgetProvider() {
}
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)
applyPeak(v, showText = true)
@@ -264,6 +268,8 @@ class ClaudeUsageWidget : AppWidgetProvider() {
* circular [RingRenderer] gauge (percentage drawn in the center) instead of a bar.
*/
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)
applyPeak(v, showText = true)
@@ -30,6 +30,33 @@ class MainActivity : AppCompatActivity() {
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
@@ -67,6 +94,8 @@ class MainActivity : AppCompatActivity() {
setupNotificationSettings()
setupWidgetStyleSetting()
setupMaterialYouSetting()
setupBackupRestore()
binding.btnDebug.setOnClickListener {
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() {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) return
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 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,
* 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_AUTH_FAILS = "auth_fail_count"
private const val KEY_WIDGET_STYLE = "widget_style"
private const val KEY_MATERIAL_YOU = "material_you"
const val STYLE_BARS = "bars"
const val STYLE_RINGS = "rings"
+96
View File
@@ -333,6 +333,48 @@
</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 -->
<LinearLayout
android:layout_width="match_parent"
@@ -375,6 +417,60 @@
</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 &amp; 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
android:id="@+id/btnRefresh"
android:layout_width="match_parent"