Skip to content

Presentation Layer Guide ​

The presentation layer handles UI components, state management, and user interactions. It uses the BLoC pattern with Cubit for state management and follows Material Design principles.

Structure ​

presentation/
├── cubit/
│   ├── feature_cubit.dart
│   └── feature_state.dart
├── pages/
│   └── feature_page.dart
└── widgets/
    └── feature_widget.dart

1. Creating State Classes ​

State classes define the current state of your feature using Freezed for immutability.

State Template ​

dart
part of 'feature_cubit.dart';

/// State for [FeatureName] feature.
///
/// Contains the list of items, loading state, selected item,
/// and any failure that occurred during operations.
@freezed
abstract class FeatureState with _$FeatureState {
  /// Constructor for [FeatureState].
  ///
  /// [items] - List of all items.
  /// [isLoading] - Whether an operation is in progress.
  /// [selectedItem] - The currently selected item for viewing/editing.
  /// [failure] - Any failure that occurred during operations.
  const factory FeatureState({
    required List<FeatureName> items,
    required bool isLoading,
    required FeatureName selectedItem,
    required Failure failure,
  }) = _FeatureState;

  /// Initial state for [FeatureState].
  ///
  /// Creates a state with empty items list, no loading,
  /// empty selected item, and no failure.
  factory FeatureState.initial() => _FeatureState(
    items: [],
    isLoading: false,
    selectedItem: FeatureName.empty(),
    failure: Failure.none(),
  );
}

Key Points for State Classes ​

  1. Part of Cubit: Always use part of 'feature_cubit.dart';
  2. Freezed: Use @freezed for immutability
  3. Required Fields: Mark essential fields as required
  4. Initial Factory: Provide an initial() factory method
  5. Documentation: Document the state class and all properties

2. Creating Cubit Classes ​

Cubits manage state and handle business operations by calling repository methods.

Cubit Template ​

dart
import 'dart:async';

import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flutter_boilerplate/core/error/failure.dart';
import 'package:flutter_boilerplate/features/[feature]/domain/entities/feature_entity.dart';
import 'package:flutter_boilerplate/features/[feature]/domain/repositories/feature_repository.dart';
import 'package:freezed_annotation/freezed_annotation.dart';

part 'feature_cubit.freezed.dart';
part 'feature_state.dart';

/// Cubit for managing [FeatureName] state and operations.
///
/// Handles CRUD operations for [FeatureName] and maintains the current
/// state of the items list, selected item, and any failures.
class FeatureCubit extends Cubit<FeatureState> {
  /// Creates a [FeatureCubit] with the given repository.
  FeatureCubit(this._repository) : super(FeatureState.initial());

  final FeatureRepository _repository;
  StreamSubscription<(List<FeatureName>, Failure?)>? _itemsSubscription;

  /// Starts listening for items changes from the repository.
  ///
  /// Subscribes to the items stream and updates state when
  /// items are added, updated, or deleted.
  void listenForChanges() {
    emit(state.copyWith(isLoading: true));

    _itemsSubscription = _repository.listenForChanges().listen(
      (result) {
        final (items, failure) = result;
        if (failure != null) {
          emit(
            state.copyWith(
              failure: failure,
              isLoading: false,
            ),
          );
        } else {
          emit(
            state.copyWith(
              items: items,
              failure: Failure.none(),
              isLoading: false,
            ),
          );
        }
      },
    );
  }

  /// Fetches a single item by its ID.
  ///
  /// [id] - The unique identifier of the item to fetch.
  /// Updates the selectedItem in state on success,
  /// or sets failure on error.
  Future<void> fetchItem(String id) async {
    emit(state.copyWith(isLoading: true));

    final (item, failure) = await _repository.getById(id);

    if (failure != null) {
      emit(
        state.copyWith(
          failure: failure,
          isLoading: false,
        ),
      );
    } else if (item != null) {
      emit(
        state.copyWith(
          selectedItem: item,
          failure: Failure.none(),
          isLoading: false,
        ),
      );
    } else {
      emit(
        state.copyWith(
          failure: const Failure(message: 'Item not found'),
          isLoading: false,
        ),
      );
    }
  }

