filament_mobile 0.9.0
filament_mobile: ^0.9.0 copied to clipboard
Renders a Laravel Filament 5 panel as a native Flutter mobile admin.

filament_mobile #
Renders a Laravel Filament 5 panel as a native Flutter mobile admin, driven by
the JSON contract that gait/filament-mobile serves.


Install #
flutter pub add filament_mobile
Point it at a Laravel panel running gait/filament-mobile.
Requirements #
- Flutter
>=3.44.0and Dart^3.12.0. A host on an older toolchain must upgrade before adding this package —pubspec.lock's SDK constraint moves with it. - Two runtime dependencies only:
flutterandequatable.
Quick start #
Implement FilamentTransport over whatever HTTP client you already use, then
hand a ResourceDataSource to the screens. This is the example app's wiring,
trimmed to the shortest thing that runs — the example itself also passes a
schema cache, an upload transport, a file picker and a link handler:
final source = RestResourceDataSource(
transport: MyTransport(baseUrl: 'https://panel.example.com', token: () => myToken),
);
// The panel index — every resource the signed-in user may see.
PanelIndexScreen(
provider: PanelProvider(source),
onResourceTap: (resource) => Navigator.of(context).push(
MaterialPageRoute(
builder: (_) => ResourceListScreen(
provider: ResourceListProvider(source: source, resource: resource),
onRecordTap: (record) => Navigator.of(context).push(
MaterialPageRoute(
builder: (_) => ResourceViewScreen(
provider: ResourceViewProvider(
source: source,
resource: resource,
id: record.id,
),
),
),
),
),
),
),
);
Navigation is yours. Every screen takes an onXTap callback rather than
pushing routes itself, so the package never assumes your router. An affordance
whose callback you do not wire is absent, not disabled — this package does
not render controls that do nothing.
The default endpoint prefix is /api/mobile-panel — absolute, so it appends
correctly to a base URL with no trailing slash. A prefix that is a whole
URL (https://api.example.com/mobile) is passed through unchanged, for a
host whose HTTP client carries no base URL of its own.
A full runnable host — real HTTP transport, schema cache, upload, file picker
and link handling — is in example/.
What ships #
| Feature | What you get |
|---|---|
| Actions | Buttons the server already authorised for this record |
| Upload | Single- and multi-file upload, through an additive port your existing transport need not implement |
| Repeater | Add and remove rows, validated per row |
| Radio | Real radio buttons, parsed off select's own model |
| Toggle buttons | Chips — one choice or many, off select's option shape |
| Slider | Material Slider / RangeSlider, divisions from the published step |
| Tags | Chips with a remove affordance, always a List<String> |
| Key/value | Add/remove pairs, key and value cells gated independently |
| Colour | A text field with a live swatch, in the panel's own format |
| Time and date bounds | showTimePicker, and the bounds a picker declares |
| Relations | A child list on the record screen — writable when the server names a child resource; "See all" opens the full paginated view, with search and sort where the server declares them |
| Rich text | A real document — headings, lists, quotes, emphasis, links |
| Schema caching | Cold start renders from cache, revalidates behind it |
| Dashboard | Stat tiles render; charts drawn by the opt-in filament_mobile_charts sibling, or your own builder |
| RTL and i18n | The panel's direction, not the device's |
Everything below is reference. Each feature section ends with a Known weaknesses list stating plainly what it does not do.
Field types this client renders #

Every wire type the server can publish for a form has a built-in widget:
text · textarea · email · password · number · select ·
multiselect · radio · toggle_buttons · slider · toggle ·
checkbox · date · datetime ·
time · color · file · tags · keyvalue · repeater
Infolist entries render through EntryRegistry: text_entry,
badge_entry, boolean_entry, image_entry and rich_entry. An
entry-typed node reaching a form — the server's Placeholder publishes as
text_entry — renders nothing: entries belong to infolists, and the field
registry's fallback arm is a SizedBox.shrink().
A card's badge slot bound to a boolean column renders a BooleanBadge
(lib/ui/semantic_badge.dart) — Filament's boolean-column idiom, a check or
a cross, instead of the literal word true. The colour goes through the
same semantic map a text badge uses: both key spellings are honoured
('true'/'false', then '1'/'0' — JSON object keys are strings, and true
as a PHP array key becomes 1), falling back to success/gray when the
panel mapped neither. Detection happens on the record's raw typed value
before stringifying — after toString() a real bool and the string "true"
are indistinguishable, and the string must stay a text badge.
A type this build does not know renders as an UnknownComponent — a visible
placeholder in debug, skipped entirely in release, so a newer server never
breaks an older client. Register your own for a custom type, or to override
a built-in:
final registry = FieldRegistry.defaults()
..register('signature', (context, component, state) {
return MySignaturePad(
value: state.value as String?,
onChanged: state.onChanged,
);
});
ResourceFormScreen(provider: provider, registry: registry);
That pairs with config('filament-mobile.types') on the server, which maps an
unmapped Filament component onto a type the contract already defines — see the
Laravel package's Supported form inputs.
Implementing FilamentTransport #
A host implements FilamentTransport over its own HTTP client, and optionally
a PanelStateBuilder to render loading, empty and failure states with its own
widgets. Two things about it are not guessable, and both bite at runtime rather
than at compile time.
Reads throw, writes don't #
FilamentTransport.get() throws on transport or HTTP failure: a 404, a
403, a timeout, all come out the same way, and the caller turns that into a
PanelFailure.
post(), put() and delete() are the opposite, deliberately. They throw
only on a transport failure — no socket, DNS, timeout. Every HTTP
response, including a 422, 403 or 404, comes back as a FilamentResponse
carrying statusCode and body. A 422's body is where a form's per-field
error messages live, and get()'s throw-everything contract has nowhere to
put them.
If your host implementation of post/put/delete throws on a non-2xx
status (the natural thing to do if you're wrapping dio or http and
following get()'s lead), you will silently turn every validation error into
a generic failure banner and the form's field messages never reach the user.
Check your client's "throw on error status" setting and disable it for these
three methods, or catch the HTTP-status exception yourself and rebuild a
FilamentResponse from it.
RestResourceDataSource.create/update/destroy turn that response into a
WriteResult — a sealed type (WriteSuccess, WriteInvalid, WriteDenied,
WriteGone, WriteFailed) so a caller's switch is exhaustive. Only a
genuine transport throw becomes WriteFailed; every HTTP status the server
can return is a named outcome instead.
One 204 gotcha worth debugging up front: a delete typically answers 204
with a stripped, empty body. Return body: const {} for it, not null and
not the result of blindly jsonDecode-ing an empty string — most JSON
decoders throw on '', and a client that lets that throw propagate turns a
genuine success into a WriteFailed. This is the kind of thing that gets
debugged for an hour the first time a delete button reports failure despite
the row actually being gone.
ResourceDataSource.state() re-evaluates a resource's form against values
typed but not yet submitted (POSTs to /{resource}/state) and has no
WriteResult to carry a denial or a 404 into — its contract is "the current
form", so it throws on a non-2xx exactly as get() does, rather than
degrading a permission error into a silently empty form.
A 401 is not a broken server — FilamentTransportException.statusCode #
PanelViewState has a fifth variant, PanelUnauthenticated, rendered by
MaterialPanelStateBuilder with a lock icon instead of the generic failure
column — telling a signed-out user they were signed out, not that the server
broke.
The host side of it takes one line: when a host's FilamentTransport.get()
implementation throws on a 401, set statusCode: 401 on the
FilamentTransportException it throws —
throw FilamentTransportException(message, statusCode: 401);
— instead of FilamentTransportException(message). The package's read-path
providers inspect statusCode and expose isUnauthenticated on a 401, and
PanelIndexScreen, ResourceListScreen, ResourceViewScreen and
ResourceFormScreen's edit path all surface that as PanelUnauthenticated.
(ResourceFormScreen's create path has nothing to read on load, so there is
no 401 to catch there; its submit() keeps a 401 inside the ordinary
save-failure banner rather than switching the whole screen — see
ResourceFormProvider.submit()'s doc comment for why.) statusCode is
advisory and optional, defaulting to null. A host that never sets it
never trips isUnauthenticated; every failure still lands on PanelFailure,
exactly as before this field existed.
Actions #
Buttons the server decided this record may run.
ResourceRecord.actions (List<RecordAction>, empty when the resource
opted none in) is the per-record list GET /{resource}/{record} publishes.
Absence means unavailable — there is no enabled: false to render greyed
out, so ResourceViewScreen renders exactly one button per entry, colored
through the same semantic vocabulary card badges use
(SemanticBadge.colorFor(), now public), and no disabled ones.
A RecordAction with a non-null confirmation is confirmed in the action's
own words — heading/description/submit/cancel — never the package's
generic delete-confirmation copy. An empty submit or cancel (the
server's fail-closed shape for a confirmation whose own copy closure threw)
is the client's cue to substitute its own string, not a signal to skip the
prompt: confirmation being non-null already decided that.
Run one through ResourceDataSource.runAction(resourceKey, id, name), which
returns a sealed ActionResult — ActionSuccess(message) or
ActionFailed(message) — the same shape as WriteResult on the write path.
ResourceViewProvider.runAction() calls it, shows message through the
screen's existing snack-bar convention, and re-fetches the record on
success: an action's most common effect is changing exactly permissions
and actions, so the re-fetch refreshes both.
A success with no message shows nothing, and that is deliberate.
Filament only sends a notification when the action declared a title
(CanNotify::sendSuccessNotification() guards on filled($title)), so an
action without one is silent on the web panel — as is one that raises
Cancel, which arrives here as a 200 with a null message. Showing a generic
"done" would have the phone claim an outcome the panel never stated; the
user's feedback is the record itself changing after the re-fetch. A failed
action with no message does still fall back, because Filament marks failure
notifications ->persistent() and success ones not: on a phone, a tap that
produces nothing at all cannot be told apart from a dead button.
Two more strings joined the FilamentStrings English-default rule above:
actionFailed ('Could not run that action.') and actionConfirm
('Confirm'). A host that upgrades and changes nothing still compiles and
still runs — and, per the same caveat as every other default, shows English
under server-translated action labels until the host supplies its own.
Upload #
The host supplies the transport and the picker.
A FileUpload field — single or ->multiple() — is editable from the
phone, through two
separate host-supplied pieces — same escape-hatch shape chartBuilder
already established for dashboard charts, and for the same reason: keeping
this package's two runtime dependencies (flutter, equatable) real for
every host, including the ones with no upload feature at all.
Why upload is a second interface, not a fifth FilamentTransport method #
FilamentTransport is an abstract interface class, and every host
implements it with implements. implements inherits the interface but
never an implementation, so adding a member to it — even one with a default
body — is a compile error (non_abstract_class_inherits_abstract_member) in
every existing host, verified against the analyzer rather than assumed.
Bytes don't fit in post()'s JSON body either: base64-encoding a 10 MB
photo produces roughly 13 MB of JSON held whole in memory on both ends, and
collides with PHP's post_max_size/memory_limit in ways that read as
server bugs, not client ones.
So upload is FilamentUploadTransport, a separate, optional port:
abstract interface class FilamentUploadTransport {
Future<FilamentResponse> upload(
String path, {
required List<int> bytes,
required String filename,
String field = 'file',
});
}
A host that never uploads is untouched. A host that does implements both —
class MyTransport implements FilamentTransport, FilamentUploadTransport.
RestResourceDataSource.uploadFile() checks transport is FilamentUploadTransport at call time; when it is not, the upload fails with
a message naming what to implement, never a throw the caller has to guard
against. Here is a full, real implementation over package:http — the
example app's, unedited:
@override
Future<FilamentResponse> upload(
String path, {
required List<int> bytes,
required String filename,
String field = 'file',
}) async {
final request = http.MultipartRequest('POST', Uri.parse('$baseUrl$path'))
..headers.addAll(_headers)
..fields['field'] = field
..files.add(
http.MultipartFile.fromBytes('file', bytes, filename: filename),
);
final response = await http.Response.fromStream(
await _client.send(request),
);
return FilamentResponse(
statusCode: response.statusCode,
body: response.body.isEmpty
? const {}
: jsonDecode(response.body) as Map<String, dynamic>,
);
}
Three statements. That brevity is the point: package:http's
MultipartRequest already does the encoding work, so implementing this port
costs a host nothing beyond what it would write anyway to call the endpoint
directly.
The host also supplies the file picker #
Choosing a file needs a platform plugin this package will not force on a
host with no upload feature, so ResourceFormScreen takes an optional
filePicker:
typedef FilamentFilePicker = Future<PickedFile?> Function(SchemaComponent field);
class PickedFile {
const PickedFile({required this.bytes, required this.filename});
final List<int> bytes;
final String filename;
}
An image_picker-backed one is a few lines:
ResourceFormScreen(
// ...
filePicker: (field) async {
final picked = await ImagePicker().pickImage(source: ImageSource.gallery);
if (picked == null) return null;
return PickedFile(
bytes: await picked.readAsBytes(),
filename: picked.name,
);
},
)
Returning null means the user cancelled; the field does nothing further,
same as any other cancelled picker. A picker that throws (a real plugin's
common failure — a denied permission) shows FilamentStrings.uploadFailed
rather than crashing the form.
Without a filePicker, or without a transport implementing
FilamentUploadTransport, the field stays read-only and shows
FilamentStrings.filePickerUnavailable — never a control that looks
tappable but cannot work, the same rule chartUnavailable follows for a
missing chart renderer. readOnly from the server always wins over a
host-supplied picker: a field the panel published config.readOnly: true
for (one whose constraints or multiple() closure throws
server-side) shows FilamentStrings.fileFieldReadOnly and stays inert even
when the host wired up both pieces — the server's word is the gate, the
picker only fills in what the server already allowed.
Upload happens on pick, not on save #
Tapping choose runs the picker, then immediately calls
ResourceDataSource.uploadFile() — the control shows
FilamentStrings.uploading and disables itself for the duration, so a slow
upload cannot be double-fired by an impatient second tap. On success the
field's value becomes the returned path and the stored filename is shown; a
failed upload never clears whatever value the field already held.
ResourceDataSource.uploadFile() returns a sealed UploadResult — the same
shape as ActionResult — UploadSuccess(path) or UploadFailed(message, {statusCode}), so a caller's switch is exhaustive.
A 422 (statusCode == 422) is this field's own refusal — too large, wrong
type — and lands on the field's error exactly like a write's per-field
validation error. Every other failure (a bare 403/500, an offline
transport, a host transport lacking the upload port) is not a fact about
what the user picked, so it reaches the form's error banner instead — the
same split submit() already makes between field-scoped and form-scoped
failures.
A multiple field is N uploads, not one #
A file node with config.multiple: true renders as a list of rows — one
per stored path, filename basenames, a per-item remove — plus an add
button. Each add tap is the same pick-then-upload loop as single-file: one
pick through the same filePicker (which still returns a single
PickedFile), one uploadFile() call, then the returned path is appended
to the field's List<String> value. The endpoint never sees more than one
file per request; the list is assembled client-side and submitted whole on
save — wholesale-replacement, so removing a row means the submitted list
simply no longer contains that path, and submitting an empty list clears
the column.
The add button stops being offered once the list reaches maxFiles — a
hint, the repeater-cap idiom: the server's array max is the rule, and
a crafted over-count submission 422s regardless of what the widget showed.
The same in-flight guard applies per pick, so a slow upload cannot be
double-fired. All the single-file fallbacks carry over unchanged: no
filePicker or no upload-capable transport and the field is read-only with
the honest note, and a server-published readOnly always wins.
A scalar arriving under a multiple: true field (an older server, a
hand-written payload) is tolerated on read rather than crashing the form —
but on write the value is always a list.
Known weaknesses, carried from the server or inherent to the port #
- Orphaned files accumulate. A user who picks a file and abandons the form leaves a stored file with no row pointing at it — and a multiple field makes this more frequent, not different: one abandoned form can strand a whole list. This package does not claim or clean these up; a host that cares prunes the storage directory on its own schedule — see the Laravel README's Upload section.
- No upload progress percentage.
FilamentUploadTransport.upload()returns aFuture, not a stream, so the control can show "uploading…" but not "43%". A percentage would need a streaming port; revisit only if a real panel wants it. - The whole file sits in memory. Both the picker's
PickedFile.bytesand the multipart request body hold the file whole — a very large file on a low-memory device can fail before the server ever sees it. The field's ownmaxSize, enforced server-side, is the practical bound today. - No reordering of a multiple field's list. The widget renders list
order and offers no drag affordance — same stance as the repeater's
reorderable.
Repeater #
Add and remove rows, validated per row.
A JSON-column Repeater::make('items')->schema([...]) field parses into
RepeaterComponent (children — the item template, published once, never
once per row — plus addable, deletable, minItems, maxItems,
itemLabel, readOnly) and renders through RepeaterFieldWidget: one
Card per row from the template, an Add control when addable and the
row count is under maxItems, and a Remove per row when deletable and
the row count is above minItems. A row's values live in the form state
under the repeater's own name as a genuine List<Map> — FormValues treats
the whole array as one leaf, the client-side mirror of the server's own
name-space split (see the Laravel README's Repeater section): the row
template's field names never flatten into the top-level form.
Both controls, and every field inside a row, honour state.enabled and
readOnly — the server's word wins, same as an upload field: a repeater the
panel published config.readOnly: true for shows the new repeaterReadOnly
string and stays inert even if the host's own gates would otherwise allow
editing. The server publishes that flag for two shapes (see below): a
nested repeater, and one whose item template holds a child the server
cannot round-trip. A relationship repeater is editable against a current
server — see the third known weakness below for what its save costs.
Each row's fields are built through the host's own FieldRegistry — the
same one that built the repeater — so a FieldRegistry.register() custom
type, or a host override of a built-in one, renders inside a row exactly as
it does everywhere else on the form.
Per-row validation reuses the existing client validator against each row's
own rules, so a required field left blank in row 2 blocks submission with
the error attached to row 2, not a generic whole-form error. A server 422
does the same on the authoritative side: Laravel's own shape for a repeater's
per-item rule failure is line_items.0.sku, and ResourceFormProvider
matches that shape directly — first segment against the writable repeaters on
screen, and exactly three segments, which is the depth this widget can key
an error into. A deeper key belongs to a nested repeater
(outer.0.inner.1.x), which nothing on screen can render, so it goes to the
form's error banner unattributed rather than into a map no widget reads: a
refusal the user cannot see is a Save that does nothing and says nothing.
A row-scoped error at the renderable depth lands in the exact same map
FieldState.errors already reads for a client-side one. Editing a
row clears that row's stale errors specifically: a repeater's onChanged
always replaces the whole row list, so change() compares each row's
identity against the previous list and clears only the errors for rows that
actually changed — a still-invalid sibling row keeps its message.
Three new FilamentStrings, all English-default: addItem ('Add item'),
removeItem ('Remove'), and repeaterReadOnly ('These items cannot be
changed.') — same rule as every other string in this package: a host that
upgrades and changes nothing still compiles, still runs, and shows English
under otherwise-translated labels until it supplies its own.
Known weaknesses, stated now #
- No reordering. The server may publish
config.reorderable: truefor a host rendering its own repeater;RepeaterFieldWidgetnever offers it regardless of what is published. - The item template is static. A
live()field inside a row does not re-settle that row —/statesettles a flat form, and a row coordinate is a problem this slice does not solve. - A relationship repeater's save is delete-all-then-recreate. Against a
current server the field is editable and writes through Filament's own
saveRelationships()— but its state on the wire is keyless, so every save deletes the existing child rows and re-creates them from the submitted state (pinned server-side inRepeaterWriteTest). Nothing this widget can soften; worth knowing before editing rows other tables point at by id. - An untouched legacy value in a stored row can now block submission.
Per-row validation seeds a synthetic
FormValuesper row, and a row cannot reconstruct the stored-vs-touched distinction the top-level form uses to spare a legacy value the user never edited — so every validated child in a row is treated as dirty. That is over-eager on purpose (a malformed colour the user just typed into a row previously submitted unchallenged), but the trade-off cuts the other way too: a stored row carrying a legacy colour value fails the format check even if the user never opened that row. - A nested repeater renders read-only. A repeater inside another
repeater's item template is published
config.readOnly: trueby the server, and this widget honours that flag like any other — the nesting needs no special case here. Its rows still round-trip (they are part of the outer array); they simply cannot be edited from a phone, because a nested row's422comes back keyedouter.0.inner.1.xand this widget has no field to render it against — that message reaches the form's error banner instead, so a save the server refuses always says something. Two levels of row coordinate is a different problem, andfilament-mobile:doctorreports the shape server-side. - A repeater with a child the server cannot round-trip is read-only. A
Hidden, an unmapped component, adisabled()field, or a relation-write child forced back into the row with->dehydrated(true)in the item template means the whole array write would delete that child's key from every row, so the server refuses the field outright rather than offering a control that eats data. Its stored rows are still returned onGETand still render, inert.doctornames the offending child. - A
selectwithoptionsUrlinside a row fetches throughFieldState.searchOptionsFor. The per-field variant ofFieldState.searchOptions, public for exactly this caller: a row's select is rendered off the item template, so its lookup must be bound to the child's own name — handing the repeater'ssearchOptionsclosure down would query the repeater's field, not the child's. Leaf fields keep readingsearchOptions, and the server side descends through the repeater to find the child (see the Laravel README's Repeater section). Against a server predating that descent the control degrades to an empty dropdown.
Why readOnly defaults to true when the key is absent: an absent
config.readOnly means a server predating repeater support, and this client
never invents a capability the server did not declare — so it renders inert
rather than guessing it can accept edits. A server on this contract states
the key both ways round (false for an ordinary JSON-column repeater,
true for a refused one), so the default is only ever reached by an older
server. It is asserted end-to-end, not assumed: laravel_contract_test.dart
parses the committed contract/laravel-panel.json — real Laravel output —
and asserts an ordinary repeater arrives editable while a relationship one
and one with a non-round-tripping child do not. That test exists because the first cut of P6c had the server
publishing the key for the refused case only, which rendered every ordinary
repeater inert with both packages' own suites green.
Radio #
Real radio buttons, parsed off select's own model.
A radio node parses into SelectComponent — the same model type a
select/multiselect node parses into, radio joining the switch (type)
in SchemaComponent.fromJson (schema_component.dart), alongside select
and multiselect — but renders through a distinct, new
RadioFieldWidget, never SelectFieldWidget. One stacked
RadioListTile per option, single selection, state.enabled and the
server's readOnly both honoured — no dropdown, no search field, because a
radio node never carries config.optionsUrl (see the Laravel README's
Radio section for why an over-cap radio inlines every option instead).
Known weaknesses, stated now #
Radio::isInline()is not on the wire. Options always stack one per row, the treatmentRadioFieldWidgetuses unconditionally.
Toggle buttons #
Chips, parsed off select's own option shape — one choice, or many when
multiple.
A toggle_buttons node parses into ToggleButtonsComponent — the same
flattened SelectOption list a select/radio node carries, plus an
always-present multiple (absent or wrong-typed reads as false, the
degradation rule every other config key already follows) — and renders
through ToggleButtonsFieldWidget: a ChoiceChip per option when
multiple is false, a FilterChip per option when true. The value is a
scalar or a List accordingly — the select/multiselect split — through
the ordinary form-state and write paths. A node never carries
config.optionsUrl, so there is no search affordance to render, and the
widget lays out however many options arrive. state.enabled and the
server's readOnly are both honoured.
Known weaknesses, stated now #
- Per-option colors, icons, tooltips and disabled state are not on the
wire. A disabled option is the server's
in:rule to refuse, not a client rendering to reproduce.
Slider #
A Material Slider, or a RangeSlider when multiple; divisions come from
the published step.
A slider node parses into SliderComponent (min/max always present —
absent reads as 0/100, Filament's own accessor defaults; step only when
the server published a numeric one; multiple always present) and renders
through SliderFieldWidget. There is no slider-specific validation:
Slider::setUp() force-registers numeric/min:/max: server-side, and
those arrive as the node's ordinary rules, which the client already
pre-validates. Both Material widgets assert on contradictory bounds and on
a value outside them, so the widget clamps rather than trusting the payload —
a stored value outside the published range reads as the bound, never a crash.
multiple is a hint, never a gate. The server detects range mode from
the state being an array, and on /schema that means an array ->default()
— a range slider with no array default publishes multiple: false while its
rules still say array (a documented server weakness, see
contract/README.md's Slider section). The widget renders from whatever the
node currently says — /state can re-answer — and never blocks a submission
on the hint; a 422 keyed to the field lands on the field as usual.
Known weaknesses, stated now #
- Pips, tooltips, behavior, fillTrack, vertical, rtl, nonLinearPoints,
minDifference/maxDifference and decimalPlaces are not on the wire, so the
phone may offer a value the web panel's own controls would not — the
enforced bounds are the published
rules, which are complete. - A string
steparrives as nostepat all — absence means "any step", so the control renders without divisions.
Tags #
Chips with a remove affordance; the value is always a List<String>.
A tags node parses into TagsComponent (separator, suggestions) and
renders through TagsFieldWidget: existing tags as chips with a remove
affordance, a text field that commits a new tag on submit, and
suggestions offered when the field published any. separator is read for
display only — a client never builds or parses the delimited form; the
value in form state is always a List<String>, exactly as the server
publishes and expects it, separator-configured or not.
One documented exception, on the server's side: a relation row whose
child model is served by zero or by several opted-in resources has no
resolvable owner, so the server cannot know the separator and publishes the
stored delimited String rather than guessing a split (see
contract/README.md's Tags section). _TagsFieldWidgetState._tags already
tolerates it — a non-List reads as no tags rather than throwing — so the
row renders empty chips instead of crashing. Stated here because "always a
List<String>" is otherwise the claim a host would trust.
The new FilamentStrings.tagHint ('Add a tag') labels the input, English
default as always — a host that upgrades and changes nothing still
compiles and still runs.
Known weaknesses, stated now #
splitKeys,tagPrefixandtagSuffixare not on the wire. A tag commits on submit only; this widget has no prefix/suffix presentation to reproduce.
Key/value #
Add and remove pairs; a row's key or value cell renders as text, not a disabled input, when its gate is off.
A keyvalue node parses into KeyValueComponent (addable, deletable,
editableKeys, editableValues, keyLabel, valueLabel,
keyPlaceholder, valuePlaceholder — all four booleans default to true
when absent, matching the server's own default) and renders through
KeyValueFieldWidget: one row per pair, an Add control when addable,
a Remove per row when deletable. Two pre-existing strings from the
Repeater section above are reused rather than duplicated:
FilamentStrings.addItem and .removeItem.
A gate that is off makes its control absent, not disabled. A row whose
key is not editable renders its key as plain, unfocusable Text, never a
greyed-out text field — same rule this package applies to every other
affordance a server gate turns off. editableValues governs the value cell
the identical way, independently: one gate off and the other on renders a
row with an editable value beside a read-only key, which is a legitimate
combination the server can configure and this widget must render correctly.
Rows carry identity independent of their key, which the first cut of
this widget got wrong. _pairs is held as List<MapEntry<String, String>>
state, seeded once and mutated in place rather than re-derived from the
map on every build — so renaming a key that transiently collides with
another row's key no longer merges the two rows into one, and adding two
rows in a row produces two rows, not one.
Known weaknesses, stated now #
- No reordering, matching the repeater — this widget has never offered one for either array-valued field.
- All four gates are client hints. See the Laravel README's Key/value
section for what "not enforced by the write path" means in practice, and
for the contrast with
disabled, which this package's write path does enforce.
Colour #
A color field renders as a text field with a live swatch — not a colour
wheel. This package takes no colour dependency, and a hand-rolled picker's
colour maths is easy to get subtly wrong and hard to test, so the honest
treatment won.
The swatch updates as you type and holds the last valid colour when the text is malformed, rather than blanking.
The value is never converted. A field the panel declared as rgb emits
rgb; the widget parses all four formats (hex, hsl, rgb, rgba) and
returns the one it was given, byte for byte where you did not edit it.
A malformed value blocks submission — but only once you have edited that
field. The check is gated on FormValues.dirty for two reasons: the client
must not invent a constraint the server does not have, and it must not block a
save over a value that was already in the database when the form opened. One
new FilamentStrings entry carries the message, English default as always.
Known weaknesses, stated now #
- No graphical picking, deliberately.
- No format conversion, deliberately.
- The
hslpattern rejects a fractional hue while accepting fractional saturation and lightness — faithful to Filament's own regex, not a decision made here. - A malformed colour inside a repeater row is not blocked, because repeater
rows synthesise a fresh
dirtyset. Pre-existing behaviour of the row validator rather than something this field introduced.
Time and date bounds #
DateKind is now a three-way — date, datetime, time — and a time field
goes straight to Flutter's own showTimePicker, so it costs no dependency.
Bounds now arrive. DateComponent.minDate / .maxDate have been parsed
since this package was written and were always null, because the server never
published them. They are live as of the matching Laravel release, and the
picker clamps to them.
DateComponent.unreadableBounds names any bound that arrived and would not
parse, which is different from one that was never declared: the first is a
contract violation and warns in debug builds, the second is ordinary. The value
still degrades to null either way — a picker with a wrong limit is a
nuisance, a crashed form is an outage.
Two bound shapes parse, because the server publishes what the panel declared
rather than normalising it: a bare "09:00" and a full
"2026-01-01 09:00:00".
Steps are parsed, and deliberately not acted on. DateComponent reads
hoursStep / minutesStep / secondsStep — absent or wrong-typed reads as
1, the vendor default — but the widgets ignore them: the stock Material
pickers have no step grid, the server enforces no step, and snapping or
rejecting a picked time would make mobile stricter than the web panel it
mirrors. The keys exist for a host rendering its own picker.
Known weaknesses, stated now #
- Bounds are hints — final ruling, by web parity. The web panel does not
enforce
minDate/maxDateserver-side either, so mobile must not be stricter than the panel it mirrors; the picker's clamp is the enforcement. The server refuses an out-of-range value only if the panel declared a rule saying so. - A
secondsfield resets seconds when the time changes. They are preserved when the hour and minute are untouched; a genuinely new time starts at:00, because welding a stale:30onto a newly picked 16:20 would be a time nobody chose. - Disabled dates and first-day-of-week are not published — final, not
deferred. A
disabledDateslist is closure-evaluated at schema-build time and would freeze behind the server's ETag cache, silently stale; the stock Material date picker derives the first day of week from the device locale and takes no parameter, so there is nothing to honour it with. Both are documented rejections, not backlog.
Relations #
A labelled section on the record screen; "See all" opens the full list.

ResourceSchema.relations (List<RelationDescriptor>, always present —
[] when the server publishes none, and read the same way on an absent
relations key: a server predating this feature) is what a resource's
Filament relation managers become on mobile. Filters stay out; search and
sort are host-declared per relation on the server and arrive on the
descriptor (below) — see the Laravel README's Relations section for the
full server picture, including why a relation manager that narrows its own
query is not published at all. Against a current server a relation can also
be writable, which is what its descriptor's resource key announces.
Each RelationDescriptor carries key, label, card (the same
CardLayout a resource's own list card uses), recordKey — the
related model's own route key, not the parent resource's, and not
always id — and resource, the child resource's key.
RestResourceDataSource.relation() parses each row by
relation.recordKey, never the parent's. resource is the write
capability flag: the server publishes it only when exactly one registered
resource owns the related model, and the parser reads an absent, null or
wrong-typed value as absent — read-only, never a throw, the same
absence-means-unavailable rule readOnly already follows.
Since P11 the descriptor also carries search (the resource level's
ResourceSearch, reused) and sorts (List<ResourceSort>, reused),
with a defaultSort getter — the same shapes ResourceSchema already
parses, and the same readings: an absent or wrong-typed key means a server
predating P11 and reads as disabled / [], never a throw. A pre-P11 server
is indistinguishable from an undeclared relation, which is exactly what the
wire shape intends.
The section, and where it fetches from #
ResourceViewScreen renders one RelationSectionWidget per entry in
resource.relations automatically — nothing a host has to wire for a
published relation to appear. Each section fetches its own first page
independently of the record's own load, through
ResourceDataSource.relation(resourceKey, id, relation, {page}):
abstract interface class ResourceDataSource {
// ...
Future<PaginatedRecords> relation(
String resourceKey,
Object id,
RelationDescriptor relation, {
int page = 1,
String? search,
String? sort,
String? direction,
});
}
P11 widened this signature — breaking for a host with its own
ResourceDataSource implementation, which must add the three optional
named parameters to compile; it is source-compatible for callers, and a
host on RestResourceDataSource needs no change at all. The REST
implementation builds the query string with the same omission rule as
list(): an unknown sort key is a 422 on the relation endpoint too, so
nothing is sent that the server has not declared.
This is a member on ResourceDataSource, not a port, and no port gained
one. lib/ports/* (FilamentTransport and friends) are what a host
implements directly over its own HTTP client; ResourceDataSource lives in
lib/data/, is implemented in production only by
RestResourceDataSource, and gains a member almost every release — a host
on RestResourceDataSource needs no change at all. Fetching goes through
the data source, not a host-supplied closure — an earlier cut of this
feature took a raw Future<Map<String, dynamic>> Function() from the host
and was corrected before release: a screen given no closure rendered
nothing for a published relation, which is exactly the class of bug this
package's own read path exists to prevent.
One section's slow or failing endpoint never blocks or blanks its
siblings — each owns its own request and its own loading/empty/failure
state, the same isolation ResourceViewScreen's own record load already
has from its relation sections.
A relation that loaded successfully with zero rows renders an empty
state, never an absent section — and the reverse holds too: a relation the
server did not publish renders no section at all. Empty and absent are
different statements, and this widget is what keeps them looking different
on screen; a failed load renders FilamentStrings.relationFailed,
never a spinner left spinning — the exact incident recorded in
docs/superpowers/HANDOFF.md that this package's standing pilot lesson is
aimed at.
"See all" — host-owned navigation, absent when unwired #
A section shows a "See all" affordance only once its first page reports
more rows than it displayed. Tapping it calls ResourceViewScreen's
optional onSeeAllTap:
ResourceViewScreen(
provider: viewProvider,
onSeeAllTap: (relation, recordId) => Navigator.of(context).push(
MaterialPageRoute(
builder: (context) => RelationListScreen(
provider: RelationListProvider(
source: dataSource,
resourceKey: resource.key,
id: recordId,
relation: relation,
),
),
),
),
)
A host that never wires onSeeAllTap gets no button at all — the same
absence-not-disabled rule onEditTap/onCreateTap already follow in this
package: a control the host cannot actually drive must not render enabled
and silently no-op on tap. Where to push, and with what navigator, is
entirely the host's call; this package has no router opinion.
The full list — RelationListScreen / RelationListProvider #
RelationListProvider mirrors ResourceListProvider's shape (status,
records, errorMessage, isUnauthenticated, hasMore, isLoadingMore,
scroll-triggered loadMore()) but fetches through
ResourceDataSource.relation() instead of .list(), and owns its own
ResourceDataSource directly rather than taking a host-wired closure — the
same seam the section widget uses. Since P11 it also mirrors
ResourceListProvider's search/sort behaviour exactly: searchTerm /
activeSort state with search() / sortBy() methods (each refetches
from page one — the list's own reset, not a new page), and the descriptor's
declared defaultSort active from the first fetch. RelationListScreen
mirrors ResourceListScreen's skeleton, scroll pagination and
PanelViewState mapping, reusing the same
CardListSkeleton/PaginatedCardList widgets both screens share, and draws
a search field and sort sheet gated on relation.search.enabled /
relation.sorts.isNotEmpty — nothing drawn for an undeclared relation or
a pre-P11 server, mirroring ResourceListScreen's own gating. The screen's
title is the relation's own label.
RelationSectionWidget — the embedded section on the record screen —
stays plain, deliberately: no search field, no sort control there. List
controls live on the full screen; the section is a preview with a "See
all".
Three new FilamentStrings, all English-default, same rule as every other
string in this package: seeAll ('See all'), relationEmpty
('Nothing here yet'), relationFailed ('Could not load').
Writes — the child resource's form, the relation's endpoint #
A relation whose descriptor carries a resource key is writable end to end.
Three members join ResourceDataSource, beside relation():
Future<WriteResult> createRelation(String resourceKey, Object id, RelationDescriptor relation, Map<String, dynamic> values);
Future<WriteResult> updateRelation(String resourceKey, Object id, RelationDescriptor relation, Object childId, Map<String, dynamic> values);
Future<WriteResult> deleteRelation(String resourceKey, Object id, RelationDescriptor relation, Object childId);
This is a breaking change for a host with its own ResourceDataSource
implementation — the interface gained three methods, so it will not
compile until it adds them. A host on RestResourceDataSource needs no
change at all; the REST implementations ship there, keyed off
relation.recordKey exactly as the read is. The same "member, not port"
reasoning as relation() itself applies: lib/ports/* stays untouched.
The form a row is edited in is the child resource's own
ResourceFormScreen — only the write target changes. That override is one
small value, RelationSubmitTarget (parent resource key, parent record id,
the relation), handed to ResourceFormProvider.submitTarget: null — every
form outside a relation — submits to the resource's own endpoints exactly as
before; non-null redirects only the write, so validation, the 422
mapping and the error banner are shared verbatim (the server keys a relation
write's 422 by the same child-form field names this screen renders).
Create vs update is decided by the provider's own recordId, exactly as
the resource write path already decides it.
RelationListScreen takes an optional childResource (the resolved
ResourceSchema for descriptor.resource — resolving it is the host's one
job; ResourceDataSource already fetches schemas). Every affordance is
permission-gated off the child resource's published permissions: an
Add button on create, per-row edit and delete on update/delete, and a
null childResource or a false flag renders no control at all — the
standing absence-not-disabled rule. Delete confirms through the same dialog
ResourceViewScreen's own delete uses, one level down, reusing the existing
deleteConfirmTitle/deleteConfirmBody/deleteConfirm/cancel strings.
The per-row controls arrive through two new, generally-useful slots —
ResourceCard.trailing and PaginatedCardList.rowTrailing — rather than a
relation-specific card fork.
The section refreshes when the record does. RelationSectionWidget
takes an optional parent (the ResourceViewProvider it sits under) and
reloads when the parent finishes a reload — a record action that changed
relation membership no longer leaves stale rows until the user leaves and
returns. It listens only for a success notification: a failed parent
reload leaves the section showing what it already had, which is strictly
better than blanking good rows behind the parent's error.
Known weaknesses, stated now #
- A relation manager that narrows its own query is invisible on mobile. The server refuses to publish it at all — see the Laravel README's Relations section for why — so there is nothing here for this client to render around it.
- The section refreshes only through
parent. WireRelationSectionWidget.parent(the record screen's own provider) and a completed parent reload refreshes the section; without it the section loads once, ininitState, and shows stale rows after an action that changes the relation's membership until the user leaves the screen and returns. - The relation manager's own filters stay ignored, and its table's search/sort are never read. Undeclared, the list arrives in relation order, unfiltered — matching the server exactly, which itself never introspects the manager's table. Search and sort appear only where the host declares them per relation; a pre-P11 server reads as undeclared.
- Only the first two columns become a card, because the server only derives that many. A relation whose meaning lives in its third column looks empty of information on the phone too.
- Writes need the server to name a child resource. A relation whose
descriptor has no
resourcekey — zero or several registered resources own the child model, or the server predates relation writes — is a read path here exactly as before. Attach and detach are not exposed on either side.
Rich text #
The document renders; links are host-wired, and absent when unwired.
An infolist entry with EntryKind.rich (a ->prose() TextEntry, or a
model column registered with HasRichContent) renders as an actual
document via RichEntryTile, this package's own export — headings,
bulleted and numbered lists, blockquotes, horizontal rules, images, and
bold/italic/strike/underline/code, using RichText/Column, this
package's two runtime dependencies unchanged. A card slot bound to the same
column shows its flattened plain text, never the raw markup: the record's
<path>.__rich sibling ({"doc": …, "text": …}) is absent entirely when
the server had nothing to convert, and every consumer without it falls
back to today's raw string.
RichDocument.fromJson parses the <path>.__rich sibling into a
RichDocument — a RichNode tree rooted at doc. The vocabulary this
build recognises mirrors the server's own closed set exactly:
NODES: doc, paragraph, text, heading, bulletList, orderedList,
listItem, blockquote, horizontalRule, image
MARKS: bold, italic, link, strike, underline, code
An unrecognised node renders its descendant text as a paragraph, never
nothing. A future Filament release adding an eleventh node type must not
make a paragraph vanish — silent content loss is worse than plain-looking
text, so RichNode.fromJson carries an unknown type through rather than
rejecting it, and RichEntryTile falls back to _descendantText for
anything its switch has no case for. An image with no src — the
server nulls it for a private-visibility attachment — is skipped rather
than rendered broken. An empty paragraph (a ProseMirror blank-line
separator) renders a single space rather than collapsing to zero height, so
blank lines in the source document stay visible as blank lines. Marks
combine: strike and underline on the same run — or strike on a link,
which already carries underline — both survive, because decorations
accumulate and are merged once via TextDecoration.combine rather than each
mark overwriting the last. Rendering uses Text.rich, not a bare
RichText, specifically so it honours MediaQuery.textScalerOf(context)
like every other tile in this package — a bare RichText ignores the
user's system font-size setting.
Links need a launcher this package will not take #
Opening a URL is a platform concern (url_launcher or similar), and this
package ships exactly two runtime dependencies — flutter and
equatable — so it takes no opinion on how a host opens one. Tapping a link
is host-wired exactly like onSeeAllTap: the host passes its own
EntryRegistry.defaults(onLinkTap: ...) to ResourceViewScreen.registry.
ResourceViewScreen(
provider: viewProvider,
registry: EntryRegistry.defaults(
onLinkTap: (href) => launchUrl(Uri.parse(href)), // the host's own package
),
)
A host that never wires onLinkTap gets a link rendered as plain,
unstyled text — not a blue underlined span that does nothing when tapped.
This is the same absence-not-disabled rule onSeeAllTap/onEditTap already
follow, applied to styling rather than to a button: a reader cannot tell an
unwired link was ever a link, which is the honest state, since this package
cannot make good on tapping it. EntryRegistry is the override point rather
than a new callback on ResourceViewScreen — a host that also wants a
custom entry type (see EntryRegistry.register) wires both through the one
registry it already owns.
Known weaknesses, stated now #
- No editing. The form field for a rich column stays a plain textarea over the raw HTML string — a user reads formatted text and edits markup. A real editor is a much larger build this slice does not attempt.
attrs.textAlignis published and ignored. It belongs to the RTL/i18n slice.- No tables, no custom TipTap blocks. Outside the closed vocabulary; an unrecognised node type renders its descendant text as a paragraph rather than disappearing, so content survives even where formatting does not.
Schema caching #
Cold start renders instantly, then revalidates behind it.
/schema is a ~200 KB document, and until now PanelProvider.load() showed
a spinner for every fetch of it, every launch. Two separate, optional ports
close that gap — separate for the same reason FilamentUploadTransport is:
FilamentTransport is an abstract interface class, hosts implement it with
implements, and implements inherits no implementation, so adding a member
to it is a compile error in every existing host.
Without either port, behaviour is exactly what it is today — in-memory only, a full document fetched every time, no spinner change, no regression. This is not a degraded mode; it is the honest no-op.
abstract interface class FilamentConditionalTransport {
/// GETs [path], sending `If-None-Match: <etag>` when [etag] is non-null.
/// A 304 must come back with `notModified: true` — never a throw, and
/// never a synthesised empty body.
Future<ConditionalResponse> getConditional(String path, {String? etag});
}
abstract interface class FilamentSchemaCache {
Future<CachedSchema?> read(String key);
Future<void> write(String key, CachedSchema value);
Future<void> clear(String key);
}
A host that implements neither is untouched. A host that implements
FilamentConditionalTransport alone gets revalidation with no persistence —
pointless on its own, since there is nothing cached to revalidate against,
but harmless. The two ports earn their keep together: FilamentSchemaCache
persists the document, FilamentConditionalTransport lets a cold start ask
the server "is my cached copy still good?" for the price of an empty 304
instead of the whole document.
Here is the example app's FilamentConditionalTransport, unedited — the
same brevity argument as upload's three statements, this time because
package:http's own Response already carries everything needed:
@override
Future<ConditionalResponse> getConditional(String path, {String? etag}) async {
// Deliberately not routed through get(): get() throws on every non-2xx,
// and a 304 is one — it would arrive there as an indistinguishable
// thrown failure instead of the "unchanged" outcome the caller needs.
final response = await _client.get(
Uri.parse('$baseUrl$path'),
headers: {..._headers, if (etag != null) 'If-None-Match': etag},
);
if (response.statusCode == 304) {
return ConditionalResponse(
notModified: true,
etag: response.headers['etag'],
);
}
if (response.statusCode < 200 || response.statusCode >= 300) {
throw FilamentTransportException(
_messageOf(response),
statusCode: response.statusCode,
);
}
return ConditionalResponse(
notModified: false,
body: jsonDecode(response.body) as Map<String, dynamic>,
etag: response.headers['etag'],
);
}
And a FilamentSchemaCache, in-memory for the example — a real host reaches
for shared_preferences or Hive, whatever it already carries; this package
adds neither as a dependency:
class InMemorySchemaCache implements FilamentSchemaCache {
final _entries = <String, CachedSchema>{};
@override
Future<CachedSchema?> read(String key) async => _entries[key];
@override
Future<void> write(String key, CachedSchema value) async {
_entries[key] = value;
}
@override
Future<void> clear(String key) async {
_entries.remove(key);
}
}
CachedSchema.document is the decoded body, re-encoded — never the raw
JSON string, and never the parsed model. Neither transport port ever
exposes raw wire bytes; both get() and getConditional() hand back an
already-decoded Map<String, dynamic>, so there is no original JSON string
in this package to store. Storing jsonEncode() of that same decoded map,
rather than the parsed PanelSchema, is what lets a key this package's
parser does not yet recognise survive the round trip instead of being
silently dropped on write.
The cache key is the host's, and scoping it is a safety property #
FilamentSchemaCache's key is supplied by the host, and the package never
invents one. /schema is per-user — policies filter which resources
appear — so a cached document is one user's view of the panel index. A
cache key that is not scoped per signed-in user — a constant, or one that
does not change across a sign-out/sign-in — lets a second user on the same
device open the first user's cached panel index, before a single request
reaches the server. This package cannot enforce the right key: it has no
identity concept, by design, the same reasoning that keeps login and token
storage out of scope entirely. Scope the key to something that changes with
the signed-in user — a user id, a hash of the session token — and clear the
cache on sign-out if the host does not already rotate the key.
A host that supplies no key gets no persistence, which fails safe: the
same honest no-op as omitting the port entirely, never a cache that silently
serves the wrong user's data. An empty or whitespace-only key counts as no
key — cacheKey: user?.id ?? '' must never become a shared, unscoped
persistent key when nobody is signed in.
Cold start: render cached, revalidate behind it #
PanelProvider.load() reads the cache first. When there is a usable entry
it publishes the cached panel immediately — status goes straight to
success, never loading — and only then revalidates over the network. A
304 changes nothing; a 200 replaces the panel and rewrites the cache.
status = loading is therefore structurally unreachable once a cache is
published: no spinner ever flashes over content the user is already looking
at.
This cache-then-revalidate pass runs on a cold start — a fresh
RestResourceDataSource. Within one data source's lifetime the panel is
memoized after the first successful load, so a later load() on the same
instance re-publishes that panel without another network revalidation; a
new data source (a new app launch) starts the cycle again.
A revalidation failure with a cache already on screen is not a failure
state. The panel stays exactly as it was, and the user is never shown an
error for a background request they never made — a stale panel is bounded:
/schema describes structure, and every read and write still hits the
server, which re-derives permissions on every request. A cached panel cannot
grant access the server will not. A cold failure with no cache behaves
exactly as it does without either port: PanelFailure.
Two outcomes still surface even with a cache on screen, because both name a state the user has to act on, not a fact about staleness:
- A 401 during revalidation still sets
isUnauthenticatedandstatus = failure— a signed-out session must not keep showing a panel, cached or not. - A cached document of a newer, unsupported schema version is discarded
and refetched, never rendered —
PanelSchema.fromJsonalready throwsUnsupportedSchemaVersionExceptionfor one, and stale bytes must never be the reasonneedsAppUpdatefails to surface.
The screens gate isUnauthenticated behind status.isFailure.
needsAppUpdate is a provider-level flag the host consumes — no screen
reads it — and setting it forces status = failure, so neither state can
be masked by a stale panel sitting in success.
Known weaknesses, stated now #
- A 304 does not save server CPU — see the Laravel README's Schema caching section; the document is still built to hash it.
- The panel can be one revalidation stale. Bounded by the reasoning
above: structure only, and the server re-derives every permission on
every request regardless of what
/schemalast said. - Cache correctness depends entirely on the host's key. A host that keys the cache badly — a constant, say — can show one user another's panel index. The package cannot detect this; it is documented as the host's obligation, in the same class as "the host owns auth."
- No eviction. One document per key, overwritten in place; a host that creates unbounded keys grows unbounded storage.
Dashboard #
Stats render; charts come from the opt-in sibling package, or your own builder.

DashboardProvider + DashboardScreen follow the same provider/screen
shape as every other read screen: LoadStatus, skeleton-first loading,
pull-to-refresh, PanelUnauthenticated on a 401. DashboardProvider.load()
calls ResourceDataSource.dashboard(), which parses GET /dashboard into a
DashboardData — a list of DashboardWidgetData, sealed into
StatsWidgetData (a row of StatData cards) and ChartWidgetData (a
labelled axis and one or more ChartDatasets), so a switch over a widget
is exhaustive. Values are live on every read — the panel has no static
dashboard document to cache — so pull-to-refresh genuinely re-runs the
panel's queries, not a cached replay. A successful load of zero widgets
renders PanelEmpty with the new dashboardEmpty string, not a blank
screen.
Stat cards render fully, including the sparkline. StatData.chart (a
List<double>?) draws through StatSparkline, a hand-rolled
CustomPainter — a polyline with no axes, labels or touch handling. Its
colour comes from SemanticBadge.colorFor(), the same semantic vocabulary
(success, danger, …) action buttons already use; there is no second
palette to configure.
Charts are published, not drawn — read this before assuming the package
can't do charts. ChartWidgetData carries parsed labels and
ChartDatasets, but this package draws none of it itself. That is not a
missing feature; it is what keeps the two-dependency promise from the
Requirements section above (flutter + equatable, nothing else) true for
every host, including the ones with no dashboard. DashboardScreen takes
an optional chartBuilder:
typedef DashboardChartBuilder =
Widget Function(BuildContext context, ChartWidgetData data);
A host that wants charts renders them with whatever charting package it
already has. The companion package
filament_mobile_charts
is the ready-made answer — one dependency, one line, every chart type the
server can publish drawn with fl_chart:
DashboardScreen(
provider: dashboardProvider,
chartBuilder: flChartBuilder(),
)
Or hand-roll the builder against your own charting package, in about ten lines:
DashboardScreen(
provider: dashboardProvider,
chartBuilder: (context, data) => LineChart(
LineChartData(
lineBarsData: [
for (final series in data.datasets)
LineChartBarData(spots: [
for (var i = 0; i < series.data.length; i++)
FlSpot(i.toDouble(), series.data[i]),
]),
],
),
),
)
Without a chartBuilder, a chart card renders its heading and the new
chartUnavailable string ("No chart renderer supplied.") — never a blank
box. This is the same honest-empty-state rule every other screen in this
package follows: a widget the client cannot render says so, rather than
pretending nothing was there.
Two known weaknesses carried over from the server, worth knowing before you build against this screen:
StatData.valueis a string, and only a string. The server stringifiesStat::getValue()once because only the panel's own formatting knows whether"1,340"should have been1340or1.3k. You cannot do arithmetic on it — a "trend arrow computed on device" feature would need the server to publish a second, numeric field, which it does not today.- No polling or realtime. The dashboard is exactly as fresh as the last
load()/pull-to-refresh; there is no timer built intoDashboardProviderand none is planned for this screen.
RTL and i18n #
The panel decides the direction, not the device.
Every screen this package ships — PanelIndexScreen, ResourceListScreen,
ResourceFormScreen, ResourceViewScreen, RelationListScreen,
DashboardScreen — wraps its own returned widget (the Scaffold included,
so the app bar's title alignment and back-button side follow too) in a
Directionality resolved from the panel's published direction,
unconditionally, not as a host opt-in. There is no wiring to do and no
flag to set: a panel whose locale is Arabic renders right-to-left the
moment /schema says so, whatever direction the host's own MaterialApp
happens to be.
That "unconditionally" is deliberate, not an oversight. Publishing a
capability and leaving the host to wire it has shipped dead twice
already in this project — P6d's fetchRelation, P6e's onLinkTap — each
time caught only in review, because nothing exercises an unwired path until
someone reads specifically for it. Direction is correctness here, not
preference: Arabic labels laid out left-to-right is not a taste a host
might reasonably want to keep. A host whose own app is already RTL sees no
change; a host embedding an Arabic panel inside an LTR app gets the right
answer instead of the wrong one, with nothing to configure.
ResourceSchema.direction (and RelationDescriptor.direction) are
populated by PanelSchema.fromJson at parse time, propagated down from the
one panel.direction value into every resource and relation the document
carries. All six screens read direction off data the host already passes
them — not a second value the host would have to thread through — which is
the property that keeps this from being a third capability that ships
published and unwired. A directly-constructed ResourceSchema, as in a
test, defaults to PanelDirection.ltr.
Overlay routes get the same wrap, deliberately, not by inheritance. A
sheet, dialog, picker or dropdown menu pushed from a screen does not
automatically pick up that screen's Directionality — Navigator mounts a
route's content above the pushing screen in the widget tree, not below it, so
Directionality is exactly as invisible to it as any other ordinary
InheritedWidget would be outside its subtree. Every route this package
pushes re-wraps itself in the direction resolved from the schema, and each one
has its own test in rtl_layout_test.dart asserting the resolved direction at
a widget inside the route — deleting any single wrap reds a named test:
| route | how it re-wraps |
|---|---|
| the list screen's sort sheet | showModalBottomSheet builder: |
| the record screen's delete confirmation | showDialog builder: |
| a record action's confirmation | showDialog builder: |
a date field's showDatePicker |
the picker's own builder: |
a datetime field's showTimePicker |
the picker's own builder: |
| a remote select's search sheet | showModalBottomSheet builder: |
| a select field's dropdown menu | per DropdownMenuItem, since DropdownButtonFormField has no builder: |
A SnackBar is deliberately not in that list, and the distinction is
worth stating because it is easy to get backwards: a snack bar is not a route.
ScaffoldMessenger hands it to the nearest registered Scaffold, which
renders it inside its own subtree — already below the screen's wrap — so it
inherits the panel's direction with no help. A wrap was written here anyway,
and measurement showed it was dead code; it was removed rather than left as
untested decoration. The rendered snack bar's direction is asserted directly,
so the day that stops being true, a test says so.
textAlign is finally honoured, closing the gap P6e's rich text left
open: a paragraph or heading node renders with the textAlign its own
attrs carries — start/center/end/justify, the server's closed
vocabulary — instead of the value being published and ignored.
Grouped digit runs are bidi-isolated under RTL, so a phone number or a
spaced IBAN keeps its own group order inside an Arabic sentence instead of
the bidi algorithm reordering the groups themselves. Measured, not assumed:
a phone number embedded in Arabic prose renders with its digit groups
swapped end-for-end under plain RTL layout, and correctly once wrapped in a
directional isolate (isolateGroupedDigits, lib/ui/bidi_text.dart). The
pattern it matches is deliberately tight — two or more digit groups
separated by spaces or hyphens, with an optional leading + — which
catches a phone number, a spaced IBAN, a hyphenated tax number, and
excludes a year, a price (19.99; . is not a listed separator) or a
plain count, none of which the bidi algorithm reorders internally and none
of which an isolate would fix. A digit is ASCII, Arabic-Indic (٠–٩,
U+0660–0669) or Extended Arabic-Indic (۰–۹, U+06F0–06F9): Dart's \d is
ASCII-only, and the eastern forms — the numerals an ar panel is most likely
to actually contain — reverse with exactly the same measured signature, so
they are matched explicitly.
There is one implementation, applied at every place this package renders a server-supplied string — five call sites, each with its own test asserting measured glyph order:
| site | what it covers |
|---|---|
entries/entry_widgets.dart (EntryTile) |
an infolist text entry |
semantic_badge.dart |
a badge's label, isolated after the colour lookup so the lookup key stays raw |
resource_card.dart (_text) |
a card's title, subtitle and meta |
entries/rich_entry_tile.dart (_span, _blockNode) |
every rich-text run, plus the two unknown-node fallbacks |
dashboard_screen.dart |
a stat tile's value and description |
The implementation is single; the application is not, and the difference matters: a new widget rendering server text has to call the helper, and only a test at that widget proves it does. The whole-branch review found three of these five call sites deletable with the entire suite still green — that gap is what the per-site tests above close.
This is a heuristic, not a semantic parse — say so plainly. A free-text value that happens to be shaped like grouped digits gets isolated too, whether or not it is actually a phone number. Harmless, since isolation is the correct rendering for a run shaped like that regardless of what it means — but it is a guess, and should be treated as one rather than as proof this package understood the data.
Known weaknesses, stated now:
- Per-field content direction is not modelled. Direction follows the panel, not the value — a panel whose locale is English but whose data happens to be Arabic still lays out left-to-right, because the server has no per-value answer to give.
- Text a host renders itself is not covered. The isolate runs only where this package renders a server-supplied string; a host drawing its own widget from the same payload gets the raw, un-isolated string.
- This package's own
FilamentStringsare not translated. They are host-supplied with English defaults by design (see Wiring, above) — a host serving an Arabic panel supplies Arabic strings itself. - Translatable-field editing is untouched. A
caption.arkey still arrives as a flat sibling besidecaption, exactly as it always has; this slice never touches per-locale field editing. - The isolate helper's idempotency guard is whole-string, not
per-match. A value that concatenated an already-isolated fragment with
a fresh, un-isolated run would have the fresh run skipped too — see the
ponytail:comment onisolateGroupedDigitsfor the narrow case this leaves unhandled and why a per-match version was tried and reverted. RichEntryTileisolates per mark leaf, not per node. A grouped-digit run that a bold boundary splits down the middle — its first group bold, the rest plain — arrives as two separate leaves, and neither alone matches the pattern, so neither is isolated.- The pattern has a leading token boundary but deliberately no trailing
one.
(?<!\w)stops a word ending in a digit from donating its last character to the run, soline1 10 20isolates10 20. There is no matching(?!\w), and the asymmetry is measured rather than lazy: a raw ISO timestamp still isolates only its date half (2026-03-24out of2026-03-24T08:41:19Z), which changes the relative order of nothing and is harmless — while a trailing guard makes it strictly worse, backtracking from2026-03-24(rejected,Tfollows) to2026-03(accepted,-is not\w) and splitting the date itself. Note\wis ASCII-only, as\dwas: an Arabic word ending in an eastern numeral is not guarded.
A note for anyone extending a screen in this package, hard-won across
three separate fixes in this slice: a State's own context — read
outside build(), e.g. in a method that opens a dialog, a bottom sheet or
a picker — is an ancestor of the Directionality that same widget's
build() wraps around its returned tree, not a descendant of it. Calling
Directionality.of(context) from such a method reads the host's ambient
direction, silently ignoring the wrap build() installs one line away.
Resolve from the schema value instead, via textDirectionOf(...)
(lib/ui/material_panel_state_builder.dart), whenever the call site is not
itself inside build(BuildContext).
Strings #
FilamentStrings defaults to English, silently.
The write path added twelve strings — save, saveFailed, the four delete
and cancel strings, and the client-side validation hints (eight today:
fieldRequired, fieldEmail, fieldUrl, fieldPattern, fieldConfirmed,
fieldColor, fieldMin, fieldMax). Every one has an
English default, so a host that upgrades and changes nothing still compiles
and still runs — and shows English hints under server-translated labels.
All eight hints are live against a current server. This client parsed
url, regex and confirmed from the start; the Laravel package's 0.6.0
is the first server that actually publishes and enforces them, closing a
one-sided corner where the parser sat wired and dead. regex arrives as
the pattern verbatim and a pattern Dart cannot compile fails open — the
server revalidates regardless, so a hint that cannot even parse is worth
nothing here.
This is sharper than it sounds, because a client hint blocks the submission: when a required field is empty the request never leaves the phone, so the server's already-translated 422 never arrives and English is the only thing the user ever sees. Measured on the pilot panel — an Arabic-locale Filament panel with 33 production resources.