Skip to main content

Playground

A list, an editor for one of its rows, and four knobs you turn while both are live. Stale time and gc time go into the client's defaults with setDefaultOptions, so every reader on the screen picks them up on its next build; latency and error rate go to the backend. It is the place to get a feel for the numbers before you pick them for a real screen: how long a device list may stay fresh before a revisit refetches it, how long a product detail should stay cached after the user leaves it, and what a flaky connection looks like while the retries run.

Live demoPlaygroundTodos with live knobs for stale time, gc time, latency and errors.~3 MB, runs in your browser; no server involved.

What to try​

  • Set Stale time to 30 s: the todos-reader facts flip to isStale=false without a request. Set it back to 0 and they read isStale=true again. The live reader picked up the new default on its next build.
  • Set Error rate to 100 % and press the Refetch icon on the list: failureCount climbs through the three retries, 300 ms apart, and the list stays on screen under a Refetch failed notice. Set the rate back to 0 and refetch to recover.
  • Set GC time to 5 s, open a todo by tapping its text, then press Close editor. Five seconds later the todo-<id> debug strip reads status=absent: nothing observes the entry any more, so it is collected. With 5 min it stays.
  • Rename a todo in the editor, or tick Done: the answer is written to the editor's entry, and only the list is refetched.
  • Press Invalidate everything: the list and the open editor's entry both refetch, and fetches goes up by one on each strip.

The code​

The list sets neither stale time nor gc time, on purpose: both come from the client's defaults, which is what the knobs change.

examples/showcase/lib/features/playground/playground_screen.dart · lines 86–93
/// The list. Stale time and gc time are left unset on purpose: they come
/// from the client's defaults, which is what the knobs change.
QueryObserverOptions<List<Todo>> todosQuery(ShowcaseApi api) =>
QueryObserverOptions<List<Todo>>(
queryKey: ShowcaseKeys.todos,
queryFn: (context) => api.todos(signal: context.signal),
retryDelay: playgroundRetryDelay,
);

A knob merges its value into the defaults the app already had, so the other defaults survive.

examples/showcase/lib/features/playground/playground_screen.dart · lines 268–277
/// The two knobs over the app's defaults, the rest of them untouched.
void _applyDefaults() {
final current = _client.getDefaultOptions();
_client.setDefaultOptions(DefaultOptions(
queries: (current.queries ?? const QueryDefaults()).mergedWith(
QueryDefaults(staleTime: _staleTime, gcTime: _gcTime),
),
mutations: current.mutations,
));
}

The editor's query seeds itself from the cached list with InitialData.compute, dated with the list's own dataUpdatedAt, so under a non-zero stale time opening the editor costs no request.

