dynamic_pages 0.4.0 copy "dynamic_pages: ^0.4.0" to clipboard
dynamic_pages: ^0.4.0 copied to clipboard

Schema-driven, type-safe generated Flutter pages for Dart models.

dynamic_pages #

Generate production-oriented Flutter forms, details pages, and refreshable lists from typed Dart/Freezed models—or render the same page families from constrained JSON schemas.

The generator emits schemas, adapters, and small typed page wrappers. Maintained runtime widgets own layout, validation, loading, errors, and theming, so package upgrades do not regenerate large widget trees.

Choose a mode #

Mode Best for Result
Typed generation Normal application models and API DTOs Compile-time types, IDE completion, generated wrappers
Runtime schemas Hand-authored or composed UI metadata Typed model with no annotation requirement
Server-driven JSON Backend-configured forms and response views Validated domain schema without arbitrary Flutter widgets
Registered templates Entire custom pages selected by an API Precompiled stateful widgets with versioned JSON props

Typed and JSON pages use the same renderers and theme tokens.

Features #

  • Regular Dart and Freezed factory model support
  • Generated form, details, and list page wrappers
  • Text, email, number, date, enum dropdown, checkbox, and custom form fields
  • Create/edit forms with sync and async validation
  • Loading state, API field-error mapping, and scroll-to-first-error
  • Dirty-state navigation confirmation
  • Read-only formatting for strings, dates, booleans, enums, and collections
  • Async lists with retry, empty state, pull-to-refresh, and custom item builders
  • Application-wide and per-page ThemeExtension customization
  • Custom fields, headers, footers, actions, and list items
  • Constrained JSON forms, details pages, and lists
  • Versioned, API-selected custom page templates

Installation #

dependencies:
  dynamic_pages: ^0.4.0
  freezed_annotation: ^3.1.0
  json_annotation: ^4.12.0

dev_dependencies:
  build_runner: ^2.7.0
  freezed: ^3.2.0
  json_serializable: ^6.14.0

Generate Freezed, JSON serialization, and page schemas together:

dart run build_runner build

The builder is automatically applied to packages that depend on dynamic_pages.

Generated forms #

Annotate a regular or Freezed factory model:

import 'package:dynamic_pages/dynamic_pages.dart';
import 'package:freezed_annotation/freezed_annotation.dart';

part 'user_models.freezed.dart';
part 'user_models.g.dart';

enum Role { admin, editor, member }

@freezed
@GenerateFormPage(title: 'Create user', submitLabel: 'Create')
abstract class UserForm with _$UserForm {
  const factory UserForm({
    @FormTextField(
      label: 'Name',
      required: true,
      minLength: 2,
      hint: 'Ada Lovelace',
    )
    required String name,

    @FormEmailField(label: 'Email', required: true)
    required String email,

    @FormDateField(label: 'Birth date')
    DateTime? birthDate,

    @FormDropdownField(label: 'Role', source: Role.values)
    required Role role,

    @FormCheckboxField(label: 'Receive product updates')
    @Default(false)
    bool receiveUpdates,
  }) = _UserForm;
}

Use the generated typed wrapper:

UserFormPage(
  initialValue: existingForm,
  onSubmit: (request) async {
    await api.createUser(request);
  },
);

The generator also exposes userFormPageSchema:

ModelFormPage<UserForm>(
  schema: userFormPageSchema,
  onSubmit: api.createUser,
);

Generated API response pages #

The same Freezed response class can generate both list and details pages:

@freezed
@GenerateDetailsPage(title: 'User details')
@GenerateListPage(
  title: 'Users',
  titleField: 'name',
  subtitleField: 'email',
  emptyMessage: 'No users returned by the API',
)
abstract class UserResponse with _$UserResponse {
  const factory UserResponse({
    @DisplayField(label: 'User ID')
    required int id,

    @DisplayField(label: 'Full name')
    required String name,

    @DisplayField(label: 'Email address')
    required String email,

    required Role role,

    @DisplayField(label: 'Active account')
    required bool active,

    @DisplayField(label: 'Created')
    required DateTime createdAt,
  }) = _UserResponse;

  factory UserResponse.fromJson(Map<String, Object?> json) =>
      _$UserResponseFromJson(json);
}

