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() } }