Skip to content

Feature/aurora integration bugs - #388

Open
den3000 wants to merge 66 commits into
feature/aurora-build-variantfrom
feature/aurora-integration-bugs
Open

Feature/aurora integration bugs#388
den3000 wants to merge 66 commits into
feature/aurora-build-variantfrom
feature/aurora-integration-bugs

Conversation

@den3000

@den3000 den3000 commented Aug 2, 2026

Copy link
Copy Markdown
Collaborator

No description provided.

den3000 and others added 30 commits July 31, 2026 18:43
Preparing the Aurora port, the popup that holds the organizer list was rewritten to work in the
fork, where a Popup is a scene of its own: the position provider was made to return 0,0, the layer
was stretched to fill the window, and the list was moved by hand from positionInWindow(). All of
that landed in commonMain, so Android and iOS got it too — and there the list falls through the
layout, opens crooked and cannot be clicked.

Split the two behaviours behind a new `popupIsSeparateScene` seam, next to `forceLandscape` and
`uiScaleBaseline`. It is deliberately a separate flag: the popup problem is scene isolation, and
rotation is only one of its consequences.

The Android/iOS branch restores the position provider as it stood before the port (f0cfecb),
including the `- 60` nudge — going to stock anchored positioning instead would be a second,
unrelated behaviour change riding along with a regression fix. The list itself is shared; only the
placement differs, which the branches pass in as a Modifier.

The maliit key-event logging on the text field is now attached only where it is needed. Keyboard
input on Aurora is still unverified, so the instrument stays — but it has no business running on
every keystroke on every platform.

Verified: core:ui compiles for Android, iOS simulator and the Aurora linux target. Behaviour on
Aurora is unchanged by construction — that branch is the code that was there.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
TimeReceiver was constructed in exactly one place — AppActivity — so on iOS and on Aurora nothing
ever drove CurrentTimeHolder. It was seeded once when the object initialised and stayed there, the
collect in MainViewModel fired once, and the clock and the "N minutes left" countdown on the room
card were frozen at whatever the time was when the app started.

Wiring the expect class into Koin would have been the smaller diff and the wrong one. Its only
public member, currentTime, has no readers anywhere — everything goes to CurrentTimeHolder
directly — and the iOS and linux actuals were plain minute timers with nothing platform-specific
in them. So the expect class goes, and a common CurrentTimeTicker takes its place, started from
AppRoot, which is the one root all three platforms share.

The tick is aligned to the wall clock, so the time flips at :00 instead of a minute after
whenever the app happened to launch.

Android keeps a TimeReceiver, because there it is not a timer: a broadcast is the only way to
notice the clock being changed under the app. It is no longer an actual, it drops ACTION_TIME_TICK
(the common ticker owns cadence now, the broadcast would only duplicate it) and it picks up
ACTION_TIMEZONE_CHANGED, which was missing — a timezone change was invisible until now.

Verified: composeApp compiles for Android, iOS simulator and the Aurora linux target.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A booking made from a laptop calendar never reached the Aurora tablet: the room list only moved on
FCM, and Aurora has none. Restarting the app was the only way to see it.

The tablet does already tick — UpdateUseCase — but that ticking is not a refresh. It asks the
screen to reload, the reload goes through GetRoomsInfoUseCase, and that serves the local cache and
only reaches the network when the cache is empty. The one thing that actually re-reads the server
is RefreshDataUseCase, and after startup nothing calls it unless a push arrives.

So poll it, on the platforms that need polling: roomRefreshInterval is a minute on linux and null
on Android and iOS, where push works and polling would be a cost with no benefit. Null means the
coroutine is never launched, so this changes nothing for them.

Nothing else moves. RefreshDataUseCase writes to the local repository's buffer, MainViewModel is
already subscribed to it through RoomInfoUseCase.subscribe(), and the existing chain carries the
update the rest of the way — so no ViewModel, no UpdateUseCase and no GetRoomsInfoUseCase changes.
It hangs off ResourceDisposerUseCase, which already owns a scope, already has dispose(), and is
already the single place background work starts from AppRoot.

Verified: composeApp compiles for Android, iOS simulator and the Aurora linux target.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A room tablet is supposed to fall back to the room it was set up with after a minute untouched.
On Aurora it never did: the whole mechanism was Android — a Window.Callback wrapper installed over
every activity, an ActivityLifecycleCallbacks to install it, and a Handler to time it. The common
half was fine; nothing was calling it.

Rewriting it for Compose turned up a second bug, this one on Android. A Dialog has a Window of its
own and a Popup adds its own View to the WindowManager, so neither dispatches through the
activity's Window.Callback. Sitting in the booking editor for a minute typing an organizer's name
therefore reset the date and rebooted the screen under the user's hands. The Compose tracker sees
those layers, so that stops happening too.

The timer is the same idea without the Handler: a shared flow of restarts collected with
collectLatest, which cancels the pending delay on every emission exactly the way removeCallbacks
plus postDelayed did. The observer is a Box with pointerInput on PointerEventPass.Initial and
onPreviewKeyEvent, consuming nothing — the initial pass is the point, on the main pass a clickable
child would have taken the event first and the root would see nothing.

It is installed in three layers, the same three that already re-apply ForcedLandscape and
ScaledUiDensity, and for the same reason: separate windows on Android, separate scenes in the
Aurora fork.

Two notes. The timer sits in core:ui rather than next to DateResetManager in core:domain, because
core:ui does not depend on core:domain and this is not worth a new module edge — the callback is
handed in from composeApp, which sees both. And hardware keys with nothing focused inside Compose
may not reach onPreviewKeyEvent, where dispatchKeyEvent saw everything; on a touch kiosk that is
the acceptable half of the trade.

InactivityManager.inactivityState is not carried over. It had no readers.

Verified: composeApp compiles for Android, iOS simulator and the Aurora linux target.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
enableUpdateButton was a stored flag written from four places that did not agree. updateEventDate
recomputed it only inside `if (selectOrganizer != Organizer.default)`; updateEventDetails
recomputed it unconditionally. So which rules applied depended on how you had got there.

It also started out false and mapToUpdateBookingState never overrode it, which means the save
button on an existing booking was dead from the moment the editor opened and only came to life if
something happened to recompute it. On Android that something was closing the date picker. On
Aurora the date picker dies on open, so it never happened at all — the button stayed enabled from
a stale state, the request went out, and the backend rejected it. That is the reported difference
in behaviour between the two platforms on an occupied slot; the platform was never the cause.

So compute it. isFinishTimeExceeded joins the state — it was a local variable calculated
identically in two places and folded into the flag, which is exactly where the two paths drifted
apart. The organizer check reads organizers/selectOrganizer straight from the state, so the button
also comes to life on its own once the organizer list has loaded, with nothing to remember to call.
isInputError deliberately stays out of it: that flag is about painting the field red once the user
has finished typing, not about whether the booking is valid.