Decode an API response into typed models:

final payload = jsonDecode(response.body) as Map<String, Object?>;
final users = [
  for (final item in payload['data']! as List<Object?>)
    UserResponse.fromJson(
      (item! as Map<Object?, Object?>).cast<String, Object?>(),
    ),
];

Then render a generated list and open its generated details page:

UserResponseListPage(
  load: repository.fetchUsers,
  onItemPressed: (context, user) {
    Navigator.of(context).push(
      MaterialPageRoute<void>(
        builder: (_) => UserResponseDetailsPage(value: user),
      ),
    );
  },
);

List wrappers also accept itemBuilder, errorBuilder, header, floatingActionButton, app-bar actions, and showAppBar.

Details wrappers accept per-field builders, header, footer, app-bar actions, and showAppBar. Use @DisplayField(hidden: true, ...) to omit a constructor parameter from generated details.

Server-driven JSON #

JSON schemas describe domain concepts only. Widget class names and arbitrary Flutter trees are rejected.

Complete custom page templates #

Use DynamicTemplatePage when a response should select an entire custom page, including its layout, cards, buttons, checkboxes, navigation, animations, controllers, and local state:

{
  "type": "template",
  "template": "glass_showcase",
  "version": 1,
  "props": {
    "title": "Glass workspace",
    "tintColor": "#9333EA",
    "loadingDurationMs": 1400,
    "navigation": [
      {"label": "Home", "icon": "home"},
      {"label": "Search", "icon": "search", "badge": 3},
      {"label": "Saved", "icon": "favorite"},
      {"label": "Profile", "icon": "person"}
    ]
  },
  "meta": {
    "requestId": "req-template-page"
  }
}

Register the application-owned Dart implementation and its supported response versions:

final templates = DynamicPageTemplateRegistry({
  'glass_showcase': DynamicPageTemplate(
    minimumVersion: 1,
    maximumVersion: 1,
    builder: (context, data) => GlassShowcase(
      config: GlassShowcaseConfig.fromProps(data.props),
      onAction: data.emitAction,
    ),
  ),
});

Then load the response:

DynamicTemplatePage.load(
  loader: api.fetchTemplatePage,
  registry: templates,
  onAction: (event) async {
    await analytics.track(
      event.id,
      properties: {
        ...event.payload,
        ...event.metadata,
      },
    );
  },
);

Use DynamicTemplatePage.fromJson(response: ..., registry: ...) when the response is already loaded. Both a direct page object and an optional {"page": {...}, "meta": {...}} envelope are accepted. Page and envelope metadata are merged, with envelope values taking precedence.

The registered builder can return any precompiled Widget, including a StatefulWidget with PageController, callbacks, custom packages, and application services. Developers customize card design, list or grid display, buttons, user details, checkboxes, and navigation by editing that widget or its configuration class. The API selects the registered template and supplies serializable props; it does not supply Dart source.

Template names are an allowlist. Unsupported names and versions render an error instead of falling back to arbitrary execution. Treat props as untrusted input and validate it in a template-specific config parser. emitAction reports an opaque action ID and JSON payload to the host; the host remains responsible for authentication, authorization, navigation, and network policy.

Complete API page responses #

DynamicApiPage accepts or loads an entire response envelope and routes it to the form, details, or list renderer:

{
  "page": {
    "type": "list",
    "title": "Products",
    "itemTitleKey": "name",
    "itemSubtitleKey": "price",
    "theme": {
      "contentWidth": "wide"
    }
  },
  "data": [
    {
      "id": 1,
      "name": "Mechanical keyboard",
      "price": "$149"
    }
  ],
  "meta": {
    "requestId": "req-42",
    "apiVersion": "2026-07"
  }
}

Load, validate, and display it directly:

DynamicApiPage.load(
  loader: api.fetchCompletePage,
  loadingBuilder: (_) => const PageSkeleton(),
  errorBuilder: (context, error, retry) => ApiErrorView(
    error: error,
    onRetry: retry,
  ),
  onItemPressed: (context, selection) {
    openItem(
      selection.item,
      requestId: selection.metadata['requestId'] as String?,
    );
  },
  onSubmit: (request) async {
    await api.executePageAction(
      action: request.action,
      values: request.values,
      metadata: request.metadata,
    );
  },
);