examples/showcase/lib/features/playground/playground_screen.dart · lines 95–115
/// One todo, for the editor. [seed] reads it from the cached list — `null`
/// when the list is not there, which means "no seed" — and [seededAt] is
/// the list's own `dataUpdatedAt`, so the seed is exactly as old as the
/// list it came from.
QueryObserverOptions<Todo> todoQuery(
ShowcaseApi api,
int id, {
required Todo? Function() seed,
required DateTime? seededAt,
}) =>
QueryObserverOptions<Todo>(
queryKey: todoKey(id),
queryFn: (context) async {
final todos = await api.todos(signal: context.signal);
return todos.where((todo) => todo.id == id).firstOrNull ??
(throw const BackendException('Todo not found', status: 404));
},
retryDelay: playgroundRetryDelay,
initialData: InitialData<Todo>.compute(seed),
initialDataUpdatedAt: seededAt,
);
The whole screen
examples/showcase/lib/features/playground/playground_screen.dart
/// Upstream's `playground` example on the showcase backend: a todo list, an
/// editor for one todo, and four knobs a reader turns while the queries are
/// live — stale time, gc time, latency and error rate. Everything on the
/// screen is read through `QueryMixin`: `watchQuery` for the list and the
/// editor's todo, `watchMutation` for the add and the patch.
///
/// Where the knobs go. Stale time and gc time go into the **client's**
/// defaults with `setDefaultOptions`, the way upstream's playground sets
/// them, on top of whatever the app had (`getDefaultOptions()`, merged, so
/// the other defaults survive). A live observer keeps its defaulted options
/// until its next `setOptions`, and the mixin re-applies options on every
/// build, so a `setState` after the change is what carries the new defaults
/// to every reader on the screen — verified by the widget tests, no
/// re-keying needed. The screen snapshots the defaults on entry and restores
/// them in `dispose`. Latency and error rate go to the **backend's** scenario
/// through `configureScenario`. Every screen of one run shares that
/// scenario, so the screen reads both knobs on entry and puts them back as it
/// found them in `dispose`. The
/// `backend` facts show the values the backend has acknowledged, which is
/// what a test waits for before it relies on them.
///
/// The editor's query is `['todos', <id>]`. The api has no single-todo GET,
/// so its `queryFn` fetches the list and picks the todo — a fetch of its own,
/// so the entry has its own lifecycle: it is seeded from the cached list with
/// `InitialData.compute` and dated with the list's `dataUpdatedAt` (so under
/// a non-zero stale time opening the editor costs no request), it is
/// refetched by `Invalidate everything` like the list, and once the editor is
/// closed its observer is gone and the gc time decides when it is dropped.
/// After a rename or a completion the PATCH's answer is written to that entry
/// with `setQueryData`, and only the list itself is invalidated (`exact`),
/// so a mutation costs exactly one `GET /api/todos`.
///
/// Both queries run with `retryDelay: RetryDelay.fixed(300 ms)` instead of
/// the client's 1/2/4 s backoff, so the three default retries under
/// `Error rate 100 %` are over in a second, in a test and in a browser.
///
/// Proofs (widget tests in `test/features/playground_test.dart`, end-to-end
/// in `e2e/tests/playground.spec.ts`): under `Error rate 100 %` a refetch's
/// `failureCount` climbs through the retries and ends in
/// `Failed at random (errorRate)` with the stale list still on screen, and
/// `0` plus a refetch brings it back; `Stale time 30 s` flips the live
/// list's `isStale` to `false` without a fetch, `0` flips it back — the
/// "defaults changed under a live observer" proof; with `GC time 5 s` an
/// editor's entry is gone five seconds after the editor closes, with `5 min`
/// it stays; `Invalidate everything` refetches the list and the open editor's
/// entry, `fetches` +1 on both; adding, renaming and completing a todo
/// reaches the list at one `GET /api/todos` per invalidation with one `POST`
/// or `PATCH` each; and leaving the screen restores the client's defaults
/// and the latency and error rate the scenario had before it.
library;

import 'dart:async';

import 'package:flutter/material.dart';
import 'package:query_kit_flutter/query_kit_flutter.dart';

import '../../shared/api.dart';
import '../../shared/chrome.dart';
import '../../shared/controls.dart';
import '../../shared/debug_strip.dart';
import '../../shared/fact_group.dart';
import '../../shared/feature.dart';
import '../../shared/feature_scaffold.dart';
import '../../shared/models.dart';
import '../../shared/scope.dart';

const Feature playgroundFeature = Feature(
id: 'playground',
title: 'Playground',
summary: 'Todos with live knobs for stale time, gc time, latency and errors.',
upstream: 'playground',
);

/// The editor's key, `['todos', id]`: under the list's prefix, as upstream
/// keys a detail, so a prefix invalidation of `['todos']` would reach it.
QueryKey todoKey(int id) => ShowcaseKeys.todos.append(<Object?>[id]);

/// Three retries 300 ms apart instead of one, two and four seconds: the
/// error-rate knob is meant to be watched, not waited for.
const RetryDelay playgroundRetryDelay =
RetryDelay.fixed(Duration(milliseconds: 300));

/// A rename, a completion, or both, for one todo.
typedef TodoPatch = ({int id, String? text, bool? done});

/// The list. Stale time and gc time are left unset on purpose: they come
/// from the client's defaults, which is what the knobs change.
QueryObserverOptions<List<Todo>> todosQuery(ShowcaseApi api) =>
QueryObserverOptions<List<Todo>>(
queryKey: ShowcaseKeys.todos,
queryFn: (context) => api.todos(signal: context.signal),
retryDelay: playgroundRetryDelay,
);