Two more things fall out. updateStateWithNewEventDetails now clears the error flags, so
"Произошла ошибка" no longer sticks to the button until the editor is closed — nothing cleared it
before. And the constructor primes the validation from the event it was opened with, instead of
waiting for an edit.

Behaviour on an occupied slot is unchanged and deliberate: the button is disabled, not enabled
with an error.

Verified: composeApp compiles for Android, iOS simulator and the Aurora linux target.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Tapping the date field on Aurora hung the app and then killed it. The bug is not ours: the fork
ships androidx.compose.material3.internal.PlatformDateFormat as a stub — `// @todo feature linux`,
`firstDayOfWeek = 0`, `weekdayNames = emptyList()`, `formatWithSkeleton` returning "". Material3's
WeekDays then walks `firstDayOfWeek - 1 until weekdayNames.size`, which is `-1 until 0`, indexes an
empty list and throws on the first frame. The fork swallows exceptions thrown from a composable and
rolls the frame back, which is why it reads as a hang rather than a crash.

There is nothing to patch around. Even with the index fixed the month headline would be empty
(formatWithSkeleton) and the grid shifted by one (daysFromStartOfWeekToFirstOfMonth), so the date
half is replaced outright with a 6x7 grid of Columns and Rows. The time half is untouched: it never
reads weekdayNames, and it passes is24Hour explicitly so it never falls back to the stub either.

The layout maths lives in shared:core/linuxMain as CalendarGrid, apart from the composable — always
six rows padded with nulls, so the dialog does not change height when you page months, and the
leading-blank calculation written the way the fork gets it wrong. Russian month and weekday names
sit next to it; note that the genitive list the date formatter uses ("25 ноября") is not the one a
calendar header needs ("Ноябрь 2026"), so there are two.

LaunchedEffect(selected) is deliberate rather than emitting on click: the Material3 version fired
once on first composition, and that first emission is what primes the picker's own confirm button.

No LazyVerticalGrid and no FlowRow — both are SubcomposeLayout, which this dialog avoids on
purpose. No year picker and no ban on past dates: the backend serves a 14-day window and Material3
did not restrict past dates either, so either would be a behaviour change riding along with a
crash fix.

Verified: compiles for the Aurora linux target, and for Android and iOS, which this does not touch
(everything new is linuxMain). Not yet run on the device.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
800 dp laid Aurora out in 1333x800, about 15% more room than the reference Android tablet, which
hid the fact that the fork draws Cyrillic wider — at the price of the whole UI being ~15% smaller
than it was designed. Testing reported that as "formats, sizes and text differ from Android".

686 dp is parity, and it turns out to be exact rather than approximate. Both real devices are
1200 px on the short side — the Quadro's window is 1200x2000, the reference is 1920x1200 — so the
substituted density is 1200/686 = 1.7493 against Android's own 1.75. The ~3% recorded in AURORA.md
is the gap between the devices' system densities, which is not the number the app ends up laying
out with.

The measurements came from the metrics overlay in the screenshots attached to the bug report, so
no separate measuring session was needed.

Not verified on the device. The arithmetic is solid; what parity does to the text wrapping that
800 dp was papering over is not, and fixing that wrapping is a separate change once someone has
looked at it on the Quadro.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
VersionOverlay() was called unconditionally, so the diagnostic line with the window size and
density shipped in release builds of Android and iOS as well as on Aurora, where it belongs.

Gate it on a platform flag rather than on isDebug directly: on Aurora isDebug is
Platform.isDebugBinary and the variant links a release binary, so isDebug would switch off exactly
the platform the overlay exists for.

Deleting the overlay outright is the eventual move, but not yet — it is still the only way to read
what ScaledUiDensity computed on the device, and the new 686 dp baseline has not been looked at on
the Quadro.

Verified: compiles for Android, iOS simulator and the Aurora linux target.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Correction to "poll the server for room updates where there is no push". That commit left iOS at
null on the assumption that FCM covers it. It does not: Collector.emit is called from exactly one
place in the whole repo, ServerMessagingService in androidMain, and composeApp/iosMain contains no
Firebase, no messaging service and no topic subscription at all.

So on iOS the subscribeOnBookingsList chain never fires, RefreshDataUseCase is never reached after
startup, and a booking made from a laptop calendar never arrives — the same defect as on Aurora,
in a platform nobody had thought to check.

One minute, matching Aurora. Android stays on push alone.

Verified: compiles for Android, iOS simulator and the Aurora linux target.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Revert this commit once push delivery is confirmed end to end.

Android has real push in production and does not need to poll. But push is only worth anything for
as long as it arrives, and when it does not, nothing else notices: the room list is served from the
cache, the cache is only refreshed by a push, and the screen stays wrong until the app restarts —
which on a wall-mounted tablet can be weeks.

That is not hypothetical. During testing the Android emulator showed a slot as free for an hour
after another client had booked it, and the backend had the booking all along. The local setup has
no working Firebase, so nothing ever arrived; a broken push in production would look identical from
the outside, and there would be no way to tell it from the tablet simply lying.

One minute, same as iOS and Aurora. If this is kept rather than reverted, it should be dialled down
to something deliberately low-frequency instead — a full minute is the test setting, not a
production one.

The AURORA.md push section is rewritten in the same commit because it still claimed the room list
was never updated at all, which stopped being true when polling was added.

Verified: composeApp compiles for Android. The Aurora and iOS targets were not built here on
purpose — a device test run is in progress and I did not want to compete with it for the daemon.
Neither is affected: androidMain is not part of the Aurora variant, and the commonMain change is a
comment.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Three things in AURORA.md were wrong or stale, all found by actually running the plan on a
TrustPhone T1 rather than by reading.

The documented way to read logs does not work. `ssh defaultuser@<ip> journalctl -f` answers "No
journal files were opened due to insufficient permissions" — defaultuser is not in the
systemd-journal group and the device has no sudo. Replaced with the three routes that do work,
starting with the one that is usually enough: the deploy task streams the app's stdout back, which
gives one log per run.

Keyboard input is no longer an open question. It types, and the organizer list filters as you go.
That item had been sitting in "Not finished yet" since the port.

The swipe-axis behaviour is now described rather than guessed at. Holding the phone sideways so the
content reads horizontally, the close gesture fires from the physical side edge and swiping up from
the bottom does nothing — which is exactly what a rotate(90f) that never touches window geometry
predicts, and is the missing description for the bug report ticket that had an empty body.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
… start time