For responses already fetched elsewhere:

DynamicApiPage.fromJson(
  response: responseJson,
  onSubmit: handleSubmit,
  onItemPressed: handleItem,
);

The envelope supports:

  • page: a constrained form, details, or list schema
  • data: a map for forms/details or a list of maps for lists
  • meta: opaque response metadata forwarded to callbacks
  • submitAction: an optional opaque action identifier

DynamicApiPage never executes URLs, method names, or commands supplied by the server. It forwards action identifiers and metadata to application callbacks, where the host app applies authentication, authorization, and API policy. Loading, retry, page themes, form-field builders, submit-button builders, details builders, list item/state/layout builders, headers, footers, and actions remain customizable. For a loaded list response, pull-to-refresh fetches and validates the complete envelope again.

JSON form #

DynamicSchemaPage.fromJson(
  schema: {
    'type': 'form',
    'title': 'Contact',
    'submitLabel': 'Send',
    'theme': {
      'contentWidth': 'narrow',
      'density': 'compact',
    },
    'fields': [
      {
        'key': 'email',
        'type': 'email',
        'label': 'Email',
        'required': true,
      },
      {
        'key': 'topic',
        'type': 'dropdown',
        'label': 'Topic',
        'options': [
          {'value': 'support', 'label': 'Support'},
          {'value': 'sales', 'label': 'Sales'},
        ],
      },
    ],
  },
  data: responseData,
  onSubmit: api.submitContact,
);

JSON details #

DynamicDetailsPage.fromJson(
  schema: {
    'type': 'details',
    'title': 'Order response',
    'fields': [
      {'key': 'number', 'label': 'Order number'},
      {'key': 'status', 'label': 'Status'},
      {'key': 'total', 'label': 'Total'},
    ],
  },
  data: orderResponse,
);

JSON list #

DynamicListPage.fromJson(
  schema: {
    'type': 'list',
    'title': 'Products response',
    'itemTitleKey': 'name',
    'itemSubtitleKey': 'price',
    'emptyMessage': 'No products',
  },
  data: productsResponse,
  onItemPressed: (context, product) {
    openProduct(product['id']);
  },
);

Supported JSON theme tokens include:

  • contentWidth: narrow, medium, wide, or a number from 320–1600
  • density: compact, comfortable, or spacious
  • padding, detailRowSpacing, and listItemSpacing: numbers from 0–64

Invalid schemas throw FormatException before rendering.

Themes #

Set application-wide defaults through Flutter's theme extensions:

MaterialApp(
  theme: ThemeData(
    extensions: const [
      DynamicPageThemeData(
        contentMaxWidth: 720,
        contentPadding: EdgeInsets.all(24),
        fieldSpacing: 18,
        detailRowSpacing: 22,
        listItemSpacing: 10,
      ),
    ],
  ),
);

Override selected tokens on one generated page:

UserFormPage(
  theme: const DynamicPageThemeData(
    contentMaxWidth: 520,
    fieldSpacing: 12,
  ),
  onSubmit: repository.createUser,
);

Resolution order is:

  1. Package fallbacks
  2. ThemeData.extensions
  3. Page-specific override
  4. JSON schema theme, followed by explicit widget override for JSON pages

DynamicPageThemeData also supports input decoration, submit button, title, error, details-label, and details-value styles.

Escape hatches #

Every page family exposes typed component builders. Builders receive current state and callbacks, so replacing a widget does not bypass validation, submission, or model construction.

Form fields and checkboxes #

Override one form field by its model key. The same API works for text fields, checkboxes, dropdowns, dates, and custom domain values:

UserFormPage(
  fieldBuilders: {
    'receiveUpdates': (context, data) => SwitchListTile.adaptive(
      title: Text(data.field.label),
      subtitle: data.errorText == null ? null : Text(data.errorText!),
      value: data.value as bool? ?? false,
      onChanged: data.onChanged,
    ),
  },
  onSubmit: repository.createUser,
);

The builder receives field, value, errorText, and onChanged.

Submit button #

Replace the complete submit control while retaining the renderer's loading and disabled state:

