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.
+