velix 0.17.1 copy "velix: ^0.17.1" to clipboard
velix: ^0.17.1 copied to clipboard

flutter foundation library including validation, a reflection based mapper and automatic two-way form binding .

License Dart Docs Flutter CI

velix

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 localeManager the locale manager
  • TranslationLoader loader a loader that is used to load localizations
  • Locale? fallbackLocale a fallback locale that will be used for missing keys in the main locale.
  • List<String>? preloadNamespaces list of namespaces to preload
  • List<Formatter>? formatters list of additional fromatter that can be used in the interpolatin process.
  • int cacheSize = 50 cache 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 assets
  • Map<String, String>? namespacePackageMap optional 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 locale
  • int minimumFractionDigits minimum number of digits
  • int maximumFractionDigits maximum number of digits

currency

  • String locale
  • String name name of the currency

date

  • String locale
  • String pattern pattern accoring to the used formatting class DateFormat, 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

4
likes
0
points
8
downloads

Publisher

unverified uploader

Weekly Downloads

flutter foundation library including validation, a reflection based mapper and automatic two-way form binding .

Repository (GitHub)
View/report issues

License

unknown (license)

Dependencies

analyzer, build, flutter, glob, intl, path, path_provider, provider, source_gen, stack_trace

More

Packages that depend on velix