App Features

A Flutter package that helps you organize your application structure by feature scope, with integrated routing, navigation, and UI utilities.

pub package

Overview

App Features provides a structured way to organize your Flutter application by features, with each feature having its own routes, dependencies, and navigation capabilities. Built on top of go_router, it offers a clean architecture approach with additional utilities for common UI patterns.

Features

  • Feature-based Architecture: Organize your code by features, each with its own routes and dependencies
  • Integrated Routing: Built on go_router with simplified navigation between features
  • Full go_router API Support: All GoRouter constructor parameters are exposed through AppFeatures.config()
  • Flexible Route Types: Features support GoRoute, ShellRoute, and any RouteBase subclass
  • Named & Path Navigation: Navigate by route name or by path with full parameter support
  • Master Layout Support: Create shell routes with bottom navigation, with full branch and shell configuration
  • Dialog & Bottom Sheet Routing: Handle dialogs and bottom sheets as part of your navigation system
  • Overlay Support: Show dialogs, bottom sheets, and loading indicators from anywhere
  • Scaffold Messenger Utilities: Display snackbars and toast messages easily
  • Event System: Subscribe to route changes and other events within features

Installation

run this command to add app_features:

flutter pub add app_features

Basic Setup

Configure App Features

In your main.dart file:

void main() {
  // Configure App Features with your features
  AppFeatures.config(
    features: [
      SplashFeature(),
      AuthFeature(),
      HomeFeature(),
    ],
    // Optional: Add a master layout for bottom navigation
    masterLayout: AppMasterLayout(),
    // Optional: Set initial route
    initLocation: '/',
    // Optional: All GoRouter parameters are supported
    redirect: (context, state) => null,
    debugLogDiagnostics: true,
    observers: [MyNavigatorObserver()],
    errorBuilder: (context, state) => ErrorPage(error: state.error),
  );

  runApp(const MyApp());
}

Set Up MaterialApp

class MyApp extends StatelessWidget {
  const MyApp({super.key});

  @override
  Widget build(BuildContext context) {
    return MaterialApp.router(
      title: 'App Features Demo',
      theme: ThemeData(
        primarySwatch: Colors.blue,
      ),
      // Use AppFeatures router for navigation
      routerConfig: AppFeatures.router,
    );
  }
}

Creating Features

Basic Feature

Create a new feature by extending the Feature class:

import 'package:app_features/app_features.dart';

class AuthFeature extends Feature {
  @override
  String get name => '/auth';

  @override
  List<RouteBase> get routes => [
    GoRoute(
      path: name,
      name: name,
      builder: (_, __) => const AuthPage(),
    ),
    GoRoute(
      path: '$name/login',
      name: 'login',
      builder: (_, __) => const LoginPage(),
    ),
    GoRoute(
      path: '$name/register',
      name: 'register',
      builder: (_, __) => const RegisterPage(),
    ),
  ];

  // Optional: Define dependencies for this feature
  @override
  void get dependencies => () {
    // Initialize services, repositories, etc.
  };
}

Feature with Dialog

You can create routes for dialogs:

GoRoute(
  path: '$name/confirm',
  name: 'confirm_dialog',
  pageBuilder: (context, state) => DialogPage(
    page: ConfirmDialog(
      message: state.extra as String,
    ),
  ),
),

Feature with Bottom Sheet

Similarly for bottom sheets:

GoRoute(
  path: '$name/options',
  name: 'options_sheet',
  pageBuilder: (context, state) => BottomSheetPage(
    page: OptionsBottomSheet(),
    isScrollControlled: true,
    showDragHandle: true,
  ),
),
// Push to a feature
AppFeatures.get<AuthFeature>().push();

// Go to a feature (clearing the stack)
AppFeatures.get<HomeFeature>().go();

// Replace current route with a feature
AppFeatures.get<ProfileFeature>().replace();

// Push and replace current route
AppFeatures.get<SettingsFeature>().pushReplacement();
// Navigate to a specific route within a feature
AppFeatures.get<AuthFeature>().push(name: 'login');

// With parameters
AppFeatures.get<ProductFeature>().push(
  name: 'product_details',
  pathParameters: {'id': '123'},
  queryParameters: {'source': 'search'},
  extra: productData,
);

Path-Based Navigation

Navigate using paths instead of route names:

// Push by path
AppFeatures.get<AuthFeature>().pushPath('/auth/login');

// Go by path
AppFeatures.get<AuthFeature>().goPath('/auth/login', extra: data);

// Replace by path
AppFeatures.get<AuthFeature>().replacePath('/auth/register');

