Put the board on the home screen, a card at a time

5b1fbaa · Gabriel Morell · 2026-09-03 15:30

25 files +405 -0
Message
{commit_body(@commit)}

Files changed

modified clients/android/app/src/main/AndroidManifest.xml
+19 −0
@@ -51,6 +51,25 @@
51 51 android:exported="false"
52 52 android:foregroundServiceType="dataSync" />
53 53
54 + <!-- The board on the home screen, a card at a time. -->
55 + <receiver
56 + android:name=".widget.BoardWidget"
57 + android:exported="false">
58 + <intent-filter>
59 + <action android:name="android.appwidget.action.APPWIDGET_UPDATE" />
60 + </intent-filter>
61 + <meta-data
62 + android:name="android.appwidget.provider"
63 + android:resource="@xml/board_widget_info" />
64 + </receiver>
65 +
66 + <!-- The cards themselves. Bound by the launcher's widget host, so it
67 + needs the permission the host holds. -->
68 + <service
69 + android:name=".widget.BoardWidgetService"
70 + android:permission="android.permission.BIND_REMOTEVIEWS"
71 + android:exported="false" />
72 +
54 73 <!-- The board as a launcher's page to the left of the home screen.
55 74 Launchers implementing Google's "minus one" gesture look for this
56 75 action and this scheme; the api version tells the client which
modified clients/android/app/src/main/java/io/neiam/apolloscrib/mqtt/AnkyraService.kt
+2 −0
@@ -15,6 +15,7 @@ import io.neiam.apolloscrib.R
15 15 import io.neiam.apolloscrib.data.Settings
16 16 import io.neiam.apolloscrib.data.VisionStore
17 17 import io.neiam.apolloscrib.targets.Targets
18 +import io.neiam.apolloscrib.widget.BoardWidget
18 19 import io.neiam.apolloscrib.ui.MainActivity
19 20 import kotlinx.coroutines.flow.MutableStateFlow
20 21 import kotlinx.coroutines.flow.StateFlow
@@ -67,6 +68,7 @@ class AnkyraService : Service() {
67 68 // about a departure that has since gone.
68 69 settings.clearDismissed()
69 70 Targets.notifyAll(this)
71 + BoardWidget.refresh(this)
70 72 }
71 73
72 74 private fun onState(newState: AnkyraClient.State) {
added clients/android/app/src/main/java/io/neiam/apolloscrib/widget/BoardWidget.kt
+105 −0
@@ -0,0 +1,105 @@
1 +package io.neiam.apolloscrib.widget
2 +
3 +import android.app.PendingIntent
4 +import android.appwidget.AppWidgetManager
5 +import android.appwidget.AppWidgetProvider
6 +import android.content.ComponentName
7 +import android.content.Context
8 +import android.content.Intent
9 +import android.net.Uri
10 +import android.widget.RemoteViews
11 +import io.neiam.apolloscrib.R
12 +import io.neiam.apolloscrib.mqtt.AnkyraService
13 +import io.neiam.apolloscrib.ui.MainActivity
14 +import io.neiam.apolloscrib.ui.theme.appThemeByKey
15 +import io.neiam.apolloscrib.data.Settings
16 +
17 +/**
18 + * The board on the home screen, a card at a time.
19 + *
20 + * A third way to read the same thing: the Smartspace targets show one query
21 + * each, the launcher feed page shows all of them, and this cycles. The cards
22 + * are built from the same [io.neiam.apolloscrib.targets.Preview]s as both, so
23 + * a route reads the same wherever it is drawn.
24 + *
25 + * The cycling is the host's: `autoAdvanceViewId` in the widget's metadata
26 + * points at the flipper, and launchers that honour it advance the card. On the
27 + * ones that do not, the flipper can still be swiped.
28 + */
29 +class BoardWidget : AppWidgetProvider() {
30 +
31 + override fun onUpdate(
32 + context: Context,
33 + manager: AppWidgetManager,
34 + widgetIds: IntArray
35 + ) {
36 + widgetIds.forEach { id -> manager.updateAppWidget(id, build(context, id)) }
37 + }
38 +
39 + private fun build(context: Context, widgetId: Int): RemoteViews {
40 + val palette = appThemeByKey(Settings(context).themeKey)
41 +
42 + return RemoteViews(context.packageName, R.layout.widget_board).apply {
43 + setInt(R.id.board_root, "setBackgroundColor", palette.bg.toArgb())
44 + setTextColor(R.id.board_empty, palette.dim.toArgb())
45 +
46 + // The factory needs the widget id, and an Intent's extras are not
47 + // part of its identity -- two widgets would share one factory
48 + // unless the data URI differs.
49 + val data = Intent(context, BoardWidgetService::class.java).apply {
50 + putExtra(AppWidgetManager.EXTRA_APPWIDGET_ID, widgetId)
51 + setData(Uri.parse("apolloscrib://widget/$widgetId"))
52 + }
53 + setRemoteAdapter(R.id.board_flipper, data)
54 + setEmptyView(R.id.board_flipper, R.id.board_empty)
55 +
56 + // Tapping any card opens the app. Read-only, like every other
57 + // surface: there is nothing on a card to act on.
58 + val open = PendingIntent.getActivity(
59 + context,
60 + widgetId,
61 + Intent(context, MainActivity::class.java),
62 + PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT
63 + )
64 + setPendingIntentTemplate(R.id.board_flipper, open)
65 + }
66 + }
67 +
68 + override fun onEnabled(context: Context) {
69 + // A widget on the home screen is a reason to be connected, the same
70 + // way the feed page is.
71 + AnkyraService.resume(context)
72 + }
73 +
74 + companion object {
75 +
76 + /**
77 + * Redraw every widget from what just arrived.
78 + *
79 + * Two calls: the data change reloads the factory, and the update
80 + * rebuilds the flipper around it. Without the second, a widget added
81 + * before the first board keeps showing its empty view.
82 + */
83 + fun refresh(context: Context) {
84 + val manager = AppWidgetManager.getInstance(context) ?: return
85 + val ids = manager.getAppWidgetIds(ComponentName(context, BoardWidget::class.java))
86 + if (ids.isEmpty()) return
87 + manager.notifyAppWidgetViewDataChanged(ids, R.id.board_flipper)
88 + context.sendBroadcast(
89 + Intent(context, BoardWidget::class.java).apply {
90 + action = AppWidgetManager.ACTION_APPWIDGET_UPDATE
91 + putExtra(AppWidgetManager.EXTRA_APPWIDGET_IDS, ids)
92 + }
93 + )
94 + }
95 + }
96 +}
97 +
98 +/** Compose colours carry an alpha as a float; RemoteViews wants an int. */
99 +internal fun androidx.compose.ui.graphics.Color.toArgb(): Int =
100 + android.graphics.Color.argb(
101 + (alpha * 255f + 0.5f).toInt(),
102 + (red * 255f + 0.5f).toInt(),
103 + (green * 255f + 0.5f).toInt(),
104 + (blue * 255f + 0.5f).toInt()
105 + )
added clients/android/app/src/main/java/io/neiam/apolloscrib/widget/BoardWidgetService.kt
+126 −0
@@ -0,0 +1,126 @@
1 +package io.neiam.apolloscrib.widget
2 +
3 +import android.content.Context
4 +import android.content.Intent
5 +import android.widget.RemoteViews
6 +import android.widget.RemoteViewsService
7 +import io.neiam.apolloscrib.R
8 +import io.neiam.apolloscrib.data.Settings
9 +import io.neiam.apolloscrib.data.VisionStore
10 +import io.neiam.apolloscrib.targets.Preview
11 +import io.neiam.apolloscrib.targets.Targets
12 +import io.neiam.apolloscrib.ui.theme.AppTheme
13 +import io.neiam.apolloscrib.ui.theme.appThemeByKey
14 +
15 +/** The cards the widget flips through. */
16 +class BoardWidgetService : RemoteViewsService() {
17 + override fun onGetViewFactory(intent: Intent): RemoteViewsFactory =
18 + BoardFactory(applicationContext)
19 +}
20 +
21 +private class BoardFactory(private val context: Context) : RemoteViewsService.RemoteViewsFactory {
22 +
23 + private val store = VisionStore(context)
24 + private var cards: List<Preview> = emptyList()
25 + private var palette: AppTheme = appThemeByKey(Settings(context).themeKey)
26 +
27 + override fun onCreate() = Unit
28 +
29 + /**
30 + * Called on create and on every notified change, on a background thread.
31 + * Reading the board here rather than per card keeps one payload being
32 + * parsed once for the whole cycle.
33 + */
34 + override fun onDataSetChanged() {
35 + palette = appThemeByKey(Settings(context).themeKey)
36 + cards = store.entries().flatMap { Targets.preview(it) }
37 + }
38 +
39 + override fun onDestroy() {
40 + cards = emptyList()
41 + }
42 +
43 + override fun getCount(): Int = cards.size
44 +
45 + override fun getViewAt(position: Int): RemoteViews {
46 + val card = cards.getOrNull(position) ?: return loading()
47 +
48 + return RemoteViews(context.packageName, R.layout.widget_card).apply {
49 + setInt(R.id.card_root, "setBackgroundColor", palette.cardBg.toArgb())
50 +
51 + setImageViewResource(R.id.card_icon, card.iconRes)
52 + setInt(R.id.card_icon, "setColorFilter", palette.primary.toArgb())
53 +
54 + setTextViewText(R.id.card_title, card.title)
55 + setTextColor(R.id.card_title, palette.content.toArgb())
56 +
57 + setTextViewText(R.id.card_subtitle, card.subtitle.orEmpty())
58 + setTextColor(R.id.card_subtitle, palette.dim.toArgb())
59 + setViewVisibility(
60 + R.id.card_subtitle,
61 + if (card.subtitle.isNullOrEmpty()) android.view.View.GONE
62 + else android.view.View.VISIBLE
63 + )
64 +
65 + removeAllViews(R.id.card_rows)
66 + if (card.style == Preview.Style.List) {
67 + if (card.rows.isEmpty()) {
68 + addView(R.id.card_rows, emptyRow(card.empty))
69 + } else {
70 + // A widget's height is whatever the user dragged it to and
71 + // cannot be measured from here, so the count is a guess
72 + // that errs towards fitting. The app's board is where all
73 + // of them are.
74 + card.rows.take(MAX_ROWS).forEach { addView(R.id.card_rows, row(it)) }
75 + val extra = card.rows.size - MAX_ROWS
76 + if (extra > 0) addView(R.id.card_rows, emptyRow("+$extra more"))
77 + }
78 + }
79 + }
80 + }
81 +
82 + private fun row(source: io.neiam.apolloscrib.targets.Row) =
83 + RemoteViews(context.packageName, R.layout.widget_row).apply {
84 + setImageViewResource(R.id.row_icon, source.iconRes)
85 + setInt(R.id.row_icon, "setColorFilter", palette.dim.toArgb())
86 + setTextViewText(R.id.row_label, source.text)
87 + setTextColor(R.id.row_label, palette.content.toArgb())
88 +
89 + removeAllViews(R.id.row_values)
90 + source.stamps.forEach { stamp ->
91 + addView(
92 + R.id.row_values,
93 + RemoteViews(context.packageName, R.layout.widget_stamp).apply {
94 + setImageViewResource(R.id.stamp_icon, stamp.iconRes)
95 + setInt(R.id.stamp_icon, "setColorFilter", palette.accent.toArgb())
96 + setTextViewText(R.id.stamp_text, stamp.text)
97 + setTextColor(R.id.stamp_text, palette.accent.toArgb())
98 + }
99 + )
100 + }
101 + }
102 +
103 + /** A line of prose where a list would go: "Nothing due", "+2 more". */
104 + private fun emptyRow(text: String) =
105 + RemoteViews(context.packageName, R.layout.widget_row).apply {
106 + setViewVisibility(R.id.row_icon, android.view.View.GONE)
107 + setTextViewText(R.id.row_label, text)
108 + setTextColor(R.id.row_label, palette.dim.toArgb())
109 + }
110 +
111 + private fun loading() = RemoteViews(context.packageName, R.layout.widget_card)
112 +
113 + override fun getLoadingView(): RemoteViews? = null
114 +
115 + /** Positions are stable: the same query keeps the same card. */
116 + override fun getViewTypeCount(): Int = 1
117 +
118 + override fun getItemId(position: Int): Long =
119 + cards.getOrNull(position)?.id?.hashCode()?.toLong() ?: position.toLong()
120 +
121 + override fun hasStableIds(): Boolean = true
122 +
123 + companion object {
124 + private const val MAX_ROWS = 4
125 + }
126 +}
added clients/android/app/src/main/res/layout/widget_board.xml
+27 −0
@@ -0,0 +1,27 @@
1 +<?xml version="1.0" encoding="utf-8"?>
2 +<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
3 + android:id="@+id/board_root"
4 + android:layout_width="match_parent"
5 + android:layout_height="match_parent"
6 + android:padding="2dp">
7 +
8 + <AdapterViewFlipper
9 + android:id="@+id/board_flipper"
10 + android:layout_width="match_parent"
11 + android:layout_height="match_parent"
12 + android:loopViews="true"
13 + android:autoStart="true"
14 + android:flipInterval="8000"
15 + android:animateFirstView="false" />
16 +
17 + <!-- Shown by the flipper itself when the board has nothing in it, so the
18 + widget says why rather than sitting blank. -->
19 + <TextView
20 + android:id="@+id/board_empty"
21 + android:layout_width="match_parent"
22 + android:layout_height="match_parent"
23 + android:gravity="center"
24 + android:padding="12dp"
25 + android:textSize="13sp"
26 + android:text="@string/widget_empty" />
27 +</FrameLayout>
added clients/android/app/src/main/res/layout/widget_card.xml
+48 −0
@@ -0,0 +1,48 @@
1 +<?xml version="1.0" encoding="utf-8"?>
2 +<!-- One card, the same shape the app and the Smartspace targets draw: a glyph,
3 + a heading, a supporting line, and rows whose values sit against the right
4 + edge. -->
5 +<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
6 + android:id="@+id/card_root"
7 + android:layout_width="match_parent"
8 + android:layout_height="match_parent"
9 + android:orientation="horizontal"
10 + android:padding="12dp">
11 +
12 + <ImageView
13 + android:id="@+id/card_icon"
14 + android:layout_width="24dp"
15 + android:layout_height="24dp"
16 + android:layout_marginEnd="10dp"
17 + android:contentDescription="@null" />
18 +
19 + <LinearLayout
20 + android:layout_width="match_parent"
21 + android:layout_height="wrap_content"
22 + android:orientation="vertical">
23 +
24 + <TextView
25 + android:id="@+id/card_title"
26 + android:layout_width="match_parent"
27 + android:layout_height="wrap_content"
28 + android:maxLines="1"
29 + android:ellipsize="end"
30 + android:textSize="15sp"
31 + android:textStyle="bold" />
32 +
33 + <TextView
34 + android:id="@+id/card_subtitle"
35 + android:layout_width="match_parent"
36 + android:layout_height="wrap_content"
37 + android:maxLines="1"
38 + android:ellipsize="end"
39 + android:textSize="12sp" />
40 +
41 + <LinearLayout
42 + android:id="@+id/card_rows"
43 + android:layout_width="match_parent"
44 + android:layout_height="wrap_content"
45 + android:layout_marginTop="2dp"
46 + android:orientation="vertical" />
47 + </LinearLayout>
48 +</LinearLayout>
added clients/android/app/src/main/res/layout/widget_row.xml
+30 −0
@@ -0,0 +1,30 @@
1 +<?xml version="1.0" encoding="utf-8"?>
2 +<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
3 + android:layout_width="match_parent"
4 + android:layout_height="wrap_content"
5 + android:orientation="horizontal"
6 + android:gravity="center_vertical">
7 +
8 + <ImageView
9 + android:id="@+id/row_icon"
10 + android:layout_width="14dp"
11 + android:layout_height="14dp"
12 + android:layout_marginEnd="5dp"
13 + android:contentDescription="@null" />
14 +
15 + <TextView
16 + android:id="@+id/row_label"
17 + android:layout_width="0dp"
18 + android:layout_height="wrap_content"
19 + android:layout_weight="1"
20 + android:maxLines="1"
21 + android:ellipsize="end"
22 + android:textSize="13sp" />
23 +
24 + <LinearLayout
25 + android:id="@+id/row_values"
26 + android:layout_width="wrap_content"
27 + android:layout_height="wrap_content"
28 + android:orientation="horizontal"
29 + android:gravity="center_vertical" />
30 +</LinearLayout>
added clients/android/app/src/main/res/layout/widget_stamp.xml
+21 −0
@@ -0,0 +1,21 @@
1 +<?xml version="1.0" encoding="utf-8"?>
2 +<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
3 + android:layout_width="wrap_content"
4 + android:layout_height="wrap_content"
5 + android:orientation="horizontal"
6 + android:layout_marginStart="6dp"
7 + android:gravity="center_vertical">
8 +
9 + <ImageView
10 + android:id="@+id/stamp_icon"
11 + android:layout_width="14dp"
12 + android:layout_height="14dp"
13 + android:layout_marginEnd="3dp"
14 + android:contentDescription="@null" />
15 +
16 + <TextView
17 + android:id="@+id/stamp_text"
18 + android:layout_width="wrap_content"
19 + android:layout_height="wrap_content"
20 + android:textSize="13sp" />
21 +</LinearLayout>
modified clients/android/app/src/main/res/values/strings.xml
+2 −0
@@ -2,4 +2,6 @@
2 2 <resources>
3 3 <string name="app_name">Apollo\'s Crib</string>
4 4 <string name="configure">Choose a query</string>
5 + <string name="widget_description">A vision\'s board, one card at a time</string>
6 + <string name="widget_empty">No board yet — open Apollo\'s Crib to connect</string>
5 7 </resources>
added clients/android/app/src/main/res/xml/board_widget_info.xml
+25 −0
@@ -0,0 +1,25 @@
1 +<?xml version="1.0" encoding="utf-8"?>
2 +<!-- The board as a home screen widget: one card at a time, cycled by the
3 + host. `autoAdvanceViewId` is what asks for the cycling — the flipper
4 + advances itself on launchers that honour it, and can be swiped by hand on
5 + the ones that do not.
6 +
7 + No `updatePeriodMillis`: a board arrives when a Pythiae publishes one, and
8 + the service notifies the widget then. Polling on a timer would redraw what
9 + it already has. -->
10 +<appwidget-provider xmlns:android="http://schemas.android.com/apk/res/android"
11 + android:minWidth="180dp"
12 + android:minHeight="110dp"
13 + android:minResizeWidth="110dp"
14 + android:minResizeHeight="70dp"
15 + android:targetCellWidth="3"
16 + android:targetCellHeight="2"
17 + android:maxResizeWidth="500dp"
18 + android:maxResizeHeight="400dp"
19 + android:resizeMode="horizontal|vertical"
20 + android:updatePeriodMillis="0"
21 + android:initialLayout="@layout/widget_board"
22 + android:previewLayout="@layout/widget_board"
23 + android:autoAdvanceViewId="@id/board_flipper"
24 + android:widgetCategory="home_screen"
25 + android:description="@string/widget_description" />

Parents: 4fa4e81