UserFormPage(
  submitButtonBuilder: (context, data) => FilledButton.icon(
    onPressed: data.onPressed,
    icon: data.isSubmitting
        ? const SizedBox.square(
            dimension: 16,
            child: CircularProgressIndicator(strokeWidth: 2),
          )
        : const Icon(Icons.person_add),
    label: Text(data.isSubmitting ? 'Creating…' : data.label),
  ),
  onSubmit: repository.createUser,
);

For visual-only changes, set submitButtonStyle in DynamicPageThemeData. Standard Flutter CheckboxThemeData, InputDecorationTheme, FilledButtonThemeData, and other Material themes continue to apply to default components.

List items and states #

Replace every list row with a typed application widget:

UserResponseListPage(
  load: repository.fetchUsers,
  itemBuilder: (context, user, index) => UserCard(
    user: user,
    onPressed: () => openUser(user),
  ),
  separatorBuilder: (context, index) => const Divider(height: 24),
  loadingBuilder: (context) => const UserListSkeleton(),
  emptyBuilder: (context, message) => EmptyUsers(message: message),
  errorBuilder: (context, error, retry) => ApiErrorView(
    error: error,
    onRetry: retry,
  ),
);

header, floatingActionButton, and app-bar actions remain available for page-level composition. When supplying itemBuilder, the application owns row interaction and should call its navigation callback from the custom widget.

Change how the collection itself is displayed with contentBuilder:

UserResponseListPage(
  load: repository.fetchUsers,
  itemBuilder: (context, user, index) => UserCard(user: user),
  contentBuilder: (context, data) => RefreshIndicator(
    onRefresh: data.refresh,
    child: GridView.builder(
      padding: const EdgeInsets.all(24),
      gridDelegate: const SliverGridDelegateWithMaxCrossAxisExtent(
        maxCrossAxisExtent: 360,
        crossAxisSpacing: 16,
        mainAxisSpacing: 16,
      ),
      itemCount: data.items.length,
      itemBuilder: data.itemBuilder,
    ),
  ),
);

ListContentBuilderData exposes the typed items, schema, optional header, refresh callback, and resolved item builder. It can render a GridView, PageView, horizontal carousel, grouped slivers, Wrap, or another application-specific collection.

Details fields and layout #

Override one details value:

UserResponseDetailsPage(
  value: user,
  fieldBuilders: {
    'active': (context, data) => StatusBadge(
      active: data.value as bool,
    ),
  },
);

Or arrange all generated details widgets in a different layout:

UserResponseDetailsPage(
  value: user,
  contentBuilder: (context, data) => GridView.count(
    padding: const EdgeInsets.all(24),
    crossAxisCount: 2,
    crossAxisSpacing: 16,
    mainAxisSpacing: 16,
    children: data.children,
  ),
);

DetailsContentBuilderData contains the typed schema, current model, and the default title/header/field/footer widgets. Developers can reuse all, some, or none of those children.

API error mapping #

Throw FormSubmissionException from a submit callback:

throw const FormSubmissionException(
  {
    'email': ['Email is already registered'],
  },
  message: 'Please correct the highlighted fields.',
);

The form maps messages to matching fields and scrolls to the first error. Unexpected errors are displayed as a page error and can also be observed with onSubmissionError.

Example #

The example application demonstrates:

  • JSON API payload → Freezed UserResponse
  • generated typed list → generated typed details navigation
  • generated create form with server field errors
  • raw JSON form, list, and details pages
  • global and page-level themes

Run it with:

cd example
flutter pub get
dart run build_runner build
flutter run

Current scope #

Version 0.3 intentionally supports forms, details, and non-paginated refreshable lists. Pagination, declarative sections, localization keys, Cupertino renderers, and full CRUD coordinators remain future additions. The schema and renderer separation allows those features without changing model constructors or generating full widget trees.

0
likes
140
points
0
downloads

Documentation

API reference

Publisher

verified publisherpinz.dev

Weekly Downloads

Schema-driven, type-safe generated Flutter pages for Dart models.

Repository (GitHub)
View/report issues

License

BSD-3-Clause (license)

Dependencies

analyzer, build, flutter, source_gen

More

Packages that depend on dynamic_pages