  /// Creates a new item with the given details.
  ///
  /// [property1] - Required property for the item.
  /// [property2] - Optional property for the item.
  /// Updates the items list on success, or sets failure on error.
  Future<void> createItem(
    String property1, [
    String? property2,
  ]) async {
    emit(state.copyWith(isLoading: true));

    final (item, failure) = await _repository.create(
      property1: property1,
      property2: property2,
    );

    if (failure != null) {
      emit(
        state.copyWith(
          failure: failure,
          isLoading: false,
        ),
      );
    } else if (item != null) {
      emit(
        state.copyWith(
          selectedItem: item,
          failure: Failure.none(),
          isLoading: false,
        ),
      );
    }
  }

  /// Updates an existing item with new content.
  ///
  /// [id] - The unique identifier of the item to update.
  /// [property1] - The new value for property1.
  /// [property2] - The new value for property2.
  /// Updates the items list on success, or sets failure on error.
  Future<void> updateItem(
    String id,
    String property1, [
    String? property2,
  ]) async {
    emit(state.copyWith(isLoading: true));

    final (item, failure) = await _repository.update(
      id: id,
      property1: property1,
      property2: property2,
    );

    if (failure != null) {
      emit(
        state.copyWith(
          failure: failure,
          isLoading: false,
        ),
      );
    } else if (item != null) {
      emit(
        state.copyWith(
          selectedItem: item,
          failure: Failure.none(),
          isLoading: false,
        ),
      );
    }
  }

  /// Deletes an item by its ID.
  ///
  /// [id] - The unique identifier of the item to delete.
  /// Updates the items list on success, or sets failure on error.
  Future<void> deleteItem(String id) async {
    emit(state.copyWith(isLoading: true));

    final (_, failure) = await _repository.delete(id);

    if (failure != null) {
      emit(
        state.copyWith(
          failure: failure,
          isLoading: false,
        ),
      );
    } else {
      emit(
        state.copyWith(
          selectedItem: FeatureName.empty(),
          failure: Failure.none(),
          isLoading: false,
        ),
      );
    }
  }

  /// Clears the current failure state.
  void clearFailure() {
    emit(state.copyWith(failure: Failure.none()));
  }

  /// Clears the selected item.
  void clearSelectedItem() {
    emit(state.copyWith(selectedItem: FeatureName.empty()));
  }

  @override
  Future<void> close() async {
    await _itemsSubscription?.cancel();
    return super.close();
  }
}

Key Points for Cubit Classes ​

  1. Repository Injection: Inject repository through constructor
  2. Stream Management: Handle subscriptions and dispose properly
  3. Loading States: Always set loading state during operations
  4. Error Handling: Handle failures and emit appropriate states
  5. State Updates: Use copyWith to update state immutably
  6. Cleanup: Cancel subscriptions in close() method

3. Creating Pages ​

Pages are full-screen widgets that represent app screens and handle routing.

Page Template ​

dart
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flutter_boilerplate/app/di.dart';
import 'package:flutter_boilerplate/core/presentation/widgets/primary_scaffold.dart';
import 'package:flutter_boilerplate/features/[feature]/presentation/cubit/feature_cubit.dart';
import 'package:flutter_boilerplate/features/[feature]/presentation/widgets/feature_widget.dart';
import 'package:flutter_boilerplate/l10n/app_localizations.dart';
import 'package:go_router/go_router.dart';

/// Page for displaying and managing [FeatureName] items.
///
/// Provides a list view of items with options to create, edit, and delete.
/// Uses [FeatureCubit] for state management and navigation.
class FeaturePage extends StatelessWidget {
  /// Creates a [FeaturePage].
  const FeaturePage({super.key});

  /// Name of the page
  static const name = 'FeaturePage';

  /// Path of the page
  static const path = '/feature';

  @override
  Widget build(BuildContext context) {
    final l10n = AppLocalizations.of(context)!;

    return BlocProvider(
      create: (context) => di<FeatureCubit>()..listenForChanges(),
      child: PrimaryScaffold(
        appBar: AppBar(
          title: Text(l10n.featurePageTitle),
          actions: [
            IconButton(
              onPressed: () => _showCreateDialog(context),
              icon: const Icon(Icons.add),
              tooltip: l10n.createFeatureTooltip,
            ),
          ],
        ),
        body: const _FeaturePageBody(),
      ),
    );
  }