The date/time picker filtered the overlapping events it had just found with
`it.startTime != startDate`. The intent was "ignore the booking I am moving, it is allowed to
overlap itself", but comparing start times is not that, and it went wrong in both directions.

Found on an Aurora device with two bookings, 10:00-10:30 by one person and 11:00-11:30 by another,
editing the second one:

- setting 10:00 left the confirm button enabled. The other booking starts at 10:00 too, so it
  matched `startTime == startDate` and was quietly dropped from the check — the picker offered a
  slot that was taken.
- setting 10:35 disabled it. 10:35-11:05 overlaps nothing but the booking's own old slot, which
  starts at 11:00 and therefore did *not* match — so the booking blocked itself and a legitimate
  move was refused.

Compare by id, the way BookingEditorViewModel.checkForBusyEvents already does; a blank id means a
booking that does not exist yet, so there is nothing to exclude. busyEvents itself already keeps
only events that collide, so this filter has no other job.

The first symptom never reached the backend — the editor's own check is id-based, so the save
button would have refused it a step later. The second one cost the user a move they were entitled
to.

Verified on the Android emulator against the same two bookings: 10:00 now reads "This time is
already booked" and 10:35 is accepted. Compiles for Android, iOS simulator and the Aurora linux
target.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
kotlinx-datetime 0.7 renamed `dayOfMonth` to `day`, both on the property and in the LocalDateTime
constructor, and the four warnings had been sitting in DateTimePickerComponent since the previous
branch.

The same cleanup does NOT apply to the Aurora calendar, and that turned out to be the interesting
part. `monthNumber` is deprecated in favour of `month.number` on Android and iOS, but the Aurora
variant builds against the fork's own kotlinx-datetime — 0.7.1-aurora — which has no `Month.number`
at all. Following the warning there breaks the linux build, so it stays as it is with a comment
saying why, or the next person to tidy warnings will make the same mistake.

Verified: all three targets compile, and the warnings are gone from both files.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Reverts the temporary polling backstop from c202888 now that the Aurora device testing it was
covering is done.

Not a git revert: that commit also rewrote the AURORA.md push section, which until then still
claimed the room list was never updated at all — untrue since polling was added. That correction
stays; only the Android interval and the notes about it being temporary go.

The reason the backstop existed has not gone away, so it is written down rather than dropped: a
push that fails to arrive is indistinguishable from nothing having changed, and the screen stays
wrong until the app restarts. It was seen during testing — the emulator showed a slot as free for
an hour after another client had booked it. Push delivery on real Firebase has still not been
confirmed end to end; if that check ever fails, this is the first thing to reach for.

Verified: composeApp compiles for Android.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
forceLandscape, popupIsSeparateScene and uiScaleBaseline were a file apiece
per source set — twelve files holding one declaration each. The package is
unchanged, so no call site moves.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
roomRefreshInterval is the only switch here so far; the file is named for what
it holds rather than for its single occupant, the way core:ui now is.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
statusBarInset and showDiagnosticsOverlay, eight files down to four. When the
overlay goes — once the scale baseline is confirmed on the Quadro — that is an
edit to this file rather than a file to delete.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The two placements are platform facts, not a branch: Aurora's popup is a scene
of its own and is positioned by hand, everywhere else an anchored position
provider does it. Behind an internal expect/actual now.

Android and iOS share one implementation and do not duplicate it — there is no
source set common to just those two, so AnchoredOrganizerList stays internal in
commonMain and both actuals are a single call to it.

popupIsSeparateScene survives with one use, the maliit key-event log on the
field: that diagnostic is about the fork's input path, not about placement.

InactivityTracker is kept in both layers — without it, a minute and a half
inside the Aurora dialog resets the screen.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The expect class was removed because on iOS and Aurora nothing ever constructed
it — the only instance was built in AppActivity. That was a DI problem, and the
common ticker fixed it by making all three platforms hold a timer, which is the
expensive answer on a device that sits on a wall for days.

So the three implementations come back, each on its system's own mechanism:

- Android subscribes to ACTION_TIME_TICK again — the system wakes the app every
  whole minute by itself, so there is no timer at all, and the flip lands on :00
  for free. ACTION_TIME_CHANGED and ACTION_TIMEZONE_CHANGED cover the clock or
  the zone moving under the app; the timezone action is new and worth keeping.
- iOS is back on NSTimer, with the first fire pushed to the next whole minute,
  plus NSSystemClockDidChange so a clock change shows up at once rather than on
  the next tick.
- Aurora has neither mechanism, so it keeps the coroutine — CurrentTimeTicker,
  which is where the wall-clock alignment already lives.

DI is the actual fix: timeReceiverModule() is an expect module in the shape of
settingsStoreModule(), and only the Android actual needs a Context, which only
the Android graph has.

Lifecycle moves off AppActivity and onto AppRoot, the root all three platforms
share, as a DisposableEffect — the receiver is registered against the
application context, so an activity is neither needed nor leaked. start() and
stop() are idempotent.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…s back

On iOS the date/time picker was inert: tapping a day in the calendar closed the modal without
applying the date, and neither time wheel would scroll. Bisected to 5725761, the compose-navigation
swap — 98a1d58 and its deps-only successor f9e95d7 are fine, 5725761 reproduces it exactly.

calf's pickers are native UIKit views on iOS, and inside a Compose dialog window they never enter
the hit test: the touch falls through to whatever Compose node is underneath. That was already
diagnosed once, on 718cc49 of feature/replace-decompose-with-compose-navigation (Calf issue #115,
"native picker draws on the wrong view inside a container"), and fixed there by rendering the modals
as state-driven overlays. But that branch later went back to dialog<> destinations, and it is that
end-state the Aurora port carried over — so the fix was lost on the way in.

Restore the arrangement that was verified end to end on
feature/calf-ui-with-compose-navigation-mvp @ 20e84fd:

- The three modals (FreeRoom, BookingEditor, FastBooking) are state-driven overlays in the main
  composition again — ActiveModal + ModalHost — not dialog<> destinations. Only Settings and Main
  stay navigation destinations, so the modal routes and the serializable NavType go away with them.
- The date/time picker gets its own Compose Dialog back, and it is now the only dialog window in the
  chain. Both halves are load-bearing: the overlay is what lets the native views receive touches at
  all, the Dialog's present animation is what masks the frame where calf has not applied our colors
  yet. The editor opens it through its own showSelectDate state, which the ViewModel never stopped
  carrying.
- The Aurora wrappers move with the window that needs them: ForcedLandscape, ScaledUiDensity and
  InactivityTracker are re-applied inside the picker's Dialog (its own scene in the fork) and
  dropped from the modal host, which is part of the main scene and covered by AppRoot.

