← Back to posts Cover image for Building Dynamic UIs: Implementing Role-Based Access Control and Feature Flags in Flutter

Building Dynamic UIs: Implementing Role-Based Access Control and Feature Flags in Flutter

· 5 min read
Weekly Digest

The Flutter news you actually need

No spam, ever. Unsubscribe in one click.

Chris
By Chris

Building an app that serves multiple user types? Whether you’re creating a marketplace for buyers and sellers, a SaaS platform with admin and user tiers, or an internal tool with varying permissions, you’ll quickly face a core architectural question: how do you manage different UI versions and feature sets without creating a maintenance nightmare?

The naive approach—duplicating entire pages for each role—leads to fragile, bloated code. Change a common element? You must update it in multiple places. This is where implementing a clean system for Role-Based Access Control (RBAC) and Feature Flags becomes essential. It lets you conditionally render UI and manage logic based on user permissions, keeping your codebase DRY, testable, and scalable.

Let’s build a practical system from the ground up.

1. Defining Your Permission Model

First, we need a source of truth for user roles and permissions. An enum is a perfect, type-safe starting point.

enum UserRole {
  guest,
  customer,
  seller,
  admin,
}

// Often, a user model will hold this
class AppUser {
  final String id;
  final String name;
  final UserRole role;
  // ... other fields

  const AppUser({
    required this.id,
    required this.name,
    required this.role,
  });
}

For more granular control beyond roles, you can model specific feature permissions. This is useful for feature flags, where you might enable a beta feature for only 10% of users, regardless of role.

class FeatureFlags {
  final bool canUseAdvancedDashboard;
  final bool canEditGlobalContent;
  final bool isNewMessagingUiEnabled;

  const FeatureFlags({
    this.canUseAdvancedDashboard = false,
    this.canEditGlobalContent = false,
    this.isNewMessagingUiEnabled = false,
  });
}

In a real app, this FeatureFlags object would typically be populated from your backend or a remote configuration service.

2. Making Permissions Accessible

You need to provide this data to your widget tree. Using a state management solution like Provider, Riverpod, or Bloc is ideal. Here’s a simple example using Provider:

// A simple state holder
class UserSession extends ChangeNotifier {
  AppUser? _currentUser;
  FeatureFlags _flags = const FeatureFlags();

  AppUser? get currentUser => _currentUser;
  UserRole get currentRole => _currentUser?.role ?? UserRole.guest;
  FeatureFlags get featureFlags => _flags;

  void login(AppUser user, FeatureFlags flags) {
    _currentUser = user;
    _flags = flags;
    notifyListeners();
  }

  void logout() {
    _currentUser = null;
    _flags = const FeatureFlags();
    notifyListeners();
  }

  // Helper method
  bool hasRole(UserRole role) => currentRole == role;
}

void main() {
  runApp(
    ChangeNotifierProvider(
      create: (_) => UserSession(),
      child: const MyApp(),
    ),
  );
}

3. The Core Building Blocks: Conditional Widgets

Now for the fun part: building reusable widgets that react to permissions.

A. The Simple Visibility Wrapper This widget shows or hides its child based on a condition. It’s perfect for hiding a specific button or section.

class ConditionalVisibility extends StatelessWidget {
  final bool isVisible;
  final Widget child;
  final bool maintainState; // Keep widget alive when invisible

  const ConditionalVisibility({
    super.key,
    required this.isVisible,
    required this.child,
    this.maintainState = false,
  });

  @override
  Widget build(BuildContext context) {
    return Visibility(
      visible: isVisible,
      maintainState: maintainState,
      maintainAnimation: maintainState,
      maintainInteractivity: maintainState,
      child: child,
    );
  }
}

// Usage in a ProductPage
// ...
Consumer<UserSession>(
  builder: (context, session, _) {
    return ConditionalVisibility(
      isVisible: session.hasRole(UserRole.seller) ||
                 session.hasRole(UserRole.admin),
      child: FilledButton(
        onPressed: () => _editProduct(),
        child: const Text('Edit Listing'),
      ),
    );
  },
),
// ...

B. The RoleGate Widget This widget renders one of two widgets based on the user’s role. It’s more explicit for swapping whole sections.

class RoleGate extends StatelessWidget {
  final UserRole requiredRole;
  final Widget grantedChild;
  final Widget? deniedChild;

  const RoleGate({
    super.key,
    required this.requiredRole,
    required this.grantedChild,
    this.deniedChild,
  });

  @override
  Widget build(BuildContext context) {
    final session = context.watch<UserSession>();
    final hasAccess = session.hasRole(requiredRole);

    if (hasAccess) {
      return grantedChild;
    }
    return deniedChild ?? const SizedBox.shrink(); // Or a placeholder
  }
}

