diff --git a/README.md b/README.md
index def06d7..7703b10 100644
--- a/README.md
+++ b/README.md
@@ -1,2 +1,2 @@
# Claude-Counter-Android
-Android app to track Claude usage limits in real-time (Session & Weekly) 📊
+Android app to track Claude usage limits in real-time (Session, Weekly & Sonnet) 📊
diff --git a/app/build.gradle.kts b/app/build.gradle.kts
index 62ce6ef..0750cc7 100644
--- a/app/build.gradle.kts
+++ b/app/build.gradle.kts
@@ -37,6 +37,7 @@ android {
buildFeatures {
compose = true
}
+
}
dependencies {
@@ -52,6 +53,8 @@ dependencies {
implementation(libs.androidx.lifecycle.viewmodel.compose)
implementation(libs.androidx.lifecycle.runtime.compose)
testImplementation(libs.junit)
+ testImplementation(libs.json)
+
androidTestImplementation(libs.androidx.junit)
androidTestImplementation(libs.androidx.espresso.core)
androidTestImplementation(platform(libs.androidx.compose.bom))
diff --git a/app/src/main/java/com/example/claudecounter/ClaudeApiService.kt b/app/src/main/java/com/example/claudecounter/ClaudeApiService.kt
index 6644027..fe19492 100644
--- a/app/src/main/java/com/example/claudecounter/ClaudeApiService.kt
+++ b/app/src/main/java/com/example/claudecounter/ClaudeApiService.kt
@@ -24,7 +24,8 @@ object ClaudeApiService {
data class UsageData(
val fiveHour: UsageWindow?,
- val sevenDay: UsageWindow?
+ val sevenDay: UsageWindow?,
+ val sevenDaySonnet: UsageWindow?
)
data class UsageResult(
@@ -80,14 +81,15 @@ object ClaudeApiService {
* Parses the raw JSON from the usage endpoint into [UsageData].
* Handles various possible JSON structures from Claude's API.
*/
- private fun parseUsageData(json: JSONObject): UsageData {
+ internal fun parseUsageData(json: JSONObject): UsageData {
// API returns "five_hour" and "seven_day" objects
val fiveHour = parseWindow(json, "five_hour")
val sevenDay = parseWindow(json, "seven_day")
- return UsageData(fiveHour = fiveHour, sevenDay = sevenDay)
+ val sevenDaySonnet = parseWindow(json, "seven_day_sonnet")
+ return UsageData(fiveHour = fiveHour, sevenDay = sevenDay, sevenDaySonnet = sevenDaySonnet)
}
- private fun parseWindow(json: JSONObject, key: String): UsageWindow? {
+ internal fun parseWindow(json: JSONObject, key: String): UsageWindow? {
val obj = json.optJSONObject(key) ?: return null
// "utilization" might be a direct percentage field
val utilization = obj.optDouble("utilization", -1.0).takeIf { it >= 0 }
@@ -98,7 +100,7 @@ object ClaudeApiService {
if (limit > 0) (used / limit * 100.0) else 0.0
}
val resetsAt = obj.optString("resets_at", "")
- return if (resetsAt.isNotBlank() || utilization >= 0) {
+ return if (resetsAt.isNotBlank() || utilization > 0.0) {
UsageWindow(utilization = utilization, resetsAt = resetsAt)
} else null
}
diff --git a/app/src/main/java/com/example/claudecounter/MainActivity.kt b/app/src/main/java/com/example/claudecounter/MainActivity.kt
index b3c5a96..e4564a8 100644
--- a/app/src/main/java/com/example/claudecounter/MainActivity.kt
+++ b/app/src/main/java/com/example/claudecounter/MainActivity.kt
@@ -99,6 +99,7 @@ private val AccentPurple = Color(0xFF7C5CFC)
private val AccentBlue = Color(0xFF4A90D9)
private val SessionColor = Color(0xFF7C5CFC)
private val WeeklyColor = Color(0xFF4A90D9)
+private val SonnetColor = Color(0xFFD97706)
private val WarningColor = Color(0xFFFF6B6B)
private val TextPrimary = Color(0xFFF0EEF8)
private val TextSecondary = Color(0xFF9B99AE)
@@ -242,6 +243,18 @@ private fun MainScreen(
accentColor = WeeklyColor
)
+ // Sonnet Card
+ if (usageState.sonnetResetsAt > 0L || usageState.sonnetUtilization > 0.0) {
+ UsageCard(
+ title = "Sonnet",
+ subtitle = "7-day window",
+ utilization = usageState.sonnetUtilization,
+ resetsAtMs = usageState.sonnetResetsAt,
+ now = now,
+ accentColor = SonnetColor
+ )
+ }
+
Spacer(Modifier.weight(1f))
// Last updated
diff --git a/app/src/main/java/com/example/claudecounter/SessionManager.kt b/app/src/main/java/com/example/claudecounter/SessionManager.kt
index 3c02323..b6b3d94 100644
--- a/app/src/main/java/com/example/claudecounter/SessionManager.kt
+++ b/app/src/main/java/com/example/claudecounter/SessionManager.kt
@@ -30,6 +30,9 @@ class SessionManager private constructor(context: Context) {
// 7-day window
val weeklyUtilization: Double = 0.0,
val weeklyResetsAt: Long = 0L,
+ // 7-day sonnet window
+ val sonnetUtilization: Double = 0.0,
+ val sonnetResetsAt: Long = 0L,
val lastFetchTime: Long = 0L
) {
val isLoggedIn: Boolean get() = orgId != null && sessionCookie != null
@@ -72,6 +75,8 @@ class SessionManager private constructor(context: Context) {
private set
var lastWeeklyResetsAt: Long = prefs.getLong(KEY_WEEKLY_RESETS_AT, 0L)
private set
+ var lastSonnetResetsAt: Long = prefs.getLong(KEY_SONNET_RESETS_AT, 0L)
+ private set
fun updateUsage(data: ClaudeApiService.UsageData) {
val sessionUtil = data.fiveHour?.utilization ?: _usageState.value.sessionUtilization
@@ -80,25 +85,30 @@ class SessionManager private constructor(context: Context) {
val weeklyUtil = data.sevenDay?.utilization ?: _usageState.value.weeklyUtilization
val weeklyResets = data.sevenDay?.resetsAt?.let { parseIso8601(it) }
?: _usageState.value.weeklyResetsAt
+ val sonnetUtil = data.sevenDaySonnet?.utilization ?: _usageState.value.sonnetUtilization
+ val sonnetResets = data.sevenDaySonnet?.resetsAt?.let { parseIso8601(it) }
+ ?: _usageState.value.sonnetResetsAt
val now = System.currentTimeMillis()
-
// Remember previous for rollover detection
lastSessionResetsAt = _usageState.value.sessionResetsAt
lastWeeklyResetsAt = _usageState.value.weeklyResetsAt
-
+ lastSonnetResetsAt = _usageState.value.sonnetResetsAt
prefs.edit()
.putFloat(KEY_SESSION_UTIL, sessionUtil.toFloat())
.putLong(KEY_SESSION_RESETS_AT, sessionResets)
.putFloat(KEY_WEEKLY_UTIL, weeklyUtil.toFloat())
.putLong(KEY_WEEKLY_RESETS_AT, weeklyResets)
+ .putFloat(KEY_SONNET_UTIL, sonnetUtil.toFloat())
+ .putLong(KEY_SONNET_RESETS_AT, sonnetResets)
.putLong(KEY_LAST_FETCH_TIME, now)
.apply()
-
_usageState.value = _usageState.value.copy(
sessionUtilization = sessionUtil,
sessionResetsAt = sessionResets,
weeklyUtilization = weeklyUtil,
weeklyResetsAt = weeklyResets,
+ sonnetUtilization = sonnetUtil,
+ sonnetResetsAt = sonnetResets,
lastFetchTime = now
)
}
@@ -112,6 +122,8 @@ class SessionManager private constructor(context: Context) {
sessionResetsAt = prefs.getLong(KEY_SESSION_RESETS_AT, 0L),
weeklyUtilization = prefs.getFloat(KEY_WEEKLY_UTIL, 0f).toDouble(),
weeklyResetsAt = prefs.getLong(KEY_WEEKLY_RESETS_AT, 0L),
+ sonnetUtilization = prefs.getFloat(KEY_SONNET_UTIL, 0f).toDouble(),
+ sonnetResetsAt = prefs.getLong(KEY_SONNET_RESETS_AT, 0L),
lastFetchTime = prefs.getLong(KEY_LAST_FETCH_TIME, 0L)
)
@@ -124,6 +136,8 @@ class SessionManager private constructor(context: Context) {
private const val KEY_WEEKLY_UTIL = "weekly_util"
private const val KEY_WEEKLY_RESETS_AT = "weekly_resets_at"
private const val KEY_LAST_FETCH_TIME = "last_fetch_time"
+ private const val KEY_SONNET_UTIL = "sonnet_util"
+ private const val KEY_SONNET_RESETS_AT = "sonnet_resets_at"
fun parseIso8601(text: String): Long = try {
java.time.OffsetDateTime.parse(text).toInstant().toEpochMilli()
diff --git a/app/src/main/java/com/example/claudecounter/UsagePollingService.kt b/app/src/main/java/com/example/claudecounter/UsagePollingService.kt
index a308f72..7e1271e 100644
--- a/app/src/main/java/com/example/claudecounter/UsagePollingService.kt
+++ b/app/src/main/java/com/example/claudecounter/UsagePollingService.kt
@@ -128,6 +128,18 @@ class UsagePollingService : Service() {
val weeklyTime = data.sevenDay?.resetsAt?.let { formatCountdown(it) } ?: ""
customView.setTextViewText(R.id.weekly_time, weeklyTime)
+ // Sonnet row
+ val hasSonnet = data.sevenDaySonnet != null && (data.sevenDaySonnet.resetsAt.isNotBlank() || data.sevenDaySonnet.utilization > 0.0)
+ if (hasSonnet) {
+ customView.setViewVisibility(R.id.sonnet_row, android.view.View.VISIBLE)
+ val sonnetPct = data.sevenDaySonnet!!.utilization
+ customView.setProgressBar(R.id.sonnet_progress, 1000, (sonnetPct * 10).toInt(), false)
+ customView.setTextViewText(R.id.sonnet_pct, "${"%.0f".format(sonnetPct)}%")
+ val sonnetTime = data.sevenDaySonnet.resetsAt.let { formatCountdown(it) }
+ customView.setTextViewText(R.id.sonnet_time, sonnetTime)
+ } else {
+ customView.setViewVisibility(R.id.sonnet_row, android.view.View.GONE)
+ }
builder.setCustomContentView(customView)
.setCustomBigContentView(customView)
.setStyle(NotificationCompat.DecoratedCustomViewStyle())
diff --git a/app/src/main/res/drawable/notif_progress_sonnet.xml b/app/src/main/res/drawable/notif_progress_sonnet.xml
new file mode 100644
index 0000000..575a996
--- /dev/null
+++ b/app/src/main/res/drawable/notif_progress_sonnet.xml
@@ -0,0 +1,17 @@
+
+
+ -
+
+
+
+
+
+ -
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/app/src/main/res/layout/notification_usage.xml b/app/src/main/res/layout/notification_usage.xml
index 4091a99..969a58a 100644
--- a/app/src/main/res/layout/notification_usage.xml
+++ b/app/src/main/res/layout/notification_usage.xml
@@ -24,4 +24,16 @@
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/src/test/java/com/example/claudecounter/ClaudeApiServiceParsingTest.kt b/app/src/test/java/com/example/claudecounter/ClaudeApiServiceParsingTest.kt
new file mode 100644
index 0000000..3c46977
--- /dev/null
+++ b/app/src/test/java/com/example/claudecounter/ClaudeApiServiceParsingTest.kt
@@ -0,0 +1,69 @@
+package com.example.claudecounter
+
+import org.json.JSONObject
+import org.junit.Assert.*
+import org.junit.Test
+
+class ClaudeApiServiceParsingTest {
+
+ @Test
+ fun testParseAllWindowsPresent() {
+ val jsonString = """{"five_hour": {"utilization": 25.5, "resets_at": "2026-02-25T12:00:00Z"}, "seven_day": {"utilization": 45.0, "resets_at": "2026-03-04T00:00:00Z"}, "seven_day_sonnet": {"utilization": 10.0, "resets_at": "2026-03-04T00:00:00Z"}}"""
+ val json = JSONObject(jsonString)
+
+ val result = ClaudeApiService.parseUsageData(json)
+
+ assertNotNull("five_hour should be parsed", result.fiveHour)
+ assertEquals("five_hour utilization", 25.5, result.fiveHour!!.utilization, 0.01)
+ assertEquals("five_hour resets_at", "2026-02-25T12:00:00Z", result.fiveHour!!.resetsAt)
+
+ assertNotNull("seven_day should be parsed", result.sevenDay)
+ assertEquals("seven_day utilization", 45.0, result.sevenDay!!.utilization, 0.01)
+ assertEquals("seven_day resets_at", "2026-03-04T00:00:00Z", result.sevenDay!!.resetsAt)
+
+ assertNotNull("seven_day_sonnet should be parsed", result.sevenDaySonnet)
+ assertEquals("seven_day_sonnet utilization", 10.0, result.sevenDaySonnet!!.utilization, 0.01)
+ assertEquals("seven_day_sonnet resets_at", "2026-03-04T00:00:00Z", result.sevenDaySonnet!!.resetsAt)
+ }
+
+ @Test
+ fun testParseMissingSonnetKey() {
+ val jsonString = """{"five_hour": {"utilization": 20.0, "resets_at": "2026-02-25T12:00:00Z"}, "seven_day": {"utilization": 55.0, "resets_at": "2026-03-04T00:00:00Z"}}"""
+ val json = JSONObject(jsonString)
+
+ val result = ClaudeApiService.parseUsageData(json)
+
+ assertNotNull("five_hour should be parsed", result.fiveHour)
+ assertEquals("five_hour utilization", 20.0, result.fiveHour!!.utilization, 0.01)
+
+ assertNotNull("seven_day should be parsed", result.sevenDay)
+ assertEquals("seven_day utilization", 55.0, result.sevenDay!!.utilization, 0.01)
+
+ assertNull("seven_day_sonnet should be null when missing", result.sevenDaySonnet)
+ }
+
+ @Test
+ fun testParseUsedLimitFallback() {
+ val jsonString = """{"five_hour": {"used": 25.0, "limit": 100.0, "resets_at": "2026-02-25T12:00:00Z"}}"""
+ val json = JSONObject(jsonString)
+
+ val result = ClaudeApiService.parseUsageData(json)
+
+ assertNotNull("five_hour should be parsed with used/limit fallback", result.fiveHour)
+ assertEquals("five_hour utilization from used/limit", 25.0, result.fiveHour!!.utilization, 0.01)
+ assertEquals("five_hour resets_at", "2026-02-25T12:00:00Z", result.fiveHour!!.resetsAt)
+ }
+
+ @Test
+ fun testParseMalformedEmptyObject() {
+ val jsonString = """{"seven_day_sonnet": {}, "five_hour": {"utilization": 10.0, "resets_at": "2026-02-25T12:00:00Z"}}"""
+ val json = JSONObject(jsonString)
+
+ val result = ClaudeApiService.parseUsageData(json)
+
+ assertNull("seven_day_sonnet with empty object should be null", result.sevenDaySonnet)
+
+ assertNotNull("five_hour should still be parsed", result.fiveHour)
+ assertEquals("five_hour utilization", 10.0, result.fiveHour!!.utilization, 0.01)
+ }
+}
diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml
index aa7730e..982ed0a 100644
--- a/gradle/libs.versions.toml
+++ b/gradle/libs.versions.toml
@@ -3,6 +3,7 @@ agp = "8.7.3"
kotlin = "2.0.21"
coreKtx = "1.15.0"
junit = "4.13.2"
+json = "20240303"
junitVersion = "1.2.1"
espressoCore = "3.6.1"
lifecycleRuntimeKtx = "2.8.7"
@@ -11,6 +12,7 @@ composeBom = "2024.12.01"
navigationCompose = "2.8.5"
material3 = "1.3.1"
+
[libraries]
androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" }
junit = { group = "junit", name = "junit", version.ref = "junit" }
@@ -31,6 +33,8 @@ androidx-lifecycle-viewmodel-compose = { group = "androidx.lifecycle", name = "l
androidx-lifecycle-runtime-compose = { group = "androidx.lifecycle", name = "lifecycle-runtime-compose", version.ref = "lifecycleRuntimeKtx" }
okhttp = { group = "com.squareup.okhttp3", name = "okhttp", version = "4.12.0" }
gson = { group = "com.google.code.gson", name = "gson", version = "2.10.1" }
+json = { group = "org.json", name = "json", version.ref = "json" }
+
[plugins]
android-application = { id = "com.android.application", version.ref = "agp" }