Add six draft posts drawn from Calendula issues

Technical, journey, and philosophy posts sourced from the Calendula issue
threads, reviewed for source-accuracy, standards, ethics, and voice. All
draft:true with staggered future pubDates; they stay hidden in prod until
approved (draft:false) and due.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-01 16:35:06 +02:00
parent cecbda26b0
commit 6b91ef1ab4
6 changed files with 484 additions and 0 deletions

View File

@@ -0,0 +1,92 @@
---
title: Compose for widgets, RemoteViews underneath
description: >-
Jetpack Glance lets you write home-screen widgets in Compose. But it compiles
down to RemoteViews — so the old constraints still bite through the nice API.
Three Calendula bugs that proved it.
pubDate: 2026-07-02
tags: [android, architecture, calendula]
draft: true
---
Calendula's home-screen widgets are written in
[Jetpack Glance](https://developer.android.com/develop/ui/compose/glance) — the
Compose-style API for widgets. You declare a `GlanceAppWidget`, write something
that looks like a composable, and attach behaviour with familiar-feeling
modifiers. It's a genuine relief compared to hand-assembling widget layouts.
But Glance isn't a new widget runtime. It's a translation layer: your
composition is compiled down to
[`RemoteViews`](https://developer.android.com/reference/android/widget/RemoteViews)
— a serialized description of a layout that *another* process (the launcher)
inflates and renders. Your app isn't running while the widget is on screen, and
it never gets the touch events. Glance hides that, but it can't repeal it. Three
Calendula widget bugs in a row were really the same lesson: the RemoteViews
constraints reach up through the Compose gloss and bite anyway.
## There's no click handler, only an action
In a real Compose UI you'd write `Modifier.clickable { doThing() }` and `doThing`
runs in your process. In Glance you write
`GlanceModifier.clickable(actionStartActivity<…>())` — and that difference is
the whole story. There's no lambda that runs on tap, because there's no code of
yours running on the far side. Glance only lets you attach an **action**
`actionStartActivity` to launch something, `actionRunCallback` to fire a
registered callback — and those compile down to the `PendingIntent`s RemoteViews
has always required. Taps don't call a function; they launch something.
That reframes every "make X tappable" request. When the **month widget's**
arrows and header did nothing ([#18]), it wasn't a broken handler — there was no
handler to break, and no action wired to the view either, so the launcher had
nothing to fire. The fix isn't "handle the tap," it's "attach the action": one
that advances the month, one that opens the app in month view. Same shape in
[#20] — the agenda widget's header should open your default view, again a
missing action, not a bug in one.
And because every day cell needs to open *its own* date, each one carries its
own action. Glance's `clickable` makes that look like an ordinary per-item
modifier, but underneath it's still one addressed intent per cell — a month grid
is a grid of them. An earlier round ([#2]) fixed exactly this: the month grid
wasn't interactive at all until each day was made to launch itself.
## The far side has to be told what "back" means
Because a tap launches an intent rather than navigating, the app has to
reconstruct context on arrival. Tapping a day in the **agenda** widget and
tapping a day in the **month** widget both open a day — but where should *Back*
take you? The widget knows; the freshly-started activity does not, unless the
action says so.
So `actionStartActivity` carries not just "open this day" but "you came from the
agenda context," and the app rebuilds a back stack from that: day → agenda →
your default view, or day → month → default. It looks like normal navigation to
the user. Under the hood it's the app trusting a breadcrumb the widget packed
into the launch, because a widget can't hand over a live navigation state —
Glance composition or not, all it can serialize is data.
## "Upcoming" has to actually mean upcoming
The last one wasn't about interaction. The agenda widget is titled **Upcoming**,
yet it listed every event of the day, finished ones included ([#12]). In a
normal Compose list you'd just filter as the list recomposes. A widget can't —
what the launcher renders is baked in when the composition is snapshotted to
RemoteViews, not recomputed as you scroll. So "don't show past events" becomes a
property of the data you build *before* Glance serializes it, exposed as a
`PastEventDisplay` preference: hide finished events outright, or dim them in
place for people who still want them visible.
## The lesson
Glance is a real improvement — declaring widgets in Compose beats the old
ceremony. But it's a nicer handle on the same box. There's still no process of
yours, still no live view tree, still only data you snapshot and actions you
pre-address, rendered by someone else. Once I stopped thinking "how do I handle
this tap" and started thinking "what action should this fire, and what does the
far side need to know," the three bugs stopped looking separate. A widget can't
*do* anything. It can only describe what should happen — and Compose syntax
doesn't change that, it just makes the describing pleasant.
[#2]: https://codeberg.org/jlmakiola/calendula/issues/2
[#12]: https://codeberg.org/jlmakiola/calendula/issues/12
[#18]: https://codeberg.org/jlmakiola/calendula/issues/18
[#20]: https://codeberg.org/jlmakiola/calendula/issues/20