Classes seladas no Flutter: Desbloqueando recursos poderosos

Tempo de leitura: 7 minutes

As classes seladas são um recurso poderoso introduzido no Dart na versão 3, atendendo a um pedido antigo dos desenvolvedores.

Neste artigo, exploraremos como aproveitar as classes seladas no Flutter e como elas podem nos ajudar a escrever um código mais legível e livre de erros.

Antes de prosseguir, verifique se você tem um bom conhecimento sobre classes seladas e correspondência de padrões do artigo anterior ou de outras fontes.

Gerenciamento de estado

O gerenciamento de estados é uma das áreas mais importantes em que as classes seladas e a correspondência de padrões se destacam. Como o estado e os eventos podem ser de tipos diferentes, as classes seladas garantem a segurança do tipo e o gerenciamento de todo o estado para nós.

Vamos ver como podemos combinar um Cubit com classes seladas.

Primeiro, precisamos definir nosso estado:

sealed class HomeState {}

class HomeStateLoading extends HomeState {}

class HomeStateLoaded extends HomeState {
  final List<HomeItem> items;

  HomeStateLoaded(this.items);
}

class HomeStateError extends HomeState {
  final String message;

  HomeStateError(this.message);
}

//our data
class HomeItem {
  final String todo;
  final bool isDone;

  HomeItem(this.todo, this.isDone);
}

O código acima deve parecer familiar para qualquer usuário do Cubit/BLoC. A única diferença é a adição da palavra-chave sealed, que nos permite combiná-la com a correspondência de padrões e nos ajuda a detectar erros antecipadamente.

Agora, vamos escrever o Cubit:

import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:union/state.dart';

class HomeCubit extends Cubit<HomeState> {
  HomeCubit() : super(HomeStateIdeal()) {
    loadData();
  }

  Future<void> loadData() async {
    emit(HomeStateLoading());
    // do some work to get the data here
    await Future.delayed(const Duration(seconds: 1));
    emit(HomeStateLoaded([
      HomeItem('Buy milk', false),
      HomeItem('Buy eggs', false),
      HomeItem('Buy bread', false),
    ]));
  }

  void toggleItem(int index) {
    final currentState = state;
    if (currentState is HomeStateLoaded) {
      final items = currentState.items;
      final item = items[index];
      items[index] = HomeItem(item.todo, !item.isDone);
      emit(HomeStateLoaded(items));
    }
  }
}

Este é um cubit padrão, nada de diferente aqui. Mas vamos ver como podemos escrever a página inicial:

import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:union/cubit.dart';
import 'package:union/state.dart';

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

  @override
  Widget build(BuildContext context) {
    final cubit = context.watch<HomeCubit>();

    return Scaffold(
      appBar: AppBar(
        title: const Text("Home Page"),
      ),
      body: switch (cubit.state) {
        HomeStateIdeal() || HomeStateLoading() => const Center(
            child: CircularProgressIndicator(),
          ),
        HomeStateError(message: var message) => Center(
            child: Text(message),
          ),
        HomeStateLoaded(items: var items) => ListView.builder(
            itemCount: items.length,
            itemBuilder: (context, index) {
              final item = items[index];
              return ListTile(
                title: Text(item.todo),
                trailing: Checkbox(
                  value: item.isDone,
                  onChanged: (_) => cubit.toggleItem(index),
                ),
              );
            },
          ),
      },
    );
  }
}

Aqui entra o poder da correspondência de padrões. Observe os recursos especiais aqui:

  1. Usamos o switch como uma expressão e atribuímos o valor retornado dele ao corpo (expressão vs. declaração)
  2. Usamos a nova sintaxe para fazer a correspondência entre HomeStateIdeal() e =>
  3. Usamos o operador || para combinar os estados HomeStateIdeal e HomeStateLoading em um único caso.
  4. Desestruturamos o valor dos estados de erro e carregado diretamente dentro do caso e o usamos em nossos widgets.
  5. Se você remover um caso, receberá um erro, garantindo que todos os casos possíveis sejam tratados

Há mais para explorar. Vamos dar uma olhada nas seguintes edições do exemplo:

body: switch (cubit.state) {
       HomeStateIdeal() || HomeStateLoading() => const Center(
           child: CircularProgressIndicator(),
         ),
       HomeStateError error => Center(
           child: Text(error.message),
         ),
       HomeStateLoaded(items: var items) when items.isNotEmpty =>
         ListView.builder(
           itemCount: items.length,
           itemBuilder: (context, index) {
             final item = items[index];
             return ListTile(
               title: Text(item.todo),
               trailing: Checkbox(
                 value: item.isDone,
                 onChanged: (_) => cubit.toggleItem(index),
               ),
             );
           },
         ),
       HomeStateLoaded() => const Center(
           child: Text("No items"),
         ),
     },

6. Você notou como definimos uma variável error que contém o estado e converte seu tipo para HomeStateError? Anteriormente, teríamos feito algo assim

if (cubit.state is HomeStateError) {
  Center(
    child: Text((cubit.state as HomeStateError).message),
  );
}