Two things this does not fix and that were hiding behind it. The scrim's own clickable was
swallowing the tap and popping the back stack, which is what made the failure look like "the modal
closes" instead of "nothing happens"; it is harmless here because the picker's Dialog sits above the
scrim. And the main screen's date field is still a no-op — MainScreen passes an empty
onOpenDateTimePickerModalRequest.

Verified on the iPad simulator against the local backend: the picker opens over the editor, a
calendar day selects (Aug 4 -> Aug 6), both wheels scroll (08 -> 10, 00 -> 04) and the value lands
back in the editor as "Book from 10:04 AM to 10:34 AM". Fast booking, its confirmation and Release
all still open, act and dismiss. Android APK builds.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Tapping outside the booking editor closes it; tapping outside the picker did nothing, so the two
modals behaved differently for the same gesture.

The picker's Dialog was relying on the platform's dismissOnClickOutside, which decides from the
dialog content's bounds — and the wrappers this branch put inside that window (InactivityTracker,
ForcedLandscape, ScaledUiDensity) all fill it, so there was no outside left to tap. Before those
wrappers existed the content was just the card, which is why this worked on the pre-swap flow.

Do it by hand instead, the way ModalHost already does: a full-size dismissing Box around the card
and an absorbing one on it. Transparent, not dimmed — the modal host under this window is what draws
the 0.9 black.

Verified on the iPad simulator that the added click handling does not cost the native pickers their
touches again: outside tap closes the picker and leaves the editor open, while a calendar day still
selects (Aug 6) and the hour wheel still scrolls (08 -> 10). Android APK builds.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The overlay switch deleted DialogBackgroundDim and the dialog<> destinations,
and the comments that named them are not something the compiler checks. The
Aurora popup pointed at the deleted scrim for its "re-apply the wrappers here"
precedent — the picker's own Dialog is that precedent now — FastBooking still
claimed a dialog window it no longer gets, and composeApp/README.md still
described the modals as destinations.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
An overlay is not on the back stack, so on Android back fell through to the
NavHost, found nothing above the start destination and finished the activity —
verified on the emulator: with the booking editor open, back left the launcher
on top. Under lock task the app would come straight back, which hides it rather
than making it any less wrong.

Not androidx.compose.ui.backhandler.BackHandler: it ships in a separate
ui-backhandler artifact this Compose version does not have, and adding a
dependency would have to clear the Aurora fork's maven too. Android is the only
one of the three with a back gesture, so the expect/actual is cheaper — iOS and
Aurora get a no-op.

Verified on the emulator: back now closes the editor and leaves the app on the
main screen.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
AURORA.md still described TimeReceiver as a coroutine on every platform and said
nothing about where the instance comes from, which is the part that was actually
broken. clients/README.md listed the tablet's platforms as Android and iOS while
the same file has an Aurora build-variant section further down, and described the
modals as navigation destinations.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The tablet goes back to the room it was set up with after a minute untouched,
but a modal open on top of it stayed: the screen underneath returned to room A
while the overlay went on saying "Book B". The next person walks up to a booking
dialog for one room over a schedule for another.

The timeout reached exactly one consumer, so there was nowhere to hang the
second reaction. It is a SharedFlow now: AppRoot still resets the date and the
room, and AppNavHost clears the active modal. Closing the overlay takes the
date/time picker with it, since that lives inside the booking editor's
composition.

Verified on the Android emulator and the iPad simulator: with the editor open on
a non-start room, after a minute the modal is gone and the screen is back on the
start room; and eighty seconds of continuous tapping inside the modal still
keeps it open, so the tracker has not started ignoring input.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The four PNGs in composeApp/icons were placeholders of the right sizes but not
the application's icon. They are now scaled from the iOS artwork, which is the
largest source in the repo at 1024x1024 — centre-cropped to 764 first, because
the artwork carries a purple border around the icon proper that a plain resize
would have kept.

Square to the edge rather than the rounded corners the placeholders had. That is
also a guess at bug #8, the white border around the icon on Aurora: if the
launcher paints a light backdrop under the transparent corners, this removes the
transparency it shows through. Unverified — it needs the device.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The organizer field sits low in the booking card, and the keyboard covered it on
both platforms: on Android the card stayed put and the keyboard came over it, on
iOS the system shortened the scene and the card was squeezed until its own
content clipped from the inside.

Aim at the field rather than the card: it reports its bottom edge while focused,
and the modal moves by however much it takes to leave FIELD_TO_KEYBOARD_GAP above
the keyboard. The card keeps its full height — measured against the tallest the
host has been, since the scrolling list inside it forbids an unbounded height —
so what does not fit is clipped by the screen instead of squashed.

The move is a draw-time translation, not a layout offset: the field reports its
position from layout, so moving it there fed the shift back into the number the
shift is derived from, and the card jittered between two positions. For the same
reason the resting position is captured while the shift is still zero, and only
retaken when the scene resizes.

Only "how much of the content the keyboard covers" is per-platform. Android
subtracts the navigation bar from the ime inset, since the content is padded away
from it already; iOS answers zero, because the scene was shortened before any of
this ran; Aurora also answers zero, with the maliit height left to wire up. The
shift may go negative to bring the card back DOWN — that is what iOS needs, and
it is capped at the card's overhang so a card that fits never drifts.

While the keyboard is up, a tap on the dim now puts the keyboard away and keeps
the modal; only a tap with no keyboard closes the modal. iOS has no dismiss key
on its keyboard, so otherwise the only way out was to close the dialog.

Calibrated against logged numbers on both platforms rather than screenshots:
Android lands the field 16px above the keyboard at density 2.0, iOS 18px.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The keyboard overlap is a platform switch like the other three, so it moves into
Platform.kt next to them instead of keeping four files of its own — three of
which existed to answer zero. LocalFocusedFieldBottom travels with it: it is the
channel that makes the overlap useful.

ModalHost gets its own file. It had grown past the navigation graph it was
tacked onto: a dim, a ViewModel store, a back gesture and the keyboard geometry
are not what a reader opens AppNavHost.kt for.

The Aurora actual keeps answering zero but no longer says TODO, since nothing is
pending in the code — the fork reports no keyboard insets at all, and the note
now says which end would have to change. AURORA.md gains the switch in the layout
table with the same caveat spelled out.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The number moved to 686 dp when the baseline was derived from screen parity
rather than from a familiar 10" tablet, and the linux actual and AURORA.md were
updated at the time; the expect's own KDoc was not.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The state event maliit sends on open carries height = 0; the real size comes in a follow-up event
that never reaches the app. So the height is polled from Keyboard.height() rather than listened
for, which also leaves no subscription for the fork's unpaired onWindowPause() to drop.

