Example app
example/ is a small Cupertino app for iOS, Android and web (no other platforms)
built against the mock API — the web build runs on this site, see
Try it live. Three
tabs — Launches, Search 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 + three-tab scaffold - main.dart client +
SlingScopesetup - in_browser_api.dart where requests go: the host on iOS/Android, the page on web
- persisted_cache.dart the cache: SQLite on iOS/Android, in memory on web
Directorydemos/ the small demos embedded in the guides
- …
- mock_latency.dart latency picker, sent as a request header
- 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 - search_screen.dart
search(text:): a union list viawhen(...) - me_screen.dart viewer profile + favourites list
Directorywidgets/
- launch_row.dart shared row used by both tabs
- cache_stats.dart live entity counts via
cache.onChange - 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
Directoryintegration_test/ the same flows on a device or emulator
- …
Wiring
Section titled “Wiring”Future<void> main() async { WidgetsFlutterBinding.ensureInitialized(); // iOS/Android: the previous run's cache from SQLite; the web: a new in-memory one. final cache = await openCache();
final log = NetworkLog(); final latency = MockLatencyController(); final httpClient = mockApiHttpClient(); // the host on iOS/Android, in the page on web final client = SlingClient<Query>( endpoint: Uri.parse(endpoint), schema: slingSchema, cache: cache, // normalized on `id`: one `Launch:<id>` entity maxAge: restoredCacheMaxAge, // iOS/Android: revalidate restored data httpClient: httpClient, transport: log.transport(latency.transport(httpClient)), // x-mock-latency-ms logRequests: true, // one console line per operation listRules: listRules, subscriptionRetryAfter: const Duration(seconds: 3), ); log.attach(client); // client.requests → the network log
runApp(SlingScope<Query>( client: client, child: MockLatencyScope( latency: latency, child: NetworkLogScope(log: log, child: const SlingApp()), ), ));}log.attach(client) is the only hook the app needs to capture every
request — client.requests emits one SlingRequest per flushed operation,
batched or not, with its timing, size and the widgets that asked for it.
SlingApp also wraps the app in a SlingRequestOverlay (debug builds): the
same requests, one line each, in a chip you can open from any screen.
openCache() (persisted_cache.dart, a conditional import like
in_browser_api.dart) is the only platform switch for the cache. On iOS and Android it
opens sling_cache.db with
sling_gql_sqflite before the client
exists, so a relaunch paints the last session’s launches at once; restored
data has no fetch time, so maxAge: 5 min revalidates it in the background.
On the web it returns the in-memory Cache(normalization: Normalization(keyField: 'id')) that schema: slingSchema would build anyway, and no sqflite code is
compiled in. The widget tests build their own client and never call
openCache(), so no state survives from one test run to the next.
On Android
Section titled “On Android”The Android emulator reaches the host machine at 10.0.2.2, so there the app’s
endpoint is http://10.0.2.2:4000/graphql (mockApiHost in
in_browser_api.dart); the manifest’s network security config allows
cleartext HTTP to 10.0.2.2 and localhost only. Same bun start, same
SQLite cache as on iOS:
cd example && flutter run -d emulator-5554# the end-to-end flows of test/app_test.dart, on the emulator (a slower# launch sequence, so each status is on screen long enough):SEQUENCE_MS=2000 node ../scripts/with-mock-api.mjs flutter test integration_test -d emulator-5554On the web
Section titled “On the web”On iOS the app talks to bun start on localhost:4000. The web version needs no
server: it brings its own copy of the mock API, built first:
cd mock-api && bun run build:browser # → example/web/mock-api.jscd ../example && flutter run -d chromeTab structure
Section titled “Tab structure”SlingApp (in app.dart) is a CupertinoTabScaffold with three tabs:
| Index | Icon | Screen |
|---|---|---|
| 0 | 🚀 | LaunchesScreen |
| 1 | 🔍 | SearchScreen |
| 2 | 👤 | 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>), merged byRelayStylePagination(seetype_policies.dart) into ONE cached list per filter. “Load more” fetches only the next page (thePaginationControllerholds its cursor while it loads); pull-to-refresh refetches the first page, which starts the list over.PaginatedQueryBuilderreads the merged list in one build.- the status segments pass
filter: LaunchFilter(status: ...)— a key argument of the policy → a different merged list. Coming back to a segment you already visited is instant: its list is cached with every page it had. 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”, “Failure” and “Scrubbed”; each row names its
status next to the date, so the icons need no legend. A status segment queries:
query.launches(first: pageSize, filter: LaunchFilter(status: LaunchStatus.success))filter is a key argument of the launches type
policy, so the segment gets its own merged list
(launches_<hash-of-filter>). That means:
- The first switch to a segment sends one new request.
- Coming back to a segment you already visited serves its cached list, every page loaded included — 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 a page still loading for the previous filter is
forgotten. See Pagination.
The membership of the cached lists follows the entities through the list
rules (list_rules.dart, see mission control); a
pull-to-refresh starts the list over from the server’s first page.
Launch detail screen
Section titled “Launch detail screen”launch_screen.dart’s doc comment:
Launch detail.
prepareis the screen’s selection as a reusable function: the [QueryBuilder] runs it, and [LaunchScreen.open] prefetches with it.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).
Rows (Launches, Me and Search tabs) open it with LaunchScreen.open, which
prefetches before pushing the route:
static void open(BuildContext context, String id) { SlingScope.of<Query>(context) .resolve((query) { final launch = query.launch(id: id); if (launch != null) _LaunchScreenState.prepare(launch); }) .ignore(); // the screen surfaces a failure itself Navigator.of(context).push( CupertinoPageRoute<void>(builder: (_) => LaunchScreen(id: id)), );}client.resolve is the imperative query: the request leaves on tap, before
the route is built. The screen’s own QueryBuilder builds a frame later, finds
the same fields in flight and waits for that request — still one request.
Awaiting resolve before push would open the screen fully populated
instead, at the price of a tap that does nothing for a round trip.
prepare is a static method, passed to QueryBuilder.prepare and used by
the prefetch above, that reads every field the screen shows:
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; }}One function, two callers: the tap’s prefetch and the screen’s own build select exactly the same fields, so they are one request.
Mission control
Section titled “Mission control”The + button on the Launches tab opens ScheduleLaunchScreen: a form whose pickers
are an ordinary QueryBuilder (rockets, launchpads, one request), a random mission
name (one in five is doomed — the mock fails a launch whose name contains “fail”), and a
MutationBuilder running scheduleLaunch. The input is built once, outside the body
(see Mutations). On success it replaces itself with the
launch’s detail screen.
From there the server does the rest: the mock runs a launch sequence, SCHEDULED → IN_FLIGHT → SUCCESS | FAILURE, publishing launchStatusChanged at each step. The detail
screen’s status icon, the list row behind it and the “Live” banner all follow — they read
the same Launch:<id> entity the events are written into. The segments follow too:
lib/list_rules.dart declares that launches(filter: {status}) holds a launch iff the
status matches (and me.favorites iff launch.favorite), and the client moves the launch
between the cached lists as its status changes. NetworkLog shows the two open
subscriptions separately from the requests, of which there are none after the mutation.
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 (on the web, the Mutations
demo has a “Server fails” switch). 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.
Toggling a favourite also adds or removes the row in the Me tab’s list: the
favorites list rule (list_rules.dart) keeps me.favorites in line with
launch.favorite, for the optimistic write, the response and a rollback.
Search tab
Section titled “Search tab”search_screen.dart debounces a CupertinoSearchTextField and reads
query.search(text:), a [SearchResult!]! union of Launch | Rocket | Astronaut. Each hit goes through the generated when(launch: …, rocket: …, astronaut: …), and each branch reads the fields its tile shows inside the
branch into a plain _Hit record. On the skeleton every branch runs once, so
the first build prints all three inline fragments and one request answers
whatever comes back; a branch that only returned a widget reading its fields
later would cost a round trip per type. Tapping a launch hit opens the detail
screen, which finds the hit’s Launch:<id> entity in cache. See
Querying.
Network log
Section titled “Network log”Tap N requests in any nav bar. The screen has three parts:
- Requests, newest first, one line each: number, type (
query,mutation), the root fields by name (company · stats · launches), then the time, duration, size and variables, and the widgets whose reads are in it (← _Header, LaunchesScreen). A failed request has a red border and its error. Tap a line for the document, the response and “Copy document”. The document shows what the widgets read; the switch on top adds back what sling_gql puts in for the cache (__typename, thefield_<hash>aliases). - Subscriptions: each connection, open or closed, with its event count.
- Dev tools: the mock latency and the cache.
NetworkLog (network_log.dart) is a ChangeNotifier with two hooks on the
client:
log.attach(client), // client.requeststransport: log.transport(latency.transport(httpClient)), // the response bodyattach records each SlingRequest as it is sent (and rebuilds as it
completes: status, size, time, the ← LaunchesScreen, LaunchRow ×20 line of
widgets that asked for it); the transport wrapper finds its entry again by
document and keeps the response body. That is the whole
debugging story of the example: no devtools extension, just “read what was
actually sent.”
Dev tools: latency and cache
Section titled “Dev tools: latency and cache”- Mock API latency: Default / Off / 500 ms / 2 s.
MockLatencyController(mock_latency.dart) is aValueNotifierwhosetransport(httpClient)adds anx-mock-latency-msheader to every request, which the mock API honours overLATENCY_MS(“Default” sends no header). Pick 2 s, then switch segments, and the loading placeholders stay on screen long enough to look at. Subscriptions do not go throughtransport. - Cache:
CacheStats(widgets/cache_stats.dart) counts the objects per type inclient.cache.entityKeys(“20 launches · 4 rockets · …”) and the size ofcache.snapshot(what a persistence layer would store), and ofcache.changesSincethe version it opened at (what an incremental one would write to catch up). It rebuilds on everycache.onChangeevent — one per response, mutation or subscription event — so the numbers move while the screens behind it fetch, mutate or receive subscription events.
Skeletons
Section titled “Skeletons”widgets/skeleton.dart has one job, and the comment on SkeletonText says it:
/// Renders [text], or a text-shaped placeholder when it is `null`.////// This is the whole "loading state" story: a missing value is `null`,/// nothing else to wire.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
do the rest. The rest is polish:
- Shaped like the content. The bar is a pill as tall as a line of the text
style, so nothing jumps when the text arrives, and it fades in over it.
SkeletonBox.circlestands in for status icons. - A screenful of rows. A skeleton list has one element; the Launches
screen shows it as eight
LaunchRows, each with its own widths (variedWidth), so the list looks like a list while it loads. - One shimmer.
SkeletonShimmer, above the whole app, sweeps a highlight across every placeholder in sync from a single animation. It runs only while a placeholder is on screen, and not at all when the system asks for reduced motion.
Pick 2 s in the network log’s latency picker to look at them.
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 asks for the page
after the merged list’s
endCursorand rebuilds only the list body; onlylaunches(first: 20, after: <cursor>)is fetched (with every field the rows read) and merged into the cached list: “Load more (40 / 181)”. - 1 more request for the detail screen. Opening a launch triggers
prepare, which selects everything the screen needs in one shot. The request is already in the log right after the tap, before the route is built (LaunchScreen.open’sresolve), and the screen adds none.
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.
Test 3 — subscriptions over SSE
Section titled “Test 3 — subscriptions over SSE”A status change made by a plain http.post — outside the app’s client, so the mutation
response never reaches its cache — updates the first row’s icon and the “Live” banner with
0 requests: the launchStatusChanged event did it. The seed data is restored in a
tear-down.
Test 4 — mission control
Section titled “Test 4 — mission control”Caches the “Scheduled” and “Success” segments, then schedules “Test Pigeon” from the form
(1 request for the rocket/launchpad pickers, 1 for the mutation, 1 for the
detail screen it lands on). Then waits, in real time, for IN_FLIGHT and SUCCESS to
appear on the detail screen — 0 requests — and goes back: the launch is on top of
“All” (added by the launches rule on the launchScheduled event), on top of “Success”,
and absent from “Scheduled” — still 0 requests, both segments were cached and the
list rules
moved it.
Test 5 — Search tab
Section titled “Test 5 — Search tab”An empty field sends nothing; typing “al” sends 1 request containing
... on Launch {, ... on Rocket { and ... on Astronaut {, and renders 3
launch, 1 rocket and 2 astronaut hits. Opening a launch hit sends 1 more,
without the name and date the hit already cached.
Test 6 — dev tools
Section titled “Test 6 — dev tools”Opening the log costs nothing; picking “2 s” makes the next request take at
least two seconds (the header reached the server, whose LATENCY_MS is 400),
and the entity count shown updates as that request writes Launchpad
entities — through cache.onChange, without leaving the screen.