dynamic_pages 0.2.1
dynamic_pages: ^0.2.1 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 |
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
ThemeExtensioncustomization - Custom fields, headers, footers, actions, and list items
- Constrained JSON forms, details pages, and lists
Installation #
dependencies:
dynamic_pages: ^0.2.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.
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–1600density:compact,comfortable, orspaciouspadding,detailRowSpacing, andlistItemSpacing: 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:
- Package fallbacks
ThemeData.extensions- Page-specific override
- 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.2 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.