Skip to content

General enhancements - #7

Open
xarantolus wants to merge 74 commits into
mainfrom
feature/enhancements
Open

General enhancements#7
xarantolus wants to merge 74 commits into
mainfrom
feature/enhancements

Conversation

@xarantolus

Copy link
Copy Markdown
Owner

Adds a lot of features I wanted for a long time, like:

  • More persistent offline cache
  • Cache-first + refresh instead of waiting until live data has loaded
  • Generally way better design
  • Better background task handling
  • Prefer using cached launches on event pages, when the launch linked in an event has fewer details

Warning: very vibe coded, didn't look at the code too much tbh.

xarantolus and others added 30 commits August 27, 2026 00:47
Self-contained Android build environment: Flutter 3.47.1, JDK 17, Android SDK
37.0/36/34, plus Claude Code, the Android docs MCP and gh for CI watching.
Gradle/pub caches and the Claude state live outside the image so they survive
rebuilds.

Also ignore generated localizations, Gradle output and signing material, and
stop the blanket *.sh rule from excluding the container scripts.
The APK was only uploaded for release commits on main, so branch builds were
thrown away. Upload it as a run artifact on every push and PR, and fall back to
a debug build where the signing secrets are unavailable (forks, PRs) so a run
always produces something downloadable.

Update the actions, pinning Flutter to the version the dev container uses.
The app was pinned to Dart 2 and no longer built on a current toolchain.

Dependencies: raise the SDK constraint to Dart 3 and move every package to a
version that supports it. Two had no Dart 3 release and were replaced -
pinch_zoom_image_last by the built-in InteractiveViewer, and infinite_widgets
by a local InfiniteGridView with the same constructor arguments. rive stays on
0.13.x because 0.14 is a rewrite that drops RiveAnimation.

API updates for the new package versions: flutter_custom_tabs v2 (launchUrl),
flutter_local_notifications v22 (named arguments, androidScheduleMode),
workmanager's ExistingPeriodicWorkPolicy, and CardThemeData. Localizations now
come from lib/l10n instead of the removed flutter_gen synthetic package.

Android: Gradle 9.3.1, AGP 9.1.0, Kotlin 2.4.0, Java 17, and SDK levels derived
from flutter.* with a floor of 37 for permission_handler. Drop the manifest
package attribute and jetifier, both gone in AGP 9. Release signing is now only
configured when a keystore is actually present, which previously made every
build without signing material fail.
Flutter skips the warning when /.dockerenv exists. Podman doesn't create that
marker, so add it in the image - running as root is deliberate here, since the
default rootless mapping is what keeps bind-mounted files owned by the host
user. Using --userns=keep-id instead fails at container create on this podman
store.
The app schedules launch notifications but never declared POST_NOTIFICATIONS or
requested it at runtime, so nothing was delivered on Android 13+ unless the user
enabled notifications by hand. Request it (alongside the existing exact-alarm
request) when subscribing to a launch or event; subscribing to an event asked
for no permissions at all before.

ScheduledNotificationBootReceiver was registered without
RECEIVE_BOOT_COMPLETED, so scheduled notifications were lost on reboot. Declare
it.

Also replace APIs deprecated in the new dependency versions: url_launcher's
launch (now routed through the UrlLauncher mixin), WidgetsBinding.instance
.window, and workmanager's isInDebugMode, which no longer has any effect.
Ship the emulator, an x86_64 android-36 system image and a ready-made AVD, so
the app can be installed and screenshotted without a physical device. Interaction
goes through the mobile-mcp server, which attaches to a running device but
cannot boot one - emulator.sh covers just that lifecycle gap.

The emulator needs /dev/kvm, so pass it through; a shell that predates joining
the kvm group is re-exec'd via sg. AVDs live on a volume so extra API levels
survive rebuilds, which is what makes checking version-specific behaviour
practical.
Falling back to a debug APK hid a broken release setup behind a green run and
produced an artifact that looked like a release build but wasn't. A release
build assumes the signing secrets exist, so say so and stop.
Artifact names reject slashes, so any branch with one (feature/...) failed the
upload after a successful build.
Two sessions sharing the AVD volume could not both run it - the emulator aborts
unless started with -read-only.

wait-idle polls the device's received-byte counter and returns once fetches
stop, so screenshots can be taken when content has actually loaded. A fixed
sleep is both slower and unreliable against an API that regularly takes 10s+,
and diffing frames doesn't work because the launch countdowns tick every second.
The Launch Library allows 15 requests an hour and regularly takes ten
seconds or more to answer, so blocking a listing on the network meant
staring at the loading animation on every start.

Listings now render whatever is cached immediately and refresh behind
it. A refresh that fails leaves the old data on screen with a notice
rather than blanking the list.