// Usage: Show a seller's dashboard tab only to sellers
RoleGate(
  requiredRole: UserRole.seller,
  grantedChild: const SellerDashboardTab(),
  deniedChild: const UpgradePromptCard(), // Nice UX for denied users
),

C. Feature Flag Widget Similar to RoleGate, but for feature toggles.

class FeatureFlag extends StatelessWidget {
  final bool flag;
  final Widget enabledChild;
  final Widget? disabledChild;

  const FeatureFlag({
    super.key,
    required this.flag,
    required this.enabledChild,
    this.disabledChild,
  });

  @override
  Widget build(BuildContext context) {
    return flag ? enabledChild : (disabledChild ?? const SizedBox.shrink());
  }
}

// Usage
Consumer<UserSession>(
  builder: (context, session, _) {
    return FeatureFlag(
      flag: session.featureFlags.isNewMessagingUiEnabled,
      enabledChild: const NewMessagingInterface(),
      disabledChild: const LegacyMessagingInterface(),
    );
  },
);

4. Structuring Your Pages

With these tools, your page logic stays clean and centralized.

class ProductDetailPage extends StatelessWidget {
  final Product product;

  const ProductDetailPage({super.key, required this.product});

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: Text(product.name)),
      body: Padding(
        padding: const EdgeInsets.all(16.0),
        child: Column(
          crossAxisAlignment: CrossAxisAlignment.start,
          children: [
            // Common UI for ALL users
            ProductImage(url: product.imageUrl),
            const SizedBox(height: 16),
            Text(product.description, style: Theme.of(context).textTheme.bodyLarge),
            const SizedBox(height: 24),
            ProductPriceWidget(price: product.price),

            const Spacer(),

            // Dynamic Action Bar based on role
            Consumer<UserSession>(
              builder: (context, session, _) {
                if (session.hasRole(UserRole.customer)) {
                  return BuyNowButton(product: product);
                } else if (session.hasRole(UserRole.seller)) {
                  return SellerActionBar(product: product);
                } else {
                  return SignInPrompt();
                }
              },
            ),
          ],
        ),
      ),
    );
  }
}

Common Pitfalls & Best Practices

  1. Don’t Check Permissions Too Deeply: Avoid scattering raw if (user.role == Role.admin) checks deep in your business logic. Centralize checks in helper widgets or services.
  2. Secure Your Backend: UI-based controls are for UX only. Always enforce permissions on your backend API. A user can modify a local app state or reverse-engineer your build.
  3. Plan for State: When you hide a widget with Visibility(maintainState: false), it’s disposed. If it’s a form with user input, use maintainState: true or structure your state to persist separately.
  4. Test Thoroughly: Write widget tests that mock different user sessions to ensure your UI correctly renders for each role and feature flag combination.

By adopting this layered approach—a clear permission model, accessible state, and reusable conditional widgets—you build a Flutter app that can gracefully adapt to any user, today or in the future. Your code remains clean, your features are easy to toggle, and adding a new user role becomes a straightforward task instead of a refactoring odyssey.

This blog is produced with the assistance of AI by a human editor. Learn more

Related Posts

Cover image for Flutter for High-Performance Desktop: Is it Ready for CAD, Image Processing, and Complex GUIs?

Flutter for High-Performance Desktop: Is it Ready for CAD, Image Processing, and Complex GUIs?

Developers are curious about Flutter's capabilities beyond typical business apps, especially for demanding desktop applications like CAD/CAM or image/video processing. This post will explore Flutter's suitability for high-performance, viewport-based desktop GUIs, discussing Dart's memory model, the 60fps update loop, and real-world examples to gauge its readiness for 'serious' complex software.

Cover image for Debugging Flutter Web Navigation: Solving the Deep Link Refresh Bug

Debugging Flutter Web Navigation: Solving the Deep Link Refresh Bug

Flutter web applications often suffer from a frustrating 'deep link refresh bug' where refreshing the browser on a nested route (e.g., /home/details) bounces the user back to the root or an incorrect path. This post will diagnose the common causes of this issue, explain how Flutter's router handles web URLs, and provide practical solutions and best practices for building robust, refresh-proof navigation in your Flutter web apps.

Cover image for Mastering Internationalization in Flutter: Centralized Strings for Scalable Apps

Mastering Internationalization in Flutter: Centralized Strings for Scalable Apps

As Flutter applications grow, managing strings for multiple languages or just keeping text consistent becomes a challenge. This post will guide developers through effective strategies for centralizing strings, implementing robust internationalization (i18n) and localization (l10n), and leveraging tools to streamline the process for small to large-scale projects.