7. Você notou como usamos a instrução when(cláusula de guarda) para garantir que os itens não estejam vazios antes de entrar no caso? Anteriormente, teríamos feito algo assim:

if(cubit.state is HomeStateLoaded)
  if((cubit.state as HomeStateLoaded).items.isNotEmpty) 
        Text(((cubit.state as HomeStateLoaded).items);
  else Text("empty);

8. Você viu como lidamos com o caso HomeStateLoaded? Se os itens não estiverem vazios, nós os tratamos de uma forma e, se os itens estiverem vazios, nós os tratamos de forma diferente

Essas alterações demonstram o poder da correspondência de padrões. Ao usar expressões switch, podemos tratar diferentes estados de forma sucinta e eficiente. A instrução when nos permite tratar casos específicos com base em suas condições, simplificando o código e tornando-o mais fácil de ler e entender.

O uso da correspondência de padrões é ilimitado, especialmente com a palavra-chave when. Veja mais detalhes sobre isso (na documentação), explicamos como usá-la com o cubit e o mesmo se aplica a qualquer outro gerenciamento de estado, como riverpod/bloc/provider e muito mais.

 

Modelagem de dados

No exemplo acima, definimos nosso HomeItem como uma classe simples que recebe uma string e um booleano. Mas e se quisermos diferentes tipos de itens, como imagens, vídeos, URLs e outros? Vamos ver como podemos usar classes seladas para isso:

//nossos dados
sealed class HomeItem {
  final bool isDone;
  HomeItem(this.isDone);
}

class StringHomeItem extends HomeItem {
  final String todo;

  StringHomeItem(this.todo, bool isDone) : super(isDone);
}

class ImageHomeItem extends HomeItem {
  final String url;

  ImageHomeItem(this.url, bool isDone) : super(isDone);
}

Nosso cubit também mudará:

import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:union/state.dart';

class HomeCubit extends Cubit<HomeState> {
  HomeCubit() : super(HomeStateIdeal()) {
    loadData();
  }

  Future<void> loadData() async {
    emit(HomeStateLoading());
    // do some work to get the data here
    await Future.delayed(const Duration(seconds: 1));
    emit(HomeStateLoaded([
      StringHomeItem("Buy milk", false),
      StringHomeItem("Buy eggs", false),
      ImageHomeItem("https://picsum.photos/200", false),
    ]));
  }

  void toggleItem(int index) {
    final currentState = state;
    if (currentState is HomeStateLoaded) {
      final items = currentState.items;
      final item = items[index];
      switch (item) {
        case StringHomeItem():
          items[index] = StringHomeItem(item.todo, !item.isDone);
        case ImageHomeItem():
          items[index] = ImageHomeItem(item.url, !item.isDone);
      }
      emit(HomeStateLoaded(items));
    }
  }
}

Sei que o toggleItem é confuso de alguma forma, mas se você usar um Freezed, poderemos nos beneficiar da função copyWith para resolver isso de forma mais limpa.

Agora, vamos definir um widget separado para renderizar o item:

class HomeItemWidget extends StatelessWidget {
  final HomeItem item;
  const HomeItemWidget({super.key, required this.item});

  @override
  Widget build(BuildContext context) {
    return switch (item) {
      StringHomeItem(todo: var todo, isDone: var isDone) => Text(todo,
          style: TextStyle(
            decoration:
                isDone ? TextDecoration.lineThrough : TextDecoration.none,
          )),
      ImageHomeItem(url: var url, isDone: var isDone) => Image.network(
          url,
          color: isDone ? Colors.grey : null,
        ),
    };
  }
}

Em seguida, usaremos esse widget em nosso ListView:

ListView.builder(
            itemCount: items.length,
            itemBuilder: (context, index) {
              final item = items[index];
              return ListTile(
                title: HomeItemWidget(item: item),
                trailing: Checkbox(
                  value: item.isDone,
                  onChanged: (_) => cubit.toggleItem(index),
                ),
              );
            },
          ),

E é isso! Ao modelar nossos dados como uma classe selada, podemos nos beneficiar da instrução switch ao criar o widget. Além disso, se adicionarmos um novo tipo ao modelo HomeItem, ele também nos notificará para atualizar o HomeItemWidget.

Outros casos de uso

As classes seladas, juntamente com a correspondência de padrões, podem ser utilizadas em vários outros casos de uso. Aqui, apresentaremos um exemplo simples para alguns cenários práticos:

Representação de erros

sealed class AppError {}

class NetworkError extends AppError {
  final String message;

  NetworkError(this.message);
}

class ServerError extends AppError {
  final int statusCode;

  ServerError(this.statusCode);
}

class ClientError extends AppError {
  final int statusCode;

  ClientError(this.statusCode);
}

class GenericError extends AppError {
  final String message;

  GenericError(this.message);
}

class ApiService {
  Future<String> fetchData() async {
    // Simulação de uma solicitação de API que pode resultar em um erro
    await Future.delayed(const Duration(seconds: 2));

    // Descomente as linhas abaixo para simular diferentes cenários de erro

    // throw NetworkError('No internet connection');
    // throw ServerError(500);
    // throw ClientError(404);
    // throw GenericError('Algo deu errado');

    return 'Data successfully fetched';
  }
}

class MyApp extends StatelessWidget {
  final ApiService apiService = ApiService();

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      home: Scaffold(
        appBar: AppBar(
          title: Text('Error Handling'),
        ),
        body: Center(
          child: FutureBuilder<String>(
            future: apiService.fetchData(),
            builder: (context, snapshot) {
              if (snapshot.connectionState == ConnectionState.waiting) {
                return CircularProgressIndicator();
              } else if (snapshot.hasError) {
                final error = snapshot.error;
                if (error is AppError) {
                  return switch (error) {
                    NetworkError(message: var message) =>
                      Text('Network Error: $message'),
                    ServerError(statusCode: var statusCode) =>
                      Text('Server Error: $statusCode'),
                    ClientError(statusCode: var statusCode) =>
                      Text('Client Error: $statusCode'),
                    GenericError(message: var message) =>
                      Text('Generic Error: $message'),
                  };
                } else {
                  return Text('Unknown error');
                }
              } else {
                return Text(snapshot.data ?? 'No data available');
              }
            },
          ),
        ),
      ),
    );
  }
}