  /// Shows the create item dialog.
  void _showCreateDialog(BuildContext context) {
    // Implementation for create dialog
  }
}

/// Body widget for the feature page.
class _FeaturePageBody extends StatelessWidget {
  const _FeaturePageBody();

  @override
  Widget build(BuildContext context) {
    final l10n = AppLocalizations.of(context)!;

    return BlocConsumer<FeatureCubit, FeatureState>(
      listener: (context, state) {
        // Handle side effects like showing snackbars
        if (state.failure.message.isNotEmpty) {
          ScaffoldMessenger.of(context).showSnackBar(
            SnackBar(
              content: Text(state.failure.message),
              backgroundColor: Theme.of(context).colorScheme.error,
            ),
          );
          BlocProvider.of<FeatureCubit>(context).clearFailure();
        }
      },
      builder: (context, state) {
        if (state.isLoading && state.items.isEmpty) {
          return const Center(
            child: CircularProgressIndicator(),
          );
        }

        if (state.items.isEmpty) {
          return Center(
            child: Column(
              mainAxisAlignment: MainAxisAlignment.center,
              children: [
                Icon(
                  Icons.inbox_outlined,
                  size: 64,
                  color: Theme.of(context).colorScheme.outline,
                ),
                const SizedBox(height: 16),
                Text(
                  l10n.noItemsMessage,
                  style: Theme.of(context).textTheme.titleMedium,
                ),
                const SizedBox(height: 8),
                Text(
                  l10n.createFirstItemMessage,
                  style: Theme.of(context).textTheme.bodyMedium?.copyWith(
                    color: Theme.of(context).colorScheme.outline,
                  ),
                ),
              ],
            ),
          );
        }

        return ListView.builder(
          itemCount: state.items.length,
          itemBuilder: (context, index) {
            final item = state.items[index];
            return FeatureWidget(
              item: item,
              onTap: () => _navigateToDetail(context, item.id),
              onEdit: () => _navigateToEdit(context, item.id),
              onDelete: () => _showDeleteDialog(context, item),
            );
          },
        );
      },
    );
  }

  /// Navigates to the item detail page.
  void _navigateToDetail(BuildContext context, String itemId) {
    GoRouter.of(context).go('/feature/$itemId');
  }

  /// Navigates to the item edit page.
  void _navigateToEdit(BuildContext context, String itemId) {
    GoRouter.of(context).go('/feature/edit/$itemId');
  }

  /// Shows the delete confirmation dialog.
  void _showDeleteDialog(BuildContext context, FeatureName item) {
    // Implementation for delete dialog
  }
}

Key Points for Pages ​

  1. Static Constants: Define name and path constants for routing
  2. BlocProvider: Provide the cubit at the page level
  3. Localization: Use AppLocalizations.of(context)! for text
  4. BlocConsumer: Use for both listening to state changes and building UI
  5. Error Handling: Show snackbars for errors and clear them
  6. Navigation: Use GoRouter.of(context) for navigation
  7. Tooltips: Add tooltips to interactive elements

4. Creating Widgets ​

Widgets are reusable UI components that can be used across different pages.

Widget Template ​

dart
import 'package:flutter/material.dart';
import 'package:flutter_boilerplate/features/[feature]/domain/entities/feature_entity.dart';
import 'package:flutter_boilerplate/l10n/app_localizations.dart';

/// Widget for displaying a [FeatureName] item.
///
/// Shows the item's information with options for interaction.
/// Used in lists and detail views.
class FeatureWidget extends StatelessWidget {
  /// Creates a [FeatureWidget].
  ///
  /// [item] - The item to display.
  /// [onTap] - Callback when the widget is tapped.
  /// [onEdit] - Callback when edit is requested.
  /// [onDelete] - Callback when delete is requested.
  const FeatureWidget({
    required this.item,
    this.onTap,
    this.onEdit,
    this.onDelete,
    super.key,
  });

