Flutter Architecture/Scaffolding for App Development.
This is project organization/feature management. Ever wonder how you can build your app and keep it clean? Separate code into smaller modules and have the modules install themselves into your app? This is the architecture for you.
Dart Board cuts the complexity by allowing you to slice features how you want, and install them into a larger more cohesive app. Keeping your app organized and nimble and ready to adapt as necessary.
TRY NOW @ https://dart-board.io
Playground: https://dartboard-playground.firebaseapp.com/#/
Flutter Architecture/Framework for Feature based development
- Dart Board
- Introduction
- Repo Structure
- Contributing
- Getting Started (Hello World)
- Dart Board Core
- Features
- Add2App
- Navigation
- State Management
- Helpful Widgets (General Utilities)
- How App is run
- General App Guidance
- Special Thanks
- Architectural Recipe
- TODO - Release Roadmap
Dart Board is a Framework/Architecture pattern for Flutter applications.
The premise is "feature management and encapsulation"
The advantages of adopting a pattern like this are the following
- App as Config/Integration
- Features as standalone modules
- AB Testing
- Feature Gates
- Feature Isolation
- Feature Developer autonomy
- Easy to port existing flutter code
- Plugs gracefully into your Apps routing code.
- Eases Add2App integrations
This lets you structure you code as fundamental building blocks of "features", and then integrate them into a consistent application with config only. At compile and runtime you can mix/match and switch feature implementations.
It's designed for squad-based development where multiple teams may work on the same app or code, however any developer can use it to generate a library of features they can re-use across multiple apps with minimal integration.
What is a feature? Many are offered out of the box, including Debugging, Full Features like Chat and MineSweeper, and tons of supporting features like Analytics, Realtime Database, State Management options, Canvas and Particle effects and more. There is no pre-set definition of what a feature can be.
Chat | Minesweeper | Logging |
---|---|---|
Contain's core framework features. Currently 3 libraries:
- dart_board_core = Kernel
- dart_board_widgets = Simple Widgets
- dart_board_core_plugin = Add2App usages
Contain's reusable features that can be included in your integrations (or other features)
Contain's features that are designed to be UI templates
They are pre-made templates that get filled in via config.
- Configurable Route
- Configurable Feature Name
- UI Config (e.g. Embedded Route Names, or Widgets, etc).
The idea being that you should be able to register a Temlate multiple times, for multiple screens with varying configs.
You access the template by navigating to the route you select.
This is a place to see Integrations of multiple features into a larger app.
- Example/Playground: Integrates everything, demonstrates it all at once.
- Blank: A minimal template with no major features. This can be adapted as a starting template.
This is the "dart-board.io" website.
The template is SplashKit (Bootstrap) and is not licensed for re-use.
For all intents and purposes, this is outside the open source components due to license restrictions.
To contribute or develop on the repo you'll need to use melos
and conventional commits
. Melos is a tool to link the mono-repo and allow for local development. Conventional commits are a commit format that help facilitate versioning and releases.
- Clone the repo
- Flutter pub global activate melos
- melos bootstrap
Most features contain a main.dart that can be run on an Android or iOS device/simulator.
Integrations folder contains "starter" and "example". They both have main.dart and are primarily developed on desktop and web to maximize compat across all platforms.
The repo is setup to to integrate into VsCode and allow running of many examples from the IDE directly.
Use CommitLint (and conventional commit format) https://github.com/conventional-changelog/commitlint
Use Melos https://pub.dev/packages/melos
Make a PR to the Repo & Get my Attention (Adam Hammer)
- in
pubspec.yaml
adddart_board_core:
to your dependencies - Create
YourFeature extends DartBoardFeature
class. - add a desired Route, e.g.
routes => [
NamedRouteDefinition("/hello_world",
(context, settings) => Material(child: Center(child: Text("Hello World)))]
- Implement your
main.dart
and set your starter route
void main() => runApp(DartBoard(
features:[HelloWorldFeature()],
initialRoute: '/hello_world']))
- Add features with
pub.dev
register them in your Features and Apps to gain access. Many require zero config and expose routes and method calls to use.
Core provide integration and manages features. It is able to facilitate features like AB testing. The DartBoard
widget will serve the trunk when working with Dart Board.
Features have a namespace
and a implementationName
The namespace
is what uniquely identifies the feature. You can load one implementationName
per namespace.
implementationName
(default: "default") is the name of the implementation, e.g. namespace = feature
and implementationName in [feature_impl_a, feature_impl_b]
Features are loaded via a graph-walk, in order, depth first. There is potentially multiple root nodes (e.g. say you define 3 Features in your main()), They will be walked in order.
E.g.
[FeatureA, FeatureB, FeatureC, FeatureA_IMPL2]
In this case, FeatureA will be loaded, then B and C. A_IMPL2 will be shuffled away, because A already has an implementation
For each feature root a depth first in-order walk of the dependencies are registered. Everytime there is a namespace conflict, it's pushed to the side as a registered implementation.
In simpler terms, the first feature to a namespace wins. Whether it's walking the tree or going over the list, left to right. This means things registered first take the namespace slot.
Only 1 implementation can be active in a namespace at time.
Setting a feature at runtime is easy, just DartBoardCore.instance.setFeatureImplementation('FeatureNamespace', 'FeatureImplementationName');
Just give it your Namespace, and the implementationName and DartBoard will reboot with the new features/tree, salvaging what it can on the way.
Again, super easy DartBoardCore.instance.setFeatureImplementation('FeatureNamespace', null);
Note: Disabling features incorrectly can lead to breakage. E.g. disabling the template or /route you are on will leave you stranded.
Features are the meat of your application. By building and composing features you build a larger application.
The built in debugging tools and integration make it very easy to see how your app is built, what features are used and what implementation is currently running.
In Dart Board everything if handled through features. Core services as a foundational framework and feature loader.
Features expose screens and API's that you can export, or use indirectly for loose coupling between components.
For example, a feature can do the following.
- Decorate the App or Page
- Provide Named Routes
- Dispatch MethodCalls between
Decorations are widgets injected at the App or Page level. They can be UI or non UI components.
Decoration's are Dart Boards way of injecting state and UI into your application.
These come in two scopes. App and Page. They inject their widgets right above and below the navigator respectively.
Common use cases for a Page Decoration might be something like
- Page tracking, UI frames and overlays that aren't global
- Page scoped state management solutions
- Templating/Scaffolding
- Can be managed with allow/deny list exposed to the features
Common use cases are things like
- Global overlays (particles/background).
- Global state solutions (Locator/Redux)
- Hooks for other extensions (e.g.
LocatorDecoration(()=>SomeType())
)
Features can include "Routes/Paths" these are essentially screens or UI blocks that can be added by path to your application. You can add routes by overrides the routes
getter in a feature.
Method Calls are the mechanism to decouple features, but still allow interaction. By using Flutter's MethodCall
class typically reserved for Add2App/Host/Flutter communication, we can also decouple features using named methods at a distance.
These can be used to loosely connect features without a direct dependency.
Feature | Description |
---|---|
Authentication | Auth Facade that allows registration/interfacing with Auth Providers (e.g. Firebase, or your own). |
Block | Bloc/Cubit bindings for features |
Canvas | Easy to integrate animated canvas features |
Chat | Example Chat feature built on top of Firebase |
Debug | Provides a /debug and /dependency_graph route's to play with the internals/registry. |
Firebase Analytics | Firebase analytics options for Dart Board |
Firebase Authentication | Firebase plugin for auth-layer (initialize Firebase with standard FlutterFire docs, e.g. include Firebase JS in your html, or set up your Mobile Runners) Web Mobile MacOS is suppoerted by flutter fire. |
Firebase Core | Marker/init package for Firebase Core. Dependency for all Firebase/FlutterFire projects. |
Firebase Database | Firebase Database features/helpers |
Image Background | Allows you to Apply images or widgets as backgrounds (page decoration) |
Locator | Object/Service Locator Framework. Lazy loading Caching. App Decoration based API to register types and services to the App. |
Log | Basic logging features, including a Log Footer and /log route overlay. |
Minesweeper | You probably don't need it. But provides a /minesweep route. Deeper example of dart_board_redux use case in action. |
Particles | Particle Overlay/Features |
Redux | Flutter Redux Bindings. Provides features a consistent way to use a shared Redux store in a feature agnostic way. Provides a AppDecoration API Function API to Create and Dispatch states. |
space_scene | Originally my Flutter Clock Challenge entry, now exposed as a feature |
SpaceX Plugin | Space X Add2App plugin (headless flutter example) |
SpaceX Repository | Space X GraphQL bindings and repository |
SpaceX UI | Space X UI bindings (list/details) Nav2.0 example |
Splash | Splash Screen Support |
Theme | Theme support with FlexColorScheme package |
Tracking | Generic Tracking Interfaces (requires delegate to actually track) |
Want to add flutter to an existing App? Generally a daunting task fraught with limitations. However, with routing and native API's to abstract the difficulty it's completely feasible.
For example check the Add2App Example
Or checkout the Add2App feature
- One Active flutter activity in the stack.
- Full Screen only
The first is because Flutter on Native can be multi-engine or single-engine. By being multi-engine a lot of difficulties are lost. You can run multiple screens and navigate back and forth. However this approach uses more memory and the isolates can not share data in a clean and easy way. By using a single engine we can pre-warm it, use it headless, preserve state etc. However, navigation stack is state, and having multiple screens native flutter complicates this. Having flutter run as a "single top" with it's own stack simplifies this considerably. This means it's ill advise to navigate between platform->flutter->platform->flutter. If there is enough demand for this functionality the router can be modified to allow context switching (e.g. multiple nav stacks). It should be easy to avoid this scenario with careful planning and risk management.
The second is more a suggestion. FlutterFragment and FlutterViewController can easily handle it. However, it does open the door to layout and transition issues. It's better to understand the difficulty and avoid it. However it's possible, just don't expect to know the size of your flutter content in the host, and don't expect to easily transition to full screen. As such Dart-Board will only support full-screen, single-top scenerios out of the box.
- Go leaves in, not trunk out (i.e. About Us, leaf. Landing Screen, trunk.) Start with small targets and build your confidence.
- Use Pigeon to manage your MC bindings and calls for type safety across Android/iOS/Flutter)
- Convert your Feature Modules to Plugins to bundle your own Java/Kotlin/Swift/ObjC code.
- Bring in dart_board_core_plugin instead of dart_board_core to get the Android bindings (iOS in the future)
Navigation with DartBoard is handled via a Custom Navigator 2.0 Router. This allows Dart Board applications to support deep linking and Add2App integrations.
The router has these rules.
- You have a stack of "paths" (e.g. [/, /somepage, /store/details/32])
- No duplicate paths in the stack, pushing a duplicate will move it to the front
- All routes must be URL reproducable
- pushDynamic (builder) routes can not be shared, but are temporarily named.
/
is a mirror of aRoute
from a feature. Configured with/initialRoute
(no change)
You can access the nav globally with DartBoardCore.nav
instance.
abstract class DartBoardNav {
/// The currently active (foreground) route
String get currentPath;
/// Change Notifier to listen to changes in nav
ChangeNotifier get changeNotifier;
/// Get the current stack
List<DartBoardPath> get stack;
/// Push a route onto the stack
/// expanded will push sub-paths (e.g. /a/b/c will push [/a, /a/b, /a/b/c])
void push(String path, {bool expanded});
/// Pop the top most route
void pop();
/// Pop routers until the predicate clears
void popUntil(bool Function(DartBoardPath path) predicate);
/// Clear all routes in the stack that match the predicate
void clearWhere(bool Function(DartBoardPath path) predicate);
/// Pop & Push (replace top of stack)
/// Does not work on '/'
void replaceTop(String path);
/// Append to the current route (e.g. /b appended to /a = /a/b)
void appendPath(String path);
// Replace the Root (Entry Point)
// Generally for Add2App
void replaceRoot(String path);
/// Push a route with a dynamic route name
void pushDynamic(
{required String dynamicPathName, required WidgetBuilder builder});
}
These route types should allow you to match a wide range of URI patterns for your features.
NamedRouteDefinition
-> Matches a portion of a path for a specific name, i.e. /page
/details
MapRoute
-> Named Route that allows multiple pages (Syntactic sugar)
UriRoute
-> Matches everything that hits it. Can globally handle routing, or can be used with PathedRoute to provide detailed parsing of the resource.
PathedRoute
-> Use this for deep-linked trees. E.g. /category/details/50
it takes a List of Lists of RouteDefinitions. Each level of depth represents the tree.
How to fulfill complicated roots?
NamedRouteDefinition
works good for static, fixed targets. But what if you want something more advanced?
E.g. you want /store/pots/2141 to resolve.
UriRoute
and PathedRoute
solve those issues for you.
PathedRoute
will handle directory structures. You do this with a list of lists. Each level can hold any number of matchers. If a path matches up to that level, the lowest matcher will take it.
// PsuedoCode
[
[
NamedRoute('/store', (ctx,settings)=>StorePage()),
],
[
NamedRoute('/pots', (ctx,settings)=>PotsPage()),
NamedRoute('/pans' (ctx,settings)=>PotsPage()),
],
[
UriRoute((context, uri)=>Parse and Display)
]
]
This PathedRoute config would respond to many routes: [/store, /store/pots, /store/pans, /store/pots/*, /store/pans/*]
The * is the UriRoute. You can use this to manage all your Routing, or you can use it with a Pathed route to parse the information.
UriRoute will parse the resource request and let you access query params, path segments and anything else encoded in the page request.
Sometimes you want to just push a screen right? Like you didn't register it in a feature, you want it to be dynamic for whatever reason.
void pushDynamic({required String dynamicRouteName, required WidgetBuilder builder});
is what you can use here. Give it a unique name which will be prefixed with _, e.g. /_YourDynamicRoute3285
If you see the _
that means you can not share this route. If you give it to someone else it's going to 404 for them. It's dynamically allocated for the users session.
The SpaceX features are designed as demonstrations of Add2App and Navigator 2.0 usage.
@override
List<RouteDefinition> get routes => [
PathedRouteDefinition([
[
NamedRouteDefinition(
route: '/launches', builder: (ctx, settings) => LaunchScreen())
],
[UriRoute((ctx, uri) => LaunchDataUriShim(uri: uri))]
]),
];
This matches /launches
and also /launches/[ANY_ROUTE_NAME]
/launches
appends the name of the mission to the URL, and you end up with something like /launches/Starlink 7
UriRoute can then pull the data from the URI and pass it to the page to load what it needs to.
Dart Board is compatible with the full suite of State Management solutions. When convenient bindings are provided to ease state management. For the initial release the focus will be on 3 primary State Management Solutions, with some footnotes about the usage of other solutions.
Simple state management for basic applications and features. Provides a universal global store that is lazily initialized as required, and provides support for instancing.
- Import and register
DartBoardLocatorFeature
in your feature. - Register states/services/repositories with
LocatorDecoration(()=>YourObject())
- Find states/services/repositories with
locate<YourObject>()
- If you are using a change notifier, you can use
locateAndBuild<YourObject>((context, yourObject)=>WidgetBuilder)
Since things are lazily initialized, you can use locate<>
calls in your LocatorDecoration
and values will be initialized in the correct order.
You do not need to use context
when using locator. The locatable objects are stored inside LocatorFeature which represents a source of truth near the root of your tree.
Locator is ideal for simple states and things like ChangeNotifier's, to quickly put together features.
Flutter Redux bindings for dart_board. Redux is abstracted to allow each feature to have it's own State objects. The SSOT store is located inside the Feature itself.
The bindings help you dispatch and listen to the right objects. Like locator it's a universal store that can have sub-states installed in it by features.
- Import and register
DartBoardReduxFeature
into the features that use it - Register initial states with
ReduxStateDecoration
- Register middleware with
ReduxMiddlewareDecoration
- Reducer's are Class and Function based. Implement
FeatureAction<T>
to create a reducer that you can dispatch and use to generate a new state. FeatureStateBuilder<T>(builder:(ctx, state) => YourWidget(state))
Widget is provided to hook up to reactive state changes to your state.
Redux is ideal for observability and history management of state. It's a more advanced use case that can generate safer code with a very predictable state management. It's recommended for medium to advanced state management requirements.
Bloc Cubit bindings don't require a feature, but an extension is available that provides 2 Decorations
- Add BlocDecoration or CubitDecoration to your Features to expose Cubits/Blocs to your App or Pages.
Bloc is a robust state management similar to redux in that it allows actions and mutation of states. It's more heavily reliant on Streams internally and in some cases may feel foreign to developers from outside the dart ecosystem, but is fairly popular within it. Originally adopted from examples Google used in some open source projects.
Cubits are Bloc
light, and a good for small to medium state management, while Bloc
's themselves are more robust and while come with more overhead to implement, but are suitable for advanced use cases.
Provider is syntatic sugar around InherittedWidget
. It can be though of a tree-based DI injector.
There is not much value in a DartBoardFeature or bindings for Provider, but if you like it you can easily import it, use it in your DartBoardDecorations
or features using it's standard API.
Use as normally, just refer to the Provider documentation.
Want to use your named routes anywhere? E.g. in a Dialog, or as a small portion of a screen?
showDialog(
useSafeArea: true,
context: navigatorContext,
barrierDismissible: true,
builder: (ctx) => RouteWidget("/request_login"));
and pass arguments RouteWidget(itemPreviewRoute, args: {"id": id})
RouteWidget can handle that for you, enabling you to break screens up into multiple decoupled features that share a common core and state.
Conversion in the widget tree
Convertor<MinesweeperState, MineFieldViewModel>(
convertor: (input) => buildVm(input),
builder: (ctx, out) => MineField(vm: out),
input: state)))
Will only trigger an update if the VM changes/doesn't hit equality.
Ideal for ViewModel generation from a DataSource, to help reduce the number of builds to relevant changes.
LifeCycleWidget(
key: ValueKey("LocatorDecoration_${T.toString()}"),
preInit: () => doSomethingBeforeCtx,
child: Builder(builder: (ctx) => child))
This widget can tap into life cycle
3 hooks
/// Called in initState() before super.initState()
final Function() preInit;
/// Called after initState() (with context)
final Function(BuildContext context) init;
/// Called in onDispose
final Function(BuildContext context) dispose;
You can use this with something like a PageDecoration to start a screen time counter, or to periodically set a reminder/start/stop a service etc.
It's very useful within the context of features and setting up integrations.
/// App Entry Point.
///
/// Features are defined here, along with config.
void main() {
runApp(DartBoard(
features: [
DetailsFeature(),
ListingFeature(),
CartFeature(itemPreviewRoute: "/details_by_id"),
DebugFeature(),
BottomNavTemplateFeature(route: '/home', config: _templateConfig),
MockCheckoutFeature()
],
initialRoute: '/home',
));
}
/// Template Config for the BottomNav template
const _templateConfig = [
{
'route': '/listings',
'label': 'Search',
'color': Colors.blue,
'icon': Icons.search
},
{
'route': '/details',
'label': 'Details',
'color': Colors.red,
'icon': Icons.file_present
}
];
This is a Dart Board App. It has a Details, Listing, Cart, Debug, a Template and a Mock checkout feature. It loads /home
initially. Home is resolved by the Template feature, and has tabs for listing and details roughts from their features.
Alternatively, and encouraged for larger projects is to build an integration
feature, as how the main example is done.
/// Entry Point for the Example
///
/// All the registration and details are in ExampleFeature.
///
/// the FeatureOverrides are to disable certain features by default
void main() {
runApp(DartBoard(
featureOverrides: {
'Snow': null,
'FireCursor': null,
'background': 'ClockEarth'
},
features: [ExampleFeature()],
initialRoute: '/main',
));
}
In this example, I am delegating to ExampleFeature which has Feature's listed in it's "dependencies".
I'd recommend to generally to an Integration, as it'll make it easier to experiment with various integrations and configs. However for starter work, don't worry, you can skip the Integration Feature, and introduce it later easily if necessary.
https://pub.dev/publishers/dart-board.io/packages
The smaller and more focussed a feature, the easier it is to develop, test and debug. If you feel that a feature is doing too many things, consider breaking it down into seperate flutter modules/plugins and retain the single-responsibility principle.
If you need to have a feature that has a directly accessible API, that's ok. Just remember the single responsibility principle when doing so. When you directly couple a feature, it can become challenging to decouple it later, so make sure that your coupling's are necessary.
There is a complexity/safety trade off between tight coupling and lose coupling. I can't make that decision for you, but it is something you should think about when cutting up your features and how you'd like to put them back together.
When building a feature, build it in isolation. Test it in isolation. Set up runners/demo's to show what the feature is supposed to do and how it works in isolation. Code that runs in multiple places is portable, code that runs in one place is not.
This will make it easier to verify things, create smaller test cases to reproduce bugs and increase the turn around time for new features and fixes.
Adding features to your app should happen after you've built the feature in isolation. Building things in an already integrated fashion increases your risk of coupling and reduces your code portability. By integrating last you naturally keep portability high.
Dart Board handles configuration eagerly. This means that Features that are registered before others take precedence. You can exploit this to force a config. E.g. If you want a mock repository with SpaceX
you can simply register the spacex_data_layer
with a mock repository before it's bicked up by spacex_ui
.
Generally your configuration can belong in your main.dart
where you initialize DartBoard
and your features.
Features themselves should have safe default configs to use, so that they work out of the box whenever possible.
Many packages are consumed in the creation of dart-board, however I try and limit them to features so they are optional.
Theme support is largely thanks to FlexColorScheme and FlexColorPicker, amazing packages that really bring that feature to life.
The NIL packages is imported as well, to use in place of empty containers for some render optimization.
- Locator = Service Locator (Repositories, API's)
- Redux/Bloc = State Management (Local state and maintaining it)
- Convertor = View Model Generation
- WidgetStream = Asyncronous widget building
Locator is a service locator pattern. It's most useful for global black boxes. E.g. API's and Services such as repositories.
It's easy to add services to a Locator from within a feature (LocatorDecoration(()=>YourService())
)
It's also easy to consume services with locate<t>()
or locateAndBuild<t extends ChangeNotifier>()
If you have complicated state that you worry about modifying. You can have more safety by using Redux or Bloc.
Widget that maps between types, and optimizes builds. Use this to convert from a data-model to a view-model.
For asynchronous blocks of the widget tree. Use async *
method to yield widgets, allowing for pretty clean loading flows.
E.g.
WidgetStream(
(BuildContext context) async* {
yield CircularProgressIndicator();
yield LaunchList(await locate<SpaceXRepository>().getPastLaunches());
},
);
Nice right? No need for a StatefulWidget, Future, FutureBuilder, Snapshots or any of that. Just a circular progress indicator, and the LaunchList when it's done.
- Work through each modules README.MD TODO section
- Navigation/Routing result codes (allow Future)
- Publish to Android store