CacheFirstController owns those rules and takes no BuildContext, so the
state machine is unit-testable and the async gaps stay out of the widget
tree. APIClient.readCache reads the store without ever falling through to
the network, which is what makes the first paint immediate — fetch(
preferCache: true) still goes online on a miss.

Covers launches, events and articles. The refresh shows as a slim
progress bar over the list; a background refresh that lands after the
user has paged further is dropped rather than shrinking the list under
them.

Also removes a dead private method the analyzer flagged as unused.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CT1urjrk4XeU2xAxWvyfmd
Gradle downloaded both on the first build. Interrupting that download
leaves a partial NDK behind, and every later build then fails with
"Android sdkmanager did not install NDK <version>" until the directory
is removed by hand.

Two NDKs are needed: 28.2.13676358 for flutter.ndkVersion, and
25.1.8937393 which rive_common hardcodes and cannot be pointed at the
newer one, since it builds with minSdk 19.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CT1urjrk4XeU2xAxWvyfmd
Android 15 forces edge-to-edge on apps targeting it and targetSdk follows
flutter.*, so content was already drawing behind the system bars: the
launch and event detail pages ran their last row under the gesture pill,
and the bottom TabBar sat against it.

Flutter's AppBar applies the status-bar inset itself, but nothing applies
the bottom one. Scrollables now pad by the bottom inset, so content still
scrolls under the translucent bar but can be scrolled clear of it, and
the TabBar — a plain TabBar, not a Material NavigationBar, so it insets
nothing on its own — gets a SafeArea.

The status bar icons were dark navy on the saturated blue app bar,
because the overlay style set only statusBarColor and
systemNavigationBarColor. Android 15 deprecated and disabled both, so
they were doing nothing on 15+ and no icon brightness was ever set. Now
the brightness is set and the colours are dropped.

Opts into edge-to-edge explicitly so API 24-34 lays out the same way
rather than leaving two layouts to verify.

Verified on API 36 in both navigation modes and both themes.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CT1urjrk4XeU2xAxWvyfmd
Two of these were real bugs rather than style.

BuildContext across async gaps: copyLink used the context after awaiting
the clipboard write, and home_page and subscription_listing used it after
awaits that can easily outlive the widget — the search index walks every
cached page, and the subscription list fetches each subscription in turn.
Each now either captures what it needs before the gap (copyLink,
LaunchEventSearchDelegate, which is built after all that paging) or checks
mounted after it. None were silenced.

The model constructors in launch_response.dart took named parameters and
discarded them, so UpcomingLaunchesResponse(count: 0, results: []) built
an object with every field null. They now assign, which is what the
strict_top_level_inference lint was pointing at. Nothing outside the model
file used them, so nothing depended on the old behaviour; tests cover it
now.

The rest is mechanical: createState returns State<T> rather than the
private state type, and error_type becomes ErrorType with its extension
folded into the enum.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CT1urjrk4XeU2xAxWvyfmd
SCHEDULE_EXACT_ALARM is denied by default from Android 14 (API 34), and
it cannot be granted from a dialog — requesting it drops the user on the
system "Alarms & reminders" screen with the toggle off.

Scheduling exactly without it does not degrade, it fails:
zonedSchedule threw PlatformException(exact_alarms_not_permitted) and
registered nothing. So on a default-configured Android 14+ device the
user ticked "Receive notifications", saw a checkmark, and would never
have heard anything.

Notifications are now scheduled inexactly when exact alarms are not
permitted, and the exact path retries inexactly if the permission is
revoked between the check and the call. A launch reminder a few minutes
late beats no reminder.

Verified end to end on API 36 with the permission denied: all three
reminders schedule (dumpsys alarm shows window=+1h, i.e. inexact), all
three fire, tapping one opens the right launch, and they survive a reboot
via RECEIVE_BOOT_COMPLETED.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CT1urjrk4XeU2xAxWvyfmd
json_serializable's build_runner needs SDK >=3.11, so the pubspec floor
moves up from 3.4. That also changes how `dart format` formats: from
language version 3.7 on it uses the "tall" style, which reflows every
file in the project.

Kept separate from the model rewrite that needs it, so that diff stays
readable — everything here is formatter output, no behaviour change.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CT1urjrk4XeU2xAxWvyfmd
The app rendered a ticking second-by-second countdown for every launch.
The API says that is wrong for most of them: of 30 upcoming launches only
7 were known to the minute, 14 only to the month and 9 only to a quarter,
and events are never known to a time at all. net_precision and
date_precision say so on every record, and we were dropping both.