Aimed at the tablet: there the keyboard rises from the bottom of the content, where the shared
ModalHost geometry expects it. On the dev phone it comes up the right-hand side and a vertical
shift does nothing.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
den3000 and others added 30 commits August 6, 2026 13:43
… what the modal did

The press notice was timed against the phone, where a keyboard follows within a second or two. On
the Quadro T a press has been seen to produce one six seconds later, with focus granted and taken
away again in between — so the three-second promise expired mid-handshake, the modal dropped back,
and the host read that drop as a keyboard that had gone away: it cleared the focus and closed the
session while the keyboard was still on its way up. The app was fighting the keyboard it had asked
for. The promise now runs for ten seconds, and is withdrawn on the two events that make it moot: a
keyboard actually showing up, and editing ending. Withdrawing it on arrival also settles the bounce
the phone showed — 535px to 324px to 0 as a spent promise lifted the card back for the remainder of
its grace.

The rest is diagnostics for a question a photograph cannot answer: whether the booking card is
moving out of the keyboard's way or merely being squeezed into what is left. ModalHost reports the
container, the card, the overlap and the shift; ScaledUiDensity reports the content size and the
density it derives from it, because that density follows the scene's short side and a scene that
shrinks for a keyboard would take the whole layout down with it — which from outside looks exactly
like a squeezed dialog.

Compiles for Android and Aurora.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The tablet lifts the modal by nothing at all: overlap 540px, shift 0px, which happens for exactly
one reason — the host never learns where the field is. The press handler was the culprit. It wrote
whatever the row's coordinates gave it, null included, and nothing restores a null: the layout
callback that would have reported a position fires when the layout changes, and by the time a field
is pressed it long has. So the report is now made only when there is something to report, and the
field says where it is on gaining focus as well, which on Aurora may be the only chance it gets —
focus arrives seconds after the layout has settled.

Whether the coordinates were the problem is now in the log too: "field pressed, bottom: …". A null
there says positionInWindow() is unusable at press time under the forced rotation, and the fix is
the coordinate frame itself — the field measured against the modal's container rather than the
window, as fix/aurora-maliit-deadlock does through ModalHostState.

Re-checked on the Android emulator: the press reports 1095px, focus follows 10ms later, the card
travels 0 to 263px as the ime rises, Done drops both focus and keyboard.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The tablet reported a field bottom of 557px in a 1200px container with 540px of keyboard, so the
host worked out that the field ended a hundred pixels above the keys and lifted the card by
nothing. The number was the field's position across the content, not down it: under ForcedLandscape
there is a 90° rotation between window and content, positionInWindow() goes through it, and the
window-Y of a point inside rotated content is its content-X. By the layout's own arithmetic that
field ends around 860px down, which is where the keyboard is.

So positions are now taken between two nodes on the same side of the rotation, where it cancels:
ModalHostState carries the container's coordinates and the field's bottom measured in them,
replacing LocalFocusedFieldBottom, and the host computes the keyboard's top as containerHeight
minus the overlap rather than reaching for window space at all. Taken from
fix/aurora-maliit-deadlock (84778f6), which arrived at the same frame from the same evidence.

Everything of ours around it stands: the height is still believed when it can be a keyboard and
estimated when it cannot, the press still promises a keyboard for ten seconds and withdraws the
promise when one arrives, the overlap is still clamped to the box, and the diagnostics that made
this diagnosable are untouched.

The Android emulator confirms the frames were equivalent there all along: the reported bottom moves
from 1095px to 1047px — the container's own offset from the top of the window — and the shift comes
out at the same 263px it did before.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The claim was that ForcedLandscape is a drawing effect the coordinates never see, so an anchor read
in window space could be used as-is inside a scene that lays out in content space. It came from the
list appearing roughly beside the field, which turned out to be a weak thing to conclude from: the
mapping walks up through every ancestor, the rotation layer included, so the Y reported for a node
inside the rotated content is that node's content-X. That is what puts the organizer list off to
the side, and it is the same confusion that had the modal deciding the keyboard was not in its way.

So the popup section now says why the anchor is wrong and that it cannot be fixed inside a scene
with no ancestor in common with the field, and the "Unexplained" line about the dropdown becomes an
explanation with a fix attached: stop making it a popup.

Also drops a guess of mine from the keyboard section — that the tablet's window was landscape while
the phone's was not. The probe measured both portrait, 720x1600 and 1200x2000, scene equal to window
on each. What differs is where maliit puts its keys: the window's bottom on the phone, which the
rotation turns into the content's right-hand side, and the content's bottom on the tablet.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A Popup on this fork is a second scene: it takes a visible pause to come up, arrives without the
rotation, density and inactivity tracking applied around everything else, and can only be aimed by
carrying the field's coordinates across. That last part never worked — the anchor came from
positionInWindow(), which maps up through the ForcedLandscape layer, so what it called Y was the
field's content-X and the list landed off to the side.

ModalHost now offers an overlay slot, composed inside the card's own box, and the linux actual
writes the list into it. It appears on the frame it opens, inherits the rotation and density already
applied around the card, and anchors against the field through their nearest shared ancestor, where
the keyboard shift and the rotation sit on both sides of the measurement and cancel. Taken from
fix/aurora-maliit-deadlock (84778f6), together with the cardCoords and overlay halves of
ModalHostState it needs.

The popup path is deleted rather than kept as a fallback: the list has one user, the booking editor,
and the editor is only ever composed inside a modal, so the fallback would have been a path nobody
runs — with a comment about coordinate frames that no longer hold. AURORA.md follows: two scenes
left instead of three, and the dropdown moves out of "unexplained" into "should be fixed, unverified
on a device".

Android is unaffected — it keeps its own actual, and the smoke test on the emulator puts the list in
the same place and the card at the same 263px shift as before.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The device block hardcoded port 22 and ~/.ssh/qtc_id, which is a real device on the LAN. The
SDK emulator is neither: qemu forwards its ssh onto a host port, and the only key it authorises
is the SDK's own. Both are now overridable the way the address already was, from
local.properties or -P, and the key path resolves against $HOME.

Defaults unchanged, so deploying to a real device is the same command it was.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The list took its width from the row and its x from the TextField inside it, and the row's
20.dp horizontal padding sits between them. On the emulator: card 1259 wide, row at x=61 and
1137 wide, field at x=96 and 854 wide — and a list 1137 wide drawn from x=96, so 96..1233
against the row's 61..1198. Off to the right, and hanging past the card's edge.

So the anchor is the row now, which is where the width already came from; the parameter says
anchorCoords rather than textFieldCoords because it is no longer the field. Android and iOS
take x from the popup's own anchorBounds and only ever used this for the vertical anchor, and
the row's top is the field's top, so nothing moves there.

