Lesson 39 of 51 advanced

Presentation Layer

Where your app meets the user's eyes

Open interactive version (quiz + challenge)

Real-world analogy

The presentation layer is like the front of a restaurant. The dining room (screens), the menu boards (widgets), and the waiters (BLoCs) all work together to give the customer a great experience. The waiters take orders to the kitchen (domain layer) and bring food back. The dining room never goes into the kitchen, and the chef never serves food directly. The presentation layer is the beautiful face of your app that hides all the complex plumbing behind the scenes.

What is it?

The Presentation Layer is the outermost layer in Clean Architecture that the user interacts with. It contains BLoCs that call domain use cases and manage UI state, Screens that provide BLoCs and define page structure, Views that contain the actual UI content, and Widgets that are small reusable components. In team_mvp_kit, the presentation layer uses patterns like TeamzViewStateMixin for common screen behaviors and AsyncContent for the universal loading/error/content pattern. BLoCs handle Either results from use cases using fold() to map success and failure to UI states.

Real-world relevance

In team_mvp_kit, every feature follows the Screen-View-Widget pattern consistently. The Screen creates and provides the BLoC. The View defines the page layout with AppBar and body. Individual widgets handle specific pieces of state using BlocSelector for optimal rebuilds. The TeamzViewStateMixin eliminates repetitive loading overlay and error handling code across dozens of screens. This consistency means any team member can jump into any feature and immediately understand the structure.

Key points

Code example

import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';

// Assume domain types: Task, Failure, GetTasksUseCase, etc.

// ---- State ----

class TaskListState extends BaseState {
  final List<Task> tasks;
  final bool isLoading;
  final bool isSubmitting;
  final Failure? failure;

  const TaskListState({
    this.tasks = const [],
    this.isLoading = false,
    this.isSubmitting = false,
    this.failure,
  });

  TaskListState copyWith({
    List<Task>? tasks,
    bool? isLoading,
    bool? isSubmitting,
    Failure? failure,
  }) {
    return TaskListState(
      tasks: tasks ?? this.tasks,
      isLoading: isLoading ?? this.isLoading,
      isSubmitting: isSubmitting ?? this.isSubmitting,
      failure: failure,
    );
  }

  @override
  List<Object?> get props => [
    tasks, isLoading, isSubmitting, failure,
  ];
}

// ---- Events ----

class LoadTasks extends BaseEvent {}

class CreateTask extends BaseEvent {
  final String title;
  final String description;
  final TaskPriority priority;
  final String assigneeId;

  const CreateTask({
    required this.title,
    required this.description,
    this.priority = TaskPriority.medium,
    this.assigneeId = '',
  });

  @override
  List<Object?> get props => [
    title, description, priority, assigneeId,
  ];
}

class DeleteTask extends BaseEvent {
  final String taskId;
  const DeleteTask(this.taskId);

  @override
  List<Object?> get props => [taskId];
}

// ---- BLoC ----

class TaskListBloc extends Bloc<BaseEvent, TaskListState>
    with SafeEmitMixin {
  final GetTasksUseCase _getTasks;
  final CreateTaskUseCase _createTask;

  TaskListBloc({
    required GetTasksUseCase getTasks,
    required CreateTaskUseCase createTask,
  })  : _getTasks = getTasks,
        _createTask = createTask,
        super(const TaskListState()) {
    on<LoadTasks>(_onLoad);
    on<CreateTask>(_onCreate);
  }

  Future<void> _onLoad(
    LoadTasks event,
    Emitter<TaskListState> emit,
  ) async {
    safeEmit(state.copyWith(
      isLoading: true,
      failure: null,
    ));
    final result = await _getTasks(const NoParams());
    result.fold(
      (failure) => safeEmit(state.copyWith(
        isLoading: false,
        failure: failure,
      )),
      (tasks) => safeEmit(state.copyWith(
        isLoading: false,
        tasks: tasks,
      )),
    );
  }

  Future<void> _onCreate(
    CreateTask event,
    Emitter<TaskListState> emit,
  ) async {
    safeEmit(state.copyWith(
      isSubmitting: true,
      failure: null,
    ));
    final result = await _createTask(CreateTaskParams(
      title: event.title,
      description: event.description,
      priority: event.priority,
      assigneeId: event.assigneeId,
    ));
    result.fold(
      (failure) => safeEmit(state.copyWith(
        isSubmitting: false,
        failure: failure,
      )),
      (_) {
        safeEmit(state.copyWith(isSubmitting: false));
        add(LoadTasks());
      },
    );
  }
}

