This in an example of how to design your app to follow the Model–View–ViewModel software architecture while combining this with other Software Design Patterns.
This simple example app shows a list of medication using a ListView
widget, as shown below. When the user presses the "+" button, an additional medication (the "Hemangiol") is added to the list.
The overall architecture of the app follows the Model-View-ViewModel architecture, which entails that you divide ("separate") your app into three main types of components:
In our case, the Model contains the domain knowledge of the device(s), and the View contains all the Flutter widgets, and the ViewModel contains and enriches data from the Model to be shown in the View. In a Flutter app, a MVVM architecture is typically implemented by having "model", "view", and "view_model" folders in your app structure, like this:
The you create classes and dart files for;
- model(s) (sometimes just one BIG model)
- views
- view models for each view
Finally, you can use the library
construct in Dart to bundle the entire app together;
Each type of component have different responsibilities:
-
Model
- The model is holding the “raw” data model and is divided into different model classes. The model knows where to get data and where to store data, e.g., in a database or a server, and knows about data formats, like JSON. See themodel.dart
as an example. -
ViewModel
- The view model is created knowing its model using “dependency injection”, which is typically done in the constructor. The view model is responsible for providing access to the data that “its” view model should be able to show and for updating the model based on user input from the view. See themedication_view_model.dart
as an example. -
View
- The view is created knowing its view model using “dependency injection” done in the constructor. The view is responsible for showing its data in the view model, which is done using theState
of aStatefulWidget
. The view is also responsible for mapping user input (like a button or text input) to the view model done by calling methods on the view model. See themedication_view.dart
as an example.
This app also illustrates the use of different GoF Software Design Patterns.
In the app, the MedicationBLoc
works as a singleton so that this BLoc can be accessed across the entire app. Note that the recommended way to create a singleton in Dart is to use the default constructor - in this case MedicationBLoc()
. This is implemented using a static final private instance property, named _instance
, creating a private constructor called MedicationBLoc._()
, and a factory called MedicationBLoc()
that returns the instance. This is shown below.
/// A BLoc singleton that can be used across the entire app.
class MedicationBLoc {
static final MedicationBLoc _instance = MedicationBLoc._();
MedicationBLoc._();
factory MedicationBLoc() => _instance;
/// The title of the app.
String get title => 'Medication List';
/// The list of [Medication] to be shown in the app.
MedicationList get medications => ...;
}
Now, this singleton BLoc can be accessed across the entire app, such as in a build
method.
@override
Widget build(BuildContext context) => MaterialApp(
home: HomeView(
model: AppViewModel(
MedicationBLoc().title,
MedicationBLoc().medications,
),
),
);
The model
folder holds a simple implementation of the GoF Observer pattern.
/// An object that maintains a list of observers.
/// Implements the Observer GoF pattern.
class Subject {
final List<Observer> _observers = [];
/// Add the [observer] to the list of observers for this subject.
void adObserver(Observer observer) => _observers.add(observer);
/// Remove the [observer].
bool removeObserver(Observer observer) => _observers.remove(observer);
/// Notify all the registered observers by calling their [update] method.
/// Call this method whenever this subject changes, to notify any observers
/// that this object may have changed.
void notify() {
for (var observer in _observers) {
observer.update();
}
}
}
/// Defines classes that can observer a [Subject].
/// Implements the Observer GoF pattern.
abstract interface class Observer {
/// Call-back method called when the subject that this observer observes, changes.
void update();
}
However, this implementation is actually not used in the example app. Instead, the Flutter build-in ChangeNotifier
is used in the view models, and the ListenableBuilder
is used in the views to keep them updated based on changes in the view models.
The MedicationViewModel
now extends the ChangeNotifier
class and can notify its listeners via the notifyListeners()
method.
class MedicationViewModel extends ChangeNotifier {
Medication medication;
MedicationViewModel(this.medication);
/// Mark if this medication has been taken or not.
void taken(bool value) {
medication.taken = value;
notifyListeners();
}
}
Now in the MedicationViewState
we use a ListenableBuilder
that listen to the widget's model and rebuilds whenever it is notified (via the notifyListeners()
method). Note that we no longer need the setState()
for notifying Flutter to rebuild the UI.
class MedicationViewState extends State<MedicationView> {
@override
Widget build(BuildContext context) => Card(
child: ListTile(
title: Text(widget.model.medication.name),
subtitle: Text(widget.model.medication.description),
trailing: ListenableBuilder(
listenable: widget.model,
builder: (BuildContext context, Widget? child) =>
widget.model.medication.taken
? const Icon(
Icons.check_box_outlined,
size: 24.0,
color: Colors.blue,
)
: const Icon(
Icons.check_box_outline_blank,
size: 24.0,
)),
onTap: medicationTaken,
),
);
void medicationTaken() =>
widget.model.taken(widget.model.medication.taken ? false : true);
}
In this app there is a very simple facade called MedicationSystem
. It provides a simple interface for getting a list of medications and for adding (prescribing) medication for a patient. Then, detailed implementation for specific medication mangement systems can then implement this interface. For example, support for the Danish Fælles Medicin Kort (FMK) can be made, and all the ugly details of how that work, can be "hidden" in the FMKMedicationSystem
class.
part of medication_app;
/// A definition of what external medication systems can.
/// Works as a facade definitions for complex external systems.
abstract interface class MedicationSystem {
/// Get a list of prescribed medication for a [patientId].
List<Medication> getMedication(String patientId);
/// Add [medication] for a patient with id [patientId].
/// Return true if successful, false otherwise.
bool addMedication(String patientId, Medication medication);
}
/// Medication System Facade implementation for the Danish Fælles Medicin
/// Kort (FMK).
class FMKMedicationSystem implements MedicationSystem {
@override
bool addMedication(String patientId, Medication medication) {
// TODO: implement addMedication
}
@override
List<Medication> getMedication(String patientId) {
// TODO: implement getMedication
}
}
A benefit of defining a facade is also that it can be used during testing where you might not have access to the real system or hasn't implemented it yet. Hence, you can create a "stub" implementation of the facade like this.
/// A stub for a [MedicationSystem] used for testing.
class StubMedicationSystem implements MedicationSystem {
MedicationList medicationList = MedicationList([
Medication(
'Panodil',
'Panodil er et svagt smertestillende middel, der anvendes ved svage '
'smerter. Midlet virker desuden febernedsættende.',
),
Medication(
'Cipralex',
'Cipralex® anvendes til behandling af depression, angst for samvær '
'med andre (socialfobi), panikangst, generaliseret angst samt '
'tvangsforestillinger og -handlinger (obsessiv-kompulsiv tilstand (OCD)).',
),
]);
@override
void addMedication(String patientId, Medication medication) =>
medicationList.medications.add(medication);
@override
MedicationList getMedication(String patientId) => medicationList;
}
The app also implements a simple state machine (state pattern) for the Medication
class (the Context
object in the State Pattern in GoF). The state machine for the medication is:
+---------+ +------------+ +--------------+
| created | -- prescribe --> | prescribed | -- administer --> | administered |
+---------+ +------------+ +--------------+
The states above is modeled as an enum
.
enum MedicationState { created, prescribed, administered }
The state and the state transitions are implemented in the Medication
class. Note that this Medication
class has a state machine (the private _machine
property) and that each method just forwards to this state machine's methods.
class Medication {
String? name, description;
bool taken = false;
late MedicationStateMachine _machine;
MedicationState get state => _machine.state;
Medication() {
_machine = CreatedMedicationState(this);
}
void create() => _machine.create();
void prescribe(String name, String description) =>
_machine.prescribe(name, description);
void administer() => _machine.administer();
}
The state machine is implemented as an overall MedicationStateMachine
interface with an implementation for each state.
abstract interface class MedicationStateMachine {
Medication get medication;
MedicationState get state;
void create();
void prescribe(String name, String description);
void administer();
}
Each state is now implemented as a class, where each of the transitions are implemented as methods. Note that we use an abstract class AbstractMedicationState
for a base implementation so that the following classes only need to implement methods for those transitions that are relevant for them. The base implementation for each method is to throw an exception that the transition is invalid. This is then overwritten in subclasses.
abstract class AbstractMedicationState implements MedicationStateMachine {
@override
Medication medication;
AbstractMedicationState(this.medication);
@override
void create() =>
throw Exception('Cannot create medication when in state: $state');
@override
void prescribe(String name, String description) =>
throw Exception('Cannot prescribe medication when in state: $state');
@override
void administer() =>
throw Exception('Cannot administer medication when in state: $state');
}
class CreatedMedicationState extends AbstractMedicationState {
CreatedMedicationState(super.medication);
@override
MedicationState get state => MedicationState.created;
@override
void prescribe(String name, String description) {
medication.name = name;
medication.description = description;
medication._state = PrescribedMedicationState(medication);
}
}
class PrescribedMedicationState extends AbstractMedicationState {
PrescribedMedicationState(super.medication);
@override
MedicationState get state => MedicationState.prescribed;
@override
void administer() {
medication.taken = true;
medication._state = AdministeredMedicationState(medication);
}
}
class AdministeredMedicationState extends AbstractMedicationState {
AdministeredMedicationState(super.medication);
@override
MedicationState get state => MedicationState.administered;
@override
// NoOp method, since we don't want an exception if called multiple times.
void administer() {}
}