The list also opens from the press instead of from the focus callback. Aurora grants focus at
the end of the maliit handshake, seconds later, so the list was arriving after the keyboard:
press, jump, pause, keyboard, pause, list. Nothing about it needs focus. The gesture detector
is remembered on Unit, hence rememberUpdatedState around the lambda it now calls.

Diagnostics under a new OrganizerList tag, printing the anchor against all three boxes —
twice now this list has been diagnosed off a screenshot, which cannot tell "offset" from
"wrong width". Sizes are spaced (`0 x 0`) because the deploy plugin reads a bare `0x0` as an
address in a native backtrace and fails the run over it.

Verified on the Quadro T emulator: anchor 61,612, list 1137 x 262, and the list logged before
the focus line rather than after it. Android and iOS compile.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The emulator is a target now, so the setup section says which two properties it needs and why
a real device needs neither. Its model has to be landscape or it reproduces the dev phone
rather than the tablet — the keyboard comes up along the content's right edge — and the first
deploy onto a cold emulator times out on the install, which is not a build error.

The dropdown item moves out of "not finished": it was two bugs, and the second one, width and
position taken from different nodes, outlived the move out of the popup.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
0.45 was the screenshot number rounded up: a 520px key strip across the Quadro T's 1200px short
side is 0.433, and up seemed the safer direction. The emulator has now answered the same 520
against the same 1200 through Keyboard.height(), which it reports honestly there — so the
fraction is two independent measurements agreeing, not one estimate, and there is nothing left
for the rounding to protect against.

Only reached where height() cannot be believed, which on the Quadro T is always: it answers
2000 there, the screen's whole long side. Visible on the emulator only in the first tick after
a press, before the real number arrives.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The script asked for :backend:app:bootRun with whatever buildVariant happened to be set, and
the Aurora variant rewrites the module list down to clients:tablet. So with `buildVariant=aurora`
active in gradle.properties — how anyone working on the Aurora client leaves it — the backend
failed to start with "project 'backend' not found", which reads as the local stack being broken
rather than as the flag it is.

Nothing about the backend is ever an Aurora build, so the flag is pinned rather than passed
through.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
settingsStoreModule() handed out a mutableMapOf with a TODO over it, so the room picked on
first run was forgotten with the process. On a wall-mounted tablet that is the difference
between being set up once and being set up after every reboot.

The fork's ak-shared-preferences backs it now. Its API matches SettingsStore one for one, but
a put only reaches the process — save() is what writes to disk, and without it this would have
been the same map through a C binding. Hence a save per write, which is affordable when the
only setting is the room.

Wrapped and logged under a Settings tag like every other call into the fork: a setting that
cannot be stored is not worth an app, and the caller gets an answer that is merely not
persisted. The tag stays silent unless something failed, and it did not.

Verified on the Quadro T emulator: room picked, app killed, app came back up in that room.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The press detector sat on the row, and the row holds the arrow as well as the field. So a tap
on the arrow ran the whole press path — the platform warned about a keyboard, the field
measured, the list opened, the card lifted — and then no focus arrived, because the tap was
never on the field. The grace timer put the card back down a moment later: a jump, a list, and
a keyboard that never came.

It moves to the field itself. The arrow has no clickable of its own on any platform, so with
the row no longer listening it is inert everywhere, which is what Android and iOS already
looked like.

That is also a regression this branch had just introduced and nobody had seen yet: opening the
list from the press is common code, so on Android the arrow would have started opening the
list too.

Verified on the Quadro T emulator.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Three names fill the list past its 150.dp cap by about nine, so the only thing the scroll
could do was travel that nine and settle back — which is indistinguishable from a scrolling
bug, and was reported as one. Twenty names, of varying length and script, so wrapping and
clipping are visible here too.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The list is the entire staff — 46 people in production — and a Column inside a verticalScroll
composes every one of them to show the three that fit under the 150.dp cap.

LazyColumn is safe here despite the note in DatePickerView.linux.kt: that warning is about
that dialog, not about the fork. LazyVerticalGrid already draws the room picker and LazyColumn
the main screen's slot list, both on Aurora.

Verified on the Quadro T emulator against twenty seeded organizers.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A flick threw the organizer list back the other way on release, while a slow drag was fine
throughout — and a slow drag is the one gesture that ends with no velocity, so it never flings
at all. That put the fault in the velocity rather than in the drag, so the fling grew a seam,
listFlingBehavior(), and the seam logged what it was handed. Three flicks in one direction on
the dev phone:

  fling v=-5851.73,   unconsumed=-5757.672
  fling v=-1241.1434, unconsumed=-0.0
  fling v=-900.90607, unconsumed=-0.0

Consistent and plausibly sized, so not a noisy tracker but one that disagrees with the drag
about direction. The first line is the tell: nearly all of it went unspent, so the fling ran
into an edge the list had just been dragged away from. The other two had room, which is when
the snap-back was visible.

The linux actual negates the velocity in and the remainder out; Android and iOS hand back the
platform default. The log line stays, because this corrects someone else's defect rather than
fixing it — a fork build that starts agreeing with the drag would need this taken out, and
nothing else would announce that.

Only the organizer list asks for it. The main screen's slot list and the room picker have the
same defect underneath, and nobody has flicked them hard enough to mind.

Verified on the dev phone.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Found while deploying a build before the local backend was up, which is a thing that happens
often enough to have read as "the emulator cannot connect" twice. It is not: the first load
fails, the screen latches that first error, and the once-a-minute refresh succeeding right
afterwards never clears it.

The evidence is in the note, including the timestamp showing the screen was displaying an
error older than every failure in the log — first, not latest.

It lives in feature/main's README because that is the screen it happens on and because it is
not Aurora-specific — it reproduces on Android by starting the app with the backend stopped.
AURORA.md gets a pointer only, since Aurora is where it keeps being met rather than where it
lives.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Three things that were being carried between sessions in someone's notes instead of the repo.

How rooms stay fresh, per platform: Android is pushed to and that is verified on hardware; iOS
and Aurora have no Firebase at all, so both poll. Together with what push-only costs, which
testing has actually seen — a slot shown free for an hour after another client booked it.

Clock and timezone changes: what each TimeReceiver does, why Aurora notices them up to a minute
late, and the one thing genuinely unverified — that Android reacts at once, which nobody has
sat down and tried.

And the organizer tags. The client asks for user_tag=employee and filters for employer; the
backend ignores the parameter, so the filter is what decides and staff carry employer. This
works in every environment it has run in and is written down because it reads like a bug, and
because the two sides only stay compatible while the backend keeps ignoring the parameter.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The polling section explains why the events are unusable but not the thing that actually stops
anyone reverting it: a second listenState subscription alongside the fork's own kills maliit —
after the first closed session the keyboard never opens again without a restart.

