From 67b54094ca6d5dc7d4729efe82a61022a3b66f14 Mon Sep 17 00:00:00 2001 From: kavemang Date: Wed, 8 Jul 2026 01:38:48 -0500 Subject: [PATCH] Add compact Android widget --- android/app/src/main/AndroidManifest.xml | 14 ++ .../noop/widget/NoopCompactGlanceWidget.kt | 183 ++++++++++++++++++ .../noop/widget/NoopCompactWidgetReceiver.kt | 9 + .../java/com/noop/widget/WidgetSnapshot.kt | 8 +- .../main/res/drawable/ic_widget_charge.xml | 9 + .../main/res/drawable/ic_widget_effort.xml | 9 + .../src/main/res/drawable/ic_widget_rest.xml | 9 + .../res/drawable/ic_widget_strap_battery.xml | 9 + android/app/src/main/res/values/strings.xml | 2 + .../main/res/xml/noop_compact_widget_info.xml | 13 ++ 10 files changed, 263 insertions(+), 2 deletions(-) create mode 100644 android/app/src/main/java/com/noop/widget/NoopCompactGlanceWidget.kt create mode 100644 android/app/src/main/java/com/noop/widget/NoopCompactWidgetReceiver.kt create mode 100644 android/app/src/main/res/drawable/ic_widget_charge.xml create mode 100644 android/app/src/main/res/drawable/ic_widget_effort.xml create mode 100644 android/app/src/main/res/drawable/ic_widget_rest.xml create mode 100644 android/app/src/main/res/drawable/ic_widget_strap_battery.xml create mode 100644 android/app/src/main/res/xml/noop_compact_widget_info.xml diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index a5f21097d..22af571c1 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -236,6 +236,20 @@ android:resource="@xml/noop_widget_info" /> + + + + + + + + diff --git a/android/app/src/main/java/com/noop/widget/NoopCompactGlanceWidget.kt b/android/app/src/main/java/com/noop/widget/NoopCompactGlanceWidget.kt new file mode 100644 index 000000000..aa1e0cd37 --- /dev/null +++ b/android/app/src/main/java/com/noop/widget/NoopCompactGlanceWidget.kt @@ -0,0 +1,183 @@ +package com.noop.widget + +import android.content.Context +import androidx.compose.runtime.Composable +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.glance.ColorFilter +import androidx.glance.GlanceId +import androidx.glance.GlanceModifier +import androidx.glance.Image +import androidx.glance.ImageProvider +import androidx.glance.action.actionStartActivity +import androidx.glance.action.clickable +import androidx.glance.appwidget.GlanceAppWidget +import androidx.glance.appwidget.cornerRadius +import androidx.glance.appwidget.provideContent +import androidx.glance.background +import androidx.glance.layout.Alignment +import androidx.glance.layout.Column +import androidx.glance.layout.Row +import androidx.glance.layout.Spacer +import androidx.glance.layout.fillMaxSize +import androidx.glance.layout.fillMaxWidth +import androidx.glance.layout.height +import androidx.glance.layout.padding +import androidx.glance.layout.width +import androidx.glance.text.FontWeight +import androidx.glance.text.Text +import androidx.glance.text.TextStyle +import androidx.glance.unit.ColorProvider +import com.noop.R +import com.noop.ui.MainActivity +import java.text.DateFormat +import java.util.Date + +/** Compact home-screen widget: Rest, Charge and Effort icon cells plus live HR and strap battery. */ +class NoopCompactGlanceWidget : GlanceAppWidget() { + + override suspend fun provideGlance(context: Context, id: GlanceId) { + val snap = runCatching { WidgetSnapshotStore.load(context) }.getOrDefault(WidgetSnapshot()) + val dark = runCatching { + when (context.getSharedPreferences("noop_prefs", Context.MODE_PRIVATE) + .getString("theme.appearance", "system")) { + "light" -> false + "dark" -> true + else -> (context.resources.configuration.uiMode and + android.content.res.Configuration.UI_MODE_NIGHT_MASK) == + android.content.res.Configuration.UI_MODE_NIGHT_YES + } + }.getOrDefault(true) + provideContent { CompactWidgetContent(snap, dark) } + } + + override fun onCompositionError( + context: Context, + glanceId: GlanceId, + appWidgetId: Int, + throwable: Throwable, + ) { + runCatching { + val rv = android.widget.RemoteViews(context.packageName, R.layout.noop_widget_error) + android.appwidget.AppWidgetManager.getInstance(context).updateAppWidget(appWidgetId, rv) + } + } +} + +private fun compactWidgetSurface(dark: Boolean) = + ColorProvider(if (dark) Color(0xFF0A1322) else Color(0xFFF4F1EA)) +private fun compactWidgetTextPrimary(dark: Boolean) = + ColorProvider(if (dark) Color(0xFFF4F6F8) else Color(0xFF1A2230)) +private fun compactWidgetTextSecondary(dark: Boolean) = + ColorProvider(if (dark) Color(0xFF8A94A4) else Color(0xFF7C8696)) + +private fun compactBandColor(recovery: Int, dark: Boolean): ColorProvider = ColorProvider( + when { + recovery >= 67 -> if (dark) Color(0xFFE8B84B) else Color(0xFFB07D17) + recovery >= 34 -> if (dark) Color(0xFFD98A3D) else Color(0xFFC2792E) + else -> if (dark) Color(0xFFE0662F) else Color(0xFFC84E1E) + }, +) + +private fun compactEffortColor(dark: Boolean): ColorProvider = + ColorProvider(if (dark) Color(0xFF4FB6A8) else Color(0xFF2E7D74)) + +@Composable +private fun CompactWidgetContent(snap: WidgetSnapshot, dark: Boolean) { + val surface = compactWidgetSurface(dark) + val textPrimary = compactWidgetTextPrimary(dark) + val textSecondary = compactWidgetTextSecondary(dark) + Column( + modifier = GlanceModifier + .fillMaxSize() + .background(surface) + .cornerRadius(16.dp) + .clickable(actionStartActivity()) + .padding(horizontal = 10.dp, vertical = 8.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalAlignment = Alignment.CenterVertically, + ) { + Row( + modifier = GlanceModifier.fillMaxWidth(), + horizontalAlignment = Alignment.CenterHorizontally, + verticalAlignment = Alignment.Bottom, + ) { + CompactScoreCell( + label = "REST", + iconRes = R.drawable.ic_widget_rest, + pct = snap.restPct, + color = snap.restPct?.let { compactBandColor(it, dark) } ?: textSecondary, + modifier = GlanceModifier.defaultWeight(), + ) + CompactScoreCell( + label = "CHARGE", + iconRes = R.drawable.ic_widget_charge, + pct = snap.recoveryPct, + color = snap.recoveryPct?.let { compactBandColor(it, dark) } ?: textSecondary, + modifier = GlanceModifier.defaultWeight(), + ) + CompactScoreCell( + label = "EFFORT", + iconRes = R.drawable.ic_widget_effort, + pct = snap.effortPct, + color = snap.effortPct?.let { compactEffortColor(dark) } ?: textSecondary, + modifier = GlanceModifier.defaultWeight(), + ) + } + Spacer(modifier = GlanceModifier.height(6.dp)) + Row(verticalAlignment = Alignment.CenterVertically) { + Text( + text = snap.heartRate?.let { "♥ $it" } ?: "♥ - ", + style = TextStyle(color = textPrimary, fontSize = 13.sp), + ) + Spacer(modifier = GlanceModifier.width(10.dp)) + Image( + provider = ImageProvider(R.drawable.ic_widget_strap_battery), + contentDescription = "Strap battery", + modifier = GlanceModifier.width(14.dp).height(14.dp), + colorFilter = ColorFilter.tint(textPrimary), + ) + Spacer(modifier = GlanceModifier.width(3.dp)) + Text( + text = snap.batteryPct?.let { "$it%" } ?: "-", + style = TextStyle(color = textPrimary, fontSize = 13.sp), + ) + } + Spacer(modifier = GlanceModifier.height(1.dp)) + Text( + text = when { + snap.connected -> "Connected" + snap.updatedAtMs > 0L -> + DateFormat.getTimeInstance(DateFormat.SHORT).format(Date(snap.updatedAtMs)) + else -> "Open NOOP to connect" + }, + style = TextStyle(color = textSecondary, fontSize = 11.sp), + ) + } +} + +@Composable +private fun CompactScoreCell( + label: String, + iconRes: Int, + pct: Int?, + color: ColorProvider, + modifier: GlanceModifier = GlanceModifier, +) { + Column( + modifier = modifier, + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Image( + provider = ImageProvider(iconRes), + contentDescription = label, + modifier = GlanceModifier.width(18.dp).height(18.dp), + colorFilter = ColorFilter.tint(color), + ) + Text( + text = pct?.let { "$it%" } ?: "—", + style = TextStyle(color = color, fontSize = 21.sp, fontWeight = FontWeight.Bold), + ) + } +} diff --git a/android/app/src/main/java/com/noop/widget/NoopCompactWidgetReceiver.kt b/android/app/src/main/java/com/noop/widget/NoopCompactWidgetReceiver.kt new file mode 100644 index 000000000..10d44f0fc --- /dev/null +++ b/android/app/src/main/java/com/noop/widget/NoopCompactWidgetReceiver.kt @@ -0,0 +1,9 @@ +package com.noop.widget + +import androidx.glance.appwidget.GlanceAppWidget +import androidx.glance.appwidget.GlanceAppWidgetReceiver + +/** Manifest entry point for the compact home-screen widget. */ +class NoopCompactWidgetReceiver : GlanceAppWidgetReceiver() { + override val glanceAppWidget: GlanceAppWidget = NoopCompactGlanceWidget() +} diff --git a/android/app/src/main/java/com/noop/widget/WidgetSnapshot.kt b/android/app/src/main/java/com/noop/widget/WidgetSnapshot.kt index a2da9400e..c04685b9b 100644 --- a/android/app/src/main/java/com/noop/widget/WidgetSnapshot.kt +++ b/android/app/src/main/java/com/noop/widget/WidgetSnapshot.kt @@ -48,11 +48,15 @@ object WidgetSnapshotStore { save(app, snap) PushGate.markPushed(snap) - val ids = runCatching { + val standardIds = runCatching { GlanceAppWidgetManager(app).getGlanceIds(NoopGlanceWidget::class.java) }.getOrDefault(emptyList()) - if (ids.isEmpty()) return + val compactIds = runCatching { + GlanceAppWidgetManager(app).getGlanceIds(NoopCompactGlanceWidget::class.java) + }.getOrDefault(emptyList()) + if (standardIds.isEmpty() && compactIds.isEmpty()) return runCatching { NoopGlanceWidget().updateAll(app) } + runCatching { NoopCompactGlanceWidget().updateAll(app) } } fun save(context: Context, snap: WidgetSnapshot) { diff --git a/android/app/src/main/res/drawable/ic_widget_charge.xml b/android/app/src/main/res/drawable/ic_widget_charge.xml new file mode 100644 index 000000000..3b86f1380 --- /dev/null +++ b/android/app/src/main/res/drawable/ic_widget_charge.xml @@ -0,0 +1,9 @@ + + + diff --git a/android/app/src/main/res/drawable/ic_widget_effort.xml b/android/app/src/main/res/drawable/ic_widget_effort.xml new file mode 100644 index 000000000..1f499320c --- /dev/null +++ b/android/app/src/main/res/drawable/ic_widget_effort.xml @@ -0,0 +1,9 @@ + + + diff --git a/android/app/src/main/res/drawable/ic_widget_rest.xml b/android/app/src/main/res/drawable/ic_widget_rest.xml new file mode 100644 index 000000000..ac170c46b --- /dev/null +++ b/android/app/src/main/res/drawable/ic_widget_rest.xml @@ -0,0 +1,9 @@ + + + diff --git a/android/app/src/main/res/drawable/ic_widget_strap_battery.xml b/android/app/src/main/res/drawable/ic_widget_strap_battery.xml new file mode 100644 index 000000000..4323658ee --- /dev/null +++ b/android/app/src/main/res/drawable/ic_widget_strap_battery.xml @@ -0,0 +1,9 @@ + + + diff --git a/android/app/src/main/res/values/strings.xml b/android/app/src/main/res/values/strings.xml index 4a1867997..46632e4d3 100644 --- a/android/app/src/main/res/values/strings.xml +++ b/android/app/src/main/res/values/strings.xml @@ -2,6 +2,8 @@ NOOP Today\'s Rest, Charge and Effort, with live heart rate and strap battery at a glance. + NOOP Compact + Compact Rest, Charge and Effort widget, with live heart rate and strap battery. NOOP widget couldn\'t draw — it will recover on the next update. +