Widgets have a reputation as static dashboards — a weather number, a calendar strip, something you glance at and never touch. That reputation is a decade out of date.
With Jetpack Glance you write the widget in Compose, and it can accept taps that run real logic: network calls, local state updates, multi-step flows. I built one into Nest, a shared-canvas app for couples and small groups, and it now handles an entire feature without the app ever opening.
What the widget actually does
Nest’s widget shows the current shared canvas — a drawing, a photo, whatever the group is looking at that day. That part is the dashboard everyone expects. The interesting part is what happens when someone sends a “Pick One” question to the group: a quick poll, like “pizza or sushi tonight?”
The widget detects the pending question and swaps its entire layout to render the question and tappable answer buttons, inline, on the home screen.
+----------------------+ +----------------------+
| Canvas (default) | | Pick One (pending) |
| | | |
| [ shared drawing ] | --> | "pizza or sushi?" |
| < name 1/3 > | | [ pizza ] [ sushi ] |
+----------------------+ +----------------------+
^ |
| v tap an option
+----------------------+ +----------------------+
| Confirmation | <--| writes answer, |
| "Picked! pizza" | | flips widget state |
+----------------------+ +----------------------+
Three widget states, one Composable, decided by what’s in local state — no navigation, because a widget has no back stack:
override suspend fun provideGlance(context: Context, id: GlanceId) {
provideContent {
val prefs = currentState<Preferences>()
val pickOneGame = currentNest?.let { readWidgetPickOne(ctx, it.id) }
val confirmLabel = prefs[PickOneAnswerCallback.CONFIRM_LABEL_KEY]
when {
pickOneGame != null && confirmLabel != null ->
PickOneConfirmationContent(confirmLabel)
pickOneGame != null && pickOneGame.role == "picker" ->
PickerQuestionContent(pickOneGame, unanswered, currentNest.id)
else ->
CanvasContent(ctx, currentNest, currentIndex, nestCount)
}
}
}
Answering from the home screen
Each answer option is a clickable bound to an ActionCallback — the widget equivalent of an onClick handler, except it runs in a different process from your app:
Box(
modifier = GlanceModifier
.clickable(actionRunCallback<PickOneAnswerCallback>(
actionParametersOf(
PickOneAnswerCallback.KEY_GAME_ID to game.gameId,
PickOneAnswerCallback.KEY_OPTION_ID to option.id,
PickOneAnswerCallback.KEY_OPTION_LABEL to displayLabel,
)
)),
) { Text(displayLabel) }
The callback does three things in order: writes the answer to Supabase, optimistically updates local widget state to show a confirmation screen, then enqueues a WorkManager job to clear that confirmation and advance to the next question after a second.
override suspend fun onAction(
context: Context, glanceId: GlanceId, parameters: ActionParameters,
) {
val gameId = parameters[KEY_GAME_ID] ?: return
// ... mark question answered locally so widget advances immediately
updateAppWidgetState(context, PreferencesGlanceStateDefinition, glanceId) { prefs ->
prefs.toMutablePreferences().apply {
this[CONFIRM_LABEL_KEY] = optionLabel
}
}
NestCanvasWidget().update(context, glanceId)
// network write to Supabase happens after the UI already looks done
}
Local-first, then network. The widget flips to “Picked!” the instant you tap — the Supabase write happens after. On a flaky connection, that’s the difference between a widget that feels instant and one that feels broken.
The constraint that shapes everything
A widget runs in the launcher’s process, not yours. No DI graph, no ViewModel, no shared repository instance. Everything Nest’s widget needs has to cross a process boundary, so the design treats that boundary as a file contract instead of a live connection.
The app writes two files into filesDir: widget_nests.json with the list of canvases, and a pre-rendered canvas_<id>.png for each one. The widget only ever reads:
fun readWidgetNests(context: Context): List<WidgetNestInfo> {
return try {
val text = File(context.filesDir, "widget_nests.json").readText()
val arr = org.json.JSONArray(text)
(0 until arr.length()).map { i ->
val obj = arr.getJSONObject(i)
WidgetNestInfo(id = obj.getString("id"), name = obj.getString("name"))
}
} catch (_: Exception) {
emptyList()
}
}
Pre-rendering the canvas to a PNG wasn’t the first design — reconstructing the drawing inside Glance was. Glance has no Canvas, no custom drawing, just a small widget-safe subset of Compose. Rendering once in the app and shipping a bitmap across the boundary turned out both simpler and faster than trying to replicate the drawing logic twice.
updatePeriodMillis="0" in the widget’s manifest metadata means nothing polls on a timer. Every update is a push, not a pull:
class NestCanvasWidgetReceiver : GlanceAppWidgetReceiver() {
override val glanceAppWidget = NestCanvasWidget()
override fun onReceive(context: Context, intent: Intent) {
super.onReceive(context, intent)
if (intent.action == "com.auliastudio.nest.WIDGET_UPDATE") {
CoroutineScope(Dispatchers.Main).launch {
glanceAppWidget.updateAll(context)
}
}
}
}
The app broadcasts WIDGET_UPDATE whenever local state changes — a new canvas, a new question. Polling would mean stale widgets or wasted battery; pushing means the widget is only ever as stale as the last write.
Navigating between canvases without leaving the widget
Groups can belong to more than one Nest. Swiping between them is two more ActionCallbacks, PrevCanvasAction and NextCanvasAction, that just rotate an index in Glance’s own preference-backed state:
class PrevCanvasAction : ActionCallback {
override suspend fun onAction(
context: Context, glanceId: GlanceId, parameters: ActionParameters,
) {
val nests = readWidgetNests(context)
if (nests.size <= 1) return
updateAppWidgetState(context, PreferencesGlanceStateDefinition, glanceId) { prefs ->
val current = prefs[NestCanvasWidget.CURRENT_INDEX_KEY] ?: 0
val newIndex = (current - 1 + nests.size) % nests.size
prefs.toMutablePreferences().apply {
this[NestCanvasWidget.CURRENT_INDEX_KEY] = newIndex
}
}
NestCanvasWidget().update(context, glanceId)
}
}
PreferencesGlanceStateDefinition is the whole trick here — Glance gives every widget instance its own small persisted preference store, keyed by GlanceId, that survives process death. That’s what lets a widget carry per-instance state (which canvas index, which confirmation is showing) without your app being alive to hold it in memory.
What this actually costs
A widget this interactive is not free complexity:
- It’s a second state machine. The widget’s
whenbranches have to stay in sync with what the app considers the current game state, and the only channel between them is files plus a broadcast. - No live connection means every interaction either writes to a shared backend (Supabase) or a local file the app will pick up next time it opens. There’s no in-memory shortcut.
- Testing means launching an actual home screen widget, not just running a Compose preview — Glance previews don’t catch every state transition bug.
Worth it for Nest, because the whole point of the app is reducing the gap between “someone did something” and “you know about it.” A widget that answers a question in one tap, without opening the app, is that gap closed as far as Android lets you close it.
The lesson that generalizes past this one feature: treat your widget’s data as a public API, not an internal detail. Files and broadcasts, not shared objects. The moment you design for that boundary honestly, the rest of the widget gets much simpler to reason about.