Skip to content

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 + SlingScope setup
      • network_log.dart records every PrintedOperation
      • theme.dart dark palette constants + slingTheme()
      • Directoryscreens/
        • launches_screen.dart paginated list + status segments
        • launch_screen.dart detail + toggleFavorite mutation
        • 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
    • test/app_test.dart the request-counting end-to-end tests
example/lib/main.dart
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.

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.

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 same Launch:<id> entities in every segment.

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:

  1. The first switch to a segment sends one new request.
  2. Coming back to a segment you already visited serves its cached pages — zero requests.
  3. The lists are independent, but the rows are not: every segment’s nodes are references to the same Launch:<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_screen.dart’s doc comment:

Launch detail.

  • prepare selects everything the screen will need up front (including the collapsed payload section), so opening it later costs no request.
  • name/date/status/rocket.name are NOT fetched again: the list already wrote Launch:<id> and Rocket:<id> entities, and launch(id:) is a lookup field 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:

example/lib/screens/launch_screen.dart
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 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_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 favorites list, each row rendered by the shared LaunchRow widget (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.dart’s NetworkLog is a ChangeNotifier that keeps every PrintedOperation the client sent, newest first, and also prints it to the console:

example/lib/network_log.dart
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.”

widgets/skeleton.dart is two widgets, and the comment on SkeletonText says the whole point:

example/lib/widgets/skeleton.dart
/// 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.

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, separate QueryBuilders, 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. prepare paid for them up front.
  • 1 request for the Me tab (on first switch). Contains both me { and favorites { — 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.