Example app
example/ is a small iOS-only Cupertino app (flutter create --platforms=ios; don’t add other platforms) built against the mock API. Two
tabs — Launches and Me — plus an in-app log of every GraphQL document sent.
Its purpose isn’t to be a real app — it’s to be something you can point at
the network log and a widget test and see sling_gql’s batching claims proven,
not asserted.
Directoryexample/
Directorylib/
- app.dart
SlingApp: dark theme + two-tab scaffold - main.dart client +
SlingScopesetup - network_log.dart records every
PrintedOperation - theme.dart dark palette constants +
slingTheme() Directoryscreens/
- launches_screen.dart paginated list + status segments
- launch_screen.dart detail +
toggleFavoritemutation - me_screen.dart viewer profile + favourites list
Directorywidgets/
- launch_row.dart shared row used by both tabs
- skeleton.dart loading placeholders
- generated/schema.dart committed, generated — see Code generation
- app.dart
- test/app_test.dart the request-counting end-to-end tests
Wiring
Section titled “Wiring”void main() { final log = NetworkLog(); final client = SlingClient<Query>( endpoint: Uri.parse(endpoint), rootFactory: Query.root, onOperation: log.add, );
runApp(SlingScope<Query>( client: client, child: NetworkLogScope( log: log, child: const SlingApp(), ), ));}onOperation: log.add is the only hook the app needs to capture every
request — SlingClient calls it once per flushed operation, batched or not.
Tab structure
Section titled “Tab structure”SlingApp (in app.dart) is a CupertinoTabScaffold with two tabs:
| Index | Icon | Screen |
|---|---|---|
| 0 | 🚀 | LaunchesScreen |
| 1 | 👤 | MeScreen |
The scaffold uses CupertinoTabScaffold’s lazy-build mechanism
(shouldBuildTab), so the Me tab’s QueryBuilder is not created — and no
me { … } request is sent — until the user first switches to that tab. This
is the same principle as skeleton-driven queries: reads that never happen cost
nothing.
Launches tab
Section titled “Launches tab”The doc comment at the top of launches_screen.dart states exactly what to
look at:
Cursor-paginated launch list.
What to look at (open the network log, top right):
- the header and the list are two independent
QueryBuilders, yet the first frame produces ONE request;- each page is
launches(first: 20, after: <cursor>)— a distinct argument set, hence a distinct alias and cache entry. “Load more” adds a cursor and only the new page is fetched; pull-to-refresh refetches all pages in one request.- the status segments pass
filter: LaunchFilter(status: ...)— a different argument set → different alias → different cache entry. Coming back to a segment you already visited is instant: its pages are cached. The rows themselves are the sameLaunch:<id>entities in every segment.
Status segments
Section titled “Status segments”A CupertinoSlidingSegmentedControl at the top of the list switches between
“All”, “Scheduled”, “Success” and “Failure”. A status segment queries:
query.launches(first: pageSize, filter: LaunchFilter(status: LaunchStatus.SUCCESS))This is a different argument set from the All query, so the selection
tree gives it a different alias (launches_<hash-of-args>) and a different
cache entry. That means:
- The first switch to a segment sends one new request.
- Coming back to a segment you already visited serves its cached pages — zero requests.
- The lists are independent, but the rows are not: every segment’s
nodesare references to the sameLaunch:<id>entities, so a heart toggled on the detail screen shows in every segment.
Switching the segment also calls _pagination.reset() on the screen’s
PaginationController, so only the first page is read — from cache, or
fetched fresh if absent. See Pagination.
Known limitation: the membership of a filtered list is fixed at fetch
time. After updateLaunchStatus or toggleFavorite, the entity’s fields
update everywhere, but adding or removing an entry from a paginated list
requires a write policy — see the roadmap.
Pull-to-refresh always reflects the current server state.
Launch detail screen
Section titled “Launch detail screen”launch_screen.dart’s doc comment:
Launch detail.
prepareselects everything the screen will need up front (including the collapsed payload section), so opening it later costs no request.name/date/status/rocket.nameare NOT fetched again: the list already wroteLaunch:<id>andRocket:<id>entities, andlaunch(id:)is alookupfield that resolves straight to the entity. Only the fields the list never selected go over the wire (open the network log).
prepare is a static method, passed to QueryBuilder.prepare, that reads
every field the screen might show — including the payload list that starts
collapsed:
static void prepare(Launch launch) { launch ..name ..date ..status ..details ..flightNumber; launch.rocket ?..name ..description ..height?.meters ..mass?.kg ..successRatePct; launch.launchpad ?..fullName ..locality; for (final p in launch.payloads ?? const <Payload>[]) { p ..name ..type$ ..orbit ..massKg ..customers; } for (final a in launch.crew ?? const <Astronaut>[]) { a ..name ..agency; }}Because prepare runs in the same batching pass as build, the payload
section’s fields are already in the cache by the time the user taps “Show
payloads” — toggling _showPayloads costs a setState and nothing else.
That’s the 0 in the front page’s “requests to expand the collapsed payload
section” stat.
The favourite heart (mutation)
Section titled “The favourite heart (mutation)”The heart next to the launch name is a MutationBuilder<Mutation>:
mutate( (m) => m.toggleFavorite(launchId: id)?.favorite, optimistic: () => launch.favorite = !favorite,)Tap it and watch the network log: one mutation { toggleFavorite(launchId:) { __typename id favorite } }. The heart flips immediately (optimistic write on the Launch:<id> entity),
the response confirms it, and when you go back the list row shows the heart too — the row
reads launch.favorite, so it depends on the same entity field. Kill the mock server and tap
again to see the rollback. See Mutations.
Me tab
Section titled “Me tab”me_screen.dart reads query.me (a Viewer object) to show:
- A circular avatar with initials on the gradient background.
- Name, agency, and favourite count.
- The full
favoriteslist, each row rendered by the sharedLaunchRowwidget (same heart, same entity dependency, same cache).
LaunchRow is extracted into widgets/launch_row.dart and used by both
tabs. Because both tabs read launch.favorite from the same Launch:<id>
entity, toggling a favourite on the detail screen updates the heart in the Me
tab’s list immediately — no additional request, just a cache read.
Known limitation: the me.favorites list membership does not update after
a toggleFavorite mutation. The entity’s favorite field updates (the heart
toggles), but inserting or removing the launch from the paginated list would
require a write policy. Pull-to-refresh (state.refetch) always shows the
current server state. See the roadmap.
Network log
Section titled “Network log”network_log.dart’s NetworkLog is a ChangeNotifier that keeps every
PrintedOperation the client sent, newest first, and also prints it to the
console:
void add(PrintedOperation op) { entries.insert(0, op); debugPrint('[sling_gql] request #${entries.length}\n${op.document}\n${op.variables}'); notifyListeners();}NetworkLogScope (an InheritedNotifier) makes it available anywhere in the
tree; NetworkLogButton in the nav bar shows the running count and opens
NetworkLogScreen, a plain list of every document and its variables. This is
the whole debugging story for the PoC — no devtools extension, just “read
what was actually sent.”
Skeletons
Section titled “Skeletons”widgets/skeleton.dart is two widgets, and the comment on SkeletonText
says the whole point:
/// Renders [text], or a skeleton box of [width] when `null`.////// This is the whole "loading state" story: a missing value is `null`,/// nothing else to wire.SkeletonBox is a surface-coloured rounded rectangle; SkeletonText renders
the real text if it’s non-null, or a SkeletonBox sized to width/font size
if it’s null. There’s no separate loading branch in any screen — every place
that shows a string calls SkeletonText and lets the accessor’s
null-while-fetching semantics do the rest.
The end-to-end tests
Section titled “The end-to-end tests”test/app_test.dart runs against the real mock API (cd mock-api && npm start first, or melos run test:example which starts it for you —
flutter test blocks HTTP by default, so the test opts back in with
useRealNetwork() from sling_gql_test). It
pumps the real SlingApp widget.
Test 1 — Launches tab, pagination, detail, mutation
Section titled “Test 1 — Launches tab, pagination, detail, mutation”Taps through the full Launches → Detail → mutation flow and asserts exact
log.entries counts:
- 1 request for the first frame. Header (
company,stats) and the list body (launches(first: 20)) are separate widgets, separateQueryBuilders, but they build in the same frame and end-of-frame batching merges them into one document. - 1 more request for “Load more”. Tapping the button appends a cursor
and rebuilds only the list body; only
launches(first: 20, after: <new cursor>)is new, so only that page is fetched. - 1 more request for the detail screen. Opening a launch triggers
prepare, which selects everything the screen needs in one shot. - 0 requests to expand payloads.
preparepaid for them up front.
Test 2 — Me tab, Success segment
Section titled “Test 2 — Me tab, Success segment”- 1 request for the Me tab (on first switch). Contains both
me {andfavorites {— batched in one document. - 1 more for the Success segment (on first switch).
LaunchFilter(status: SUCCESS)is a distinct argument set → new alias → new cache entry; the variable carries{status: SUCCESS}. - 0 requests switching back to All. All pages are still in cache.
- Every row in the Success segment shows the success icon.