Version: 3.4.0 License: BSD 3-Clause
fabric_flutter is a Flutter package — a reusable component and architecture library, not a standalone runnable application. There is no main.dart or platform (ios/, android/, web/) folder in this repository; instead, consumer apps depend on the package and compile it as part of their own build. Through its Firebase- and Material-based building blocks, it targets the platforms Flutter itself supports, with first-class support for mobile (iOS/Android) and web (it ships web-specific plugins such as google_sign_in_web and webview_flutter_wkwebview/webview_flutter_android).
The package provides production-ready, Firebase-powered building blocks:
- Pre-built UI components — 41 self-contained, reusable widgets covering authentication flows, data tables, media upload, country/language pickers, Google Maps, charts, smart buttons/images, and inputs.
- Provider-based state management — Purpose-built
ChangeNotifierstate containers for user/auth, analytics, notifications, and Firestore document/collection/API data. - Firebase integration layer — Helpers for Auth, Firestore, Cloud Storage, Cloud Functions, Analytics, Messaging, and Realtime Database, kept out of the UI layer.
- Type-safe data models —
json_serializabledomain models with null-tolerant deserialization (fromJson(null)falls back to{}). - Developer utilities — Input validation, JWT parsing, localization (ISO countries/languages), GSM provider lookups, regex helpers, formatting, and auth-aware routing.
Operational flow: A consumer app wraps its widget tree with the InitApp component (lib/component/init_app.dart), which bootstraps the provider tree (StateGlobal, StateUser, StateAnalytics, StateNotifications, StateUsers, StateViewAuth). StateUser listens to FirebaseAuth.authStateChanges(), resolves the ID token and custom claims, and loads the user document from Firestore; UI widgets then react via context.watch/Provider.of. Routing is handled by Flutter's built-in named routes assembled by RouteHelper into public, authenticated, and admin route tables.
Requirements: Dart SDK ^3.13.7 and Flutter >=3.47.8 (stable channel).
Sourced directly from pubspec.yaml.
| Category | Packages |
|---|---|
| SDK | Dart ^3.13.7, Flutter (stable channel, >=3.47.8) |
| State management | provider ^6.1.5+1 (ChangeNotifier — no Riverpod/Bloc/GetX) |
| Firebase / backend | firebase_core ^5.1.0, firebase_auth ^7.0.1, cloud_firestore ^7.1.0, cloud_functions ^7.0.1, firebase_storage ^14.0.1, firebase_analytics ^13.0.1, firebase_messaging ^17.0.1, firebase_database ^13.0.1 |
| Authentication | firebase_auth, google_sign_in ^7.2.0, google_sign_in_web ^1.1.3 |
| Serialization | json_annotation ^4.12.0 (dev: json_serializable ^6.15.2, build_runner ^2.17.1) |
| Networking | http ^1.6.0, connectivity_plus ^7.3.2 |
| Voice & speech | speech_to_text ^7.5.0 (push-to-talk dictation, VoiceDictationButton) |
| Media | image_picker ^1.2.4, file_picker ^13.2.0, image ^4.12.0, video_player ^2.14.1, mime ^2.1.0, image_network ^2.6.0 |
| Maps & web views | google_maps_flutter ^2.19.0, webview_flutter ^4.14.1, webview_flutter_android ^4.14.1, webview_flutter_wkwebview ^3.27.2, web ^1.1.2, pointer_interceptor ^0.10.1+3 |
| Localization & i18n | flutter_localizations, devicelocale ^0.9.1, intl ^0.20.3, dlibphonenumber ^1.1.74 |
| UI & content | google_fonts ^9.0.0, cupertino_icons ^2.0.0, omni_datetime_picker ^2.3.3, scrollable_positioned_list ^0.3.8, gap ^3.0.1, json_explorer ^0.1.2 |
| Utilities | package_info_plus ^10.2.2, url_launcher ^6.3.3, collection ^1.19.1, ansicolor ^2.0.3 |
| Dev only | widgetbook ^3.25.0 (catalog in widgetbook/, not exported from lib/) |
| Testing & linting | flutter_test, integration_test, flutter_lints ^6.0.0 |
The library is organized into clean, layered directories under lib/:
lib/
├── variables.dart # Global flags (e.g. kIsTest detects the FLUTTER_TEST environment)
├── component/ # UI layer: 41 self-contained, reusable widgets
│ ├── init_app.dart # Core provider bootstrap (InitApp / InitAppChild)
│ ├── route_page.dart # Route-aware page scaffold
│ ├── user_*.dart # User management widgets (admin, avatar, chip, dropdown)
│ ├── google_*.dart # Google Maps / Charts widgets
│ ├── smart_button.dart, smart_image.dart, input_data.dart, ...
│ ├── voice_dictation_button.dart # Press-to-talk speech dictation (callbacks only, no controller)
│ └── iframe_minimal*.dart # Conditional web/native implementations
├── state/ # Business logic / state containers (ChangeNotifier + Provider)
│ ├── state_shared.dart # Abstract base: pagination, filters, debounce, streams
│ ├── state_api.dart # HTTP API-backed state (extends StateShared)
│ ├── state_document.dart # Firestore single-document state
│ ├── state_collection.dart # Firestore collection state
│ ├── state_global.dart # App-wide state (connectivity, package info)
│ ├── state_user.dart # Authenticated user, claims, roles
│ ├── state_users.dart # User directory state
│ ├── state_analytics.dart # Firebase Analytics
│ ├── state_notifications.dart# FCM notifications
│ └── state_view_auth.dart # Auth view state
├── serialized/ # Data models: @JsonSerializable classes + generated *.g.dart pairs
│ ├── user_data.dart / user_data.g.dart
│ ├── agent_*.dart # Agent bridge protocol models (request, response, command, element, ...)
│ ├── media_data.dart, logs_data.dart, filter_data.dart, ... (every model has a .g.dart twin)
├── helper/ # Stateless utilities / repository-style helpers
│ ├── agent/ # Agent bridge (opt-in, disabled by default)
│ │ ├── agent_bridge.dart # AgentBridge: configure + handle(Map) -> Map dispatcher
│ │ ├── agent_registry.dart # Capability registry of AgentCommand
│ │ ├── agent_element_index.dart, agent_element_binding.dart # Live element index
│ │ ├── agent_builtin_commands.dart # navigate, set_value, tap, read_value, wait_for, ...
│ │ ├── agent_authorizer.dart # Seam where authentication and role checks plug in
│ │ ├── agent_token_authorizer.dart, agent_principal_resolver.dart # Bearer token + role gate
│ │ ├── agent_dispatcher.dart, agent_rate_limiter.dart # Size cap, rate limit, attribution
│ │ ├── agent_audit.dart # Redacting audit log routed to an injectable sink
│ │ └── agent_transport.dart, agent_bridge_server*.dart # In-process, native, and web transports
│ ├── http_request.dart # HTTP networking (AuthScheme, request helpers over package:http)
│ ├── firestore_helper.dart # Firestore utilities (Timestamp conversions)
│ ├── auth_service.dart # Injectable auth seam (AuthService / FirebaseAuthService)
│ ├── firebase_storage_helper.dart, media_helper.dart
│ ├── route_helper.dart # Auth-aware route table builder (RouteHelper)
│ ├── provider_helper.dart # ProviderHelper.isProviderDefined<T>(context)
│ ├── user_query.dart # Account-scoped `user` collection queries (UserQuery)
│ ├── url_safety.dart # Scheme allow-list before launchUrl / WebView (UrlSafety)
│ ├── options.dart, utils.dart, format_data.dart, input_validation.dart
│ ├── iso_countries.dart, iso_language.dart, gsm.dart, jwt.dart, regex_helper.dart
│ └── app_localizations_delegate.dart, log_color.dart, user_roles*.dart
├── placeholder/ # Loading/fallback widgets and default locale data
└── view/ # Full-page composed views (view_auth_page, view_featured, view_hero)
test/ # Mirrors lib/ structure, files suffixed _test.dart
├── helper/ # Tests for lib/helper/*
├── serialized/ # Tests for lib/serialized/*
└── *_test.dart # Component/root-level tests
| Layer | Directory | Responsibility |
|---|---|---|
| UI / Presentation | component/, view/ |
Self-contained, reusable widgets and full-page composed views. Accept callbacks (VoidCallback, ValueChanged<T>); never call Firebase SDKs directly. |
| State containers | state/ |
ChangeNotifier-based business logic. StateShared provides pagination, filters, debounce, and streams; StateDocument/StateCollection/StateAPI back data sources. Client-generated SQL is excluded from outgoing query parameters by default and can be enabled with includeSql. |
| Serialization models | serialized/ |
@JsonSerializable(explicitToJson: true) domain entities with null-tolerant fromJson and matching toJson. Each model has a generated *.g.dart twin. |
| Helpers / Infrastructure | helper/ |
Stateless utilities: networking, Firestore/Storage helpers, validation, formatting, routing, localization. |
Layer rules: component/ and view/ depend on state/ and helper/; state/ depends on helper/ and serialized/; serialized/ depends only on json_annotation (and Firestore types where needed); helper/ holds stateless logic. Never call Firebase SDKs directly from widgets — route through state/ classes or helper/ utilities.
Caution
Generated *.g.dart files must never be edited by hand. The serialized models in lib/serialized/ rely on json_serializable, which produces a <model>.g.dart twin for every @JsonSerializable class. This project uses json_serializable only — there is no freezed, so no *.freezed.dart targets exist.
Whenever you add, remove, or modify a field in any lib/serialized/*.dart model, regenerate the targets and commit the regenerated file alongside the model:
dart run build_runner build --delete-conflicting-outputsAn equivalent npm shortcut is defined in package.json:
npm run serialize # → flutter pub run build_runner build --delete-conflicting-outputsThe --delete-conflicting-outputs flag is required so the generator can safely overwrite stale outputs. Do not hand-edit *.g.dart files or pubspec.lock.
Because fabric_flutter is a package rather than an app, "compilation" here means resolving dependencies, running code generation, and validating the library locally. There is no flutter run target inside this repository; instead, the package is consumed by a host app (which compiles it for mobile/web).
- Flutter SDK — stable channel. Confirm with
flutter --version; switch withflutter channel stable && flutter upgradeif needed. - Dart SDK —
^3.13.7(bundled with Flutter 3.47.8). - Firebase project — required only by host apps that exercise the Firebase-backed widgets/state; configure Firebase for your target platforms.
-
Clone the repository
git clone https://github.com/FabricElements/fabric_flutter.git cd fabric_flutter -
Resolve dependencies (always run first in a fresh checkout)
flutter pub get
-
Run code generation (only needed if
lib/serialized/models changed)dart run build_runner build --delete-conflicting-outputs # or: npm run serialize -
Analyze and format
flutter analyze # must report no issues dart format . # trailing commas drive stable wrapping
-
Run the test suite
flutter test
Add fabric_flutter to the host app's pubspec.yaml, then wrap the app with InitApp:
void main() {
runApp(
const InitApp(
notifications: true,
child: MyApp(),
),
);
}The host app is what runs on a device, emulator, or browser via its own flutter run (for example flutter run -d chrome for web). InitApp installs the provider tree so the package's components and state classes are available throughout the app.
Brand color. Pass seedColor to derive the light and dark color schemes from your brand color with MaterialThemeM3.schemeFromSeed (Material 3 fidelity variant, which keeps the brand hue recognisable in both brightnesses). Without it, the scheme is seeded from the ambient theme's primary color.
const InitApp(
seedColor: Color(0xFF0B57D0),
child: MyApp(),
);ViewAuthPage can offer passwordless sign-in by emailed link, alongside
Google, Apple, phone, and anonymous. It is opt-in — pass email: true:
ViewAuthPage(
url: 'https://app.example.com/connect?request=rid-42',
email: true,
emailLinkDomain: 'app.example.com', // custom Firebase Hosting domain
iOSBundleId: 'com.example.app',
androidPackageName: 'com.example.app',
emailLinkAndroidInstallApp: true,
)There are no password fields, no password reset, and no sign-up form. The only email entry point sends a link; opening that link is the only way to complete the flow. The address is remembered locally so the same device finishes without retyping it, and a link opened on a device that never requested it prompts for the address instead.
Continue URL. ActionCodeSettings.url (the continue URL the link returns
the user to) defaults to the current location — Uri.base on web,
including path and query — so a host page resumes exactly where it left off (for
example /connect?request=<rid>). Pass url to override it, which is also what
native hosts should do, since there is no ambient URL on a device. After a web
completion the Firebase oobCode/mode/apiKey parameters are stripped from
the address bar with history.replaceState, leaving the host's own path and
query in place. The oobCode is redeemed before the URL is rewritten,
because the code is single-use.
Project setup.
-
Firebase Console → Authentication → Sign-in method → enable Email link (passwordless sign-in).
-
Add every domain the app is served from to Authentication → Settings → Authorized domains, and add the custom link domain to the hosting setup if you pass
emailLinkDomain. -
iOS: add the link domain to Associated Domains (
applinks:app.example.com) and host anapple-app-site-associationfile on the domain pointing at the app's team and bundle id. -
Android: declare an App Links intent filter for the link domain and host an
assetlinks.jsonon that domain listing the app's package name and signing certificate fingerprints. -
On Android and iOS the link is delivered to the app, not to a page, so the host must forward it to the page:
// From your deep-link / app_links handler. final page = AppGlobal.navigatorKey.currentState ?.context .findAncestorStateOfType<ViewAuthPageState>(); await page?.completeEmailLinkSignIn(link);
completeEmailLinkSignInis a no-op for a link Firebase did not issue, so it is safe to forward every inbound link without filtering first. The package deliberately does not depend on an app-links plugin. -
Recommendation: because a client cannot stop a determined caller from also using the password provider, add a
beforeSignInblocking Cloud Function that rejects non-email-link sign-ins for these users, so passwordless is enforced where it can actually be enforced.
AuthService exposes the same surface for custom UIs:
sendSignInLinkToEmail, isSignInWithEmailLink, signInWithEmailLink, and
completeEmailLinkSignIn.
On web, ViewAuthPage signs in with Google and Apple through a full-page
redirect rather than a popup. A popup is a separate browser window, and
automated browsers, embedded webviews, and strict popup blockers routinely
refuse to open one — the sign-in then fails with auth/popup-blocked and the
user is left on the page with nothing to do. A redirect navigates the whole tab
to the provider and back, which no blocker can prevent.
// Default: redirect. Nothing to configure.
ViewAuthPage(url: 'https://app.example.com/connect')
// Opt back into the popup flow.
ViewAuthPage(
url: 'https://app.example.com/connect',
webSignInMode: AuthWebSignInMode.popup,
)AuthWebSignInMode has two values:
| Value | Behaviour on web |
|---|---|
redirect (default) |
signInWithRedirect for Google and Apple. |
popup |
signInWithPopup, with an automatic retry through the redirect flow when the popup is blocked or closed by a blocker. |
Native platforms are unaffected: the mode is ignored on iOS and Android, where the provider SDKs use their own native flows.
Handling the return. A redirect leaves the page and comes back, so the
result has to be collected on the load that follows. ViewAuthPage does this
itself: on web in redirect mode it calls getRedirectResult() once during
initialization, shows its normal loading state while resolving, and on success
continues down exactly the same post-sign-in path a popup would have taken — the
host's FirebaseAuth.authStateChanges() listener navigates as usual. A failure
is surfaced through the same localized alert the popup path shows and is
announced to assistive technology; a user-cancelled redirect stays silent, and
account-exists-with-different-credential and network-request-failed get
their own messages.
Nothing sensitive is ever put in the URL. The result is read from Firebase's own storage, so no token, code, or address is written to the address bar or to browser history.
A host that starts the redirect from somewhere other than this page can collect the result itself at app start:
final page = AppGlobal.navigatorKey.currentState
?.context
.findAncestorStateOfType<ViewAuthPageState>();
await page?.resolveRedirectSignIn();resolveRedirectSignIn is a no-op when no redirect is pending, so it is safe to
call unconditionally. AuthService exposes the same seam for custom UIs.
Project setup — authDomain must be the app's own domain. This is the part
that most often breaks a redirect, and it is a Firebase project setting rather
than a code change.
- Serve the app from a domain you control, and set that domain as the Firebase
project's
authDomain(Firebase Console → Authentication → Settings → Authorized domains, and theauthDomainin yourfirebaseConfig). The default<project>.firebaseapp.comworks only while the app is served from it. - That domain must serve Firebase's auth helper at
/__/auth— which Firebase Hosting does automatically. If the app is served from a different host, proxy/__/auth/*to the Firebase Hosting origin, or the redirect cannot complete. - Add the OAuth client's authorized redirect URI:
https://<app domain>/__/auth/handler. Google and Apple both reject a redirect to a URI that is not registered on the client.
The reason for step 1 is browser storage partitioning: the redirect result is
handed back through storage scoped to the authDomain origin. When the app and
authDomain are the same origin, that storage is first-party and always
readable. When they differ, the helper page is a third-party frame, and browsers
that partition or block third-party storage can drop the result — the user
returns signed out with no error. Serving the app from its own authDomain
avoids the problem entirely.
AdaptiveNavigationScaffold switches the top-level navigation by WindowSizeClass: a bottom NavigationBar below 600dp, a NavigationRail from 600dp, and an extended rail from 1200dp. The body gets the Material 3 window margin (16dp compact, 24dp medium and up) unless applyMargin is false.
AdaptiveNavigationScaffold(
destinations: const [
AdaptiveDestination(icon: Icon(Icons.home_outlined), label: 'Home'),
AdaptiveDestination(icon: Icon(Icons.settings_outlined), label: 'Settings'),
],
selectedIndex: index,
onDestinationSelected: (value) => setState(() => index = value),
body: pages[index],
)Accessibility is a first-class concern in this package. Three conventions apply across every interactive component.
1. Text scaling (opt-in). InitApp renders text with TextScaler.noScaling by default, so the operating system text-size preference does not change the app's text.
This is deliberate, not an oversight: honoring the OS text scale caused a layout bug on iOS, and applications built on this package are sized against the fixed-scale behavior. Please do not remove it — the default is load-bearing.
Applications that want the standard Flutter behavior can opt in and are responsible for verifying their own layouts (especially on iOS):
const InitApp(
// Honor the user's preference, clamped to 1.0–1.4 by default.
honorSystemTextScale: true,
child: MyApp(),
);
const InitApp(
// Opt in and allow the user's full preference through, unclamped.
honorSystemTextScale: true,
minTextScaleFactor: null,
maxTextScaleFactor: null,
child: MyApp(),
);The bounds are exposed as kDefaultMinTextScaleFactor (1.0) and kDefaultMaxTextScaleFactor (1.4), and are only applied once honorSystemTextScale is true. The clamping itself is available as the standalone clampedTextScaler helper.
2. The semantics hook trio. Interactive components accept three optional, nullable parameters that map directly onto Flutter's Semantics:
| Parameter | Maps to | Purpose |
|---|---|---|
semanticsLabel |
Semantics.label |
Human/agent-readable name; falls back to the visible label when null. |
automationKey |
Semantics.identifier |
Deterministic automation id, [RouteName]_[ContextBlock]_[ComponentType]_[ActionOrId]. |
semanticHint |
Semantics.hint |
Extra guidance, including why a control is disabled. |
SmartImage(
url: photoUrl,
semanticsLabel: 'Profile photo',
automationKey: 'profile_header_image_avatar',
);
// Purely decorative imagery is hidden from assistive technology instead.
SmartImage(url: backgroundUrl, excludeSemantics: true);Components implementing the trio include SmartButton, CardButton, InputData, SmartImage, UserAvatar, CountryPicker, LanguagePicker, PhoneInput, PopupEntry, and UsersDropdown, which forwards it to the widget it wraps.
3. Assistive-technology preferences. Components that animate or auto-advance respect MediaQuery.disableAnimationsOf and MediaQuery.accessibleNavigationOf — Tabs, StepperExtended, PaginationContainer, AlertData, and ViewFeatured skip animations, suppress auto-advance, and offer explicit controls when a screen reader is active. Status changes (connection lost/restored, page changes, upload results, auth failures) are announced through SemanticsService, and Breadcrumbs exposes a navigation landmark with the current page marked (WCAG 2.4.8).
All accessibility strings are localized through AppLocalizations; add or override them the same way as any other label.
A component's automationKey is a single identity across four surfaces, so an agent, an accessibility tool, and a test all refer to the same element by the same key:
| Key surface | Consumer | Reads the key from |
|---|---|---|
| Agent registry id | AgentElementIndex / in-app AgentElement |
id of the registered element |
| Semantics identifier | Semantics.identifier |
find.bySemanticsIdentifier(key) |
| Web DOM attribute | flt-semantics-identifier |
the browser / DOM queries |
| Test finder | find.bySemanticsIdentifier(key) |
the same semantics node |
One key = agent id = semantics identifier = test finder = web DOM attribute. The same
automationKey that a query uses to locate the widget on the web is the id the in-app agent
registers and the id a widget test asserts against — there is no second, parallel metadata to
maintain. A null or empty automationKey registers nothing and adds no identifier, so unkeyed
widgets cost nothing.
An agent that works on a long page needs to bring the section it is editing into view — and keep it there. Scrollable.ensureVisible runs once, so it misses targets whose surroundings keep changing after the call: content that loads asynchronously above the target, images that finish decoding, expandable tiles that open. Reveal here means "scroll the target to an alignment, then re-check until the layout stops moving".
ScrollReveal (lib/helper/scroll_reveal.dart) is a stateless helper. It works through nested scrollables, backs off as soon as the user scrolls or the target unmounts, and jumps instead of animating when the platform asks for reduced motion; it also re-checks the alignment only when the target is genuinely out of view, so a settled page costs no frames:
final revealed = await ScrollReveal.reveal(
sectionKey.currentContext!,
alignment: 0.1, // 0 = leading edge, 1 = trailing edge
settle: const Duration(milliseconds: 1500), // give up after this long
quiet: const Duration(milliseconds: 500), // ...or once it has held still this long
);
// `false` when the target unmounted, has no enclosing scrollable, or the user took over.The same helper is the single entry point for the components below. Pass a RevealController to a component's existing controller: parameter and call RevealController.reveal, mapping the target to that component's own layout:
| Component | reveal target |
Reveals |
|---|---|---|
PaginationContainer |
an int index, or a predicate bool Function(dynamic item) |
that item, loading further pages as needed |
StepperExtended |
a step int index, or RevealTarget(target: index, within: fieldKey) |
the step, or a field inside it when within is a mounted GlobalKey |
ExpansionTable |
a TableRowData or a row int index |
that row, and — with RevealTarget(column: 1) — a column of it |
Each call returns true only when the target actually ended up in view, so a caller can branch on it rather than assume. A controller attached to no mounted component — or an out-of-range index — returns false without side effects. Targets are re-resolved against the live data on every rebuild, so a list that loads more items or is prepended to still reveals the same item. Only one reveal runs at a time: starting a new one cancels the one in flight, which then returns false.
Embedding in a generative surface. When a component renders inside a parent chat ListView, it must not fight the parent for gestures or claim its own scroll extent. Three opt-in parameters cover that:
PaginationContainer(embedded: true)sizes itself to its items, never captures a drag, and replaces the scroll-edge pagination trigger with an explicit "load more" button. Passingitems:a fixed list together withembedded: trueturns on that whole mode at once (shrinkWrap, no stream, no further pages).StepperExtended(scrollable: false)(the default) renders a fixed column; a reveal then scrolls the enclosing scrollable.ExpansionTable(columnWidth: ...)keeps its horizontal panning on the inner viewport, so vertical drags still reach the parent list.LogsList(scrollable: false)(the default) is the fixed-column mode.
Example for a chat-embedded tile:
ListView(
children: [
SomeBuilderSurface(
table: ExpansionTable(
data: contactTable,
columnWidth: 300,
// The agent scrolls to a specific contact later:
controller: contactTableController,
),
),
PaginationContainer(
embedded: true,
items: suggestions,
itemBuilder: (context, index, item) => ListTile(title: Text('$item')),
),
],
);To land on a specific row of a contact table, or a specific plan step:
await contactTableController.reveal(
RevealTarget(target: contactRow, column: 1),
);
await planStepperController.reveal(3, alignment: 0.1);Both are no-ops until the component is mounted, and both report false instead of throwing when it is not.
The agent bridge lets an external agent — an MCP client, a cloud workflow, an automated test — drive the app the way a user can: read the current screen, set any input, tap any button, navigate, and run host-registered actions. It reuses the accessibility metadata above as its contract: automationKey is the element identifier, semanticsLabel the label, semanticHint the description. There is no parallel metadata to maintain, and the binding adds no Semantics node, so the accessibility tree is unchanged.
Building against the bridge?
doc/agent.mdis the practical reference: how to configureAgentBridge, how to make a widget agent-ready withAgentElement, and the exact parameters and result shape of every built-in command. This section is the summary; that document is the contract.
The bridge is transport agnostic (handle takes a decoded request map and returns a decoded response map), performs no access control of its own, and is disabled by default:
AgentBridge.instance.configure(
enabled: true,
appName: 'My App',
appVersion: '1.0.0',
routes: [AgentRouteInfo(name: '/dashboard', title: 'Dashboard')],
// Replace this before exposing the bridge outside a trusted process.
authorizer: MyTokenAndRoleAuthorizer(),
);
// Register a domain command; it is published by `describe` alongside the built-ins.
AgentBridge.instance.registry.register(
AgentCommand.define(
id: 'archive_order',
title: 'Archive order',
category: 'orders',
requiresRole: 'admin',
params: [AgentParam(name: 'orderId', required: true)],
handler: (context) => archiveOrder(context.require<String>('orderId')),
),
);Focus & reveal — an agent navigating a screen needs to scroll the right element into view and move input focus to it before it can type or activate. AgentElement accepts an optional focusNode that requestFocus() uses directly (falling back to the first focusable descendant); reveal() calls ScrollReveal.reveal on the element's context. Two built-in commands give an external agent these same capabilities:
| Command | Params | Result |
|---|---|---|
element.reveal |
elementId (string), alignment (number, optional, default 0.1) |
{ id, revealed } — revealed is true when the target ended in view |
element.focus |
elementId (string) |
{ id, focused } — focused is false when no focusNode and no focusable descendant exists |
Commands that interact with an element (tap, set_value) reveal and focus the element before acting, so an agent never has to sequence those steps manually.
Tap activates through ActivateIntent — tap first tries the Flutter Actions system (ActivateIntent, the same path keyboard activation takes) and falls back to the element's own activator callback. The result reports which path was used: {id, method: 'actions' | 'activator', activated: true}. Disabled elements are rejected before either path.
Form validation — when an InputData field is wrapped in AgentElement, the bridge gains access to its FormField validator:
| Command | Params | Result |
|---|---|---|
set_value |
elementId (string), value (any) |
{ id, value, valid, error } — runs the field validator after setting |
read_value |
elementId (string) |
Full snapshot including error (String?) from the last validation |
form.submit |
elementId (string, any field in the form) |
{ valid: true } when all pass, or { valid: false, errors: { "<elementId>": "<error>" } } — validates the enclosing Form but does not submit |
Requests are { "id", "method": "describe" | "state" | "invoke" | "ping", "params": {...} } and responses are { "id", "ok": true, "result": {...} } or { "id", "ok": false, "error": { "code", "message" } }, with the codes unauthorized, not_found, invalid_params, disabled, and failed. describe is shaped to map 1:1 onto MCP tool definitions. The built-in commands are navigate, set_value, tap, read_value, wait_for, screen_state, list_commands, element.reveal, element.focus, and form.submit.
For route reporting and navigate to work, wire the navigator:
MaterialApp(
navigatorKey: AppGlobal.navigatorKey,
navigatorObservers: [AgentNavigatorObserver.instance],
);Authentication and per-command role checks are not part of the dispatcher. They plug in through AgentAuthorizer, which receives every request and the resolved command — including its requiresRole — and may attach meta (for example the resolved principal) that is forwarded to the handler as AgentCommandContext.meta.
Starting a transport is the second (and only other) opt-in step. All three transports share one entry point shape and funnel through the same dispatcher, so none of them can widen the attack surface:
// Native (desktop, mobile) — WebSocket and POST on one loopback port.
await AgentBridgeServer.start(
options: const AgentBridgeServerOptions(port: 8757),
verifier: (token) async => myBackend.principalFor(token),
auditSink: (record) => myAuditCollection.add(record.toJson()),
);
// Web — installs a callable `window.fabricAgentBridge(request)`.
// In-process — `AgentInProcessTransport.start(...)` for tests and embedding.The bridge implements no login, token minting, refresh, or consent UI. An agent obtains an access token from your OAuth 2.0 authorization server and presents it in the reserved params.auth field of the request envelope (the Bearer prefix is optional); the native transport also accepts an Authorization header or a ?token= query parameter and normalizes it into the same field. The host supplies the verification callback:
typedef AgentTokenVerifier = FutureOr<AgentPrincipal?> Function(String token);AgentTokenAuthorizer resolves the token through a short-lived cache, denies with unauthorized when it is missing, invalid, or expired, enforces AgentCommand.requiresRole against the principal's role and groups, and forwards the principal to handlers via AgentCommandContext.meta. describe/ping and state are gated independently (requireAuthenticationForDiscovery, requireAuthenticationForState, both true by default).
On the web the transport is a property on window, so any script in the page can call it. The exposure is bounded by three facts, all covered by tests: the property exists only after a successful start(...) (a build that never starts the bridge has none at all), stop() deletes it, and with the defaults an unauthenticated caller is refused on every method — ping, describe, state, and invoke all answer unauthorized and disclose neither the command catalog, the route list, nor any on-screen value. See the threat model and web exposure model in the protocol document before shipping a web build.
Defaults are deliberately restrictive: the bridge is disabled until enabled, binds to 127.0.0.1:8757, rejects requests over 64 KiB, allows 4 concurrent connections, and rate-limits each principal to 60 requests per minute. Starting a transport throws when the bridge is disabled, and throws when no verifier is supplied while the bridge still carries the permissive default authorizer — so on the web window.fabricAgentBridge is never installed by a build that did not deliberately opt in.
Every invoke emits one AgentAuditRecord (timestamp, request id, principal id, command id, outcome, duration, transport, and a redacted parameter summary) to the injected sink. Parameter values are never recorded — only a type and size summary — and credential-shaped keys are replaced by <redacted> with no size, so not even a token's length leaks.
The full wire protocol, envelope, error taxonomy, safety table, and audit shape are documented in doc/agent-bridge-protocol.md.
Web semantics — Flutter web only builds the semantics DOM after accessibility is explicitly enabled. External agents (MCP clients, browser automation) that read the UI through the accessibility tree need this. Set AgentBridge.instance.ensureSemanticsOnWeb = true before configure() to acquire a SemanticsHandle on the first authorized request; the handle is held only while an agent is active and released after semanticsIdleTimeout (default 5 minutes) of inactivity, on reset(), or when the bridge is disabled. The screen_state command and state() method report semanticsEnabled so an agent can verify the tree is populated.
VoiceDictationButton (lib/component/voice_dictation_button.dart) is a self-contained, press-and-hold microphone button built on speech_to_text. It never holds a TextEditingController or touches app-level state — it only reports transcripts and status through constructor callbacks, so the parent decides what to do with the text (including whether/when to submit it). It also fires a short haptic tap (HapticFeedback.mediumImpact on start, HapticFeedback.lightImpact on stop) so the user gets tactile confirmation without watching the icon; pass enableHapticFeedback: false to disable it:
VoiceDictationButton(
onPartialTranscript: (text) => _controller.text = text,
onFinalTranscript: (text) {
_controller.text = text;
_sendMessage();
},
onListeningChanged: (isListening) => setState(() => _isRecording = isListening),
onError: (message) => ScaffoldMessenger.of(context)
.showSnackBar(SnackBar(content: Text(message))),
)Host apps must add the platform permissions below; the package itself ships no android/, ios/, or web/ folders.
- Android (
android/app/src/main/AndroidManifest.xml):<uses-permission android:name="android.permission.RECORD_AUDIO"/> <uses-permission android:name="android.permission.INTERNET"/>
- iOS (
ios/Runner/Info.plist):<key>NSMicrophoneUsageDescription</key> <string>This app uses the microphone for voice dictation.</string> <key>NSSpeechRecognitionUsageDescription</key> <string>This app uses speech recognition to transcribe your voice.</string>
- Web: requires HTTPS (or
localhostduring development) and a genuine user gesture to trigger the microphone permission prompt — the button's pointer-down handler satisfies this. Browser support for the Web Speech API is inconsistent: Safari and Firefox may report unavailable viaonAvailabilityChangedinstead of supporting dictation the way Chrome/Edge do.
Warning
Test on a physical iOS device, not the Simulator. The iOS Simulator's microphone input is well known to be unreliable for SFSpeechRecognizer — sessions can start and stop cleanly, with no error ever reported, yet onPartialTranscript/onFinalTranscript never fire because no real audio ever reaches the recognizer. This is a Simulator/OS limitation, not something VoiceDictationButton (or speech_to_text) can detect or work around from Dart.
fabric_flutter ships inside an untrusted client. Assume the binary can be decompiled, patched, and re-signed. Nothing in this package is a security control — the security boundary is Firestore Security Rules, Cloud Functions, and Cloud Storage rules in the consuming project. Full rules with file:line citations live in .github/instructions/security.instructions.md.
Firestore evaluates a list operation by matching the rule against the query's filters. An orderBy restricts which documents come back, but it is not a constraint a rule can require, and request.query exposes only limit, offset, and orderBy. A list rule therefore cannot distinguish a legitimate ordered read from an enumeration of the whole collection — so a project cannot tighten list at all while a client still issues an unfiltered collection query.
UserQuery (lib/helper/user_query.dart) expresses the scope as a filter, which is what lets a consuming project deny unscoped listing:
// Scoped: carries `groups.<group> != null` as a filter and leads the ordering
// with the same field. Same documents as before; now visible to a `list` rule.
final query = UserQuery.byGroup(group: 'acme');
// Reports whether a query is constrained to an account, ignoring ordering.
UserQuery.isScoped(query); // trueBoth listing entry points scope automatically when given a group, and both accept a caller-supplied query so a project whose rules deny unscoped listing can pass one it has already narrowed:
// Scoped by group.
UserAdmin(group: 'acme');
await UserRolesFirebase.getUsers(group: 'acme');
// Or supply a query narrowed however the project requires.
UserAdmin(query: myScopedQuery);
await UserRolesFirebase.getUsers(query: myScopedQuery);Without a group and without a query, both still read across the whole collection. That default is preserved deliberately so pinned consumers do not break, and it requires the project to permit unscoped list.
StateUsers resolves batched lookups with per-document get reads rather than a whereIn filter on FieldPath.documentId. A whereIn filter is a list; reading by id is a get. Splitting them lets a project keep allow get open for resolvable users while denying allow list outright. Billed reads are unchanged.
Uri.parse succeeding is not validation — it accepts javascript:alert(1) and file:///etc/passwd. UrlSafety (lib/helper/url_safety.dart) enforces an https/http allow-list plus a non-empty host before anything reaches launchUrl:
if (UrlSafety.isSafe(value)) await UrlSafety.launch(value);ExpansionTable, JsonExplorerSearch, and GoogleChartContainer route through it, and render a value as tappable only when it passes.
Client-side role checks (StateUser.admin, StateUser.accessByRole, UserRoles.roleFromData) decide what to render and nothing more. The project must enforce server-side:
- Every role and group check that gates data.
- The
user-actions-add,user-actions-remove, anduser-actions-rolecallables — the client sendsUserData.toJson()plus agroup, so the function must allow-list the fields it accepts and never let a caller-suppliedrole,roles, orgroupsmap reach a write. listandgeton the user collection.- Cloud Storage access:
FirebaseStorageHelperreads and writes objects directly, with no application logic in between. Path, content type, size, and read access are governed only by Storage rules.
IframeMinimal on native enables unrestricted JavaScript and permits every navigation request, so its src must be trusted content.
The verification suite is built on package:flutter_test. Tests live under the root test/ directory and mirror the structure and naming of lib/, suffixed with _test.dart (e.g. lib/helper/jwt.dart → test/helper/jwt_test.dart).
flutter test # run the full suite
flutter test test/serialized/user_data_test.dart # run a single file- Arrange–Act–Assert — every test body uses explicit
// Arrange,// Act,// Assertsections (seetest/serialized/password_data_test.dart). - Structure — a top-level
group('<ClassName>', …)per class, nestedgroups per behavior, and descriptivetest('should …')names. - Serialized models — cover round-trips (
fromJson→toJson→fromJson), null/empty tolerance (fromJson(null)), and edge cases. - Widget tests — use
testWidgetswithWidgetTester, pumping the widget inside a minimalMaterialAppscaffold.
Important
Tests must never open real HTTP connections or touch Firebase/Firestore/Storage or any external service. Use in-memory data, canned JSON maps, and mocked clients only.
Production code exposes the kIsTest flag (from lib/variables.dart), which detects the FLUTTER_TEST environment so Firebase/platform interactions are skipped automatically during tests.
Golden tests are run through the same flutter test runner. When a widget's intended appearance changes, regenerate the reference images with:
flutter test --update-goldensRun goldens in the same isolated, mock-only manner as the rest of the suite (no live services). Commit updated golden images only when the visual change is intentional and reviewed.
widgetbook/ holds a dev-only Widgetbook catalog for the shared widgets (CardButton, SmartButton, StatusChip, SectionTitle, AdaptiveNavigationScaffold, StepperExtended). Every use case covers light and dark, the VisualDensity values used by DensitySpacing, and compact, medium, and expanded widths. Run it with:
flutter run -t widgetbook/main.dartThe catalog is not exported from lib/, nothing under lib/ imports it, and widgetbook is a dev dependency only. test/widgetbook/catalog_golden_test.dart renders the same use cases as golden tests (test/widgetbook/goldens/). Regenerate them only for the new catalog test:
flutter test test/widgetbook/catalog_golden_test.dart --update-goldensThis repository is heavily optimized for AI-assisted workflows using GitHub Copilot. Human contributors and AI assistants are held to the same standards so generated and hand-written code stay indistinguishable.
Important
Before editing or generating code, review .github/copilot-instructions.md — it is the canonical source of truth for conventions. Ensure your AI chat assistant is grounded in that file (and the scoped rules under .github/instructions/) so suggestions remain consistent with the codebase.
Warning
Changes under lib/ must also follow .github/instructions/security.instructions.md, which records the client trust boundary and the query-scoping rules with file:line citations. General practice for FabricElements shared packages lives in .github/instructions/cross-repo.instructions.md.
Key parameters enforced for both humans and AI:
- Linting — From
package:flutter_lints/flutter.yamlplusanalysis_options.yaml:prefer_single_quotes: true(always single quotes) andavoid_print: true(usedebugPrint(), optionally colorized vialib/helper/log_color.dart). The analyzer ignoresundefined_prefixed_namefor conditional web imports. - Trailing commas are mandatory on all multi-line argument/parameter lists so
dart formatproduces stable, readable wrapping. - Documentation follows Effective Dart: Documentation for every public and private API element:
- Triple-slash
///doc comments only — never/* */or//for API docs. - A single-sentence summary that is capitalized, ends with a period, and starts with a third-person verb ("Builds…", "Stores…", "Provides…"), separated from further detail by a blank
///line. - Reference in-scope types and members with square brackets (e.g.
[BuildContext],[PasswordData],[stream]) and literals with backticks (`null`). - No Javadoc-style
@param/@returns/@throwstags — weave behavior into prose.
- Triple-slash
- fabric_flutter first — reuse existing components (
SmartButton,SmartImage,InputData,ContentContainer, …), helpers (FormatData,InputValidation,Utils,Options), and theme tokens before writing anything new; only fall back to Flutter-recommended options when the package genuinely cannot solve the need. - State access — obtain state via
provider(context.read/context.watch/Provider.of); guard optional providers withProviderHelper.isProviderDefined<T>(context). - No new dependencies unless absolutely necessary; never commit secrets, API keys, or Firebase credentials.
When a new domain feature or data entity is introduced, keep its files synchronized: the serialized/ model and regenerated .g.dart, any state/ container (registered in InitApp if app-wide), supporting helper/, component/ UI, mirrored test/ files, and this README.md.
See CONTRIBUTING.md for full details. In summary:
- Create a feature branch (one issue per change).
- Install the pre-commit hooks (recommended):
The hook runs
./scripts/install-hooks.sh
dart formaton staged Dart files,flutter analyze, aprint()check (usedebugPrint()), and a single-quote/documentation check on eachgit commit. Bypass only in emergencies withgit commit --no-verify. - Add or update tests for every bug fix or feature (mandatory).
- Validate locally — all of these must pass before submitting:
flutter analyze # clean dart format . # no diffs flutter test # all green dart run build_runner build --delete-conflicting-outputs # if models changed
Note
There are currently no GitHub Actions CI workflows in this repository. The required validation is the local sequence above (flutter analyze + flutter test, plus the pre-commit hooks). Run it before opening a pull request.
When bumping the package version, update the Version line at the top of this README, the version field in pubspec.yaml, and add a CHANGELOG.md entry.
If Google Sign-In fails on an Android emulator:
- Open the host app's
android/folder in Android Studio. - Sync Gradle and resolve any build errors.
- Verify
applicationIdinandroid/app/build.gradle. - Update the Kotlin version to the latest in the Gradle files.
- Generate SHA-1 and SHA-256 certificates (instructions).
- Add the fingerprints to the Firebase Console.
- Re-download
google-services.json. - Clean and rebuild the project.
Some configuration files can be ignored on your local checkout without untracking them:
git update-index --skip-worktree default_values.txt # stop tracking local changes
git update-index --no-skip-worktree default_values.txt # resume tracking
git ls-files -v . | grep ^S # list skip-worktree files- Issues & bugs: GitHub Issues
- Contributing guide: CONTRIBUTING.md
- Copilot / AI instructions: .github/copilot-instructions.md
- Changelog: CHANGELOG.md
- License: BSD 3-Clause