Para uma abordagem mais funcional, também podemos usar o record:

class ApiService {
  Future<(String?, AppError?)> fetchData() async {
    // Simulação de uma solicitação de API que pode resultar em um erro
    await Future.delayed(const Duration(seconds: 2));
    
    return (null, NetworkError('No internet connection'));

    return ('Data successfully fetched', null);
  }
}

Manipulação de eventos com BLoC

import 'package:flutter_bloc/flutter_bloc.dart';

// Sealed class representing different types of events
sealed class Event {}

class ButtonPressEvent extends Event {
  final String buttonId;

  ButtonPressEvent(this.buttonId);
}

class InputChangeEvent extends Event {
  final String inputText;

  InputChangeEvent(this.inputText);
}

class TimerTickEvent extends Event {
  final int tick;

  TimerTickEvent(this.tick);
}

class EventBloc extends Bloc<Event, String> {
  EventBloc() : super('') {
    on<Event>((event, emit) {
      switch (event) {
        case ButtonPressEvent():
          emit('Button pressed: ${event.buttonId}');
        // Manipular o evento de pressionamento de botão
        case InputChangeEvent():
          emit('Input changed: ${event.inputText}');
        // Manipular o evento de alteração de entrada
        case TimerTickEvent():
          emit('Timer tick: ${event.tick}');
        // Manipular o evento de tique do cronômetro
      }
    });
  }
}

O uso de classes seladas e a correspondência de padrões podem melhorar a legibilidade do seu código e torná-lo menos propenso a erros. Quer se trate de gerenciamento de estado, representação de tipos de dados, manipulação de respostas de API, modelagem de dados ou manipulação de erros, as classes seladas fornecem recursos poderosos que podem melhorar muito sua experiência de desenvolvimento com o Flutter.

 

Conclusão

Concluindo, as classes seladas no Flutter oferecem recursos poderosos que trazem vários benefícios para os desenvolvedores. Ao combinar as classes seladas com a correspondência de padrões, podemos escrever um código mais legível e livre de erros. As classes seladas oferecem segurança de tipo e garantem que todos os casos possíveis sejam tratados, assegurando um gerenciamento de estado robusto.

Uma das principais áreas em que as classes seladas se destacam é o gerenciamento de estado. Os desenvolvedores do Flutter geralmente lidam com diferentes tipos de estados e eventos, e as classes seladas fornecem uma abordagem estruturada para lidar com eles. Os exemplos de código demonstraram como as classes seladas podem ser usadas com o Cubit, uma solução popular de gerenciamento de estado. O uso da correspondência de padrões com expressões de switch simplifica o código e o torna mais conciso.

Além disso, as classes seladas podem ser aplicadas além do gerenciamento de estado. Elas podem ser utilizadas para modelagem de dados, em que diferentes tipos de objetos de dados precisam ser representados. Ao definir classes seladas para objetos de dados, como o exemplo HomeItem, podemos nos beneficiar da correspondência de padrões para lidar com diferentes tipos de itens de forma concisa e eficiente.

O artigo também destacou outros casos de uso de classes seladas, incluindo tratamento de erros e tratamento de eventos com BLoC. As classes seladas oferecem uma abordagem estruturada para representar diferentes tipos de erros ou eventos, permitindo que os desenvolvedores lidem com eles de forma clara e organizada.

Para simplificar ainda mais o código e aumentar a produtividade, o artigo mencionou o pacote Freezed, que complementa as classes seladas e oferece recursos adicionais, como a função copyWith, para um código mais otimizado.

Em geral, as classes seladas no Flutter, combinadas com a correspondência de padrões, oferecem recursos poderosos que melhoram a legibilidade, a manutenção e a segurança do tipo do código. Ao aproveitar as classes seladas, os desenvolvedores podem desbloquear uma série de benefícios em diferentes aspectos do desenvolvimento do Flutter, tornando a experiência de desenvolvimento mais eficiente e agradável.

Você pode encontrar o código acima no seguinte repositório do GitHub (link)