An expensive negative result from origin/fix/aurora-maliit-deadlock, and until now it lived
only in a handoff note, which is precisely where the next person to tidy this up would not
look.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The emulator is documented as a target now, so the thing standing between a new M-series
machine and that target belongs here too. It does not start, and the IDE blames a timeout: in
fact libsfdk hardcodes -accel hvf, Hypervisor.framework on ARM cannot run an x86_64 guest, and
qemu is dead inside a second. The wrapper that rewrites the accelerator is written out.

Also why Docker is unaffected and why an empty buildengines.xml is not a broken install — both
are things that look like faults on this platform and are not.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The Apple Silicon emulator workaround gets its own file. It is about one machine's SDK
installation, not about the app — nothing in it changes when the tablet changes, and nobody on
Intel or Linux needs it — so AURORA.md keeps a pointer and AURORA_QEMU.md keeps the detail.

localQuickStart gains what was being carried around in notes: check for a backend already
serving before starting another; run it in your own terminal, because a bootRun started as
somebody's background job dies with them and the symptom looks like a broken client; the
/v1/ in the workspaces path, whose absence answers "No static resource"; why the seed inserts
twenty organizers rather than three; and the four Android-emulator traps — adb not on PATH,
zsh not word-splitting an adb variable, -s when two devices are attached, and the soft
keyboard staying hidden until show_ime_with_hard_keyboard is set. Plus the reminder to put
api.url.debug back after building for iOS.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Removing the whole overlay went too far. "Which build is this one?" is asked of a tablet on a
wall, not of a debug build, so the version label stays and stays ungated — which is what it was
before a debug gate was put on it to keep the metrics out of release.

What comes off is the rest of that line: window size, system density and font scale, and the
density ScaledUiDensity computed. That was scaffolding for measuring the 686 dp baseline, and
UiScaleDiagnostics existed only to feed it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

take down the scaffolding

AuroraWindowProbe was installed to answer one question — does the window resize when the
keyboard opens — and it answered it: no, it does not, which is why the keyboard height still
comes from maliit. Its other half, the screen/scene/contentScale line, has served its purpose
too; the port is on a device and the numbers are in AURORA.md.

VersionOverlay and UiScaleDiagnostics go with it, and so does showDiagnosticsOverlay, which
existed only to gate the overlay — on Aurora hardcoded to true, because the Aurora variant
links a release binary and isDebug is false there.

ScaledUiDensity keeps its own UiScale log line. It costs nothing, it says what the overlay's
last useful field said, and the scale baseline on the Quadro is still unconfirmed — noted in
AURORA.md, along with what to put back if it has to be measured again.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
ForcedLandscape, ScaledUiDensity and statusBarInset were spelled out twice — once in AppRoot and
once inside the date/time picker's Dialog, which the fork gives a scene of its own so nothing put
around the root reaches it. Two spellings of an ordering that is not free is one spelling too
many.

They are now one composable, AuroraWindowFrame, and it is called in exactly two places: the linux
application {} block, and the linux actual of a new DialogSceneFrame — the seam that re-applies it
around content that gets a scene of its own. Common code asks for DialogSceneFrame and knows
nothing else; Android and iOS get content() and start AppRoot with the theme alone, because all
three layers are no-ops there. statusBarInset moves to core:ui so the frame can reach it, which
empties composeApp's platform files.

The order inside the frame is the part worth keeping, and two thirds of it was got wrong while
doing this:

- the inset goes inside the rotation, or the padding lands in the window's portrait space and
  shows as a stripe down the side;
- the inset goes inside the scale, not the other way round. Under the padding ScaledUiDensity
  normalises 1157px instead of the window's 1200, and 1200/686 = 1.7493 against the reference
  tablet's 1.75 is the entire point of the baseline — measured the other way the UI came out 3.6%
  larger, which is what the emulator showed before this was fixed;
- the theme goes outside the frame. AppTheme paints the background through its own Surface, and
  with the theme inside AppRoot the strip the inset leaves bare came out white. The bare Aurora
  window is not dark.

AppRoot keeps the inactivity tracker, the effects and the version overlay, and nothing about the
window at all.

Also written down, because the history shows it has been undone twice: what the picker's Dialog is
for, and that the compose-navigation swap is how the nested-dialog regression got in last time.

Verified on the Quadro T emulator, the TrustPhone, the Android emulator and the iOS simulator:
status-bar strip dark, density back to 1.7492712, picker upright and themed, calf's hour wheel
still scrolling under a swipe on iOS, and the organizer field, keyboard, list and selection
unchanged on Android.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
… the keyboard

Two comments that explained the wrong thing.

ModalHost's said it "provides a modal-scoped ViewModelStoreOwner", which reads as
though there would otherwise be none. There is one on every platform — the Activity
on Android, the ComposeUIViewController on iOS, and on Aurora the fork's own
scene-scoped default — and all three live as long as the app. What the modal needs
is not an owner but a shorter-lived one: a ViewModel is cached in its store by class
and parametersOf only runs on creation, so under a root owner the booking editor
opened for a second booking is handed the instance built for the first. Keying the
call per booking would answer that and leave an instance behind for each, in a store
nothing ever clears, on a tablet that runs for weeks. The KDoc now says that, and
that this is the scoping the modals had from their nested nav graph before they
stopped being dialog<> destinations.

The iOS entry point justified OnFocusBehavior.DoNothing by "the modal handles the
keyboard itself with imePadding()". There is no imePadding in the project and there
has not been since the modal started moving the card by a draw-time translation
aimed at the focused field. The reason for the setting is unchanged — UIKit must not
shove the content up on top of a shift the app has already applied — so only the
explanation is replaced.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
ModalHost had grown into two files' worth of work: the dim, the ViewModel scope and
the back gesture on one side, and on the other the arithmetic that keeps the field
being typed into clear of the keyboard — the resting position captured while the
shift is still zero, the retake when the scene resizes, the clamp of an overlap
Aurora reports as the screen's whole long side. The second half is now
ModalKeyboardShift.kt, and the host measures for it and applies what it answers.

The watch that ends editing when the keyboard goes away behind the app's back went
with it, and had to. It reads the same overlap the shift does, and on Aurora
softKeyboardOverlapPx() is a produceState polling the maliit session every 100 ms —
a second reader is a second poll of the one binding already known to be fragile. So
the overlap is read once, and everything derived from it lives on that side of the
call. The KDoc says so, since separating them looks like an improvement.

The host's own layout is now two named composables, DimBox and KeyboardShiftableBox,
which take the same pair of callbacks: a height and a position. Those are not two
ways of asking the same thing — the height feeds composition and has to invalidate
it, the position feeds measurement in the layout phase — and neither can stand in
for the other, because the same LayoutCoordinates instance comes back every time and
a state holding it never reports a change. That would have been a height that
quietly stops updating exactly when the scene shortens for the keyboard on iOS,
which is the one platform it matters on. The note is in the code now rather than in
somebody's head.

