From 8ae6183901b0b505ca41e7f8b915852d20aafa8b Mon Sep 17 00:00:00 2001 From: Amir Khodak Date: Sun, 21 Jun 2026 03:24:16 +0000 Subject: [PATCH] feat: Material You colors, settings backup/restore, add LICENSE - 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. --- LICENSE | 45 +++ .../khodak/claudeusage/ClaudeUsageWidget.kt | 6 + .../me/khodak/claudeusage/MainActivity.kt | 51 +++ .../MainActivity.kt.tmp.283970.ad8df417b08b | 310 ++++++++++++++++++ .../java/me/khodak/claudeusage/ThemeColors.kt | 30 ++ .../claudeusage/data/PreferencesManager.kt | 37 +++ app/src/main/res/layout/activity_main.xml | 96 ++++++ 7 files changed, 575 insertions(+) create mode 100644 LICENSE create mode 100644 app/src/main/java/me/khodak/claudeusage/MainActivity.kt.tmp.283970.ad8df417b08b create mode 100644 app/src/main/java/me/khodak/claudeusage/ThemeColors.kt diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..42f768a --- /dev/null +++ b/LICENSE @@ -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. diff --git a/app/src/main/java/me/khodak/claudeusage/ClaudeUsageWidget.kt b/app/src/main/java/me/khodak/claudeusage/ClaudeUsageWidget.kt index d7aee78..49a6b46 100644 --- a/app/src/main/java/me/khodak/claudeusage/ClaudeUsageWidget.kt +++ b/app/src/main/java/me/khodak/claudeusage/ClaudeUsageWidget.kt @@ -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) diff --git a/app/src/main/java/me/khodak/claudeusage/MainActivity.kt b/app/src/main/java/me/khodak/claudeusage/MainActivity.kt index 23059eb..a8ad3ce 100644 --- a/app/src/main/java/me/khodak/claudeusage/MainActivity.kt +++ b/app/src/main/java/me/khodak/claudeusage/MainActivity.kt @@ -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) diff --git a/app/src/main/java/me/khodak/claudeusage/MainActivity.kt.tmp.283970.ad8df417b08b b/app/src/main/java/me/khodak/claudeusage/MainActivity.kt.tmp.283970.ad8df417b08b new file mode 100644 index 0000000..a6adf19 --- /dev/null +++ b/app/src/main/java/me/khodak/claudeusage/MainActivity.kt.tmp.283970.ad8df417b08b @@ -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() + } +} diff --git a/app/src/main/java/me/khodak/claudeusage/ThemeColors.kt b/app/src/main/java/me/khodak/claudeusage/ThemeColors.kt new file mode 100644 index 0000000..db16b85 --- /dev/null +++ b/app/src/main/java/me/khodak/claudeusage/ThemeColors.kt @@ -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 +} diff --git a/app/src/main/java/me/khodak/claudeusage/data/PreferencesManager.kt b/app/src/main/java/me/khodak/claudeusage/data/PreferencesManager.kt index 947eab3..8c8ae23 100644 --- a/app/src/main/java/me/khodak/claudeusage/data/PreferencesManager.kt +++ b/app/src/main/java/me/khodak/claudeusage/data/PreferencesManager.kt @@ -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 + 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" diff --git a/app/src/main/res/layout/activity_main.xml b/app/src/main/res/layout/activity_main.xml index 55af20e..e29196f 100644 --- a/app/src/main/res/layout/activity_main.xml +++ b/app/src/main/res/layout/activity_main.xml @@ -333,6 +333,48 @@ + + + + + + + + + + + + + + + + + + + + + + + + +