Skip to content

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 + SlingScope setup
      • 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 + toggleFavorite mutation
        • search_screen.dart search(text:): a union list via when(...)
        • 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
    • test/app_test.dart the request-counting end-to-end tests
    • Directoryintegration_test/ the same flows on a device or emulator
      • …
example/lib/main.dart
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.

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:

Terminal window
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-5554

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:

Terminal window
cd mock-api && bun run build:browser # → example/web/mock-api.js
cd ../example && flutter run -d chrome

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.

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 by RelayStylePagination (see type_policies.dart) into ONE cached list per filter. “Load more” fetches only the next page (the PaginationController holds its cursor while it loads); pull-to-refresh refetches the first page, which starts the list over. PaginatedQueryBuilder reads 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 same Launch:<id> entities in every segment.

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:

  1. The first switch to a segment sends one new request.
  2. Coming back to a segment you already visited serves its cached list, every page loaded included — 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 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_screen.dart’s doc comment:

Launch detail.

  • prepare is the screen’s selection as a reusable function: the [QueryBuilder] runs it, and [LaunchScreen.open] prefetches with it.
  • 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).

Rows (Launches, Me and Search tabs) open it with LaunchScreen.open, which prefetches before pushing the route:

example/lib/screens/launch_screen.dart
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:

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;
}
}

One function, two callers: the tap’s prefetch and the screen’s own build select exactly the same fields, so they are one request.

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 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_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.

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_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.

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, the field_<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:

example/lib/main.dart
log.attach(client), // client.requests
transport: log.transport(latency.transport(httpClient)), // the response body

attach 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.”

  • Mock API latency: Default / Off / 500 ms / 2 s. MockLatencyController (mock_latency.dart) is a ValueNotifier whose transport(httpClient) adds an x-mock-latency-ms header to every request, which the mock API honours over LATENCY_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 through transport.
  • Cache: CacheStats (widgets/cache_stats.dart) counts the objects per type in client.cache.entityKeys (“20 launches · 4 rockets · …”) and the size of cache.snapshot (what a persistence layer would store), and of cache.changesSince the version it opened at (what an incremental one would write to catch up). It rebuilds on every cache.onChange event — one per response, mutation or subscription event — so the numbers move while the screens behind it fetch, mutate or receive subscription events.

widgets/skeleton.dart has one job, and the comment on SkeletonText says it:

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

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 asks for the page after the merged list’s endCursor and rebuilds only the list body; only launches(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’s resolve), and the screen adds none.
  • 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.

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.

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.

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.

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.