/// One todo, for the editor. [seed] reads it from the cached list — `null`
/// when the list is not there, which means "no seed" — and [seededAt] is
/// the list's own `dataUpdatedAt`, so the seed is exactly as old as the
/// list it came from.
QueryObserverOptions<Todo> todoQuery(
ShowcaseApi api,
int id, {
required Todo? Function() seed,
required DateTime? seededAt,
}) =>
QueryObserverOptions<Todo>(
queryKey: todoKey(id),
queryFn: (context) async {
final todos = await api.todos(signal: context.signal);
return todos.where((todo) => todo.id == id).firstOrNull ??
(throw const BackendException('Todo not found', status: 404));
},
retryDelay: playgroundRetryDelay,
initialData: InitialData<Todo>.compute(seed),
initialDataUpdatedAt: seededAt,
);

/// `POST /api/todos`, then the list is invalidated so it shows the new row.
MutationOptions<Todo, String, void> addTodoMutation(
ShowcaseApi api,
QueryClient client,
) =>
MutationOptions.simple<Todo, String>(
mutationFn: (text) => api.createTodo(text),
onSuccess: (_, __, ___) => client.invalidateQueries(
filters: QueryFilters(queryKey: ShowcaseKeys.todos, exact: true),
),
);

/// `PATCH /api/todos/:id`. The answer is the todo as the backend now has it,
/// so it goes straight into the editor's entry; the list is the only thing
/// that still needs a request, hence `exact`.
MutationOptions<Todo, TodoPatch, void> patchTodoMutation(
ShowcaseApi api,
QueryClient client,
) =>
MutationOptions.simple<Todo, TodoPatch>(
mutationFn: (patch) =>
api.updateTodo(patch.id, text: patch.text, done: patch.done),
onSuccess: (todo, _, __) {
client.setQueryData<Todo>(todoKey(todo.id), todo);
return client.invalidateQueries(
filters: QueryFilters(queryKey: ShowcaseKeys.todos, exact: true),
);
},
);

class PlaygroundScreen extends StatefulWidget {
const PlaygroundScreen({super.key});


State<PlaygroundScreen> createState() => _PlaygroundScreenState();
}