  /// The item to display.
  final FeatureName item;

  /// Callback when the widget is tapped.
  final VoidCallback? onTap;

  /// Callback when edit is requested.
  final VoidCallback? onEdit;

  /// Callback when delete is requested.
  final VoidCallback? onDelete;

  @override
  Widget build(BuildContext context) {
    final l10n = AppLocalizations.of(context)!;
    final theme = Theme.of(context);

    return Card(
      margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
      child: InkWell(
        onTap: onTap,
        borderRadius: BorderRadius.circular(12),
        child: Padding(
          padding: const EdgeInsets.all(16),
          child: Column(
            crossAxisAlignment: CrossAxisAlignment.start,
            children: [
              Row(
                children: [
                  Expanded(
                    child: Text(
                      item.property1,
                      style: theme.textTheme.titleMedium,
                      maxLines: 1,
                      overflow: TextOverflow.ellipsis,
                    ),
                  ),
                  PopupMenuButton<String>(
                    onSelected: (value) {
                      switch (value) {
                        case 'edit':
                          onEdit?.call();
                          break;
                        case 'delete':
                          onDelete?.call();
                          break;
                      }
                    },
                    itemBuilder: (context) => [
                      PopupMenuItem(
                        value: 'edit',
                        child: Row(
                          children: [
                            const Icon(Icons.edit),
                            const SizedBox(width: 8),
                            Text(l10n.editAction),
                          ],
                        ),
                      ),
                      PopupMenuItem(
                        value: 'delete',
                        child: Row(
                          children: [
                            const Icon(Icons.delete),
                            const SizedBox(width: 8),
                            Text(l10n.deleteAction),
                          ],
                        ),
                      ),
                    ],
                    tooltip: l10n.moreOptionsTooltip,
                  ),
                ],
              ),
              if (item.property2?.isNotEmpty == true) ...[
                const SizedBox(height: 8),
                Text(
                  item.property2!,
                  style: theme.textTheme.bodyMedium?.copyWith(
                    color: theme.colorScheme.outline,
                  ),
                  maxLines: 2,
                  overflow: TextOverflow.ellipsis,
                ),
              ],
              const SizedBox(height: 8),
              Text(
                l10n.lastUpdated(item.updatedAt),
                style: theme.textTheme.bodySmall?.copyWith(
                  color: theme.colorScheme.outline,
                ),
              ),
            ],
          ),
        ),
      ),
    );
  }
}

Key Points for Widgets ​

  1. Reusability: Design widgets to be reusable across different contexts
  2. Callbacks: Use callback functions for user interactions
  3. Localization: Use localized strings for all text
  4. Theming: Use theme colors and text styles consistently
  5. Accessibility: Add tooltips and semantic labels
  6. Null Safety: Handle optional properties safely
  7. Performance: Use const constructors where possible

5. Context Usage Guidelines ​

Following the AGENTS.md rules:

dart
// ❌ Don't use context extensions
context.read<FeatureCubit>()
context.go('/path')

// ✅ Use explicit methods instead
BlocProvider.of<FeatureCubit>(context)
GoRouter.of(context).go('/path')

6. Testing ​

Test your presentation layer components:

dart
// test/features/[feature]/presentation/cubit/feature_cubit_test.dart
void main() {
  group('FeatureCubit', () {
    late MockFeatureRepository repository;
    late FeatureCubit cubit;

    setUp(() {
      repository = MockFeatureRepository();
      cubit = FeatureCubit(repository);
    });

    test('should emit initial state', () {
      expect(cubit.state, FeatureState.initial());
    });

    blocTest<FeatureCubit, FeatureState>(
      'should create item successfully',
      build: () => cubit,
      act: (cubit) => cubit.createItem('Test Item'),
      expect: () => [
        FeatureState.initial().copyWith(isLoading: true),
        FeatureState.initial().copyWith(
          selectedItem: testItem,
          isLoading: false,
        ),
      ],
    );
  });
}

The presentation layer is where users interact with your app. Keep it focused on UI concerns while delegating business logic to the domain layer through repositories.