// Push replacement by path
AppFeatures.get<AuthFeature>().pushReplacementPath('/auth/login');
// Navigate with a URL fragment
AppFeatures.get<DocsFeature>().go(
  name: 'docs',
  fragment: 'section-2',
);

Static Navigation Helpers

// Navigate by path directly from AppFeatures
AppFeatures.go('/auth/login', extra: data);
AppFeatures.push<bool>('/confirm');

// Get a named location URI
final uri = AppFeatures.namedLocation(
  'product_details',
  pathParameters: {'id': '123'},
);

// Access the current router state
final currentState = AppFeatures.state;

Basic Navigation

// Go back
AppFeatures.pop();

// Check if can go back
if (AppFeatures.canPop()) {
  // Do something
}

// Refresh current route
AppFeatures.refresh;

// Restart app (go to initial route)
AppFeatures.restart();

Master Layout

Create a master layout for bottom navigation:

class AppMasterLayout extends MasterLayout {
  @override
  List<Feature> get features => [
    HomeFeature(),
    ExploreFeature(),
    ProfileFeature(),
  ];

  @override
  BottomNavigationBuilder get bottomNavigationBar =>
    (navigationShell) => BottomNavigationBar(
      currentIndex: navigationShell.currentIndex,
      onTap: navigationShell.goBranch,
      items: const [
        BottomNavigationBarItem(
          icon: Icon(Icons.home),
          label: 'Home',
        ),
        BottomNavigationBarItem(
          icon: Icon(Icons.explore),
          label: 'Explore',
        ),
        BottomNavigationBarItem(
          icon: Icon(Icons.person),
          label: 'Profile',
        ),
      ],
    );
}

Utilities

Overlay Utilities

// Show a dialog
AppFeatures.overlay.openDialog(
  MyCustomDialog(data: someData),
);

// Show a bottom sheet
AppFeatures.overlay.openModalBottomSheet(
  MyBottomSheet(),
  isScrollControlled: true,
  showDragHandle: true,
);

// Show a loading indicator
AppFeatures.overlay.showLoading();

Scaffold Messenger Utilities

// Show a success message
AppFeatures.scaffoldMessenger.showSuccessMessage('Profile updated successfully');

// Show an error message
AppFeatures.scaffoldMessenger.showErrorMessage('Failed to update profile');

// Show a toast message
AppFeatures.scaffoldMessenger.showToast('Processing...');

// Show a custom snackbar
AppFeatures.scaffoldMessenger.showSnackBar(
  content: Text('Custom message'),
  backgroundColor: Colors.purple,
);

Feature Guards

Protect feature routes with per-feature redirects:

class ProfileFeature extends Feature {
  @override
  String get name => '/profile';

  @override
  FutureOr<String?> redirect(BuildContext context, GoRouterState state) {
    if (!AuthService.isLoggedIn) return '/auth/login';
    return null;
  }

  @override
  List<RouteBase> get routes => [
    GoRoute(path: name, name: name, builder: (_, __) => const ProfilePage()),
  ];
}

The feature redirect runs before any route-level redirect. Returns null to allow navigation, or a path to redirect.

Async Initialization

Features that need async setup (database, SDK, etc.) can override init():

class PaymentFeature extends Feature {
  @override
  String get name => '/payment';

  @override
  Future<void> init() async {
    await StripeService.initialize(publishableKey: 'pk_...');
  }

  @override
  List<RouteBase> get routes => [...];
}

Use configAsync instead of config to await all feature initializations:

void main() async {
  WidgetsFlutterBinding.ensureInitialized();

  await AppFeatures.configAsync(
    features: [SplashFeature(), PaymentFeature()],
    masterLayout: AppMasterLayout(),
  );

  runApp(const MyApp());
}

Feature Middleware

Lifecycle hooks are triggered automatically when navigating between features:

class AnalyticsFeature extends Feature {
  @override
  String get name => '/analytics';

  @override
  void onEnter(GoRouterState state) {
    AnalyticsService.logScreenView(state.matchedLocation);
  }

  @override
  void onLeave(GoRouterState state) {
    AnalyticsService.logScreenExit(state.matchedLocation);
  }

  @override
  List<RouteBase> get routes => [...];
}

onLeave fires on the old feature before onEnter on the new one. Not triggered when navigating within the same feature.

Event System

Subscribe to route changes within a feature. Multiple listeners per route are supported:

@override
listen() {
  on('product_details', (pathParams, queryParams, extra) {
    final productId = pathParams?['id'];
    loadProductData(productId);
  });

  // Multiple listeners on the same route
  on('product_details', (pathParams, queryParams, extra) {
    logAnalytics('viewed_product');
  });
}