// ---- Screen (provides BLoC) ----

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

  @override
  Widget build(BuildContext context) {
    return BlocProvider(
      create: (_) => getIt<TaskListBloc>()
          ..add(LoadTasks()),
      child: const TaskListView(),
    );
  }
}

// ---- View (content with Scaffold) ----

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

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: const Text('My Tasks'),
        actions: const [TaskCountBadge()],
      ),
      body: BlocConsumer<TaskListBloc, TaskListState>(
        listenWhen: (prev, curr) =>
            prev.failure != curr.failure &&
            curr.failure != null,
        listener: (context, state) {
          ScaffoldMessenger.of(context).showSnackBar(
            SnackBar(
              content: Text(state.failure!.message),
              backgroundColor: Colors.red,
              action: SnackBarAction(
                label: 'Retry',
                textColor: Colors.white,
                onPressed: () => context
                    .read<TaskListBloc>()
                    .add(LoadTasks()),
              ),
            ),
          );
        },
        builder: (context, state) {
          if (state.isLoading && state.tasks.isEmpty) {
            return const Center(
              child: CircularProgressIndicator(),
            );
          }
          if (state.tasks.isEmpty) {
            return const Center(
              child: Text('No tasks yet. Create one!'),
            );
          }
          return RefreshIndicator(
            onRefresh: () async {
              context.read<TaskListBloc>()
                  .add(LoadTasks());
            },
            child: ListView.builder(
              itemCount: state.tasks.length,
              itemBuilder: (context, index) {
                return TaskCard(
                  task: state.tasks[index],
                );
              },
            ),
          );
        },
      ),
      floatingActionButton: FloatingActionButton(
        onPressed: () => context.push('/tasks/create'),
        child: const Icon(Icons.add),
      ),
    );
  }
}

// ---- Reusable Widget ----

class TaskCard extends StatelessWidget {
  final Task task;
  const TaskCard({super.key, required this.task});

  @override
  Widget build(BuildContext context) {
    return Card(
      margin: const EdgeInsets.symmetric(
        horizontal: 16, vertical: 4,
      ),
      child: ListTile(
        leading: Icon(
          task.isDone
              ? Icons.check_circle
              : Icons.radio_button_unchecked,
          color: task.isDone ? Colors.green : null,
        ),
        title: Text(
          task.title,
          style: TextStyle(
            decoration: task.isDone
                ? TextDecoration.lineThrough
                : null,
          ),
        ),
        subtitle: Text(task.description),
        trailing: task.isHighPriority
            ? const Icon(Icons.priority_high,
                color: Colors.red)
            : null,
        onTap: () => context.push(
          '/tasks/${task.id}',
        ),
      ),
    );
  }
}

// ---- BlocSelector Widget ----

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

  @override
  Widget build(BuildContext context) {
    return BlocSelector<TaskListBloc, TaskListState, int>(
      selector: (state) =>
          state.tasks.where((t) => !t.isDone).length,
      builder: (context, pendingCount) {
        if (pendingCount == 0) {
          return const SizedBox.shrink();
        }
        return Padding(
          padding: const EdgeInsets.only(right: 16),
          child: Badge(
            label: Text('$pendingCount'),
            child: const Icon(Icons.task_alt),
          ),
        );
      },
    );
  }
}

