General enhancements - #7
Open
xarantolus wants to merge 74 commits into
Open
Conversation
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
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
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Adds a lot of features I wanted for a long time, like:
Warning: very vibe coded, didn't look at the code too much tbh.