Countdowns now tick only when the API claims minute precision or better;
anything vaguer renders the window it actually claims ("NET August
2026"). Status moves from a table row to a pill on the card, because
"TBD" is what tells you whether a date is worth planning around.

The models are regenerated with json_serializable rather than
hand-written, so the types are declared once and parsing is generated —
no dynamic, no hand-rolled casts. Dates arrive as DateTime and ISO-8601
durations as Duration, parsed once at the boundary instead of re-parsed
at each use site. That typing immediately earned itself: it caught
rocket.spacecraft_stage changing from an object to a list in 2.3.0, which
a lenient parser would have silently turned into null.

Listings move to /launches/ with net__gte rather than /launches/upcoming/,
because the upcoming endpoint drops the past entirely and
hide_recent_previous is inert in 2.3.0 — a launch used to vanish the
moment it lifted off. The cutoff is rounded to midnight so the URL, which
is the cache key, stays stable for the day.

Cards keep the full image: 2.3.0 exposes thumbnails, but they are 256x256
square crops that would upscale badly in a full-width card, so
ApiImage.urlFor only picks one when the box is really smaller.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CT1urjrk4XeU2xAxWvyfmd
The page was a dozen sections stacked in one scroll, which made
everything equally prominent and therefore nothing prominent: it opened
on an image, then a mission description, and only mentioned status in a
table several screens down.

It now opens on a hero that answers what this is, whether it is
happening, and when — image, status, and a countdown that respects the
API's precision — followed by a chip row for rocket, orbit, mission type
and pad. Everything else moves into collapsible sections that show a
one-line preview while closed, so nothing is hidden, just ranked.

Adds two things the API always sent and the app never showed: the launch
time at the pad's own timezone, and the countdown timeline where it
exists (about one launch in eight).

The old separate countdown widget and the zoomable hero image go away;
the hero carries the time, and mission patches and pad maps are still
zoomable where zoom actually helps.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CT1urjrk4XeU2xAxWvyfmd
Events get the same treatment as launches, minus the parts that do not
apply: no status pill, because events have no status, and the time always
renders as a window, because event dates are never precise to a time —
the coarsest in the sample is a whole year.

Linked launches become their own section, which is the way through from
an event to the launch it hangs off.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CT1urjrk4XeU2xAxWvyfmd
Three additions, all from data already fetched and previously discarded:

Boosters get a real card — serial number, which flight of that airframe,
new or reused, turnaround since its last flight, and the landing attempt
with its type, zone and downrange distance. Present on 44-78% of
launches, and the part enthusiasts actually come for.

Launches get three quiet lines of context: where this sits in the
provider's year, in the pad's history, and how long since that pad last
flew. This needed agency_launch_attempt_count_year rather than
agency_launch_attempt_count — the all-time count read as "723rd SpaceX
launch this year", which is obvious nonsense once it is on screen.

Rockets get a spec table: height, diameter, mass to LEO and GTO, stages,
maiden flight and the success record. Only rows the API filled in are
rendered, so a thinly-described rocket shows a short table rather than a
column of "Unknown".

Also:

- The mission section no longer repeats the mission name that is already
  the hero title, and disappears entirely when there is no description.
- Without a photo the hero paints a colour panel instead of putting white
  text and a dark scrim over the light-theme placeholder glyph.
- The viewer's own timezone is now the prominent line and the pad's local
  time a quieter footnote beneath it.
- Tapping an update notification opens the page with Updates expanded,
  via separate payload actions, rather than dropping you at the top.
- Pinch-zoom on images is gone; it fought the horizontal pager between
  launches and never worked well.
- Detail pages rise from the bottom again. Flutter's Android default is
  now a predictive-back side slide, which reads as sideways motion in a
  vertically scrolled list.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CT1urjrk4XeU2xAxWvyfmd
liveNow and watchLive were scaffolding for a live indicator that is not
wanted, and turnaround was superseded by boosterTurnaround. Unused
localization keys are invisible once added, so they go now rather than
accumulating.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CT1urjrk4XeU2xAxWvyfmd
…estone

Every collapsible section was rendering its own heading inside itself, so
"Updates", "Programs", "Mission Patches", "Launches", "Space Stations"
and "Info" each appeared twice — and the inner ones were padded to zero
on the left while the content around them was inset, so they did not even
line up. The event page also repeated the event name that is already the
hero title. Headings now belong to the section; the renderers return only
entries.

Mission patches were worse than cosmetic: rendering filtered to patches
that actually have artwork while the section count did not, so a launch
whose only patch has no image advertised "Mission Patches 1" and expanded
to nothing. Both now derive from the same filtered list.

The timeline highlights whichever milestone is currently running. The API
gives each one a start offset and nothing else — no duration, no end — so
an entry is treated as running until the next begins, and the final entry
is deliberately never highlighted because nothing says when it finishes.
The clock only ticks between the first and last milestone, and only when
the launch time is known to the minute.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CT1urjrk4XeU2xAxWvyfmd
The feed fitted about one and a half articles on screen — a title, a
full-width image, the whole summary and a date each — which made it
impossible to scan for what was new. Articles are now compact cards:
thumbnail, headline, source and a relative time, about five per screen.
The summary is gone because it is almost always a truncated lede that
adds nothing to the headline.

A third of articles name a launch, and the id they carry is the same
Launch Library uuid our launch models use. Those articles now show a chip
that opens the launch directly. It resolves only against launches already
in the cache, so the decoration never costs one of the fifteen requests
an hour — an article about a launch we do not hold simply shows no chip.

Article parsing moves to json_serializable like the rest; the previous
hand-written version cast json["id"] straight into a non-nullable int.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CT1urjrk4XeU2xAxWvyfmd
Making every section collapsible made them all equally hidden and turned
the page into a filing cabinet — and it never addressed the real
complaint, which was that the content itself looked basic. Nothing
collapses now. Each topic is one card with a small uppercase label, in the
same rounded, bordered language as the listings, so a long scroll still
has structure to follow.

The Info block was a bordered Table, which drew a grid around every fact
and made the page read like a spreadsheet; it and the rocket specs are
now plain label/value rows.

No cards inside cards. Content within a card is flat: the booster loses
its tinted inner box, articles gain a `flat` mode that drops their Card
shell, and a section whose content is already cards — the launches linked
from an event — gets a bare SectionLabel instead of a card wrapper.

Where a card names something in its label, the inner heading that
repeated it is suppressed rather than shown twice.

The update-notification deep link now scrolls to the updates card instead
of expanding it, since there is nothing left to expand.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CT1urjrk4XeU2xAxWvyfmd
Opening a launch played two animations at once: the page flew up from the
bottom while the image flew independently as a shared element. Worse, a
Hero flight renders in an overlay above the destination route, so the
scrim, status pill and countdown sat hidden behind the flying image for
its whole duration and then appeared all at once when it landed — which
is what read as the text popping in.

The image is no longer a Hero, so the page arrives as one piece.
ImageWidget only wraps in a Hero when both heroTag and id are given, so
omitting them at the detail page is all it takes; the listing cards keep
their tags.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CT1urjrk4XeU2xAxWvyfmd
Removing the Hero was necessary but not sufficient. Despite its name,
OpenUpwardsPageTransitionsBuilder does not simply slide the page up — it
reveals it through a clip rectangle sweeping bottom to top
(Align(bottomLeft) > ClipRect > SizedBox(height: clipAnimation.value)).

Anything at the top of the page is therefore uncovered last and all at
once, which is the hero image and the text sitting on it. So the text
still snapped into place at the end of the push even with no Hero
involved.

FadeUpwardsPageTransitionsBuilder is a plain SlideTransition plus
FadeTransition with no clipping, so the image and the text over it move
and fade in together.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CT1urjrk4XeU2xAxWvyfmd
Three things the detail pages got wrong, all visible in one screenshot of
the updates section:

- RippleLinkWidget wrapped its content in a bare Material, which is
  MaterialType.canvas and paints ThemeData.canvasColor — the *page*
  background. Inside a DetailCard that drew a slab of the wrong colour
  behind every update.
- Its subtitle row overflowed when a long source name met a full
  timestamp. The date has a bounded width and is the more useful half, so
  the name is now the one that ellipsises.
- The video and info sections passed article cards straight into a
  DetailCard, so each one drew a second card edge inside the first.

The counts next to the section labels are gone too: they restated what
the list below already shows.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CT1urjrk4XeU2xAxWvyfmd
The news tab is the third TabBarView child, so its page was not built —
and did not start loading — until the user swiped to it, which is why it
always opened on a spinner. The home page now warms the HTTP cache at
startup, so the tab has something to show the moment it opens.

News only. Doing the same for launches and events would spend two of the
Launch Library's fifteen hourly requests on tabs the user may never open;
SpaceFlightNews has no such budget and answers in about two seconds.

While it is loading it also takes a real page: the API defaults to ten
articles, which is barely a screen and a half, against 25 for 21 KB.

APIClient.fetch now joins callers that ask for the same URL while it is
already in flight, so the prefetch and a tab opened on top of it are one
request rather than two. That matters more for the Launch Library than
for this, which is why it lives in the shared client.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CT1urjrk4XeU2xAxWvyfmd
A listing card was max(screenHeight / 3, 250) tall, so the same card was
250dp on a short phone and 400 on a 20:9 one — the taller the device, the
fewer cards fit, which is the opposite of what a taller screen is for. It
now takes the width it actually got (LayoutBuilder, so a landscape
two-column grid is right too) at a 3:2 ratio, capped at 360dp so a wide
window does not show one enormous card. The grid's mainAxisExtent has to
be computed with the same column count or the cell and the card disagree.

The photos are a mix of ~16:9 and 3:2, so no ratio crops nothing; 3:2
keeps the image the largest thing on the card.

ArticleCardWidget had the same bug, and the logos and diagrams on detail
pages were a fifth of the screen height — a flat cap instead, since they
do not get more informative on a taller phone.

Three smaller things while in here:

- An unnamed spacecraft stage rendered "Unknown" under "Unknown" at the
  bottom of every rocket card. Sections with nothing to say are dropped.
- A long info label ("Launch window start") ran straight into its value.
- Updates were indented by the card's padding plus a ListTile's own 16.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CT1urjrk4XeU2xAxWvyfmd
GitHub now forces actions/checkout, actions/setup-java and
actions/upload-artifact onto Node 24 and warns on every run. Bumped to
the current majors (v7, v6, v7), plus action-gh-release v3, which is the
same Node 24 move. None of them changed an input we pass.

flutter-gh-pages goes v7 to v9 for currency; it is a composite action so
it was never affected by the Node deprecation, and it takes no inputs
from us. It only runs on release commits on main, so that is where it
gets exercised.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CT1urjrk4XeU2xAxWvyfmd
The shared-element flight from the card to the detail page is back. The
reason it was removed is real — the flying image renders in an overlay
above the destination route, so the scrim, the status pill and the
countdown are invisible until it lands, and then appeared all at once —
but the fix for that is not to drop the flight. LaunchHero now fades
everything drawn over the image in across the last 40% of the push, so
it arrives deliberately instead of snapping on at the end.

Three other things:

- Video and info images run to the card's edges now, the way a listing
  card's photo does; the article pads its own text instead.
- An unknown orbit is "N/A" / "Unknown" in the API rather than a missing
  object, so the target-orbit chip and row were printing that back at
  the user. Orbit.label returns null for the placeholders.
- The missing-image placeholder filled whatever box it was given, which
  in a launch-pad card with no map meant a rocket the height of the
  card. It is bounded and dimmed now: a placeholder, not content.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CT1urjrk4XeU2xAxWvyfmd
xarantolus and others added 30 commits August 28, 2026 20:21
LoadMore only asks for more when its footer appears, so every page ended
with a wait on a spinner. The list now asks once it has been built out
past its middle, which keeps it a page ahead.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CT1urjrk4XeU2xAxWvyfmd
The launch embedded in an event is abbreviated — no timeline, no
boosters, no updates — while the same launch from a listing is
mode=detailed and filed under its own URL. Opening one now prefers that
copy and falls back to the embedded one.

Cache only, never a request: a fuller page is not worth one of fifteen an
hour, and the embedded copy is a fine fallback.

Worth knowing it will not fire often on fresh data. The listing covers
the next 50 launches, and events with a launch attached tend to point
further out than that — in one probe all six did. The hit rate grows with
use, since paging the launches listing seeds those pages too.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CT1urjrk4XeU2xAxWvyfmd
A pull request from a branch in this repo fires both push and
pull_request, so every commit was built twice once a PR was open.

The trade-off: pull_request is what builds PRs from forks, which no
longer get CI. Fine while this is a one-person repo with a single
long-lived branch.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CT1urjrk4XeU2xAxWvyfmd
Both endpoints cap at 100 per page — limit=200 and limit=500 silently
return 100 — and we were asking for 50. A page costs one request whichever
size it is, so half of one was waste.

Gzipped that is ~320 KB against ~175 KB, and three cold starts came back
at 3720/4066/3729 ms against a 3566-3744 baseline, so it costs nothing
measurable. It doubles what the detail-cache seeding covers (100 launches
now, plus all 26 upcoming events in a single page) and doubles what
search can see.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CT1urjrk4XeU2xAxWvyfmd
Three things were wrong with it.

It built the whole index before calling showSearch, so tapping the button
just spun the FAB — measured at 21 seconds and 3 requests against the dev
API, and the loop is capped at ten. The delegate now opens empty and
fills from a ValueNotifier as pages arrive, so the field and keyboard are
up immediately.

It spent Launch Library requests to do that. Paging used preferCache,
which falls through to the network on a miss, and pages past the first
are only cached if you scrolled that far — so the first search on a fresh
install could take most of the fifteen hourly requests and leave the app
throttled for the rest of the hour. It reads the cache only now; an
uncached page is simply not searched.

And it rebuilt the haystack constantly: the fields of every item were
gathered and lowercased on every keystroke, then the suggestion list ran
the entire filter a second time. Flattened once per item instead.

Local only, by choice. The API does support search=, but that is a
request per query.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CT1urjrk4XeU2xAxWvyfmd
A pill that is only as wide as its three icons, centred, labelling just
the selected destination, with content scrolling underneath it.

Only the background is translucent — surface at 72% behind a blur — while
the icons and the label stay at full strength. The blur is not decoration:
a flat wash over a launch photo leaves the labels fighting whatever is
behind them.

The layout part is the Scaffold. extendBody stops it reserving space for
the bar, and also stops it removing the bottom inset from the body, so
every scrollable now has to clear the bar itself. The home page adds the
bar's allowance to the body's inset once, which keeps
bottomSystemBarPadding correct everywhere below without changing it.

Checked in both themes and both navigation modes.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CT1urjrk4XeU2xAxWvyfmd
"05:10 PM at the pad" read as today even when the pad was on a different
date — and it often is: for a viewer in UTC, 23 of 49 launches fall on
another calendar day where they lift off. The pad line now states its
date whenever it differs from the viewer's, and stays a bare time when it
does not.

The bar drops to 45% with a wider blur. Only the background is
translucent; icons and label stay at full strength.

Also removed the Twitter bot from the credits page. The attribution
formatter that renders a tweet link as "@account on Twitter" stays — that
is about sourcing an update, not the bot.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CT1urjrk4XeU2xAxWvyfmd
Three complaints, one cause: the bar read the controller's index, which
only moves once a swipe has committed. It follows the controller's
animation now, so it tracks the drag itself.

That also fixes the jump on tap. Selection is a fraction rather than a
boolean, so the label grows out of the icon and the tint crosses over
instead of the bar changing shape in a single frame.

The label is no longer accent-coloured either — only the icon is. Blue
text on a translucent blurred background was hard to read, worst in the
dark theme where it sits close to what it is blurring.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CT1urjrk4XeU2xAxWvyfmd
Only entries in the API's update feed produced a notification, so the
change a subscriber most wants was silent: a launch going from "NET
October" to an actual time. The reminders quietly start working at that
point and nobody hears about it.

Only that one transition. A time that drifts by hours, or a precision
that gets vaguer again, happens constantly for unconfirmed launches and
would be noise — timeBecameKnown says so in one place, with tests.

Events get the same treatment, and both forget the recorded precision on
unsubscribe alongside the update key.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CT1urjrk4XeU2xAxWvyfmd
A tablet held upright showed one card across 1067dp — three on screen
where twenty-one now fit. Columns come from the available width, aiming
at about 400dp each: phone 1, phone landscape 2, tablet portrait 3,
tablet landscape 4. The grid extent and the scroll-to-item maths take the
same count rather than each assuming two.

Detail pages cap at 700dp and centre. They are a single column, so on a
tablet the prose ran about 110 characters a line against the 45 to 75
that reads comfortably. A phone is narrower than the cap, so nothing
changes there — checked.

Not attempted: two-pane master/detail. It is the right tablet idiom, but
the detail view is a pushed route wrapping a PageView with the
shared-element flight, so inlining it would throw that away, change back
navigation and break the list scroll-sync. Worth its own pass.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CT1urjrk4XeU2xAxWvyfmd
A listing of ticking clocks is harder to read than a listing of times,
and every visible card was running its own one-second timer to produce
one. Cards show a local time now — "Tomorrow, 11:26 AM" — while vaguer
dates keep their window, and the countdown stays on the detail page.

The detail page's own local line is friendly too. "Today", "Tomorrow" or
a weekday within the week is quicker to read than a full date, which now
only appears once the launch is further out than that.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CT1urjrk4XeU2xAxWvyfmd
The API publishes an OpenAPI document at /2.3.0/schema/ and lookup
endpoints for its vocabularies, which is a better reference than the
shapes visible in whatever sample happens to be live.

Two real gaps came out of it:

- Precision. The code matched WEEK, but /config/net_precisions/ says the
  abbreviation is WK, and there was no case at all for AM, PM, FY or DEC.
  Those fell through to "unknown", which renders as a precise calendar
  day — a launch known only to a decade was being shown as a Wednesday.
  All seventeen are covered now, with a decade rendering as "NET 2030s".
- Status. /config/launch_statuses/ has nine; id 9, Payload Deployed, was
  not in any predicate, so a deployed launch got the same grey badge as
  "To Be Determined" instead of a success.

The rest checks out: every field the models read exists in the schema,
the only type differences are the two ISO durations we parse on purpose,
and mode is list|normal|detailed as used. hide_recent_previous is not
declared at all, which confirms it is gone rather than inert.

Adding the decade broke the exhaustive switch in timeDisplayFor, which is
the typed-parsing rule in CLAUDE.md doing its job.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CT1urjrk4XeU2xAxWvyfmd
Checking the model against the news API's own schema turned up an array
we never parsed: an article carries events[] alongside launches[], both
Launch Library ids. Events are the variant more likely to resolve, since
every upcoming event fits in the one cached page, while a launch named by
an article is usually already past.

Resolution stays cache-only — no chip rather than a request — and the
chip itself is now shared between the two.

Worth knowing why this is rarely seen at all: the association is applied
retroactively. None of the last fortnight's articles name a launch, 21 in
100 do at three weeks, and 45 in 100 beyond a month. The feed shows the
newest, which are exactly the untagged ones.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CT1urjrk4XeU2xAxWvyfmd
The search button now opens a news search when the news tab is the one
showing. The two searches are nothing alike underneath: the launches one
filters what is already cached and must not spend a Launch Library
request, while the news API has no advertised limit and answered
twenty-five rapid requests without complaint, so this one just asks.

Text search only, as you type, debounced by 350ms — typing "starship"
made exactly one request. Results are the same article rows as the feed,
paging in as you scroll, and repeat queries come from the HTTP cache.

Two things it has to get right: a generation guard, so a slow answer for
"star" cannot land on top of the results for "starship", and keeping the
previous results on screen while the next query runs rather than blanking
between keystrokes.

The button needed a Builder to read the tab index — the state's own
context sits above the DefaultTabController, so asking it there throws.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CT1urjrk4XeU2xAxWvyfmd
The background job now asks what is left before spending anything —
/api-throttle/ is free to call — and stops at half the hourly limit, so
opening the app afterwards still has room. Whatever is spare goes on
further listing pages, which deepens the search corpus and files more
launches under their own URLs for everything that looks one up by id.

Split into two tasks, because they want different constraints. The
essential refresh needs only a connection; reading deeper is bulk data
nobody asked for, so it waits for an unmetered connection. One task
cannot carry both.

The delay at the end is not padding. flutter_cache_manager writes its
index three seconds after the last put, so the first version fetched the
extra page and then lost it — 100 launches cached instead of 191, the
response on disk with nothing able to find it. With the wait, all 191
upcoming launches are cached.

Not done: waiting for the budget to refill. next_use_secs counts down to
the oldest single request ageing out of a rolling window, not to a reset,
so waiting would free one slot rather than fifteen.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CT1urjrk4XeU2xAxWvyfmd
Opening a launch from a result and coming back sent the list to the top.

SearchDelegate swaps its body between "suggestions" and "results", and
returning from a pushed route flips it. Every swap destroyed the list —
scroll position gone, query re-run — 26 search requests for one search.

Keys cannot fix it, because the delegate cross-fades the two bodies, so
both are mounted at once and a GlobalKey would be duplicated. Nor can the
state live on the delegate: disposing it when showSearch completes tears
it down while the route is still animating out and the list is still
listening, which is a red screen rather than a lost scroll position.

So the news search is its own page, with its own text field. State and
scroll survive the round trip to a launch and back, and that trip now
costs no request at all.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CT1urjrk4XeU2xAxWvyfmd
Tapping the clear button took focus, which closed the keyboard — but
clearing a query is almost always the start of typing a new one, not the
end of searching. It hands focus straight back now.

The field also claimed to search "launches and events" while sitting on
the news page; it has its own hint.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CT1urjrk4XeU2xAxWvyfmd
The clock chip on an event page is how long the event runs, and it was
rendered in whole days. Event durations are hours and minutes: on
production a Starship static fire window is PT3H32M12S and a press
conference PT1H2M35S, so inDays truncated both to zero and the chip said
"0 days" whichever event you opened.

Hours and minutes now, seconds dropped, and nothing at all under a
minute rather than "0 minutes". Minutes are also dropped next to days,
which would be precision the value does not have.

Worth noting the dev API has no event durations at all — 0 of 300 — so
this only shows up against production.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CT1urjrk4XeU2xAxWvyfmd
A launch embedded in an event comes back in list mode: no
launch_service_provider at all, whatever mode the event request asked for.
Rendered straight, Starship Flight 14 read "Unknown" inside an event and
"SpaceX" in the feed.

The event page already preferred the cached detailed copy when opening one, so
do the same for the card. Cache only, never a request.

Also clears an analyzer backlog that had crept back in: (_, __) is now spelled
(_, _), plus five redundant imports and one null-aware rewrite.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CT1urjrk4XeU2xAxWvyfmd
FocusNode.requestFocus does nothing when the node already has the primary
focus, and dismissing the keyboard never takes focus away — so clearing the
field with the keyboard down left the caret in place and no keyboard, on both
search surfaces.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CT1urjrk4XeU2xAxWvyfmd
A custom tab is another activity, not a route, so nothing in the app loses
focus while it is open — and the engine re-shows the keyboard on resume, over
the results the user came back to read. It happened whether or not the keyboard
was up when they left, because dismissing it never dropped the focus.

UrlLauncher unfocuses before handing off, and the news search page unfocuses on
the first scroll: scrolling means reading, not typing.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CT1urjrk4XeU2xAxWvyfmd
The launches and events tabs already pick a column count from the available
width; the news list did not, so a 2400px landscape gave a 96dp thumbnail
marooned at the far left of a full-width row and fit two and a half articles on
screen.

The lists stay ListView.builders over *rows* rather than becoming grids: an
article card has no fixed height — a one-line headline with no chip is much
shorter than a three-line one with a launch attached — and a grid would have to
pick one extent for both. Cards within a row stretch to match each other.

The width rule moves to columnsForWidth so there is one of it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CT1urjrk4XeU2xAxWvyfmd
flutter_cache_manager counts objects and never bytes, and defaults to 200.
Measured on a device, that caused two separate problems.

The seeding starved the listings: one listing page writes 101 objects — the
page plus a seeded copy of each of its 100 launches — so a second page went
over the cap and evicted the first. The index sat at exactly 200 entries
holding one launches page, so search saw 100 launches however deep the deepen
job had read, and those requests bought data that was gone before anyone could
use it. The JSON store now allows 800; after scrolling into page two it holds
both listing offsets and all 191 launches.

Orphans were never reclaimed: cleanup only deletes what the index lists, and
that index is a debounced whole-file write both isolates rewrite. Measured 459
files against 200 entries — 259 unreachable and undeletable. CacheJanitor
sweeps the store directories oldest-first to a byte budget instead of reading
either index; orphans are old, so they go first, and evicting a live file is
safe because the store refetches.

Article images are the bulk and they are absurd: one NASA photo measured
8256x5504 and 16.6 MB, downloaded to fill a 96 dp row. BoundedImageFileService
re-encodes anything over 2 MB to a longest edge of 2048 — the cap
decodeBucketFor already applies — and keeps the result only when it is actually
smaller, which is the trap maxWidthDiskCache falls into. 16.2 MB now stores as
2.5 MB.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CT1urjrk4XeU2xAxWvyfmd
Only downloads over 2 MB are re-encoded, and in this app those are all news
press photos drawn in a 96 dp row — about 288 physical pixels — so 2048 was
three and a half times the bytes for no visible difference. Measured over one
run of the news feed, whole-cache: 132 MB unbounded, 72 MB at 2048, 39 MB at
1024.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CT1urjrk4XeU2xAxWvyfmd
Nothing expired a stored response. stalePeriod counts from the last read, not
the last write, and the cache-only reads never fall through to the network, so
an entry that keeps being read is kept forever. A launch that has flown drops
out of the listings — they start at net__gte yesterday — so it is never
re-seeded either: a status frozen at "Go for Launch" would stay that way for a
rocket that landed weeks ago. The JSON store now has an absolute seven-day
limit by write time. Images are exempt, because an image at a URL does not
change.

The card also said "Unknown" for a launch with no provider, which is every
launch embedded in an event: the schema pins those to LaunchBasic whatever mode
is asked for, so no request can fill it in. It now shows nothing, and picks up
the abbrev fallback it was skipping.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CT1urjrk4XeU2xAxWvyfmd
flutter_cache_manager already measures both stalePeriod and the object cap from
an entry's `touched`, which it refreshes on every lookup that reaches its
database — so images were already least-recently-used, just on a seven-day
window that is far too short for content that never changes. Sixty days, and
300 objects rather than 200.

300 is picked so the library's LRU, not CacheJanitor's byte budget, is normally
what bites: bounded images average ~350 KB, so 300 is ~105 MB against a 128 MB
budget. The janitor cannot order by last use — Android mounts /data noatime, so
FileStat.accessed never leaves the write time — and evicting a photo that is
looked at daily just for being old is worse than not being the one to decide.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CT1urjrk4XeU2xAxWvyfmd
At 0xE6 the bottom of every launch card measured a background luminance of
0.004 — pure black, 19.6:1 against white text where 4.5:1 is the bar — so the
photo stopped existing behind the text. It reads worst in the dark theme, where
a blacked-out card bottom blends into the page.

Card is now 0xC2 -> 0x3D -> 0x00 and the hero 0xD6 -> 0x4D -> 0x00, measured
from raw screencap pixels at 4.98:1 on the card title, 5.99:1 on the subtitle
and 4.88:1 on the hero, all at the 95th percentile with medians far above.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CT1urjrk4XeU2xAxWvyfmd
TimelineEventType has id, abbrev and description — no name — so the fallback
never fired and implied a field the API does not have.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CT1urjrk4XeU2xAxWvyfmd
Four sections were gated on `description != null`, but the API sends an empty
string rather than omitting the field: 3 of 100 launches have an empty
rocket.configuration.description, which drew an empty block inside the ROCKET
card. The same file already guards failreason with `(x ?? "").isNotEmpty`; the
four now match it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CT1urjrk4XeU2xAxWvyfmd
None of it was reachable. The cache is keyed by URL and every URL the app
builds is /2.3.0/..., so a response written by an older build can never be read
back, and the seven-day response age limit clears any that linger.

Removed after checking each name against the 2.3.0 schema rather than assuming:
feature_image (present in the spec, but on InfoURL and VidURL — not on Event),
country_code, infoURLs/vidURLs and turn_around_time_days are all absent, and
image, country, latitude and longitude are objects and numbers respectively. So
the bare-URL and string-number branches were dead too. ApiImage.fromJsonOrUrl is
now fromJsonOrNull, which is what it does.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CT1urjrk4XeU2xAxWvyfmd
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