Add a detailed widget beside the compact one

3ead170 · Gabriel Morell · 2026-09-08 20:58

26 files +386 -0
Message
{commit_body(@commit)}

Files changed

modified clients/android/app/src/main/AndroidManifest.xml
+20 −0
@@ -70,6 +70,21 @@
70 70 android:resource="@xml/board_widget_info" />
71 71 </receiver>
72 72
73 + <!-- The same board in detail, as its own widget. A home screen has
74 + room for both, and they answer different questions: the compact
75 + one for a glance, this one for the readings a glyph cannot
76 + label. -->
77 + <receiver
78 + android:name=".widget.RichBoardWidget"
79 + android:exported="false">
80 + <intent-filter>
81 + <action android:name="android.appwidget.action.APPWIDGET_UPDATE" />
82 + </intent-filter>
83 + <meta-data
84 + android:name="android.appwidget.provider"
85 + android:resource="@xml/rich_board_widget_info" />
86 + </receiver>
87 +
73 88 <!-- The cards themselves. Bound by the launcher's widget host, so it
74 89 needs the permission the host holds. -->
75 90 <service
@@ -77,6 +92,11 @@
77 92 android:permission="android.permission.BIND_REMOTEVIEWS"
78 93 android:exported="false" />
79 94
95 + <service
96 + android:name=".widget.RichBoardWidgetService"
97 + android:permission="android.permission.BIND_REMOTEVIEWS"
98 + android:exported="false" />
99 +
80 100 <!-- The board as a launcher's page to the left of the home screen.
81 101 Launchers implementing Google's "minus one" gesture look for this
82 102 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
@@ -18,6 +18,7 @@ import kotlinx.serialization.encodeToString
18 18 import kotlinx.serialization.json.Json
19 19 import io.neiam.apolloscrib.targets.Targets
20 20 import io.neiam.apolloscrib.widget.BoardWidget
21 +import io.neiam.apolloscrib.widget.RichBoardWidget
21 22 import io.neiam.apolloscrib.ui.MainActivity
22 23 import kotlinx.coroutines.flow.MutableStateFlow
23 24 import kotlinx.coroutines.flow.StateFlow
@@ -142,6 +143,7 @@ class AnkyraService : Service() {
142 143 settings.clearDismissed()
143 144 Targets.notifyAll(this)
144 145 BoardWidget.refresh(this)
146 + RichBoardWidget.refresh(this)
145 147 }
146 148
147 149 private fun onState(newState: AnkyraClient.State) {
modified clients/android/app/src/main/java/io/neiam/apolloscrib/ui/MainActivity.kt
+2 −0
@@ -45,6 +45,7 @@ import io.neiam.apolloscrib.data.Pairing
45 45 import io.neiam.apolloscrib.feed.FeedProvider
46 46 import kotlinx.coroutines.delay
47 47 import io.neiam.apolloscrib.widget.BoardWidget
48 +import io.neiam.apolloscrib.widget.RichBoardWidget
48 49 import io.neiam.apolloscrib.data.Settings
49 50 import io.neiam.apolloscrib.ui.theme.ALL_THEMES
50 51 import io.neiam.apolloscrib.ui.theme.CribTheme
@@ -106,6 +107,7 @@ class MainActivity : ComponentActivity() {
106 107 // screen keeps the old colours until something
107 108 // unrelated happens.
108 109 BoardWidget.refresh(this)
110 + RichBoardWidget.refresh(this)
109 111 },
110 112 onConnect = {
111 113 AnkyraService.start(this)
added clients/android/app/src/main/java/io/neiam/apolloscrib/widget/RichBoardWidget.kt
+96 −0
@@ -0,0 +1,96 @@
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 detailed board on the home screen, a card at a time.
19 + *
20 + * Its own widget rather than a mode on the compact one, because a home screen
21 + * has room for both and they answer different questions. The compact card is
22 + * for a glance -- when is the next one -- and this is for the readings a
23 + * glyph cannot label: pressure, visibility, which tide and how long.
24 + *
25 + * Both draw the same board. This one draws it through
26 + * [io.neiam.apolloscrib.ui.factsFor], the same reading the app's detailed mode
27 + * uses, so a card says the same thing on a home screen as it does in the app.
28 + */
29 +class RichBoardWidget : 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_rich_board).apply {
43 + setInt(R.id.rich_board_root, "setBackgroundColor", palette.bg.toArgb())
44 + setTextColor(R.id.rich_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, RichBoardWidgetService::class.java).apply {
50 + putExtra(AppWidgetManager.EXTRA_APPWIDGET_ID, widgetId)
51 + setData(Uri.parse("apolloscrib://richwidget/$widgetId"))
52 + }
53 + setRemoteAdapter(R.id.rich_flipper, data)
54 + setEmptyView(R.id.rich_flipper, R.id.rich_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.rich_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, RichBoardWidget::class.java))
86 + if (ids.isEmpty()) return
87 + manager.notifyAppWidgetViewDataChanged(ids, R.id.rich_flipper)
88 + context.sendBroadcast(
89 + Intent(context, RichBoardWidget::class.java).apply {
90 + action = AppWidgetManager.ACTION_APPWIDGET_UPDATE
91 + putExtra(AppWidgetManager.EXTRA_APPWIDGET_IDS, ids)
92 + }
93 + )
94 + }
95 + }
96 +}
added clients/android/app/src/main/java/io/neiam/apolloscrib/widget/RichBoardWidgetService.kt
+117 −0
@@ -0,0 +1,117 @@
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.types.SourceType
11 +import io.neiam.apolloscrib.ui.RichFacts
12 +import io.neiam.apolloscrib.ui.factsFor
13 +import io.neiam.apolloscrib.ui.theme.AppTheme
14 +import io.neiam.apolloscrib.ui.theme.appThemeByKey
15 +
16 +/**
17 + * The cards behind the detailed widget.
18 + *
19 + * Built from [factsFor], which is the same reading the app's detailed mode
20 + * uses — a headline that answers the question and beneath it the things that
21 + * qualify it, each saying its own name. That function has no Compose in it,
22 + * which is what lets a RemoteViews surface share it rather than growing a
23 + * second opinion about what a weather card should say.
24 + */
25 +class RichBoardWidgetService : RemoteViewsService() {
26 +
27 + override fun onGetViewFactory(intent: Intent): RemoteViewsFactory =
28 + Factory(applicationContext)
29 +
30 + private class Factory(private val context: Context) : RemoteViewsFactory {
31 +
32 + private val store = VisionStore(context)
33 + private var cards: List<RichFacts> = emptyList()
34 + private var palette: AppTheme = appThemeByKey(Settings(context).themeKey)
35 +
36 + override fun onCreate() = Unit
37 +
38 + override fun onDataSetChanged() {
39 + palette = appThemeByKey(Settings(context).themeKey)
40 +
41 + // The Plus board where there is one, as the compact widget does:
42 + // the same board with more on it, and Basic's own answer for every
43 + // type Plus does not extend.
44 + val board = store.plusEntries().ifEmpty { store.entries() }
45 +
46 + // Transit is left to the compact widget. Its detailed card is a
47 + // route badge and a list of departures each with its own delay and
48 + // crowding, which is a scrolling thing, and a widget card does not
49 + // scroll -- it would come out truncated in a way that hides the
50 + // very detail it is here for.
51 + cards = board
52 + .filter { it.type != SourceType.Gtfs && it.type != SourceType.GtfsPlus }
53 + .mapNotNull { factsFor(it) }
54 + }
55 +
56 + override fun onDestroy() {
57 + cards = emptyList()
58 + }
59 +
60 + override fun getCount(): Int = cards.size
61 +
62 + override fun getViewAt(position: Int): RemoteViews {
63 + val card = cards.getOrNull(position) ?: return loading()
64 +
65 + return RemoteViews(context.packageName, R.layout.widget_rich_card).apply {
66 + setInt(R.id.rich_root, "setBackgroundColor", palette.cardBg.toArgb())
67 +
68 + setImageViewResource(R.id.rich_icon, card.iconRes)
69 + setInt(R.id.rich_icon, "setColorFilter", palette.primary.toArgb())
70 +
71 + setTextViewText(R.id.rich_title, card.title)
72 + setTextColor(R.id.rich_title, palette.content.toArgb())
73 +
74 + setTextViewText(R.id.rich_headline, card.headline.orEmpty())
75 + setTextColor(R.id.rich_headline, palette.accent.toArgb())
76 + setViewVisibility(
77 + R.id.rich_headline,
78 + if (card.headline == null) android.view.View.GONE else android.view.View.VISIBLE
79 + )
80 +
81 + setTextViewText(R.id.rich_caption, card.caption.orEmpty())
82 + setTextColor(R.id.rich_caption, palette.dim.toArgb())
83 + setViewVisibility(
84 + R.id.rich_caption,
85 + if (card.caption == null) android.view.View.GONE else android.view.View.VISIBLE
86 + )
87 +
88 + removeAllViews(R.id.rich_facts)
89 +
90 + // Four, because a widget card does not scroll and a fifth row
91 + // would be drawn off the bottom of it -- present, unreadable,
92 + // and indistinguishable from a card that simply ended.
93 + card.facts.take(4).forEach { fact ->
94 + addView(
95 + R.id.rich_facts,
96 + RemoteViews(context.packageName, R.layout.widget_fact).apply {
97 + setTextViewText(R.id.fact_label, fact.label)
98 + setTextColor(R.id.fact_label, palette.dim.toArgb())
99 + setTextViewText(R.id.fact_value, fact.value)
100 + setTextColor(R.id.fact_value, palette.content.toArgb())
101 + }
102 + )
103 + }
104 + }
105 + }
106 +
107 + private fun loading() = RemoteViews(context.packageName, R.layout.widget_rich_card)
108 +
109 + override fun getLoadingView(): RemoteViews? = null
110 +
111 + override fun getViewTypeCount(): Int = 1
112 +
113 + override fun getItemId(position: Int): Long = position.toLong()
114 +
115 + override fun hasStableIds(): Boolean = true
116 + }
117 +}
added clients/android/app/src/main/res/layout/widget_fact.xml
+28 −0
@@ -0,0 +1,28 @@
1 +<?xml version="1.0" encoding="utf-8"?>
2 +<!-- A named value. The label carries the meaning the glyph carries on a
3 + compact row, which is the whole reason this layout exists. -->
4 +<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
5 + android:layout_width="match_parent"
6 + android:layout_height="wrap_content"
7 + android:orientation="horizontal"
8 + android:paddingTop="2dp"
9 + android:paddingBottom="2dp">
10 +
11 + <TextView
12 + android:id="@+id/fact_label"
13 + android:layout_width="0dp"
14 + android:layout_height="wrap_content"
15 + android:layout_weight="1"
16 + android:maxLines="1"
17 + android:ellipsize="end"
18 + android:textSize="12sp" />
19 +
20 + <TextView
21 + android:id="@+id/fact_value"
22 + android:layout_width="wrap_content"
23 + android:layout_height="wrap_content"
24 + android:layout_marginStart="10dp"
25 + android:maxLines="1"
26 + android:textSize="13sp"
27 + android:textStyle="bold" />
28 +</LinearLayout>
added clients/android/app/src/main/res/layout/widget_rich_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/rich_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/rich_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/rich_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_rich_card.xml
+63 −0
@@ -0,0 +1,63 @@
1 +<?xml version="1.0" encoding="utf-8"?>
2 +<!-- One card in the detailed widget: the thing being asked about, large, and
3 + beneath it the things that qualify it, each saying its own name.
4 +
5 + The compact card is a glyph and a number against the right edge, which
6 + works because you know what a droplet means. This is for the readings
7 + where you do not: pressure, visibility, how long until the tide. -->
8 +<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
9 + android:id="@+id/rich_root"
10 + android:layout_width="match_parent"
11 + android:layout_height="match_parent"
12 + android:orientation="vertical"
13 + android:padding="14dp">
14 +
15 + <LinearLayout
16 + android:layout_width="match_parent"
17 + android:layout_height="wrap_content"
18 + android:orientation="horizontal"
19 + android:gravity="center_vertical">
20 +
21 + <ImageView
22 + android:id="@+id/rich_icon"
23 + android:layout_width="18dp"
24 + android:layout_height="18dp"
25 + android:layout_marginEnd="8dp"
26 + android:contentDescription="@null" />
27 +
28 + <TextView
29 + android:id="@+id/rich_title"
30 + android:layout_width="match_parent"
31 + android:layout_height="wrap_content"
32 + android:maxLines="1"
33 + android:ellipsize="end"
34 + android:textSize="13sp"
35 + android:textStyle="bold" />
36 + </LinearLayout>
37 +
38 + <TextView
39 + android:id="@+id/rich_headline"
40 + android:layout_width="match_parent"
41 + android:layout_height="wrap_content"
42 + android:layout_marginTop="6dp"
43 + android:maxLines="1"
44 + android:ellipsize="end"
45 + android:textSize="30sp" />
46 +
47 + <TextView
48 + android:id="@+id/rich_caption"
49 + android:layout_width="match_parent"
50 + android:layout_height="wrap_content"
51 + android:maxLines="2"
52 + android:ellipsize="end"
53 + android:textSize="12sp" />
54 +
55 + <!-- Filled at bind time, one row per fact. Empty for a card that has a
56 + headline and nothing to qualify it, which is a whole answer. -->
57 + <LinearLayout
58 + android:id="@+id/rich_facts"
59 + android:layout_width="match_parent"
60 + android:layout_height="wrap_content"
61 + android:layout_marginTop="8dp"
62 + android:orientation="vertical" />
63 +</LinearLayout>
modified clients/android/app/src/main/res/values/strings.xml
+1 −0
@@ -3,5 +3,6 @@
3 3 <string name="app_name">Apollo\'s Crib</string>
4 4 <string name="configure">Choose a query</string>
5 5 <string name="widget_description">A vision\'s board, one card at a time</string>
6 + <string name="rich_widget_description">A vision\'s board in detail, one card at a time</string>
6 7 <string name="widget_empty">No board yet — open Apollo\'s Crib to connect</string>
7 8 </resources>
added clients/android/app/src/main/res/xml/rich_board_widget_info.xml
+30 −0
@@ -0,0 +1,30 @@
1 +<?xml version="1.0" encoding="utf-8"?>
2 +<!-- The detailed board as a home screen widget: one card at a time, cycled
3 + by the host.
4 +
5 + Larger than its compact sibling by default, because that is the point of
6 + it — a headline and a handful of named values need room that four rows
7 + of glyph-and-number do not.
8 +
9 + `autoAdvanceViewId` asks for the cycling: the flipper advances itself on
10 + launchers that honour it, and can be swiped by hand on the ones that do
11 + not.
12 +
13 + No `updatePeriodMillis`: a board arrives when a Pythiae publishes one, and
14 + the service notifies the widget then. -->
15 +<appwidget-provider xmlns:android="http://schemas.android.com/apk/res/android"
16 + android:minWidth="250dp"
17 + android:minHeight="180dp"
18 + android:minResizeWidth="110dp"
19 + android:minResizeHeight="70dp"
20 + android:targetCellWidth="4"
21 + android:targetCellHeight="3"
22 + android:maxResizeWidth="500dp"
23 + android:maxResizeHeight="400dp"
24 + android:resizeMode="horizontal|vertical"
25 + android:updatePeriodMillis="0"
26 + android:initialLayout="@layout/widget_rich_board"
27 + android:previewLayout="@layout/widget_rich_board"
28 + android:autoAdvanceViewId="@id/rich_flipper"
29 + android:widgetCategory="home_screen"
30 + android:description="@string/rich_widget_description" />

Parents: 48d3a04