// Remove a specific listener
off('product_details', myCallback);

// Remove all listeners for a route
offAll('product_details');

Global Event Bus

Cross-feature communication without direct dependencies:

// Feature A emits an event
AppFeatures.emit('user_logged_in', data: user);

// Feature B listens
AppFeatures.on('user_logged_in', (data) {
  final user = data as User;
  refreshProfile(user.id);
});

// Remove a listener
AppFeatures.off('user_logged_in', myCallback);

// Remove all listeners for an event
AppFeatures.offAll('user_logged_in');

// Clear all event listeners
AppFeatures.clearEvents();

Nested Features

Organize large apps by grouping sub-features under a parent:

class ShopFeature extends Feature {
  @override
  String get name => '/shop';

  @override
  List<Feature> get subFeatures => [
    CartFeature(),
    CheckoutFeature(),
    ProductDetailsFeature(),
  ];

  @override
  List<RouteBase> get routes => [
    GoRoute(path: name, name: name, builder: (_, __) => const ShopPage()),
  ];
}

// Sub-features are registered automatically. Access them normally:
AppFeatures.get<CartFeature>().push();

Advanced Usage

Branch Configuration

Configure individual branches in the master layout via Feature getters:

class HomeFeature extends Feature {
  @override
  String get name => '/home';

  @override
  List<RouteBase> get routes => [
    GoRoute(path: name, name: name, builder: (_, __) => const HomePage()),
  ];

  // Preload this branch when master layout first loads
  @override
  bool get preloadBranch => true;

  // Set a custom initial location for this branch
  @override
  String? get branchInitialLocation => '/home/feed';

  // Add observers to this branch's navigator
  @override
  List<NavigatorObserver>? get branchObservers => [MyObserver()];

  // Set a restoration scope id for state restoration
  @override
  String? get branchRestorationScopeId => 'home_branch';
}

Shell Route Configuration

Configure the shell route directly on MasterLayout:

class AppMasterLayout extends MasterLayout {
  @override
  List<Feature> get features => [HomeFeature(), ProfileFeature()];

  @override
  BottomNavigationBuilder get bottomNavigationBar =>
    (navigationShell) => BottomNavigationBar(
      currentIndex: navigationShell.currentIndex,
      onTap: navigationShell.goBranch,
      items: const [
        BottomNavigationBarItem(icon: Icon(Icons.home), label: 'Home'),
        BottomNavigationBarItem(icon: Icon(Icons.person), label: 'Profile'),
      ],
    );

  // Optional: Set a restoration scope id for the shell
  @override
  String? get restorationScopeId => 'main_shell';

  // Optional: Add a top-level redirect for the shell
  @override
  GoRouterRedirect? get redirect => (context, state) => null;

  // Optional: Control whether navigation changes notify root observers
  @override
  bool get notifyRootObserver => true;
}

Custom Navigator Container

Use a custom container instead of the default IndexedStack:

class AppMasterLayout extends MasterLayout {
  @override
  List<Feature> get features => [HomeFeature(), ProfileFeature()];

  @override
  ShellNavigationContainerBuilder get navigatorContainerBuilder =>
    (context, navigationShell, children) {
      return MyCustomContainer(
        currentIndex: navigationShell.currentIndex,
        children: children,
      );
    };

  @override
  StatefulShellRouteBuilder get masterPageBuilder =>
    (context, state, navigationShell) {
      return CustomMasterPage(navigationShell: navigationShell);
    };
}

Custom Master Page Builder

class AppMasterLayout extends MasterLayout {
  @override
  List<Feature> get features => [
    HomeFeature(),
    ProfileFeature(),
  ];

  @override
  StatefulShellRouteBuilder get masterPageBuilder =>
    (context, state, navigationShell) {
      return CustomMasterPage(
        navigationShell: navigationShell,
        // Custom properties
      );
    };
}

Using ShellRoute in Features

Features can now use any RouteBase, not just GoRoute:

class SettingsFeature extends Feature {
  @override
  String get name => '/settings';

  @override
  List<RouteBase> get routes => [
    ShellRoute(
      builder: (context, state, child) => SettingsShell(child: child),
      routes: [
        GoRoute(
          path: name,
          name: name,
          builder: (_, __) => const SettingsPage(),
        ),
        GoRoute(
          path: '$name/profile',
          name: 'settings_profile',
          builder: (_, __) => const ProfileSettingsPage(),
        ),
      ],
    ),
  ];
}

License

This project is licensed under the MIT License - see the LICENSE file for details.

Libraries

app_features