class _PlaygroundScreenState extends State<PlaygroundScreen> with QueryMixin {
static const List<(String, StaleTime)> _staleTimes = <(String, StaleTime)>[
('0', StaleTime.zero),
('5 s', StaleTime.duration(Duration(seconds: 5))),
('30 s', StaleTime.duration(Duration(seconds: 30))),
];

static const List<(String, GcTime)> _gcTimes = <(String, GcTime)>[
('5 s', GcTime.duration(Duration(seconds: 5))),
('5 min', GcTime.duration(Duration(minutes: 5))),
];

static const List<(String, Duration)> _latencies = <(String, Duration)>[
('0', Duration.zero),
('300 ms', Duration(milliseconds: 300)),
('2 s', Duration(seconds: 2)),
];

static const List<(String, double)> _errorRates = <(String, double)>[
('0', 0),
('50 %', 0.5),
('100 %', 1),
];

// The library's own defaults, so the knobs start out telling the truth.
StaleTime _staleTime = StaleTime.zero;
GcTime _gcTime = _gcTimes.last.$2;
Duration _latency = Duration.zero;
double _errorRate = 0;

/// What the backend has acknowledged, or null before the first answer.
({Duration latency, double errorRate})? _applied;
String? _scenarioError;
int _configVersion = 0;

/// The todo the editor is open on, and the last one it was open on — the
/// strip keeps showing that entry after the editor closes, which is how a
/// test watches it being collected.
int? _editingId;
int? _stripId;

final TextEditingController _newTodo = TextEditingController();

late final ShowcaseApi _api;

/// Held from `didChangeDependencies`: `dispose` restores the defaults, and
/// an inherited widget cannot be looked up from a State that is going.
late final QueryClient _client;
DefaultOptions? _snapshot;

/// The backend's two knobs as they were before this screen turned them,
/// which `dispose` puts back: the screen is one of many in the app, and the
/// others run at the backend's own latency.
({Duration latency, double errorRate})? _before;

/// Whether the read of [_before] has answered, one way or the other.
bool _read = false;


void didChangeDependencies() {
super.didChangeDependencies();
if (_snapshot == null) {
_api = ShowcaseScope.apiOf(context);
_client = queryClient;
_snapshot = _client.getDefaultOptions();
_applyDefaults();
unawaited(_start());
}
}

/// Reads the knobs as they are — a config request with nothing in it
/// changes nothing and answers the config — then applies this screen's.
Future<void> _start() async {
try {
final config = await _api.configureScenario();
_before = (
latency: Duration(milliseconds: (config['latency']! as num).toInt()),
errorRate: (config['errorRate']! as num).toDouble(),
);
} on Object {
// Unknown, so nothing to restore beyond zeros.
}
_read = true;
if (!mounted) {
// Left before the read answered: `dispose` sent nothing, and this
// screen's own knobs were never pushed, so put back what was read.
_restoreScenario();
return;
}
await _pushScenario();
}

void _restoreScenario() {
final before = _before;
_api
.configureScenario(
latency: before?.latency ?? Duration.zero,
errorRate: before?.errorRate ?? 0,
)
.ignore();
}


void dispose() {
final snapshot = _snapshot;
if (snapshot != null) {
_client.setDefaultOptions(snapshot);
// Still reading: `_start` restores once the read answers.
if (_read) _restoreScenario();
}
_newTodo.dispose();
super.dispose();
}

/// The two knobs over the app's defaults, the rest of them untouched.
void _applyDefaults() {
final current = _client.getDefaultOptions();
_client.setDefaultOptions(DefaultOptions(
queries: (current.queries ?? const QueryDefaults()).mergedWith(
QueryDefaults(staleTime: _staleTime, gcTime: _gcTime),
),
mutations: current.mutations,
));
}

/// New defaults reach a live observer on its next `setOptions`, and the
/// mixin runs that on every build — so the `setState` is the delivery.
void _changeDefaults(void Function() change) {
setState(() {
change();
_applyDefaults();
});
}

void _changeScenario(void Function() change) {
setState(change);
unawaited(_pushScenario());
}

/// Sends the two backend knobs. Answers can cross when the knobs are turned
/// quickly, so only the latest request's answer is shown as applied.
Future<void> _pushScenario() async {
final version = ++_configVersion;
final latency = _latency;
final errorRate = _errorRate;
try {
await _api.configureScenario(latency: latency, errorRate: errorRate);
if (mounted && version == _configVersion) {
setState(() {
_applied = (latency: latency, errorRate: errorRate);
_scenarioError = null;
});
}
} on Object catch (error) {
if (mounted && version == _configVersion) {
setState(() => _scenarioError = '$error');
}
}
}

void _invalidateEverything() {
_client.invalidateQueries().ignore();
}

void _openEditor(int id) {
setState(() {
_editingId = id;
_stripId = id;
});
}

void _closeEditor() {
setState(() => _editingId = null);
}

/// One knob: its name, and a segmented button in a named semantics group,
/// so a test can pick the `0` of one knob apart from the others'.
/// Not the shared [knob]: these four sit in a `Wrap`, so each one has to
/// shrink-wrap and cannot carry the shared one's sideways scroller, which
/// would be unbounded in a `Wrap` cell. The knob itself is [knobButton],
/// the same widget in both.
Widget _knob<T extends Object>(
BuildContext context, {
required String title,
required String name,
required List<(String, T)> choices,
required T selected,
required ValueChanged<T> onChanged,
}) =>
Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: <Widget>[
Text(title, style: Theme.of(context).textTheme.labelLarge),
const SizedBox(height: 4),
knobButton<T>(
name: name,
choices: choices,
selected: selected,
onChanged: onChanged,
),
],
);

Widget _knobs(BuildContext context) {
final small = Theme.of(context).textTheme.bodySmall;
final applied = _applied;
return SectionCard(
title: 'Knobs',
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: <Widget>[
Wrap(
spacing: 24,
runSpacing: 12,
children: <Widget>[
_knob<StaleTime>(
context,
title: 'Stale time',
name: 'stale-time',
choices: _staleTimes,
selected: _staleTime,
onChanged: (value) => _changeDefaults(() => _staleTime = value),
),
_knob<GcTime>(
context,
title: 'GC time',
name: 'gc-time',
choices: _gcTimes,
selected: _gcTime,
onChanged: (value) => _changeDefaults(() => _gcTime = value),
),
_knob<Duration>(
context,
title: 'Latency',
name: 'latency',
choices: _latencies,
selected: _latency,
onChanged: (value) => _changeScenario(() => _latency = value),
),
_knob<double>(
context,
title: 'Error rate',
name: 'error-rate',
choices: _errorRates,
selected: _errorRate,
onChanged: (value) => _changeScenario(() => _errorRate = value),
),
],
),
const SizedBox(height: 8),
Text(
"Stale time and GC time go into the client's defaults "
'(setDefaultOptions); every reader on this screen picks them up '
'on its next build. An entry keeps the longest gc time a reader '
"ever gave it. Latency and error rate go to the backend's "
'scenario, and the retries are 300 ms apart here.',
style: small,
),
const SizedBox(height: 8),
FactGroup(
name: 'backend',
dense: true,
facts: <String>[
if (applied == null)
'backend=pending'
else ...<String>[
'latency=${applied.latency.inMilliseconds}ms',
'errorRate=${(applied.errorRate * 100).round()}%',
],
],
),
if (_scenarioError != null) ...<Widget>[
const SizedBox(height: 8),
Notice('Scenario not applied: $_scenarioError', error: true),
],
const SizedBox(height: 12),
Align(
alignment: Alignment.centerLeft,
child: FilledButton.tonalIcon(
onPressed: _invalidateEverything,
icon: const Icon(Icons.restart_alt),
label: const Text('Invalidate everything'),
),
),
],
),
);
}