Smaller things along the way: the two nested CompositionLocalProviders are one call
with two values, since nothing between them depended on the store owner; the card's
height cap is a Dp carrying Dp.Unspecified rather than an Int carrying 0, which
requiredHeightIn already reads as no constraint, so the then/if ladder is gone; and
two imports that had been dead since the coordinate frames changed are dropped.

One thing is not a pure move: the tallest-the-host-has-been height is now derived in
composition from the container height rather than in the size callback beside it.
The result converges to the same number, at the cost of one extra recomposition when
the scene resizes.

Compiles for Aurora linuxX64, Android and the iOS simulator. Not run on a device:
what wants confirming is the iPad simulator, where the height cap is the half of
this that does any work.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…ulator finds one

The Aurora variant links a release executable, so the URL baked in is api.url.release
and api.url.debug is never read. The logging section already says that flag is why
the log needs installing by hand; the setup section, which is where somebody reads
before a first run, did not mention it at all.

It costs a session every time. A release URL left pointing at a stand on the office
LAN is unreachable from anywhere else, and the app then starts, draws its whole
layout and stays empty — which reads as an emulator that cannot reach the network
rather than as an address nobody looked at.

The number that was missing: on the SDK emulator the host is 10.0.2.2, qemu's
user-mode networking putting it at the same address the Android emulator uses, so
the localQuickStart backend needs no forwarding to be reachable. With the one-line
curl over ssh that settles it from the guest before the app is blamed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A tablet moved to another zone went on converting in the one it was started in. The
clock still ticked, so nothing looked broken — but every answer derived from "what
time is it now" was wrong by the offset until somebody restarted the app: which
booking is the current one, where the day is cut into slots, how long is left before
a room frees up. Testing hit it from the other end and reported the shape without
the cause: the time was picked up, the zone was not, and afterwards the countdown to
a room being freed stayed broken.

The zone was captured in five places. `defaultTimeZone` in shared:core was the one
that mattered — roughly forty reads of `currentLocalDateTime` and `currentLocalDate`
go through it, which is most of the data layer. `SlotUseCase` and `SelectRoomUseCase`
each held their own copy in a constructor default, and Koin builds both as singletons,
so those were captured once per process too. `RoomInfoUseCase` held a third.
`CurrentTimeTicker` held a fourth, and that one is Aurora's, the platform with no
system notification to fall back on.

All of them now read the zone instead of storing it, and the local copies are gone:
one accessor, used everywhere. The remaining direct calls to `currentSystemDefault()`
were behaviourally fine — a call inside a function is already fresh — but they were a
sixth, seventh and eighth way to spell the same thing, so they go through the shared
accessor as well.

Where a computation converts more than once, the zone is taken into a local first.
Not for the saved lookups: those conversions are one answer, and a zone read again
halfway through it could be a different one. `RoomInfoUseCase` was the worst of these
— the read sat inside a filter over every event of every room, next to a `now` that
was already a snapshot.

Injecting the zone was considered and dropped. The constructor parameters existed for
tests that do not exist, nothing ever passed one, and the cost of having them was
exactly the capture being fixed here. If they are wanted back, they should come back
as `() -> TimeZone`, the way CurrentTimeTicker takes it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The time in the header was not coming from the ticker. It was formatted out of
`selectedDate`, which carries a time only incidentally: nothing advances it except
the date arrows and the inactivity reset. On a tablet nobody is touching, that reset
is what moved it — once a minute, to whatever the time was at that moment — so the
header advanced a minute at a time and looked like a working clock while sitting
between zero and sixty seconds behind. Compared minute against minute it reads as
exactly one minute slow, which is how it was filed.

The countdown next to it is driven by CurrentTimeHolder and was always right, so the
two numbers on the same screen disagreed. That is what made it findable: a probe on
the tick showed the tick landing within thirty milliseconds of the minute and
reaching the screen a tenth of a second later, on Aurora as on Android. Nothing was
wrong with the clock — the header was reading a different value.

So the day comes from `selectedDate`, which is what the arrows move and what the
header has to follow, and the time comes from `currentDate`, which is the clock. Only
while the selected day is today: on any other day the time on screen would be neither
the selected one nor useful, and the future pattern drops it anyway.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A room whose booking had just begun went on showing itself free. Which booking counts
as the current one is decided in the repository, against the clock — but only while
the repository is emitting, which it does on a refresh and not on a timer. So the
room did not turn busy when its booking started; it turned busy the next time data
happened to arrive. On the platforms that poll that is up to a minute late. On
Android, which has no poll, it is whenever a push turns up — and a push that never
turns up is indistinguishable from nothing having changed, on a tablet that runs for
weeks.

The tick already reached this ViewModel and already moved the countdown; it now
re-reads the rooms as well. That read is the local one — the network is touched only
when the cache has nothing, the same rule every other caller gets.

Worth knowing for the next person watching a slot list: this also refreshes the slots
once a minute on Android, where previously nothing happened between pushes.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Under TCG the guest loses time against the host and there is no NTP to pull it back —
a minute and a quarter after a couple of hours, measured. So the emulator's now is
genuinely not the Mac's, and any judgement about the app's clock has to be made
against the guest.

This is worth a paragraph because the wrong comparison is the natural one and it is
convincing: the app's header sits whole minutes behind the macOS menu bar, and the
gap grows over a session, which reads exactly like a clock that runs slow. It came
within one message of filing a working fix as a failure.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The tick was re-reading rooms, and re-reading rooms asked the slot component to
rebuild. That is worse than the churn it looks like. The list is sliced from *now*,
so a minute later it is a different list — the first slot has a new start, a run of
consecutive bookings is a different group — and the component, which tries to carry
`isOpen` across an update, has nothing to carry it to. An expanded group collapses
on its own once a minute.

Under a finger it is not cosmetic. A rebuild landing mid-scroll leaves the list
taking no touches at all: nothing on it responds until the room is switched away in
the corner and switched back. Reported on the iPad and reproduced on Aurora.

What the tick is for is the room — busy or free, and the countdown — and that needs
no slot rebuild. So the tick reads rooms with `refreshSlots = false` and the list is
left to the things that really change it: a poll, a push, or the user.

This does not close the underlying defect, only stops this branch reaching it. The
poll rebuilds the list once a minute on Aurora and iOS the same way, and has since
before any of this — so the collapse, and the wedged list behind it, are still
reachable there. Both want their own fix: identity for the groups that survives a
re-slice, and whatever in the list stops answering when it is rebuilt mid-gesture.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant