Flutter Slick
A batteries-included application framework for Flutter. Slick gives you an opinionated
MVVM layer, typed routing built on go_router, permission/feature-access
control, authentication hooks, and a library of form, layout and grid widgets —
so you can assemble production apps without re-inventing the plumbing for every screen.
Slick is the foundation behind a family of production apps (payments, POS, fleet management). The patterns documented here are the ones used in those apps daily.
Contents
- Installing
- Importing — there is no barrel file
- Quick start — wire up an app in three files
- Routing —
SlickRoute, parameters, redirects, navigation - MVVM —
ViewModel+ViewModelWidget - Authentication
- Feature access & permissions
- Form fields & validation
- Buttons
- Layout —
SpacedColumn,SpacedRow,MediaLayout,DynamicColumnLayout - Data grids —
StandardGrid - Navigation menus —
NavigationItem,StandardMenuItem - Wizard
- Breadcrumbs
- Dialogs & alerts
- Text components
- Utilities — formatting, validators, dates, colours
- Example app
Installing
dependencies:
flutter_slick: ^5.4.0
Slick builds on provider, get_it, go_router, dropdown_search,
connectivity_plus, flutter_staggered_grid_view, intl and validators. These come
in transitively — you do not need to add them yourself unless you use them directly.
Importing
Slick has no barrel file — import the specific file you need. Every public type lives
under package:flutter_slick/<path>.dart:
import 'package:flutter_slick/navigation/logic.dart'; // NavigationLogic
import 'package:flutter_slick/navigation/slick_route.dart'; // SlickRoute
import 'package:flutter_slick/navigation/view_model.dart'; // ViewModel
import 'package:flutter_slick/navigation/view_model_widget.dart';
import 'package:flutter_slick/components/layout/spaced_column.dart';
import 'package:flutter_slick/components/fields/text.dart';
import 'package:flutter_slick/utils/validators.dart';
The path mirrors the folder layout shown throughout this document.
Quick start
A Slick app comes together in three pieces: routes, app setup (providers +
NavigationLogic), and the root widget (MaterialApp.router).
1. Define your routes
Routes are typed classes that extend SlickRoute and use go_router's
@TypedGoRoute code generation. Create routes.dart:
import 'package:flutter/material.dart';
import 'package:flutter_slick/navigation/slick_route.dart';
import 'package:go_router/go_router.dart';
import 'screens/home.dart';
import 'screens/sample.dart';
part 'routes.g.dart';
@TypedGoRoute<HomeRoute>(path: '/')
class HomeRoute extends SlickRoute with $HomeRoute {
const HomeRoute();
@override
Widget build(BuildContext context, GoRouterState state) => const HomeScreen();
}
@TypedGoRoute<SampleRoute>(path: '/sample')
class SampleRoute extends SlickRoute with $SampleRoute {
const SampleRoute();
@override
Widget build(BuildContext context, GoRouterState state) => const SampleScreen();
}
Run the generator to produce routes.g.dart (which defines the $HomeRoute mixins and
the $appRoutes list):
dart run build_runner build --delete-conflicting-outputs
Add
go_router_builderandbuild_runnerto yourdev_dependenciesfor code generation. See the go_router_builder docs.
2. Set up providers and NavigationLogic
NavigationLogic is the heart of Slick — it owns the GoRouter, history, and
auth/permission redirects. Provide it (and your auth service) at the app root:
import 'package:flutter/material.dart';
import 'package:flutter_slick/navigation/logic.dart';
import 'package:provider/provider.dart';
import 'application.dart';
import 'routes.dart';
import 'services/my_authentication.dart';
void main() {
runApp(
MultiProvider(
providers: [
Provider<MyAuthenticationService>(
create: (_) => MyAuthenticationService(),
),
ChangeNotifierProvider<NavigationLogic>(
create: (context) => NavigationLogic(
routes: $appRoutes,
authState: context.read<MyAuthenticationService>().authState,
signInPath: const SignInRoute().location,
signOutPath: const LogOutRoute().location,
defaultLoading: () => const Center(child: CircularProgressIndicator()),
),
),
],
child: const Application(),
),
);
}
3. Build the root widget
Consume NavigationLogic and feed its goRouter into MaterialApp.router:
import 'package:flutter/material.dart';
import 'package:flutter_slick/navigation/logic.dart';
import 'package:provider/provider.dart';
class Application extends StatelessWidget {
const Application({super.key});
@override
Widget build(BuildContext context) {
return Consumer<NavigationLogic>(
builder: (context, logic, _) {
return MaterialApp.router(
title: 'My Slick App',
routerConfig: logic.goRouter,
);
},
);
}
}
That's it. You now have type-safe routing, auth-aware redirects, history tracking and a loading state baked in — including snapshot loading, which keeps the current screen on display while the next one loads.
Routing
Route parameters
Path and query parameters become typed constructor fields. The generator extracts them from the URL for you:
// Path parameter: /name/slick
@TypedGoRoute<UrlParamRoute>(path: '/name/:name')
class UrlParamRoute extends SlickRoute with $UrlParamRoute {
const UrlParamRoute({required this.name});
final String name;
@override
Widget build(BuildContext context, GoRouterState state) =>
UserScreen(name: name);
}
// Query parameter: /name-query?name=slick
@TypedGoRoute<QueryParamRoute>(path: '/name-query')
class QueryParamRoute extends SlickRoute with $QueryParamRoute {
const QueryParamRoute({this.name});
final String? name;
@override
Widget build(BuildContext context, GoRouterState state) =>
UserScreen(name: name ?? 'default');
}
Navigating
Get NavigationLogic from GetIt (it registers itself as a singleton) or from
Provider, then call goTo with a typed route instance:
import 'package:get_it/get_it.dart';
import 'package:flutter_slick/navigation/logic.dart';
final navigation = GetIt.I<NavigationLogic>();
navigation.goTo(const SampleRoute()); // push onto the stack
navigation.goTo(const UrlParamRoute(name: 'slick')); // with a path param
navigation.goReplace(const HomeRoute()); // replace current route
navigation.goBack(); // back through Slick history
navigation.goUp(); // up to the parent route (/a/b -> /a)
Inside a ViewModel the same instance is available as navigation, and
navigateBack(context) is provided as a convenience.
NavigationLogic also exposes canGoBack, canGoUp, currentPath, an
isLoading flag, and addRouteChangeListener / removeRouteChangeListener for reacting
to navigation. Set useSmartBack: true (and optionally smartBackOrder) on the
constructor to make goBack() prefer going up the hierarchy before popping history.
Redirects
Override onRedirect on a route to guard it. Return a path to redirect, or null to
allow navigation. This is the canonical place for auth and sync checks:
@TypedGoRoute<DashboardRoute>(path: '/dashboard')
class DashboardRoute extends SlickRoute with $DashboardRoute {
const DashboardRoute();
@override
Widget build(BuildContext context, GoRouterState state) => const DashboardScreen();
@override
FutureOr<String?> onRedirect(BuildContext context, GoRouterState state) async {
final user = await context.read<MyAuthenticationService>().currentUser;
if (user == null) return const SignInRoute().location;
return null; // allow
}
}
Override
redirectis reserved by Slick — always useonRedirect.
Other overridable members on SlickRoute:
previousPath— put a different route on the history stack when this one is entered.menuItems/tabItems— per-route menu/tab definitions (see Navigation menus).
Custom transitions
With the default snapshot loading, SlickRoute pages have no page
transition: the previous page's snapshot crossfades into the new view instead. Pass
useSnapshotLoading: false to NavigationLogic to use page transitions again. They come
from Material's PageTransitionsTheme. Set them globally on your ThemeData, per route,
or per loading view (see MVVM):
MaterialApp.router(
theme: ThemeData(
pageTransitionsTheme: const PageTransitionsTheme(
builders: {
TargetPlatform.android: CupertinoPageTransitionsBuilder(),
TargetPlatform.iOS: CupertinoPageTransitionsBuilder(),
},
),
),
routerConfig: logic.goRouter,
);
MVVM
Slick's MVVM splits each screen into a ViewModel (state + logic) and a
ViewModelWidget (the view). The widget owns the view-model lifecycle, shows a loading
state while the view model initialises, and rebuilds whenever the view model notifies.
Defining a ViewModel
Extend ViewModel and implement initState() (async — runs once, before the view is
shown). Call redraw() to rebuild the view, and loading() / idle() to toggle a
loading indicator:
import 'package:flutter_slick/navigation/view_model.dart';
class VehicleListViewModel extends ViewModel {
List<Vehicle> vehicles = [];
@override
Future<void> initState() async {
vehicles = await VehicleService.getAll();
}
Future<void> refresh() async {
loading(); // show a loading indicator
vehicles = await VehicleService.getAll();
idle(); // hide it
}
void search(String query) {
vehicles = VehicleService.search(query);
redraw(); // rebuild the view (instead of setState)
}
}
ViewModel also gives you:
navigation— theNavigationLogicsingleton;navigateBack(context).isLoading,isInitialised,loadingIndicatorStyle(auto,overlay,none,fullScreen).dirtyStateMonitor— unsaved-changes tracking (form fields mark it dirty automatically).showInfoMessage/showWarningMessage/showErrorMessage— routed through theAlertController.
Binding it to a view
Extend ViewModelWidget<T>, implement createViewModel() and buildView(), and read
state from the vm getter:
import 'package:flutter/material.dart';
import 'package:flutter_slick/navigation/view_model_widget.dart';
import 'package:flutter_slick/components/layout/spaced_column.dart';
class VehicleListScreen extends ViewModelWidget<VehicleListViewModel> {
VehicleListScreen({super.key});
@override
VehicleListViewModel createViewModel() => VehicleListViewModel();
@override
Widget buildView(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('Vehicles')),
body: SpacedColumn(
children: [
for (final v in vm.vehicles) Text(v.registration),
ElevatedButton(onPressed: vm.refresh, child: const Text('Refresh')),
],
),
);
}
}
While initState() is running (or loading() is active), the widget shows
buildLoading(context) — by default the defaultLoading widget you configured on
NavigationLogic. Override buildLoading / buildLoadingOverlay for a custom loading
view. The loading→view swap is a short crossfade by default (180 ms; it is not a page
change, so it does not replay the route's zoom or slide) — override transitionTheme
to use a PageTransitionsTheme there instead, e.g. Theme.of(context).pageTransitionsTheme
to match the route animation.
Snapshot loading
By default a route change does not jump to a loading screen at all. Just before
NavigationLogic changes route it captures the pixels of the current page, and the
destination page keeps showing that capture, dimmed with a progress indicator over it,
until its ViewModelWidget has initialised and painted. The capture then crossfades
into the finished view. The user sees the screen they were on, a spinner, and then the
new screen — no intermediate loading layout.
How it fits together:
SlickRoute.buildPagebuilds every page without a page transition (the snapshot is the transition) and wraps it in aSlickPageHost, which owns the capture boundary and paints the previous page's snapshot. If you overridebuildPage, wrap your widget inSlickPageHostyourself to keep this working.- A
ViewModelWidgetclaims the snapshot on its first build, unless it overridesbuildLoading/buildLoadingOverlay— a custom loading view always wins. OverrideuseSnapshotLoadingon the widget to decide explicitly. - Pages that never claim the snapshot (a plain widget, a redirect to a non-
SlickRoutepage) drop it after two frames and render as normal. - Only route changes made through
NavigationLogic(goTo,goReplace,goBack,goUp) can be captured. Browser back/forward shows the destination's loading widget. - The capture waits for the end of the current frame, so the route change starts one frame (≈16 ms) later than before. Platform views (web views, maps) are not part of a capture, and the deprecated HTML web renderer cannot capture at all; in both cases the loading widget is shown instead.
Configure it on NavigationLogic:
NavigationLogic(
routes: $appRoutes,
useSnapshotLoading: true, // the default
defaultLoadingOverlay: () => const DefaultLoadingOverlay(), // dimmed scrim + spinner
);
defaultLoadingOverlay is the one indicator for anything drawn over content that stays on
screen: the snapshot here, and the view when a view model calls
loading(style: LoadingIndicatorStyle.overlay). Keep it translucent so that content shows
through.
The default overlay is staged so that quick loads stay quiet: nothing is drawn for the first 100 ms, then the scrim fades in over 300 ms, and the spinner only fades in after 800 ms. A view model that initialises within a few frames shows no dimming and no spinner at all; the snapshot just crossfades into the new view. Tune the timings through the constructor:
defaultLoadingOverlay: () => const DefaultLoadingOverlay(
indicatorDelay: Duration(seconds: 1),
scrimOpacity: 0.2,
),
Set useSnapshotLoading: false to go back to page transitions and loading widgets on
every route change.
ViewModelWidget includes handy view helpers:
popDialog(context, dirtyWarning: true)— pops, prompting to discard unsaved changes.showActionDialog(context, title, content, actionLabel)andshowDiscardDialog.showMessage(context, text)andshowErrorSnackBar(context, text).
ViewModelWidgets nest freely — a view can build another ViewModelWidget and each
manages its own lifecycle.
Authentication
Extend AuthenticationService<User> with your own user type. The base class already
provides broadcast authState / tokenState streams (via authStateChangeController
and tokenChangeController) and a dispose() that closes them — you implement the
provider-specific methods and emit on the controllers:
import 'package:flutter_slick/services/authentication.dart';
class MyAuthenticationService extends AuthenticationService<AuthUser> {
AuthUser? _user;
@override
AuthUser? get user => _user;
@override
Future<void> signIn(String email, String password) async {
_user = await api.login(email, password);
authStateChangeController.add(_user); // notify listeners
tokenChangeController.add(_user?.token);
}
@override
Future<void> signOut() async {
_user = null;
authStateChangeController.add(null);
}
@override
Future<void> refreshToken(AuthUser user) async { /* ... */ }
@override
Future<void> createUser(String email, String password) async { /* ... */ }
@override
Future<void> resetPasswordReset(String email) async { /* ... */ }
@override
Future<void> confirmPasswordReset(String code, String pw) async { /* ... */ }
@override
Future<void> sendResetPasswordEmail(String? email) async { /* ... */ }
}
Wrap provider/back-end errors in AuthenticationException so callers can handle them
uniformly. Pass the service's authState stream to NavigationLogic(authState: ...) so
routing waits for the first auth event before evaluating redirects.
Feature access & permissions
FeatureAccessController decides which routes a user may visit, based on a list of
permitted path prefixes. Initialise it once with a callback that returns the current
user's access, then hand it to NavigationLogic:
import 'package:flutter_slick/services/feature_access_controller.dart';
final access = await FeatureAccessController.initialise(() async {
final perms = await api.getMyPermissions();
return FeatureAccessDetails(
userId: perms.userId,
availableAccess: perms.paths, // e.g. ['/dashboard', '/vehicles']
readOnlyAccess: perms.readOnlyPaths, // optional
);
});
NavigationLogic(
routes: $appRoutes,
featureAccessController: access,
signInPath: const SignInRoute().location, // required with access control
accessDeniedPath: const AccessDeniedRoute().location, // required with access control
);
With a controller set, NavigationLogic automatically redirects unauthenticated users to
signInPath and authenticated-but-unauthorised users to accessDeniedPath. Paths match
by prefix, and snake_case URL segments are matched against camelCase permissions.
access.hasPermission(path)/hasReadOnlyPermission(path)— check access directly.await access.refreshAvailableAccess()— re-fetch after a role change.NavigationLogic.isReadOnly()— is the current route read-only for this user?- Menus are filtered automatically — items the user can't reach are hidden (see below).
Form fields & validation
Slick fields are drop-in form widgets that integrate with Form/GlobalKey<FormState>,
report typed values through onChanged, and mark the DirtyStateMonitor dirty on edit.
import 'package:flutter/material.dart';
import 'package:flutter_slick/components/fields/text.dart';
import 'package:flutter_slick/components/fields/email.dart';
import 'package:flutter_slick/components/fields/password.dart';
import 'package:flutter_slick/components/fields/number.dart';
import 'package:flutter_slick/components/fields/currency.dart';
import 'package:flutter_slick/components/fields/date.dart';
import 'package:flutter_slick/components/fields/checkbox.dart';
import 'package:flutter_slick/components/fields/dropdown.dart';
import 'package:flutter_slick/components/layout/spaced_column.dart';
import 'package:flutter_slick/utils/validators.dart';
final _formKey = GlobalKey<FormState>();
String _name = '';
double? _price;
Country? _country;
Form(
key: _formKey,
child: SpacedColumn(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
StandardTextField(
labelText: 'Name',
initialValue: _name,
onChanged: (v) => _name = v?.trim() ?? '',
validator: CombinedFieldValidator([
RequiredValidator(),
LengthValidator(min: 1, max: 50),
]),
),
EmailField(
labelText: 'Email',
onChanged: (v) => _email = v,
validator: EmailFieldValidator(),
),
PasswordField(
labelText: 'Password',
onChanged: (v) => _password = v,
),
NumberField(
labelText: 'Quantity',
onChanged: (v) => _qty = v, // int?
validator: RangeValidator(min: 1, max: 99),
),
CurrencyField(
labelText: 'Price',
currency: 'R',
onChanged: (v) => _price = v, // double?
),
StandardDateField(
labelText: 'Start date',
onChanged: (v) => _start = v, // DateTime?
firstDate: DateTime(2020),
lastDate: DateTime(2030),
),
DropdownField<Country>(
labelText: 'Country',
items: countries,
selectedItem: _country,
itemAsString: (c) => c.name,
showSearchBox: true,
onChanged: (c) => _country = c,
),
CheckboxField(
labelText: 'I agree to the terms',
initialValue: false,
onChanged: (v) => _agreed = v,
),
],
),
);
// Validate and read the values:
if (_formKey.currentState!.validate()) {
// submit _name, _price, _country, ...
}
Common fields and their value types:
| Widget | File | Value | Notes |
|---|---|---|---|
StandardTextField |
fields/text.dart |
String? |
maxLength, maxLines, prefixIcon, inputFormatters, readOnly, keyboardType |
EmailField |
fields/email.dart |
String? |
pairs with EmailFieldValidator |
PasswordField |
fields/password.dart |
String? |
obscured with a show/hide toggle |
NumberField |
fields/number.dart |
int? |
|
CurrencyField |
fields/currency.dart |
double? |
requires currency symbol; readOnly |
StandardDateField |
fields/date.dart |
DateTime? |
firstDate/lastDate, includeTime |
StandardDurationField |
fields/duration.dart |
Duration |
HH:MM input |
CheckboxField |
fields/checkbox.dart |
bool |
|
DropdownField<T> |
fields/dropdown.dart |
T? |
showSearchBox, readOnly, custom compare |
InlineMultiSelectField<T> |
fields/inline_multi_select/inline_multi_select_input.dart |
List<T> |
async suggestionCallback, inline chips |
Set autoValidate: true on any field to validate when focus is lost.
Validators
Validators implement FieldValidator<T> and are callable, so they slot straight into a
field's validator. Built-ins:
RequiredValidator()— non-null / non-empty.LengthValidator(min:, max:)— string length;ListLengthValidatorfor lists.EmailFieldValidator()— valid email.RangeValidator(min:, max:)— numeric range;DateRangeValidator(min:, max:)for dates.FieldMatchValidator(name, getValue)— matches another field (e.g. confirm password).CombinedFieldValidator([...])— runs validators in order, returns the first error.DependentValidator(() => ...)— builds the validator lazily, for cross-field rules.
Every built-in validator has a default message (unchanged from previous versions).
Override it with errorMessage:
validator: RequiredValidator(errorMessage: 'This field is required'),
LengthValidator, ListLengthValidator, RangeValidator and DateRangeValidator
also accept errorMessageBuilder, for a message that depends on which bound
(BoundsViolation.belowMin / .aboveMax / .outsideRange) was violated, and on
min/max themselves:
validator: RangeValidator(
min: 1,
max: 99,
errorMessageBuilder: (violation, min, max) => switch (violation) {
BoundsViolation.belowMin => 'Must be at least $min',
BoundsViolation.aboveMax => 'Must be at most $max',
BoundsViolation.outsideRange => 'Must be between $min and $max',
},
),
FieldMatchValidator has its own errorMessageBuilder, String Function(String fieldName).
CombinedFieldValidator and DependentValidator accept errorMessage too, which
replaces whichever child validator's message would otherwise be shown.
errorMessageBuilder (where available) takes precedence over errorMessage, which
takes precedence over the default.
Write your own by extending FieldValidator<T>:
class PhoneValidator extends FieldValidator<String> {
@override
String? call(String? value) {
if (value == null || value.isEmpty) return null;
return RegExp(r'^\+?\d{7,15}$').hasMatch(value) ? null : 'Invalid phone number';
}
}
Multi-select with inline chips
import 'package:flutter_slick/components/fields/inline_multi_select/inline_multi_select_input.dart';
InlineMultiSelectField<Driver>(
label: 'Search for drivers',
items: allDrivers,
initialValues: selectedDrivers,
dropDownMode: true,
prefixIcon: const Icon(Icons.person),
suggestionCallback: (query) async =>
allDrivers.where((d) => d.name.toLowerCase().contains(query.toLowerCase())).toList(),
chipLabelBuilder: (d) => Text(d.name),
onChanged: (selected) => setState(() => selectedDrivers = selected),
);
Buttons
Slick's standard buttons accept an async onPressed and show an inline loading
indicator while it runs, with an optional onError handler:
import 'package:flutter_slick/components/buttons/standard_elevated_button.dart';
import 'package:flutter_slick/components/buttons/standard_outlined_button.dart';
import 'package:flutter_slick/components/buttons/standard_text_button.dart';
StandardElevatedButton(
onPressed: () async => await save(), // spinner shown until this completes
onError: () async => showError('Save failed'),
child: const Text('Save'),
);
StandardOutlinedButton.icon(
icon: const Icon(Icons.download),
onPressed: () async => await export(),
child: const Text('Export'),
);
StandardTextButton(
onPressed: () async => await cancel(),
child: const Text('Cancel'),
);
Also available: PrimaryActionButton / SecondaryActionButton (extended FABs taking
iconData + labelText) and WordedToggle (an animated labelled on/off switch backed by
a ValueNotifier<bool>).
Layout
Spacing
SpacedColumn and SpacedRow insert consistent gaps between children (default 16.0) —
no manual SizedBoxes. These are the most-used widgets in Slick apps.
import 'package:flutter_slick/components/layout/spaced_column.dart';
import 'package:flutter_slick/components/layout/spaced_row.dart';
SpacedColumn(
crossAxisAlignment: CrossAxisAlignment.start,
spaceSize: 12,
children: [nameField, surnameField, phoneField],
);
SpacedRow(
mainAxisAlignment: MainAxisAlignment.end,
children: [cancelButton, saveButton],
);
Responsive layouts
MediaLayout rebuilds with a MediaConstraints describing the current screen, so you can
branch on size. You can also construct MediaConstraints(context) directly:
import 'package:flutter_slick/components/layout/media_layout.dart';
MediaLayout(
builder: (context, constraints) {
return constraints.isSmall
? const _MobileLayout()
: const _DesktopLayout();
},
);
// or, ad-hoc:
final constraints = MediaConstraints(context);
final columns = constraints.isSmall ? 1 : 2;
MediaConstraints exposes screenWidth, isSmall, isLarge, isSmallScreen, and
accepts forceMobileOnlyView: true to force the mobile branch.
Dynamic columns
DynamicColumnLayout wraps a set of widgets into rows using one of three strategies —
explicit widths, ratios (with a separate mobile ratio set), or a fixed count per row:
import 'package:flutter_slick/components/layout/dynamic_column_layout.dart';
// Even rows of N widgets, wrapping as needed:
DynamicColumnLayout(
widgetsPerRow: 3,
spacing: 20,
runSpacing: 20,
children: cards,
);
// Proportional widths, with a mobile override:
DynamicColumnLayout(
widgetRatios: const [2, 1], // 2:1 split on desktop
widgetRatiosMobile: const [[1], [1]], // stacked on mobile
children: [main, sidebar],
);
A StaggeredGridView (components/layout/grid/staggered_grid_view.dart) is also provided
for Pinterest-style staggered grids, with crossAxisSpacing / mainAxisSpacing.
Data grids
StandardGrid<T> renders a responsive data table with optional row selection, expandable
detail rows, per-row actions, striping and a mobile card view. Cells are arbitrary widgets;
items ties each row back to a typed model for actions and expansion.
import 'package:flutter_slick/components/layout/grid/standard_grid.dart';
StandardGrid<Vehicle>(
title: 'Fleet',
headers: const ['Registration', 'Drivers', 'Status', 'Fleet #'],
highlightedIndices: const [0], // emphasise these columns
items: vm.vehicles, // typed models, one per row
data: vm.vehicles.map((v) => [
Text(v.registration),
Text('${v.driverCount}'),
Text(v.status),
Text('Fleet ${v.fleetNumber}'),
]).toList(),
stripedRows: true,
onExpandRow: (index) async => VehicleDetail(vm.vehicles[index]), // lazy detail row
mobileViewBuilder: (index, context) => VehicleCard(vm.vehicles[index]),
rowActions: (index) => [
TableAction<Vehicle>(
title: 'Edit',
icon: Icons.edit,
action: (vehicle) => vm.navigation.goTo(EditVehicleRoute(id: vehicle.id)),
),
],
);
Theming hooks:
gridRowColors: GridRowColors(evenRow:, oddRow:, hoverOrExpanded:)— brand row colours (falls back to theme colours when null).headerColor— explicit header background.columnWidths,rowBorder,isSelectable+onSelectionChanged.
Navigation menus
Describe your app's menu as a tree of NavigationItems. Each item points at either a
routePath or has children (not both). Pass them to NavigationLogic
(defaultMenuItems / defaultTabItems) or per route (SlickRoute.menuItems):
import 'package:flutter_slick/navigation/item.dart';
final menuItems = [
NavigationItem(
labelText: 'Dashboard',
iconData: Icons.dashboard,
routePath: const DashboardRoute(),
),
NavigationItem(
labelText: 'Fleet',
iconData: Icons.directions_car,
children: [
NavigationItem(labelText: 'Vehicles', routePath: const VehiclesRoute()),
NavigationItem(labelText: 'Drivers', routePath: const DriversRoute()),
],
),
];
When a FeatureAccessController is configured, read NavigationLogic.filteredMenuItems
(or SlickRoute.filteredMenuItems) to get the tree with items the user can't access
already removed — including parents whose children are all hidden.
Render items with StandardMenuItem (in a Drawer, NavigationRail, etc.). It supports
nested children, selected styling, indicator icons, and omits the tap target for items
with a null routePath (store-compliance friendly). Use routePathSelection on a
NavigationItem to keep it highlighted across a set of related routes.
Wizard
Wizard is a ready-made multi-step flow. Give it a list of WizardPages; each page
controls its own Next/Back behaviour with async hooks (return false from onNext to
block advancing — e.g. on a failed validation). The Next button shows a spinner while an
async onNext runs.
import 'package:flutter_slick/components/wizard/wizard_ui.dart';
import 'package:flutter_slick/components/wizard/wizard_page.dart';
Wizard(
title: const Text('Create vehicle'),
actions: const [],
onCancel: () => navigation.goBack(),
pages: [
WizardPage(
title: 'Details',
child: const VehicleDetailsForm(),
onNext: () async => await saveDraft(), // false blocks navigation
),
WizardPage(
title: 'Drivers',
child: const AssignDriversForm(),
hideCancelButton: true,
),
WizardPage(
title: 'Review',
child: const ReviewStep(),
nextButtonText: 'Finish',
onNext: () async => await submit(),
),
],
);
Pages can hide individual buttons (hideNextButton, hideBackButton, hideCancelButton)
and customise their labels. Set isModal: true when hosting the wizard in a dialog.
Breadcrumbs
Add a breadcrumb trail driven by the current route. Register a BreadcrumbService (with
optional resolvers that turn entity IDs in the URL into friendly names), then drop a
BreadcrumbNavigation widget into your scaffold:
import 'package:flutter_slick/navigation/breadcrumb_setup.dart';
import 'package:flutter_slick/components/navigation/breadcrumb_navigation.dart';
final breadcrumbs = BreadcrumbSetup.register(
entityNameResolver: myEntityNameResolver, // id -> display name
entityTypeResolver: myEntityTypeResolver,
excludedSegments: const ['edit'],
minimumDepthToShow: 2,
);
// In a screen:
BreadcrumbNavigation(
breadcrumbService: breadcrumbs,
separatorIcon: Icons.chevron_right,
);
Dialogs & alerts
StandardDialog wraps AlertDialog and integrates with dirty-state tracking via a
beforeClose guard:
import 'package:flutter_slick/components/dialogs/dialog.dart';
showDialog(
context: context,
builder: (_) => StandardDialog(
title: const Text('Confirm'),
content: const Text('Delete this vehicle?'),
beforeClose: () async => true, // return false to keep the dialog open
actions: [
TextButton(onPressed: () => Navigator.pop(context), child: const Text('Cancel')),
FilledButton(onPressed: confirmDelete, child: const Text('Delete')),
],
),
);
For view-model–driven alerts, call showInfoMessage / showWarningMessage /
showErrorMessage on a ViewModel. By default these queue and show a simple dialog; to
route them through your own UI (snackbars, toasts, branded dialogs), register an
AlertController:
import 'package:flutter_slick/services/alert_controller.dart';
AlertController(
onShowError: ({required message, title}) => myToast.error(message),
onShowInfo: ({required message, title}) => myToast.info(message),
showWarning: ({required message, title}) => myToast.warn(message),
);
Text components
Lightweight typed text widgets with consistent styling and formatting:
import 'package:flutter_slick/components/text/title.dart';
import 'package:flutter_slick/components/text/body.dart';
import 'package:flutter_slick/components/text/label.dart';
import 'package:flutter_slick/components/text/currency.dart';
import 'package:flutter_slick/components/text/percent.dart';
import 'package:flutter_slick/components/text/error.dart';
import 'package:flutter_slick/components/text/nullable.dart';
import 'package:flutter_slick/components/text/readonly_field.dart';
import 'package:flutter_slick/components/text/link.dart';
TitleText('Vehicle details'); // 18px
BodyText('Some body copy'); // 16px
LabelText('REGISTRATION'); // 12px
CurrencyText(1234.5); // formatted currency
PercentText(0.42); // formatted percentage
ErrorText('Something went wrong'); // error-coloured
NullableText(maybeValue); // 'n/a' when null
ReadonlyField(label: 'Status', text: 'Active');
LinkText('Open dashboard', const DashboardRoute()); // tappable, navigates
Utilities
Formatting (utils/format.dart)
import 'package:flutter_slick/utils/format.dart';
formatDate(date); // date only
formatDateTime(date); // date + time
formatCurrency(1234.5); // currency string
formatPercent(0.42); // percentage
formatNumber(1000); // grouped number
formatNullable(value); // 'n/a' when null
formatInitials('Greg van Berkel');
// Extension methods on double:
(1234.5).formatCurrency(includeSymbol: true, includeCents: true);
(0.1234).format(2); // 2 decimal places
Dates (utils/slick_datetime.dart)
SlickDateTime is a DateTime wrapper with formatting helpers and a sentinel "min"
concept (handy with code-generated API clients). Convert with the .slick extension:
import 'package:flutter_slick/utils/slick_datetime.dart';
final d = DateTime.now().slick; // SlickDateTime?
d.formatShortDate();
d.formatDateTime();
d.monthName();
Colours (utils/color_extensions.dart)
import 'package:flutter_slick/utils/color_extensions.dart';
theme.primary.darken(20);
theme.primary.lighten(40);
colorA.avg(colorB);
Other utilities: utils/parse.dart (duration parsing/formatting), utils/strings.dart
(normalizePath() extension), and the validators covered above.
Other services
ConnectionService(services/connection_service.dart) — wrapsconnectivity_plus;hasConnectivity()and anonHasConnectionStateChangedcallback.ErrorService(services/error.dart) — an app-wide error broadcast stream;raise(error).ApiService<User, UserModel>(services/api.dart) — a base for API clients that react to auth state and manage a token.
Example app
A runnable example covering app setup, typed routes (path/query params, redirects),
view-model loading states and custom transitions lives in the
example/ directory. Start with example/lib/main.dart,
example/lib/application.dart and example/lib/routes.dart.
License
See the repository for license details.
Libraries
- components/buttons/primary_action
- components/buttons/secondary_action
- components/buttons/worded_toggle
- components/dialogs/dialog
- components/feedback/default_loading
- components/feedback/default_loading_overlay
- components/fields/checkbox
- components/fields/currency
- components/fields/date
- components/fields/dropdown
- components/fields/duration
- components/fields/email
- components/fields/file
- components/fields/inline_multi_select/chip_input
- components/fields/inline_multi_select/inline_multi_select_input
- components/fields/number
- components/fields/password
- components/fields/text
- components/indicator_icon
- components/layout/dynamic_column_layout
- components/layout/grid/action_row
- components/layout/grid/data_row
- components/layout/grid/staggered_grid_view
- components/layout/grid/standard_grid
- components/layout/grid/table_text_cell
- components/layout/media_layout
- components/layout/spaced_column
- components/layout/spaced_row
- components/text/body
- components/text/currency
- components/text/error
- components/text/label
- components/text/link
- components/text/nullable
- components/text/percent
- components/text/readonly_field
- components/text/title
- components/wizard/wizard_page
- components/wizard/wizard_ui
- components/wizard/wizard_vm
- flutter_slick
- navigation/item
- navigation/logic
- navigation/route_snapshot
- navigation/slick_route
- navigation/view_model
- navigation/view_model_widget
- services/alert_controller
- services/alert_request
- services/api
- services/authentication
- services/connection_service
- services/dirty_state_monitor
- services/error
- services/feature_access_controller
- services/view_model_state_manager
- utils/action_dialog_options
- utils/color_extensions
- utils/format
- utils/go_router_extensions
- utils/parse
- utils/slick_datetime
- utils/strings
- utils/validators