Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions android/app/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
Expand Up @@ -236,6 +236,20 @@
android:resource="@xml/noop_widget_info" />
</receiver>

<!-- Compact home-screen widget, offered as a separate picker entry so the existing 2x2 widget
remains available unchanged. -->
<receiver
android:name="com.noop.widget.NoopCompactWidgetReceiver"
android:exported="true"
android:label="@string/widget_compact_name">
<intent-filter>
<action android:name="android.appwidget.action.APPWIDGET_UPDATE" />
</intent-filter>
<meta-data
android:name="android.appwidget.provider"
android:resource="@xml/noop_compact_widget_info" />
</receiver>

<!-- Optional native phone-call alerts. The receiver only reacts to ringing/offhook/idle state
and never reads the incoming-number extra. Delivery is gated by the Notifications screen
settings and by the just-in-time READ_PHONE_STATE runtime permission. -->
Expand Down
183 changes: 183 additions & 0 deletions android/app/src/main/java/com/noop/widget/NoopCompactGlanceWidget.kt
Original file line number Diff line number Diff line change
@@ -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<MainActivity>())
.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),
)
}
}
Original file line number Diff line number Diff line change
@@ -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()
}
8 changes: 6 additions & 2 deletions android/app/src/main/java/com/noop/widget/WidgetSnapshot.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
9 changes: 9 additions & 0 deletions android/app/src/main/res/drawable/ic_widget_charge.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="#FF000000"
android:pathData="M13,2L4,14H11L9,22L20,9H13L13,2Z" />
</vector>
9 changes: 9 additions & 0 deletions android/app/src/main/res/drawable/ic_widget_effort.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="#FF000000"
android:pathData="M4,8H6V16H4V8ZM7,6H9V18H7V6ZM10,11H14V13H10V11ZM15,6H17V18H15V6ZM18,8H20V16H18V8Z" />
</vector>
9 changes: 9 additions & 0 deletions android/app/src/main/res/drawable/ic_widget_rest.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="#FF000000"
android:pathData="M20.5,15.3C19.2,18.6 16,21 12.2,21C7.1,21 3,16.9 3,11.8C3,7.9 5.4,4.6 8.9,3.3C7.8,4.7 7.2,6.5 7.2,8.4C7.2,13 11,16.8 15.6,16.8C17.4,16.8 19.1,16.3 20.5,15.3Z" />
</vector>
9 changes: 9 additions & 0 deletions android/app/src/main/res/drawable/ic_widget_strap_battery.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="#FF000000"
android:pathData="M4,7C4,5.9 4.9,5 6,5H18C19.1,5 20,5.9 20,7V8H21C21.6,8 22,8.4 22,9V15C22,15.6 21.6,16 21,16H20V17C20,18.1 19.1,19 18,19H6C4.9,19 4,18.1 4,17V7ZM6,7V17H18V7H6ZM8,9H16V15H8V9Z" />
</vector>
2 changes: 2 additions & 0 deletions android/app/src/main/res/values/strings.xml
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@
<resources>
<string name="app_name">NOOP</string>
<string name="widget_description">Today\'s Rest, Charge and Effort, with live heart rate and strap battery at a glance.</string>
<string name="widget_compact_name">NOOP Compact</string>
<string name="widget_compact_description">Compact Rest, Charge and Effort widget, with live heart rate and strap battery.</string>
<string name="widget_error">NOOP widget couldn\'t draw — it will recover on the next update.</string>

<!-- Navigation: bottom-bar tabs, the More page rows, and the top-bar screen titles. These are the
Expand Down
13 changes: 13 additions & 0 deletions android/app/src/main/res/xml/noop_compact_widget_info.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Compact home-screen widget metadata. updatePeriodMillis=0: the app pushes updates through
WidgetSnapshotStore whenever live state or today's Rest/Charge/Effort changes (#516). -->
<appwidget-provider xmlns:android="http://schemas.android.com/apk/res/android"
android:description="@string/widget_compact_description"
android:minWidth="140dp"
android:minHeight="88dp"
android:targetCellWidth="2"
android:targetCellHeight="1"
android:resizeMode="horizontal|vertical"
android:widgetCategory="home_screen"
android:updatePeriodMillis="0"
android:initialLayout="@layout/glance_default_loading_layout" />