Widget _todos(BuildContext context) {
final todos = watchQuery(todosQuery(_api));
final add = watchMutation(addTodoMutation(_api, _client));
final adding = add.value;

return SectionCard(
title: 'Todos',
trailing: Row(
mainAxisSize: MainAxisSize.min,
children: <Widget>[
if (todos.isFetching) const Pill('fetching'),
IconButton(
tooltip: 'Refetch',
onPressed: todos.isFetching ? null : todos.refetch,
icon: const Icon(Icons.refresh),
),
],
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: <Widget>[
FactGroup(
name: 'todos-reader',
dense: true,
facts: <String>[
'status=${todos.status.name}',
'failureCount=${todos.failureCount}',
'isStale=${todos.isStale}',
],
),
const SizedBox(height: 8),
switch (todos) {
QueryPending() => const Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: <Widget>[
SkeletonBox(),
SizedBox(height: 8),
SkeletonBox(),
SizedBox(height: 8),
SkeletonBox(),
],
),
QueryError(:final error, staleData: null) =>
Notice('$error', error: true),
QuerySuccess(:final data) ||
QueryError(staleData: final data!) =>
Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: <Widget>[
if (todos case QueryError(:final error)) ...<Widget>[
Notice('Refetch failed: $error', error: true),
const SizedBox(height: 8),
],
// Bounded and scrolling on its own, so the editor and the
// strips below it are built whatever the list's length.
SizedBox(
height: 200,
child: ListView(
children: <Widget>[
for (final todo in data)
_TodoRow(
todo: todo,
selected: todo.id == _editingId,
onOpen: () => _openEditor(todo.id),
),
],
),
),
],
),
},
const Divider(height: 24),
Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: <Widget>[
Expanded(
child: TextField(
controller: _newTodo,
enabled: !adding.isPending,
decoration: const InputDecoration(
labelText: 'New todo',
isDense: true,
),
onSubmitted: (_) => _submitNewTodo(add),
),
),
const SizedBox(width: 12),
FilledButton(
onPressed: adding.isPending ? null : () => _submitNewTodo(add),
child: const Text('Add todo'),
),
],
),
const SizedBox(height: 8),
FactGroup(
name: 'add',
dense: true,
facts: <String>['adding=${adding.status.name}'],
),
if (adding case MutationError(:final error)) ...<Widget>[
const SizedBox(height: 8),
Notice('Add failed: $error', error: true),
],
],
),
);
}

void _submitNewTodo(MutationController<Todo, String, void> add) {
add.mutate(
_newTodo.text,
// Per-call, not on the options: clearing the field is this widget's
// business, invalidating the list is the mutation's.
callbacks: MutateCallbacks<Todo, String, void>(
onSuccess: (_, __, ___) => _newTodo.clear(),
),
);
}


Widget build(BuildContext context) {
final editingId = _editingId;
final stripId = _stripId;
return FeatureScaffold(
feature: playgroundFeature,
children: <Widget>[
_knobs(context),
_todos(context),
if (editingId != null)
_TodoEditor(
key: ValueKey<int>(editingId),
id: editingId,
onClose: _closeEditor,
),
QueryDebugStrip(queryKey: ShowcaseKeys.todos, label: 'todos'),
if (stripId != null)
QueryDebugStrip(queryKey: todoKey(stripId), label: 'todo-$stripId'),
],
);
}
}

