Skip to content

Repository files navigation

fabric_flutter

Version: 3.4.0 License: BSD 3-Clause

CI

Project Overview & Core Application

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 ChangeNotifier state 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_serializable domain 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).


Tech Stack & Architecture Map

Core Dependencies

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

Architectural Map

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

Architecture Layers & Responsibilities

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.

🔴 Code Generation Notice

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-outputs

An equivalent npm shortcut is defined in package.json:

npm run serialize   # → flutter pub run build_runner build --delete-conflicting-outputs

The --delete-conflicting-outputs flag is required so the generator can safely overwrite stale outputs. Do not hand-edit *.g.dart files or pubspec.lock.


Getting Started & Local Compilation

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

Prerequisites

  • Flutter SDK — stable channel. Confirm with flutter --version; switch with flutter channel stable && flutter upgrade if 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.

Setup Steps

  1. Clone the repository

    git clone https://github.com/FabricElements/fabric_flutter.git
    cd fabric_flutter
  2. Resolve dependencies (always run first in a fresh checkout)

    flutter pub get
  3. Run code generation (only needed if lib/serialized/ models changed)

    dart run build_runner build --delete-conflicting-outputs
    # or: npm run serialize
  4. Analyze and format

    flutter analyze    # must report no issues
    dart format .      # trailing commas drive stable wrapping
  5. Run the test suite

    flutter test

Using the package in a host app

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(),
);

Passwordless email-link sign-in (ViewAuthPage.email)

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.

  1. Firebase Console → Authentication → Sign-in method → enable Email link (passwordless sign-in).

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

  3. iOS: add the link domain to Associated Domains (applinks:app.example.com) and host an apple-app-site-association file on the domain pointing at the app's team and bundle id.

  4. Android: declare an App Links intent filter for the link domain and host an assetlinks.json on that domain listing the app's package name and signing certificate fingerprints.

  5. 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);

    completeEmailLinkSignIn is 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.

  6. Recommendation: because a client cannot stop a determined caller from also using the password provider, add a beforeSignIn blocking 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.

Web sign-in mode: redirect by default (ViewAuthPage.webSignInMode)

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.

  1. 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 the authDomain in your firebaseConfig). The default <project>.firebaseapp.com works only while the app is served from it.
  2. 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.
  3. 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.

Adaptive navigation (AdaptiveNavigationScaffold)

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

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.

Agent identity

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.

Scroll reveal & embedding in parent surfaces

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. Passing items: a fixed list together with embedded: true turns 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.

Agent bridge (lib/helper/agent/)

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.md is the practical reference: how to configure AgentBridge, how to make a widget agent-ready with AgentElement, 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.

Transports, authentication, and audit

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.

Voice dictation (VoiceDictationButton)

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 localhost during 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 via onAvailabilityChanged instead 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.


Security model & data access

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.

Account-scoped user queries (UserQuery)

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); // true

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

Untrusted URLs (UrlSafety)

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.

What the consuming project must enforce

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, and user-actions-role callables — the client sends UserData.toJson() plus a group, so the function must allow-list the fields it accepts and never let a caller-supplied role, roles, or groups map reach a write.
  • list and get on the user collection.
  • Cloud Storage access: FirebaseStorageHelper reads 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.


Testing, Goldens & Verification Suite

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

Running tests

flutter test                                  # run the full suite
flutter test test/serialized/user_data_test.dart   # run a single file

Conventions

  • Arrange–Act–Assert — every test body uses explicit // Arrange, // Act, // Assert sections (see test/serialized/password_data_test.dart).
  • Structure — a top-level group('<ClassName>', …) per class, nested groups per behavior, and descriptive test('should …') names.
  • Serialized models — cover round-trips (fromJson → toJson → fromJson), null/empty tolerance (fromJson(null)), and edge cases.
  • Widget tests — use testWidgets with WidgetTester, pumping the widget inside a minimal MaterialApp scaffold.

Isolation & mock environments (no live network)

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 (visual regression) 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-goldens

Run 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 catalog (development only)

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

The 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-goldens

AI-Assisted Engineering Rules

This 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.yaml plus analysis_options.yaml: prefer_single_quotes: true (always single quotes) and avoid_print: true (use debugPrint(), optionally colorized via lib/helper/log_color.dart). The analyzer ignores undefined_prefixed_name for conditional web imports.
  • Trailing commas are mandatory on all multi-line argument/parameter lists so dart format produces 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/@throws tags — weave behavior into prose.
  • 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 with ProviderHelper.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.


Contributing Workflow

See CONTRIBUTING.md for full details. In summary:

  1. Create a feature branch (one issue per change).
  2. Install the pre-commit hooks (recommended):
    ./scripts/install-hooks.sh
    The hook runs dart format on staged Dart files, flutter analyze, a print() check (use debugPrint()), and a single-quote/documentation check on each git commit. Bypass only in emergencies with git commit --no-verify.
  3. Add or update tests for every bug fix or feature (mandatory).
  4. 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.


Debug & Development Tips

Android Google Sign-In issues

If Google Sign-In fails on an Android emulator:

  1. Open the host app's android/ folder in Android Studio.
  2. Sync Gradle and resolve any build errors.
  3. Verify applicationId in android/app/build.gradle.
  4. Update the Kotlin version to the latest in the Gradle files.
  5. Generate SHA-1 and SHA-256 certificates (instructions).
  6. Add the fingerprints to the Firebase Console.
  7. Re-download google-services.json.
  8. Clean and rebuild the project.

Working with locally ignored files

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

Additional Resources

Releases

Packages

Used by

Contributors

Languages