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.