How does a screen read and change server data?
A screen names what it wants as a request — a DTO from the project's shared package — and
watches it. It changes something by sending a command. Everything between the two — caching,
sharing one fetch between widgets, applying updates, retries, the signed-in account — is the client
behind dw, and a screen writes none of it.
What requests and commands are on the wire is requests and updates and commands and idempotency. This page is the Flutter side.
| You want | A screen writes | The read underneath |
|---|---|---|
| one object, an optional one, a list | DwReadBuilder(dw.request(request), builder: …) | dw.request(request): AsyncValue<T>, AsyncValue<T?>, AsyncValue<List<T>> |
| a feed that grows as it scrolls | DwPagedListView(request: request, itemBuilder: …) | dw.pages(request): AsyncValue<DwPagedData<T>> |
| numbered pages with a total | DwReadBuilder(dw.table(request), builder: …) | dw.table(request): AsyncValue<DwTablePage<T>> |
| a chat-like window around a point | DwWindowListView(request: request, …) | dw.window(request, anchor: ...): AsyncValue<DwWindowData<T>> |
| to change something | dw.action((_) => <Feature>Commands.x(…)) | dw.command(command): DwCallResult<R> |
| a value once, outside any widget | — | await dw.client.fetch(request): DwCallResult<R> |
A screen shows a read through one of the three widgets, and nothing else. Taking the
AsyncValue of ref.watch(dw.request(…)) apart in a widget — .value, .when(, .hasError, a
switch over AsyncError(…) — is what dart run dartway_cli:dartway check refuses (forbiddenRequestRead); a
controller in a feature's logic/, or wiring in core/, may watch a read to derive its own state.
Reading: dw.request
DwReadBuilder(
dw.request(const ListMyBookings()),
placeholder: PlaceholderObjects.listOf(PlaceholderObjects.booking, 4),
builder: (context, bookings) => BookingList(bookings),
)
The request kind decides the value: a DwSingleRequest<T> gives T (an absent object is a
dw.notFound refusal), a DwMaybeRequest<T> gives T?, a DwListRequest<T> gives List<T>.
dw.request does not take a paginated request — those have their own bindings below.
A request is its own cache key. Widgets watching equal requests share one provider, one fetch and
one set of channel subscriptions; dw.request(...) returns the same provider for an equal request, so
calling it inside build is the intended use. The flip side: a field that changes on every build —
DateTime.now() in a request — is a new request every frame. The example's schedule reads "from the
start of today" out of a provider that changes once a day for exactly that reason
(example/dartway_example_flutter/lib/app/schedule/logic/today_controller.dart).
The value stays live on its own. When a command's answer or another user's change carries an object the request is interested in, it is applied to what is on screen — inserted, replaced, removed or re-fetched, as the request declares. How a request declares it is requests and updates and channels and realtime. The screen contains no refresh code:
// example/dartway_example_flutter/lib/app/schedule/logic/schedule_commands.dart
static Future<DwCallResult<SessionBooking>> book(ClubSession session) =>
dw.command(BookSession(sessionId: session.id));
// example/dartway_example_flutter/lib/app/schedule/widgets/session_card.dart
AppButton.primary(
l10n.book,
onTap: dw.action(
(_) => ScheduleCommands.book(session),
onSuccessNotification: l10n.youAreBooked,
),
)
The answer carries the booking and the session; both lists on screen take them before the command completes, and other devices get them on their channels.
A watched request is released DwClientOptions.releaseDelay (one second by default) after its last
watcher leaves, keeping its subscriptions meanwhile — so a rebuilt screen, or one left and re-entered
quickly, finds current data instead of refetching.
Errors are typed
Every way a read ends short of data is an AsyncError whose error is one of four types:
| Error | Means |
|---|---|
DwRefusalException | The server refused; .refusal is the DwCallRefusal to render. Also a request whose validate() failed (nothing was sent), and every request once the build is incompatible. |
DwFailedException | The server failed. .incidentId is what the operator finds it by; .call names the request. |
DwNotAuthenticatedException | The request needs a signed-in user, and there is none. |
DwTimeoutException | No answer for callTimeout and no data to show. The client keeps retrying; the value becomes data when the server answers. |
An error branch sorts by type, never by message.
Refreshing stays data
A request that is running again — a pull to refresh, an update that asked for a refetch, a
reconnected socket — keeps its last value as AsyncData. The screen does not flash back to a
skeleton, and a list does not lose its scroll position because the socket blinked.
Pull to refresh is the notifier's refetch(), which completes when answered — and the retry of
DwReadBuilder's failed view is the same call:
onRefresh: () => ref.read(dw.request(const ListMyBookings()).notifier).refetch(),
Use refetch(), not ref.invalidate: the client keeps a request alive for the release delay after
its provider is thrown away, so a rebuilt provider reattaches to the same failed state instead of
asking again.
Feeds: dw.pages
A DwPageRequest<T> is an offset feed. dw.pages accumulates its pages into one list, and a
screen shows it as DwPagedListView. From
example/dartway_example_flutter/lib/app/news/widgets/news_post_list.dart:
DwPagedListView<NewsPost>(
request: const ListNews(),
placeholder: PlaceholderObjects.newsPost,
placeholderCount: 5,
emptyBuilder: (context) => Center(child: AppText.body(context.l10n.noNewsYet)),
itemBuilder: (context, post) => NewsPostCard(post: post),
)
The next page is asked for as the end comes near, and the screen writes no trigger. The slot
after the last loaded row is built lazily, like the rows — when it comes within the scroll view's
cache extent — and building it asks for the next page. A first page too short to fill the screen
fills it page by page; no scroll listener, pixel threshold or "more" button is written by hand.
While a page loads the slot shows the app's readLoadingBuilder; after a failed page it shows a
retry (edgeBuilder to draw your own, at a constant height) and asks nothing by itself. A page
that came back with no rows while more follow is not an empty feed: the list keeps loading. The first answer is DwReadBuilder's: a skeleton of placeholder rows
or the app's loading view, onRefused branches, the app's failed view with a retry. header
scrolls with the rows.
Its own scroll view, or a sliver in yours. DwPagedListView is a CustomScrollView. A feed that
is one part of a page scrolling as a whole — a profile above it — is DwPagedListView.sliver(…)
inside the page's own CustomScrollView, and pages as the end of the feed comes into that scroll
view's cache extent. Inside a Column or a ListView of the page's own it would not scroll by
itself, and its end — always built — would page to the end at once.
Underneath, DwPagedData<T> carries items (every loaded object, updates applied), hasMore,
loadingMore and loadMoreError — why the last loadMore did not load. A failed next page is
not an error of the AsyncValue: the loaded items stay, and the error sits beside them for the
slot to show. loadMore() is safe to call as often as you like: one request while one is in
flight, none when there is no more. refetch() reloads from the top, as many rows as are loaded.
Tables: dw.table
A DwTableRequest<T> has page and pageSize fields. Each page is its own request, so paging is
watching another request, and the previous page is released like any other. From
example/dartway_example_flutter/lib/admin/users/widgets/admin_users_table.dart:
return DwReadBuilder(
dw.table(request),
placeholder: DwTablePage(
PlaceholderObjects.listOf(PlaceholderObjects.profile, 4),
total: 4,
page: 1,
pageSize: request.pageSize,
),
builder: (context, page) => UsersPage(page), // rows, and a pager over page.page / page.pageCount
);
DwTablePage<T> carries items, total, page, pageSize and pageCount. A row on the page is
updated in place when it changes. Nothing is inserted into a table page: an object that is not on it,
or a deletion, reads the page again, because its rows and its total may have moved. A page below 1 is
refused locally, without a round trip.
Windows: dw.window
A DwWindowRequest<T, S, I> is a newest-first sequence read around a point — a chat, an activity
log. dw.window(request, anchor: cursor) opens it around the row of cursor (a string from
request.cursorOf(item) or DwWindowCursor.encode(sortValue, id)), or at the newest rows when
anchor is null. Windows of one request opened at different anchors are different entries.
Window sort values may be int, String, DateTime or DwCalendarDay, followed by an int or
String id to make the position total. A calendar-day cursor carries the canonical YYYY-MM-DD
string and compares by civil date; it is not converted to a midnight instant.
final provider = dw.window(request, anchor: request.cursorOf(lastRead));
ref.watch(provider); // AsyncValue<DwWindowData<T>>
ref.read(provider.notifier).loadOlder();
ref.read(provider.notifier).loadNewer();
DwWindowData<T> carries items (newest first), hasOlder, hasNewer, loadingOlder,
loadingNewer, loadError, and two counters a list needs:
unseenNewerCount— rows that arrived live past the newest loaded one while the window does not reach the newest rows; zero once it does;prependedCount— how many rows the last change put before the previous first row.
A screen rarely reads these by hand: DwWindowListView is the list built on
them.
Changing: dw.command
final result = await dw.command(BookSession(sessionId: session.id));
The answer is a DwCallResult<R>, sealed:
| Result | Means |
|---|---|
DwCallOk(:value) | Done. Its updates are already applied to every watched request. |
DwCallRefused(:refusal) | The server said no — an answer for the user. |
DwNotAuthenticated() | It needs a signed-in user; the session is already dropped. |
DwCallFailed(:incidentId) | The server failed. |
Inside dw.action there is nothing to unwrap: a result that is not DwCallOk is handled — a
refusal shown through DwFlutterConfig.refusalText, a not-authenticated answer signing out, a failure
reported. That is the one way to send a command; see
actions and refusal texts. dw.command is called in a feature's
logic/ — <feature>_commands.dart, or the <Thing>Controller of a flow — or, for app-wide
wiring no button starts (a push token), in lib/core/; only there is a result read: a controller switches over it to move the flow on, a command function reads
result.valueOrThrow to hand a value to the action's followUpIfMountedAction. A widget reads none,
and nothing wraps a command in try/catch: dart run dartway_cli:dartway check fails all of it
as forbiddenCommandCall.
Three things happen without code:
- validation first. A command that is
DwSelfValidatingand does not validate answers its first refusal at once, and nothing is sent; - retries are safe. A command carries an idempotency key kept across its retries, so a retry
after a lost answer is answered with the stored outcome instead of running twice. Network failures
are retried until
callTimeout(30 seconds by default); any answer is final; - a timeout is an unknown outcome. When no answer came in time, the future completes with a
DwTimeoutException— the command may have run. Repeating the intent is a new command, which is why the timeout is generous.
The signed-in account
ref.watch(dw.accountId); // int?, null when signed out
dw.accountId is known from the stored session right after start, before the server has answered —
the example's router and profile gate decide by it
(appRouterStateProvider in example/dartway_example_flutter/lib/core/router/app_router_state.dart). The server corrects it: a
rejected token makes it null.
Every watched request belongs to an account. State is kept per signed-in account, and a request
that follows "my" channel (DwLiveChannel.ofCaller) resolves it for the account signed in. So:
dw.signIn(session)adopts a session — the answer ofDwVerifyCode— and stores it. When the account changes, every watched request is released and asked again for the new account; the value goes through loading and never shows the previous account's data;dw.signOut()ends the local session at once — every watched request is asked again anonymously — and asks the server to revoke the key. A revocation that did not happen is reported to the error pipeline asDwSignOutException; the session on the device is over either way.
// example/dartway_example_flutter/lib/auth/logic/auth_controller.dart
if (result case DwCallOk(value: final session)) {
await dw.signIn(session);
}
A screen resets nothing on sign-in or sign-out. The requests it watches follow the account.
Hearing without reading: dw.listen
Some screens need what is published, not what is stored: a badge "3 new posts" has its starting count from a request and only has to hear the posts that follow. Reading a page to subscribe would send rows nobody shows.
// The starting number is a request; the badge watches it as usual.
final state = ref.watch(dw.request(const GetMyFeedReaderState()));
// The posts that follow are only heard.
final subscription = dw.listen(const [DwLiveChannel(AcmeChannel.feed)]).listen((
object,
) {
if (object is FeedPost) newPosts.value++;
});
// …subscription.cancel() when the badge goes away
The channels are subscribed while the stream is listened to and released when it is cancelled,
shared with any watched request on the same channel; a caller channel follows a switch of account.
Objects arrive as published — data objects and DwDeletedObjects. What is published while the socket
is down is not replayed: the exact number belongs to the request, which is read again after every
reconnect.
Live status and reconnects
final status = ref.watch(dw.liveStatus); // DwConnectionStatus
Calls are HTTP and work whatever this says. The status tells whether the data on screen follows the server:
DwConnectionStatus | Means |
|---|---|
idle | No socket: nothing watched is live, nobody is signed in (every subscription requires an account), or the client is not started. Nothing is being missed. |
connecting | Opening or authenticating the socket. |
connected | Subscriptions are made as they are needed. |
disconnected | The socket is down and the client waits before the next attempt. Data on screen is not live. |
incompatible | This build cannot talk to this server. Terminal — see update required. |
example/dartway_example_flutter/lib/ui_kit/3_special/common/connection_status_indicator.dart
renders it as a coloured dot, treating idle and connected alike.
When the socket comes back:
- calls waiting out a retry backoff go at once;
- subscriptions are made again for every watched request;
- a request whose data was fetched before its subscription became active — and may have missed an update meanwhile — is read again, with the old value still on screen as data.
A subscription the server refused is tried again after a reconnect; one the server closed (access revoked) stays closed for that account.
Showing a read: DwReadBuilder
DwReadBuilder takes any read — dw.request, dw.table, dw.pages, dw.window — and renders
every answer it can give:
DwReadBuilder(
dw.request(GetCourse(courseId: id)),
placeholder: placeholderCourse,
onRefused: {
DwCoreRefusal.notFound: (context, _) => const CourseUnavailable(),
AcmeRefusal.courseClosed: (context, refusal) => CourseClosed(refusal),
},
builder: (context, course) => CourseView(course),
)
- data is
builder; - loading is
builderoverplaceholder, drawn as a skeleton (Skeletonizer, orSliverSkeletonizerwhen the builder returned a sliver); without a placeholder it is the app'sDwFlutterConfig.readLoadingBuilder— the right choice where drawing the builder over stand-in data would itself mislead (a card showing a stranger's identifiers for a moment); - a refusal whose code
onRefusednames is that branch — the "unavailable" screen of a thing that is gone or not yours, the "closed" screen of a thing with a rule of its own; - any other refusal, a failure, or a server out of reach is the app's
DwFlutterConfig.readFailedBuilder, handed a retry that runsrefetch(). A failure (not a refusal, which is an answer, and not an unreachable server, which the client keeps asking) goes to the error pipeline withDwErrorSource.asyncBuild— once per failure, however often the screen rebuilds over it; - not signed in renders nothing: the session ended under a screen on its way out, and the sign-in screen is the message.
The app says once how a read looks while it loads and when it fails. readLoadingBuilder and
readFailedBuilder are required by DwFlutterCore; the skeleton passes the kit's
AppProgressIndicator and LoadFailedMessage (lib/core/dw_core.dart), and every
DwReadBuilder, DwPagedListView and DwWindowListView shows them. No screen spells its own
spinner — CircularProgressIndicator outside ui_kit/ is forbiddenProgressIndicator.
Several reads nest. The inner builder stands in the outer one's builder; each loads, fails and
retries on its own.
A box, not a sliver. DwReadBuilder's branches are box widgets: in a CustomScrollView's
slivers it goes inside a SliverToBoxAdapter, and a feed there is DwPagedListView.sliver.
The body comes from a builder; the chrome from logic/. A value the screen's chrome needs
whatever the read answers — a title, whether a button is enabled, a badge, whether to leave room for
a bar — is a provider in the feature's logic/ answering a plain value with a fallback (a .select
of the read inside it), and the widget watches that provider. A DwReadBuilder never stands in an
app bar, and a badge's read never gates or skeletonises the page it sits on. From
template/dartway_starter_flutter/lib/admin/user_card/:
// logic/user_card_name.dart
final userCardNameProvider = Provider.autoDispose.family<String?, int>(
(ref, profileId) => ref.watch(
dw.request(GetUserCard(profileId: profileId)).select((card) => card.value?.profile.displayName),
),
);
// admin_user_card_page.dart
AdminScaffold(
title: ref.watch(userCardNameProvider(id)) ?? l10n.adminUsers,
body: DwReadBuilder(dw.request(GetUserCard(profileId: id)), onRefused: {…}, builder: …),
)
A value derived from reads as an AsyncValue — a logic/ provider combining two reads into the
body of a section — is shown with DwReadBuilder.derived(provider, retry: (ref) => …): the same
branches, the provider's errors being the reads', and a retry that names the reads to ask again.
State beside the server's: hooks, or a controller
What the server holds is watched, never copied. What is left is state of the screen itself, and it is held one way:
- a widget's own — a text or scroll controller, a focus node, an animation, a timer, a
subscription, a toggle — is a hook in a
HookWidget/HookConsumerWidget(useTextEditingController,useFocusNode,useAnimationController,useEffectwith its cleanup,useState); a prop the widget resyncs from is auseEffectkeyed on it; - shared by several widgets, or a flow with logic — a sign-in in steps, a form with an async
submit, the reply a chat composer is writing — is a Riverpod
Notifiernamed<Thing>Controllerin the feature'slogic/, usuallyautoDisposeand afamilyof what it is about (example/dartway_example_flutter/lib/app/chat/logic/chat_composing_controller.dart).
StatefulWidget, setState and a ChangeNotifier/ValueNotifier held as state fail
dart run dartway_cli:dartway check anywhere in lib/ (forbiddenStateHolder). An API that needs a
State or a Listenable of its own takes // dw:allow-stateful <reason> on the class, and every run
lists it; the skeleton and the example have none, the router included
(the checker).
Where a rule lives
A business rule — may this invoice be paid, is this slot still bookable — is a function of data, not
of a WidgetRef. Written as extension on WidgetRef, it reads its inputs through providers and can be
tested only by booting the app around it. Put the decision in a factory on the state type, or an
extension on the data object in lib/shared/, with time passed in rather than read; the provider or
widget only says where the data comes from. Then the rule is a plain unit test (#226).
One-off reads: dw.client
For a value needed once, outside any widget, the client itself is dw.client:
final result = await dw.client.fetch(const GetInvoice(invoiceId: 7)); // DwCallResult<Invoice>
A fetch is not watched: no state is kept and no update reaches the value. It validates first,
retries and times out like a command. dw.files.getLink(fileId) is a fetch of this kind — see
uploads on the client.
Related
- Flutter core — building
dw. - Window list view — the list over
dw.window. - Actions and refusal texts — sending commands from a tap.
- The
dartway-data-layerskill — what to do, step by step, inside a project.