Package uimodel
helps to build comprehensible Models and ViewModels for Flutter apps, then have it linked with UI View layer in a single line per your custom Widget.
Tests coverage: 100.0% (77 of 77 lines)
[UiModel] mixin is a thin wrapper around Toggler, an (observable) state machine register that runs underhood your Models. It exposes a subset of Toggler api useful in Widget build
method.
[UiModelLink] mixin adds the magic watches(model, changes-mask) link layer to the Widget.
[UiNotifier] is a concrete implementation of a ToggledNotifier that talks directly to Flutter [Element]s of your UI Widgets.
Together these allow a "StatelessWidget with UiModelLink" to observe changes in "Model with UiModel", and rebuild accordingly. All by a single watches
invocation in the Widget's build
:
// in mymodels.dart:
class ViewModel with UiModel {...} // below seen as 'm' singleton
// in mywidget.dart:
class MyWidget extends StatelessWidget with UiModelLink {
MyWidget({ Key? key, }) : super(key: key);
@override
Widget build(BuildContext context) {
watches(m, smDn | smUp | smEnd ); // three flags/events in 'm' observed
// watches(m.viewmodel, smPos | smAlt | smEnd ); // or in m.submodel
Example App with state management based on Toggler then linked to Flutter View with [UiModelLink] can be found in the example/main.dart file. Both UI and Model code is also given at bottom of this README.
- TODO
import 'package:flutter/material.dart';
import 'package:uimodel/uimodel.dart'; // UiModel, UiModelLink
import 'package:toggler/toggler.dart'; // for DebugInfoBar UI and our Model
final m = ViewModel(); // "ambient" singleton instance
void main() {
runApp(const MyApp());
}
class MyApp extends StatelessWidget {
const MyApp({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'Toggler and UiModel Example',
theme: ThemeData(
primarySwatch: Colors.lime,
),
home: const Scaffold(
body: CounterView(),
),
);
}
}
/// main route page
class CounterView extends StatelessWidget {
const CounterView({
Key? key,
}) : super(key: key);
@override
Widget build(BuildContext context) {
return Column(mainAxisAlignment: MainAxisAlignment.center, children: [
Counter(),
DebugInfoBar(),
const OperatorBar(),
CountryBar(),
]);
}
}
View is wired to its ViewModel notifier with masks (prefixed sm) of a Toggler index (prefixed tg). Both are const int numbers. Indice are used to read and set flags state, masks are used to select more than one flag to observe. You may generate index/mask pairs using script that comes with Toggler package.
const tgUp = 10; // tgIndex (of tgUp change signal)
const smUp = 1 << tgUp; // smMask (of above tgIndex)
const tgDn = 11;
const smDn = 1 << tgDn; // ...more definitions ommited here
/// link Counter to UiModel (here explicitly via UiModelLink mixin. (In Widgets
/// below we will use `extends UiModeledWidget` shim, it means the same).
class Counter extends StatelessWidget with UiModelLink {
Counter({
Key? key,
}) : super(key: key);
@override
Widget build(BuildContext context) {
watches(m, smDn | smUp | smInfo); // three flags/signals in 'm' observed
// watches(m.submodel, smSubSmth | smSubOther); // here: two of submodel
return Expanded(
child: Center(
child: ListView(shrinkWrap: true, children: [
Column(
children: [
Text(
'Counted ${m.ctTxt}', // read straight from Model
style: Theme.of(context).textTheme.headline4,
),
m[tgInfo] // if tgInfo is set, display 'label' with debug info
? Text(m.label)
: const Text(
'skipping unlucky numbers\nand multiplies of 15',
textAlign: TextAlign.center,
),
],
)
]),
));
}
}
/// UiModeledWidget is a sugar shim for `StatelessWidget with UiModelLink`
class CountryBar extends UiModeledWidget {
CountryBar({
Key? key,
}) : super(key: key);
@override
Widget build(BuildContext context) {
watches(m, smNoCntry - 1); // observe all in radioGroup
return Container(
constraints: const BoxConstraints.expand(height: 36),
child: m[tgNoCntry]
? ListView(
shrinkWrap: true,
scrollDirection: Axis.horizontal,
children: [
Row(
children: countryid // make buttons for all in radioGroup
.map((e) => TextButton(
style: bBlack,
onPressed: () => m.cntry(e),
child: Text(countries[e])))
.toList(),
)
])
: ElevatedButton(
onPressed: () => m.cntry(tgNoCntry), child: Text(m.country)),
);
}
}
class DebugInfoBar extends UiModeledWidget {
DebugInfoBar({
Key? key,
}) : super(key: key);
@override
Widget build(BuildContext context) {
watches(m, smAny); // any changes
final ntf = (m.tg.notifier as UiNotifier);
final info = m[tgInfoTB] // either bin or hex, on a tap
? 'chb bin: ${b(m.tg.chb)}\nserial:${m.tg.serial} dbg:${m.dbg}'
: 'chb hex: ${h(m.tg.chb)}, serial:${m.tg.serial}\nNotifiers: ${ntf.observers} recent:${m.tg.recent}';
return m[tgInfo]
? ButtonBar(
alignment: MainAxisAlignment.start,
children: [
TextButton(
style: bBlue,
// E[tgIndex] is true if tgIndex is enabled.
// Example model logic disables InfoBar taps for India.
onPressed: m.E[tgInfoTB] ? () => m.toggle(tgInfoTB) : null,
child: Text(info),
)
],
)
: const SizedBox.shrink();
}
}
/// No rebuilds needed, hence no ModelLink: just wire up buttons to Model
class OperatorBar extends StatelessWidget {
const OperatorBar({
Key? key,
}) : super(key: key);
@override
Widget build(BuildContext context) {
return ButtonBar(children: [
IconButton(onPressed: m.info, icon: const Icon(Icons.info_outline)),
IconButton(onPressed: m.zero, icon: const Icon(Icons.first_page)),
IconButton(onPressed: m.sub, icon: const Icon(Icons.remove_circle)),
IconButton(onPressed: m.add, icon: const Icon(Icons.add_circle)),
IconButton(onPressed: m.max, icon: const Icon(Icons.last_page)),
]);
}
} // end of App UI
Example class below is our whole App Model, with some bits being (Model) signals, and some being (ViewModel) state that affects Widgets final look:
class ViewModel with UiModel {
int _ct = 0;
int _sk = 0;
int dbg = 0; // shown in info lines
void _mark(int i) => tg.toggle(i);
String country = countries[tgCtSel];
int skipnum = -1; // "unlucky" number to skip
ViewModel({ToggledNotifier? notifier}) {
tg.radioGroup(tgChina, tgNoCntry); // tg from UiModel mixin
tg.set1(tgCtSel); // init its state in constructor
// ... set/restore state here, then:
tg.fix = fix; // bind your business / ViewModel logic
tg.notifier = notifier ?? UiNotifier(); // last! attach notifier
}
String get label => tg[tgUpSkip] || tg[tgDnSkip]
? 'skipped: $_sk (${tg.serial})'
: tg.recent == tgUp
? 'went up (${tg.serial})'
: tg.recent == tgDn
? 'went down (${tg.serial})'
: '(state changes count ${tg.serial})';
String get ctTxt => '$_ct';
int get ct => _ct;
set ct(int v) {
if (_ct == v) return;
_sk = _ct;
_ct = v;
if (_ct > counterMax) _ct = 0;
if (_ct < 0) _ct = counterMax;
_mark(_ct < _sk ? tgDn : tgUp);
}
// Do not use closures in UI event bindings. Model methods allows for
// easy testing and can be later refactored to add functionality
// without even touching View layer code.
void info() => tg.toggle(tgInfo);
void cntry(int i) => tg.set1(i);
void zero() => ct = 0;
void max() => ct = counterMax;
void sub() => ct--;
void add() => ct++;
// end of ViewModel part
/// Our "business logic": counter skips unlucky numbers as recognized
/// in user set country, and also always skips multiplies of '15'.
bool fix(Toggler o, Toggler n) {
skipUp() {
_sk = _ct;
_ct++;
n.set1(tgUpSkip);
}
skipDn() {
_sk = _ct;
_ct--;
n.set1(tgDnSkip);
}
n.clear(tgDnSkip);
n.clear(tgUpSkip);
if (n.recent > tgNoCntry && n[tgNoCntry]) {
n.set1(tgCtSel);
}
if (n.recent < tgNoCntry) {
skipnum = unlucknum[n.recent]; // set unlucky number
country = countries[n.recent]; // and label
n.recent == tgIndia ? n.disable(tgInfoTB) : n.enable(tgInfoTB);
} else if (n.recent == tgUp) {
if (_ct == skipnum) skipUp();
if (_ct != 0 && _ct % 15 == 0) {
_sk = _ct;
_ct += 1;
if (_ct == skipnum) skipUp();
n.set1(tgUpSkip);
}
} else if (n.recent == tgDn) {
if (_ct == skipnum) skipDn();
if (_ct != 0 && _ct % 15 == 0) {
_sk = _ct;
_ct -= 1;
if (_ct == skipnum) skipDn();
n.set1(tgDnSkip);
}
}
return true;
} // end of Model part
}
-
Q. How much ballast this adds to my App?
-
A. Both [uimodel] and toggler packages are lean, Toggler having zero dependencies, and Uimodel depending only on Toggler and flutter/widgets Element. Toggler instance adds 64 bytes to the [Object] (then to your Model via [UiModel] mixin). [UiModelLink] mixin adds not much more to the Widget. The per Model [UiNotifier] object costs a dart:core Map then adds a dart:core List per every mask watched. Both [toggler] and [uimodel] libraries together add less than 255 loc to your executable.
-
Q. Looks too good to be true. Where is the trap?
-
A. You must understand you work with indice and bitmasks. These are named for reading humans but compiler sees only numbers. Analyzer will not tell you "hey, its a wrong type here". Please read about caveats in Toggler documentation. Then adhere to proposed naming convention.