Line-by-line walkthrough

  1. 1. TaskListState holds tasks, loading flags, and failure following the tri-state pattern.
  2. 2. copyWith enables immutable state updates with selective field overrides.
  3. 3. LoadTasks triggers data fetch. CreateTask carries form data. DeleteTask carries the task ID.
  4. 4. TaskListBloc depends on use cases injected through the constructor, not repositories.
  5. 5. _onLoad sets loading true, calls the use case, and uses fold() on the Either result.
  6. 6. Left branch (failure) emits state with the failure. Right branch (success) emits state with tasks.
  7. 7. _onCreate sets isSubmitting true, calls create use case, handles both outcomes.
  8. 8. On successful creation, it dispatches LoadTasks to refresh the list automatically.
  9. 9. TaskListScreen is the entry point -- it creates and provides the BLoC, then renders TaskListView.
  10. 10. getIt() uses the service locator to get a fully wired BLoC instance.
  11. 11. The cascade operator (..) immediately sends LoadTasks to start fetching data.
  12. 12. TaskListView uses BlocConsumer for both building UI and listening for errors.
  13. 13. listenWhen filters to only react when a new failure appears in the state.
  14. 14. The SnackBar includes a Retry action that dispatches LoadTasks again.
  15. 15. The builder shows a spinner while loading with no existing data, an empty state message, or the list.
  16. 16. RefreshIndicator enables pull-to-refresh that dispatches LoadTasks.
  17. 17. TaskCard is a reusable widget showing task details with icons for completion and priority.
  18. 18. TaskCountBadge uses BlocSelector to only rebuild when the pending count changes.

Spot the bug

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

  @override
  Widget build(BuildContext context) {
    final bloc = ProfileBloc(
      getProfile: GetProfileUseCase(getIt()),
    );
    bloc.add(LoadProfile());

    return BlocProvider.value(
      value: bloc,
      child: BlocBuilder<ProfileBloc, ProfileState>(
        builder: (context, state) {
          return Scaffold(
            appBar: AppBar(title: Text(state.user?.name ?? '')),
            body: Text(state.user?.email ?? ''),
          );
        },
      ),
    );
  }
}
Need a hint?
Look at how the BLoC is created. What happens when this widget rebuilds?
Show answer
The BLoC is created directly in the build method, so every time the widget rebuilds, a NEW BLoC is created and the old one is never disposed (memory leak). Also, BlocProvider.value does not manage the BLoC lifecycle so it will not dispose it. Fix: use BlocProvider with a create callback instead of BlocProvider.value. Change to: BlocProvider(create: (_) => ProfileBloc(getProfile: GetProfileUseCase(getIt()))..add(LoadProfile()), child: ...). This ensures one BLoC is created and properly disposed.

Explain like I'm 5

Think of the presentation layer as the stage of a school play. The Screens are the stage managers who set everything up before the show (creating BLoCs, getting dependencies). The Views are the actual scenes with all the props and backdrops (Scaffold, AppBar, layout). The Widgets are the actors who each have a small part to play (a card here, a button there). And the BLoC is the director backstage who tells the actors what to say based on what the audience does. The audience (user) never sees the director, just the beautiful performance!

Fun fact

The Screen-View-Widget separation pattern used in team_mvp_kit was popularized by the Very Good Ventures team (a Flutter consulting company that works closely with Google). They call it the 'feature-driven development' approach. The idea is that the Screen is the 'smart' component that knows about dependency injection and BLoC creation, while the View and Widgets are 'dumb' components that only know about rendering. This makes widgets extremely easy to test because you can provide mock BLoCs.

Hands-on challenge

Build a complete presentation layer for a 'Notes' feature: (1) NoteListState with notes, isLoading, isSubmitting, failure fields, (2) NoteListBloc that uses GetNotesUseCase, CreateNoteUseCase, and DeleteNoteUseCase with Either handling, (3) NoteListScreen that provides the BLoC, (4) NoteListView with BlocConsumer for error SnackBars and a list display, (5) NoteCard widget with BlocSelector that only shows a 'synced' indicator when the note's sync status changes. Include pull-to-refresh and an empty state.

More resources

Open interactive version (quiz + challenge) ← Back to course: Flutter & Dart