velix 0.17.1
velix: ^0.17.1 copied to clipboard
flutter foundation library including validation, a reflection based mapper and automatic two-way form binding .
Introduction #
Velix is Dart/Flutter library implementing some of the core parts required in every Flutter application:
- type meta data
- specification and validation of type constraints
- general purpose mapping framework
- json mapper
- model-based two-way form data-binding
- i18n
- command pattern for ui actions
Check out some articles on Medium:
Detailed infromation can be found in the corresponding Wiki.
Lets get a quick overview on the topics
Validation #
As in some popular Typescript libraries like yup, it is possible to declare type constraints with a simple fluent language
var type = IntType().greaterThan(0).lessThan(100);
type.validate(-1); // meeeh....will throw
Type Meta-Data #
In combination with a custom code generator, classes decorated with @Dataclass emit the meta data:
@Dataclass()
class Money {
// instance data
@Attribute(type: "length 7")
final String currency;
@Attribute(type: ">= 0")
final int value;
const Money({required this.currency, required this.value});
}
The information will be used by the mapping framework and a form data-binding.
Mapping #
A general pupose mapping framnework let's you declaratively specify mappings:
var mapper = Mapper([
mapping<Money, Money>()
.map(all: matchingProperties()),
mapping<Product, Product>()
.map(from: "status", to: "status")
.map(from: "name", to: "name")
.map(from: "price", to: "price", deep: true),
mapping<Invoice, Invoice>()
.map(from: "date", to: "date")
.map(from: "products", to: "products", deep: true)
]);
var invoice = Invoice(...);
var result = mapper.map(invoice);
As a special case, json mapping is supported as well:
// overall configuration
JSON(
validate: true,
converters: [Convert<DateTime,String>((value) => value.toIso8601String(), convertTarget: (str) => DateTime.parse(str))],
factories: [Enum2StringFactory()]
);
// funny money class
@Dataclass()
@JsonSerializable(includeNull: true) // doesn't make sense here, but anyway...
class Money {
// instance data
@Attribute(type: "length 7")
@Json(name: "c", required: false, defaultValue: "EU")
final String currency;
@Json(name="v", required: false, defaultValue: 0)
@Attribute()
final int value;
const Money({required this.currency, this.value});
}
var price = Money(currency: "EU", value: 0);
var json = JSON.serialize(price);
var result = JSON.deserialize<Money>(json);
Commands #
Commands let's you wrap simple methods in command objects, that
- are stateful ( enabled / disabled )
- can invoke interceptors while being executed ( e.g. exception handling, tracing, ...), and
- influence the UI automatically while running ( e.g. spinner fro long-running commands )
class _PersonPageState extends State<PersonPage> with CommandController<PersonPage>, _PersonPageCommands {
...
// commands
@override
@Command(i18n: "person.details", icon: CupertinoIcons.save)
Future<void> _save) async {
await ... // service call
updateCommandState();
}
// it's always good patternm to have state management in one single place, instead scattered everywhere
@override
void updateCommandState() {
setCommandEnabled("save", _controller.text.isNotEmpty);
}
}
I18N #
An i18n is implemented with more or less the same scope and api as popular libraries like i18next. but some additional features i haven't found anywhere else.
The solution is made up of two distinct elements.
A LocaleManager is reposonsible to keep track of the current locale.
It is constructed via
LocaleManager(this._currentLocale, {List<Locale>? supportedLocales })
and can return the current locale via the locale property.
Being a ChangeNotifier - it can inform listeners about any changes of this property,
I18N is a class that is used to configure the setup. The constructor accepts the parameters:
LocaleManager localeManagerthe locale managerTranslationLoader loadera loader that is used to load localizationsLocale? fallbackLocalea fallback locale that will be used for missing keys in the main locale.List<String>? preloadNamespaceslist of namespaces to preloadList<Formatter>? formatterslist of additional fromatter that can be used in the interpolatin process.int cacheSize = 50cache size fro prcomputed interpolation fucntions in a LRU cache
After initialization a string extension
tr([Map<String,dynamic>? args])
can be used to translate keys that consist of a - ":" separated .- namespace and path and optional args.
Example
"app:main.title".tr()
"app:main.greeting".tr({"user": "Andi"})
Let's look at the different concepts.
TranslationLoader #
The class TranslationLoader is used to load translations.
abstract class TranslationLoader {
/// load translations given a namespace and a list of locales
/// [locales] list of [Locale]s that determine the overall result. Starting with the first locale, teh resulting map is computed, the following locales will act as fallbacks, in case of missing keys.
Future<Map<String, dynamic>> load(List<Locale> locales, String namespace);
}
The result is a - possibly recursive - map contaning the localizations.
The argument is a list of locales, since we wan't to have a fallback mechanism in case of missing localization values in the main locale. This list is computed with the following logic:
- start with the main locale
- if the locale has a country code, continue wioth the language code
- continue with a possible fallback locale
- if the fallback locale has a country code, continue with it's language code
Example: Locale is "de_DE", fallback "en_US"
will result in: ["de_DE", "de", "en_US", "en"]
Loaders should merge an overall result, starting in the order of the list by only adding values in case of missing entries.
AssetTranslationOrder
The class AssetTranslationOrder is used to load localizations from assets.
Arguments are:
this.basePath = 'assets/locales'base path for assetsMap<String, String>? namespacePackageMapoptional map that assigns package names to namespaces
Example
{
"validation": "velix"
}
tells the loader, that the namespace "validation" is stored in the assets of teh package "velix".
Translations are stored in json files under the locale dir.
Example
assets/
de/
validation.json
Interpolation #
Translations can contain placeholders that will be replaced by supplied values. Different possibilities exist:
variable
"hello {world}!" with arguments {"world": "world"} will result in "hello world!".
variable with format
"{value:number}"
will format numbers given the current locale ( influencing "," or "." in floating point numbers )
variable with format and arguments
"{price:currency(name: 'EUR')}"
Different formatters allow different parameters, with all formatters typically supporting String laccle as a locale code.
Implemented formatters are:
number
String localeint minimumFractionDigitsminimum number of digitsint maximumFractionDigitsmaximum number of digits
currency
String localeString namename of the currency
date
String localeString patternpattern accoring to the used formatting classDateFormat, e.g.yMMMd
Unil now, the parameters where part of the template itself. In order to be more flexible, they can also relate to dynamic parameters by prefixing the with "$".
Example:
Template is: "{price:currency(name: $currencyName)"
and is called with the args {"price": 100, "currencyName": "EUR"}
Setup #
A typical setup requires to initialize the main elements:
var localeManager = LocaleManager(Locale('en', "EN"), supportedLocales: [Locale('en', "EN"), Locale('de', "DE")]);
var i18n = I18N(
fallbackLocale: Locale("en", "EN"),
localeManager: localeManager,
loader: AssetTranslationLoader(
namespacePackageMap: {
"validation": "velix"
}
),
missingKeyHandler: (key) => '##$key##',
preloadNamespaces: ["validation", "example"]
);
// load namespaces
runApp(
ChangeNotifierProvider.value(
value: localeManager,
child: TODOApp(i18n: i18n),
),
);
and an application running as a locale consumer including some localizationDelegates:
...
child: Consumer<LocaleManager>(
builder: (BuildContext context, LocaleManager localeManager, Widget? child) {
return CupertinoApp(
...
// localization
localizationsDelegates: [I18nDelegate(i18n: i18n), GlobalCupertinoLocalizations.delegate,],
supportedLocales: localeManager.supportedLocales,
locale: localeManager.locale
);
}
)
Model-base form data-binding #
Motivation #
Looking at the effort required in Flutter to handle even simple forms i was shocked and started looking for alternatives that gave my about the same level of verbosity or productivity as i have known for example in Angular.
While there are some form related libraries, they all skip the problem of binding widgets to values automatically, so i started my own library reusing ideas, that i have known for at least 20 years :-) Starting with the form binding quickly other solutions where integrated as well, that simplify development.
Solution idea #
The idea is a model based declarative approach, that utilizes
- reflection information with respect to the bound classes and fields
- including type constraints on field level
- in combination with technical adapters that handle specific widgets and do the internal dirty work.
As a result, all the typical boilerplate code is completely gone, resulting in a fraction of necessary code.
Example
Let's look at a simple form example first.
Both the reflection and validation information is covered by specific decorators:
@Dataclass()
class Address {
// instance data
@Attribute(type: "length 100")
final String city;
@Attribute(type: "length 100")
final String street;
// constructor
Address({required this.city, required this.street});
}
@Dataclass()
class Person {
// instance data
@Attribute(type: "length 100")
String firstName;
@Attribute(type: "length 100")
String lastName;
@Attribute(type: ">= 0")
int age;
@Attribute()
Address address;
}
Inside the state of the form page, we can bind values easily
class PersonFormPageState extends State<PersonFormPage> {
// instance data
late FormMapper mapper;
// override
@override
void initState() {
super.initState();
mapper = FormMapper(instance: widget.person, twoWay: true);
mapper.isDirty.addListener(() {
setState(() {
});
});
}
@override
void dispose() {
super.dispose();
mapper.dispose();
}
@override
Widget build(BuildContext context) {
Widget result = SmartForm(
autovalidateMode: AutovalidateMode.onUserInteraction,
key: mapper.getKey(),
...
mapper.text(path: "firstName", context: context, placeholder: 'First Name'),
mapper.text(path: "lastName", context: context, placeholder: 'Last Name'),
mapper.text(path: "age", context: context, placeholder: 'Age'),
mapper.text(path: "address.city", context: context, placeholder: 'City'),
mapper.text(path: "address.street", context: context, placeholder: 'Street'),
);
// set value
mapper.setValue(widget.person);
// done
return result;
}
}
You can already see some highlights:
- automatic handling of type validation ( e.g. length constraints ) and generation of error messages
- automatic coercion of types ( e.g. "age" is bound to a text field )
- handling of paths ( "address.city" ) including the necessary reconstruction of immutable classes ( with final fields )
- two-way data-binding, if requested
Two-way databinding will modify the underlying model immediately after every change in a associated widget.
If disabled, you would have to explicitly call form.getValue() to retrieve the updated model. The additional benefit you
would gain here is that the form mapper remembers the initial values and will change its dirty state accordingly, which means that
a reverted change will bring the form back to a non-dirty state!
Benefits #
Velix drastically reduces the manual wiring and repetitive boilerplate that normally comes with Flutter forms. With it, you get:
-
No manual controllers – Forget TextEditingController, FocusNode, and onChanged spaghetti for every single field.
-
Type-aware validation out of the box – Your @Attribute metadata drives validation rules automatically, without repeating them in the UI layer.
-
Immutable model support – Handles reconstruction of immutable (final) classes automatically when updating nested fields.
-
Two-way binding – Keep your widgets and model in sync without extra glue code; or opt for one-way binding with explicit getValue() retrieval.
-
Path-based binding – Easily bind deeply nested fields (address.city) without manually drilling down in your widget code.
-
Automatic dirty-state tracking – Know instantly if a form has unsaved changes and when it has been reverted to its original state.
-
Minimal code footprint – Complex forms can be expressed in a fraction of the lines you’d normally need.
Comparison to Existing Flutter Solutions #
While Flutter has some established form libraries like flutter_form_builder and reactive_forms, they still expect you to:
-
Define FormControl or TextEditingController instances manually
-
Wire each widget to its controller or form control
-
Duplicate validation rules across model and UI layers
-
Write boilerplate for converting between text and typed fields
Velix takes a different route:
-
Model-driven – The form is generated from your annotated model, not from widget-level configuration.
-
Automatic widget adapters – You don’t manually connect a controller; you just tell Velix which property to bind.
-
Unified validation & transformation – Rules live once, in the model, and apply everywhere.
-
Nested object awareness – Works with object graphs, not just flat maps of fields.
The result is a WPF/Angular-style binding experience in Flutter — something currently missing from the ecosystem.
Installation #
The library is published on pub.dev