/// One row of the list: a button named by the todo's text, so a test opens
/// the editor by that text, and a mark for the one the editor is on.
class _TodoRow extends StatelessWidget {
const _TodoRow({
required this.todo,
required this.selected,
required this.onOpen,
});

final Todo todo;
final bool selected;
final VoidCallback onOpen;


Widget build(BuildContext context) {
final scheme = Theme.of(context).colorScheme;
return SemanticsGroup(
name: 'todo ${todo.id}',
child: Row(
children: <Widget>[
Icon(
todo.done ? Icons.check_circle : Icons.radio_button_unchecked,
size: 18,
color: todo.done ? scheme.primary : scheme.outline,
),
const SizedBox(width: 8),
Expanded(
child: MergeSemantics(
child: Semantics(
button: true,
child: InkWell(
onTap: onOpen,
borderRadius: BorderRadius.circular(6),
child: Padding(
padding:
const EdgeInsets.symmetric(horizontal: 8, vertical: 10),
child: Text(
todo.text,
style: TextStyle(
decoration:
todo.done ? TextDecoration.lineThrough : null,
fontWeight: selected ? FontWeight.bold : null,
),
),
),
),
),
),
),
if (selected) const Pill('editing'),
],
),
);
}
}

/// The editor for one todo. Its own `State` with the mixin, so closing it
/// removes the widget and with it the observer on `['todos', id]` — the
/// moment the entry's gc timer starts.
class _TodoEditor extends StatefulWidget {
const _TodoEditor({super.key, required this.id, required this.onClose});

final int id;
final VoidCallback onClose;


State<_TodoEditor> createState() => _TodoEditorState();
}

class _TodoEditorState extends State<_TodoEditor> with QueryMixin {
final TextEditingController _text = TextEditingController();

/// Whether the field has been filled from the data once. Later data
/// (a refetch, the PATCH's answer) does not overwrite what is typed.
bool _filled = false;


void dispose() {
_text.dispose();
super.dispose();
}


Widget build(BuildContext context) {
final api = ShowcaseScope.apiOf(context);
final client = queryClient;
final id = widget.id;

final todo = watchQuery(todoQuery(
api,
id,
seed: () => client
.getQueryData<List<Todo>>(ShowcaseKeys.todos)
?.where((todo) => todo.id == id)
.firstOrNull,
seededAt:
client.getQueryState<List<Todo>>(ShowcaseKeys.todos)?.dataUpdatedAt,
));
final patch = watchMutation(patchTodoMutation(api, client));
final saving = patch.value;

final data = todo.dataOrNull;
if (data != null && !_filled) {
_filled = true;
_text.text = data.text;
}

return SectionCard(
title: 'Edit todo #$id',
trailing: Row(
mainAxisSize: MainAxisSize.min,
children: <Widget>[
if (todo.isFetching) const Pill('fetching'),
IconButton(
tooltip: 'Close editor',
onPressed: widget.onClose,
icon: const Icon(Icons.close),
),
],
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: <Widget>[
FactGroup(
name: 'editor',
dense: true,
facts: <String>[
'editing=$id',
'status=${todo.status.name}',
'failureCount=${todo.failureCount}',
'isStale=${todo.isStale}',
'saving=${saving.status.name}',
],
),
const SizedBox(height: 8),
if (data == null)
switch (todo) {
QueryError(:final error) => Notice('$error', error: true),
_ => const SkeletonBox(width: 240),
}
else ...<Widget>[
if (todo case QueryError(:final error)) ...<Widget>[
Notice('Refetch failed: $error', error: true),
const SizedBox(height: 8),
],
Row(
children: <Widget>[
Expanded(
child: TextField(
controller: _text,
enabled: !saving.isPending,
decoration: const InputDecoration(
labelText: 'Text',
isDense: true,
),
),
),
const SizedBox(width: 12),
FilledButton(
onPressed: saving.isPending
? null
: () => patch.mutate(
(id: id, text: _text.text, done: null),
),
child: const Text('Rename'),
),
],
),
CheckboxListTile(
title: const Text('Done'),
value: data.done,
controlAffinity: ListTileControlAffinity.leading,
contentPadding: EdgeInsets.zero,
onChanged: saving.isPending
? null
: (done) => patch.mutate((id: id, text: null, done: done)),
),
if (saving case MutationError(:final error))
Notice('Save failed: $error', error: true),
],
],
),
);
}
}