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

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_builder and build_runner to your dev_dependencies for 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');
}

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 redirect is reserved by Slick — always use onRedirect.

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 — the NavigationLogic singleton; navigateBack(context).
  • isLoading, isInitialised, loadingIndicatorStyle (auto, overlay, none, fullScreen).
  • dirtyStateMonitor — unsaved-changes tracking (form fields mark it dirty automatically).
  • showInfoMessage / showWarningMessage / showErrorMessage — routed through the AlertController.

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.buildPage builds every page without a page transition (the snapshot is the transition) and wraps it in a SlickPageHost, which owns the capture boundary and paints the previous page's snapshot. If you override buildPage, wrap your widget in SlickPageHost yourself to keep this working.
  • A ViewModelWidget claims the snapshot on its first build, unless it overrides buildLoading / buildLoadingOverlay — a custom loading view always wins. Override useSnapshotLoading on the widget to decide explicitly.
  • Pages that never claim the snapshot (a plain widget, a redirect to a non-SlickRoute page) 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) and showDiscardDialog.
  • showMessage(context, text) and showErrorSnackBar(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; ListLengthValidator for 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.

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.

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) — wraps connectivity_plus; hasConnectivity() and an onHasConnectionStateChanged callback.
  • 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/standard_elevated_button
components/buttons/standard_outlined_button
components/buttons/standard_text_button
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/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/standard_menu_item
components/text/body
components/text/currency
components/text/error
components/text/label
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