Por Carlos Lira

A maioria das pessoas só conhece o Redux quando esbarra no React, e acaba entendendo que sua arquitetura é uma parte do React… Isso é muito triste, porque o Redux é incrível! É uma arquitetura de fluxo de dados unidirecional criada para qualquer interface do usuário, que permite anexar funções como enviar uma requisição HTTP a uma API e despachar a resposta ao seu alterador de estado — discutiremos isso um pouco mais adiante — mas é ainda mais útil quando combinado com uma implementação de exibição declarativa que pode inferir as atualizações da interface do usuário a partir de alterações de estado (como o Flutter!)

Uma breve explicação antes de prosseguirmos. O Redux permite incluir funções personalizadas que processam a ação antes de ser enviada para o próximo dispatcher e, eventualmente, para o reducer para formar o novo estado. Essas funções são chamadas de middleware.

Eles são realmente úteis em muitos cenários; por exemplo: você faz chamadas para uma API e a resposta dessas chamadas afeta o estado do seu aplicativo. O que você deveria fazer? Primeiro de tudo, use o Redux, isso fará com que os dados importantes para a interface do usuário sejam centralizados em um único estado e estejam disponíveis sempre que você desejar. O Flutter usa o Dart e, nesse caso, existem dois middlewares que nos ajudarão: redux_api_middleware e redux_thunk.

O middleware thunk original é uma implementação em javascript que pode ser encontrada aqui. O middleware thunk que usaremos foi portado para Dart por Brian Egan. O middleware original da API também é uma implementação em javascript que pode ser encontrada aqui. O middleware da API foi portado por mim para o Dart (confira no meu GitHub :D)

O middleware thunk permite enviar chamadas assíncronas, o que é perfeito para o middleware da API, porque as solicitações HTTP também são assíncronas. O middleware chama a API e envia a resposta ao seu reducer possibilitando identificar o próximo estado do aplicativo.

Também adicionaremos um middleware de log para que possamos ver facilmente o tráfego passando por nosso middleware.

  1. Criar um novo projeto Flutter.
  2. Adicionar as dependências.
  3. Criar os arquivos relacionados ao Redux, como ações e redutores.
  4. Editar o ponto de entrada do aplicativo.
  5. Adicionar um registrador de solicitações de API.
  6. Criar as rotas do aplicativo.
  7. Criar os componentes da interface do usuário.

Neste tutorial, usarei a extensão VSCode do Flutter. Dito isto, vamos codar!

  1. Invoque View > Command Palette.
  2. Digite “flutter e selecione Flutter: New Project.
  3. Digite um nome de projeto, como myapp, e pressione Enter.
  4. Crie ou selecione o diretório pai para a nova pasta do projeto.
  5. Aguarde a conclusão da criação do projeto e a exibição do arquivo main.dart.

Seu aplicativo Flutter procura suas dependências na seção pubspec.yaml. Vamos mudar este arquivo para adicionar nossas próprias dependências. Para isso, vamos precisar do redux , do flutter_redux , do redux_thunk e do redux_api_middleware .

Como se sabe, a comunidade Flutter está crescendo todos os dias, assim como o número de bibliotecas e componentes disponíveis. Essas bibliotecas e componentes da comunidade potencialmente têm vulnerabilidades que podem ser herdadas pelo aplicativo, portanto, seja cuidadoso.

Para evitar herdar vulnerabilidades presentes em componentes, tome as devidas precauções antes de utilizá-los: verifique quantas pessoas estão usando estes componentes, use sempre a versão mais atualizada (o que pelo menos em parte garante que eventuais bugs tenham sido corrigidos) e, caso se sinta confortável, faça uma auditoria no código procurando possíveis vulnerabilidades.

Dito isto, no momento em que o escrevi, meu arquivo pubspec.yaml tinha a seguinte aparência:

name: flutter_with_redux
description: A new Flutter project.
version: 1.0.0+1

environment:
  sdk: ">=2.1.0 <3.0.0"

dependencies:
  flutter:
    sdk: flutter
  redux: ^3.0.0
  flutter_redux: ^0.5.3
  redux_thunk: ^0.2.1
  redux_api_middleware: ^0.1.8

dev_dependencies:
  flutter_test:
    sdk: flutter

flutter:
  uses-material-design: true

Primeiro, vamos criar a estrutura de pastas base para o nosso aplicativo. Crie estas pastas na pasta lib: models, actions, reducers e components. Depois disso, sua estrutura de pastas lib deve ficar assim:

lib
├── actions
├── components
├── models
├── reducers
└── main.dart

Os nomes são meio autoexplicativos, mas não custa falar um pouco sobre eles.

A pasta de models é onde está o mapeamento de dados. Gosto de separá-los por módulos; por exemplo: temos um módulo de usuário, portanto, todos os modelos relacionados a esse módulo devem ser criados dentro da pasta de usuário na pasta de models.

A pasta de actions é onde estão nossas ações, eu também gosto de dividi-las por módulos; por exemplo: temos um módulo de usuário, portanto, todas as ações que dizem respeito a esse módulo devem estar em um arquivo user_actions.dart na pasta de actions.

A pasta reducers é onde estão os reducers e também é dividida por módulo. No exemplo que estamos usando (módulo de usuário), todos os reducers relacionados a esse módulo devem estar em um arquivo user_reducer.dart na pasta de reducers.

A pasta de components é onde estarão nossos componentes, e a maneira como gosto de organizá-la é certamente controversa: assim como todas as outras pastas, também separo os componentes como módulos, em vez da separação típica de apresentação e contêineres. Não sou contra, apenas prefiro a organização dos módulos. Caso prefira, é possível usar a abordagem de apresentação e contêineres, mas neste artigo usarei a separação de módulos.

Usarei a abordagem descrita acima, mas fique à vontade para modificá-la como desejar. Crie uma pasta de user na pasta de models e, em seguida, crie um user.dart e um user_state.dart na pasta de user.

class User {
  final int id;
  final String name;

  User({
    this.id,
    this.name,
  });

  factory User.fromJSON(Map<String, dynamic> json) => User(
    id: json['id'] as int,
    name: json['name'] as String,
  );
}

class UserDetails {
  final String name;
  final String email;
  final String website;

  UserDetails({
    this.name,
    this.email,
    this.website,
  });

  factory UserDetails.fromJSON(Map<String, dynamic> json) => UserDetails(
    name: json['name'] as String,
    email: json['email'] as String,
    website: json['website'] as String,
  );
}

Agora crie um user_state.dart na pasta user.

import 'package:flutter_with_redux/models/user/user.dart';

class UserState {
  ListUsersState list;
  UserDetailsState details;

  UserState({
    this.list,
    this.details,
  });

  factory UserState.initial() => UserState(
    list: ListUsersState.initial(),
    details: UserDetailsState.initial(),
  );
}

class ListUsersState {
  dynamic error;
  bool loading;
  List<User> data;

  ListUsersState({
    this.error,
    this.loading,
    this.data,
  });

  factory ListUsersState.initial() => ListUsersState(
    error: null,
    loading: false,
    data: [],
  );
}

class UserDetailsState {
  dynamic error;
  bool loading;
  UserDetails data;

  UserDetailsState({
    this.error,
    this.loading,
    this.data,
  });

  factory UserDetailsState.initial() => UserDetailsState(
    error: null,
    loading: false,
    data: null,
  );
}

O modelo de estado do aplicativo centraliza todo o estado do aplicativo em um singleton, incluindo o estado do usuário descrito acima. Crie um app_state.dart na pasta models.

import 'package:meta/meta.dart';

import 'package:flutter_with_redux/models/user/user_state.dart';

@immutable
class AppState {
  final UserState user;

  AppState({
    this.user,
  });

  factory AppState.initial() => AppState(
    user: UserState.initial(),
  );

  AppState copyWith({
    UserState user,
  }) {
    return AppState(
      user: user ?? this.user,
    );
  }
}

As ações de chamada à API padrão do Redux, também conhecidas como RSAAs, são o tipo de ação que o redux_api_middleware intercepta e contém a definição de solicitação. Usaremos uma amostra de API fornecida pelo JSONPlaceholder para este tutorial. Crie um user_actions.dart na pasta actions.

import 'package:redux/redux.dart';
import 'package:redux_thunk/redux_thunk.dart';
import 'package:redux_api_middleware/redux_api_middleware.dart';

import 'package:flutter_with_redux/models/app_state.dart';


const LIST_USERS_REQUEST = 'LIST_USERS_REQUEST';
const LIST_USERS_SUCCESS = 'LIST_USERS_SUCCESS';
const LIST_USERS_FAILURE = 'LIST_USERS_FAILURE';

RSAA getUsersRequest() {
  return
    RSAA(
      method: 'GET',
      endpoint: 'http://jsonplaceholder.typicode.com/users',
      types: [
        LIST_USERS_REQUEST,
        LIST_USERS_SUCCESS,
        LIST_USERS_FAILURE,
      ],
      headers: {
        'Content-Type': 'application/json',
      },
    );
}

ThunkAction<AppState> getUsers() => (Store<AppState> store) => store.dispatch(getUsersRequest());


const GET_USER_DETAILS_REQUEST = 'GET_USER_DETAILS_REQUEST';
const GET_USER_DETAILS_SUCCESS = 'GET_USER_DETAILS_SUCCESS';
const GET_USER_DETAILS_FAILURE = 'GET_USER_DETAILS_FAILURE';

RSAA getUserDetailsRequest(int id) {
  return
    RSAA(
      method: 'GET',
      endpoint: 'http://jsonplaceholder.typicode.com/users/$id',
      types: [
        GET_USER_DETAILS_REQUEST,
        GET_USER_DETAILS_SUCCESS,
        GET_USER_DETAILS_FAILURE,
      ],
      headers: {
        'Content-Type': 'application/json',
      },
    );
}

ThunkAction<AppState> getUserDetails(int id) => (Store<AppState> store) => store.dispatch(getUserDetailsRequest(id));

As RSAAs despacham Ações Padrão de Fluxo, também conhecidas como FSAs, que contêm o tipo despachado, a carga útil da resposta e um erro (caso ocorra). Esse FSA será enviado ao redutor do usuário, o qual retornará um novo estado com base no FSA. Crie um user_reducer.dart na pasta reducers.

import 'dart:convert';

import 'package:redux_api_middleware/redux_api_middleware.dart';

import 'package:flutter_with_redux/actions/user_actions.dart';

import 'package:flutter_with_redux/models/user/user.dart';
import 'package:flutter_with_redux/models/user/user_state.dart';

UserState userReducer(UserState state, FSA action) {
  UserState newState = state;

  switch (action.type) {
    case LIST_USERS_REQUEST:
      newState.list.error = null;
      newState.list.loading = true;
      newState.list.data = null;
      return newState;

    case LIST_USERS_SUCCESS:
      newState.list.error = null;
      newState.list.loading = false;
      newState.list.data = usersFromJSONStr(action.payload);
      return newState;

    case LIST_USERS_FAILURE:
      newState.list.error = action.payload;
      newState.list.loading = false;
      newState.list.data = null;
      return newState;


    case GET_USER_DETAILS_REQUEST:
      newState.details.error = null;
      newState.details.loading = true;
      newState.details.data = null;
      return newState;

    case GET_USER_DETAILS_SUCCESS:
      newState.details.error = null;
      newState.details.loading = false;
      newState.details.data = userFromJSONStr(action.payload);
      return newState;

    case GET_USER_DETAILS_FAILURE:
      newState.details.error = action.payload;
      newState.details.loading = false;
      newState.details.data = null;
      return newState;

    default:
      return newState;
  }
}

List<User> usersFromJSONStr(dynamic payload) {
  Iterable jsonArray = json.decode(payload);
  return jsonArray.map((j) => User.fromJSON(j)).toList();
}

UserDetails userFromJSONStr(dynamic payload) {
  return UserDetails.fromJSON(json.decode(payload));
}

O reducer principal combina todos os reducers para que eles possam ser acessados ​​no singleton do AppState. Crie um app_reducer.dart na pasta reducers.

import 'package:flutter_with_redux/models/app_state.dart';
import 'package:flutter_with_redux/reducers/user_reducer.dart';

AppState appReducer(AppState state, action) {
  return AppState(
    user: userReducer(state.user, action),
  );
}

Esta seção é bastante simples: nosso método de compilação retornará um StoreProvider do pacote Redux que gerenciará nossa store e a deixará acessível através dos StoreConnectors sobre os quais falaremos a seguir. Edite o arquivo main.dart.

import 'package:flutter/material.dart';

import 'package:redux/redux.dart';
import 'package:redux_thunk/redux_thunk.dart';
import 'package:redux_api_middleware/redux_api_middleware.dart';

import 'package:flutter_redux/flutter_redux.dart';

import 'package:flutter_with_redux/logger.dart';
import 'package:flutter_with_redux/routes.dart';

import 'package:flutter_with_redux/models/app_state.dart';
import 'package:flutter_with_redux/reducers/app_reducer.dart';

import 'package:flutter_with_redux/components/user/users_screen.dart';
import 'package:flutter_with_redux/components/user/user_details_screen.dart';

void main() => runApp(App());

class App extends StatelessWidget {
  final store = Store<AppState>(
    appReducer,
    initialState: AppState.initial(),
    middleware: [thunkMiddleware, apiMiddleware, loggingMiddleware],
  );

  @override
  Widget build(BuildContext context) {
    return StoreProvider(
      store: this.store,
      child: MaterialApp(
        title: "Flutter with redux",
        theme: ThemeData(
          primarySwatch: Colors.blue,
        ),
        routes: {
          AppRoutes.users: (context) => UsersScreen(),
          AppRoutes.userDetails: (context) => UserDetailsScreen(),
        },
      ),
    );
  }
}

É possível notar que o middleware de log, as rotas do aplicativo e as telas estão ausentes. Vamos consertar isso.

Isso será muito simples, é um middleware que intercepta FSAs e imprime seu tipo, carga útil e erro. Crie um arquivo logger.dart na pasta lib.

import 'package:redux/redux.dart';

import 'package:redux_api_middleware/redux_api_middleware.dart';

void loggingMiddleware<State>(
  Store<State> store,
  dynamic action,
  NextDispatcher next,
) {
  if (action is FSA) {
    print('{');
    print('  Action: ${action.type}');

    if (action.payload != null) {
      print('  Payload: ${action.payload}');
    }

    print('}');
  }

  next(action);
}

Esta é simplesmente uma maneira de organizar nossas rotas nomeadas. Crie um routes.dart na pasta lib.

class AppRoutes {
  static final users = '/';
  static final userDetails = '/details';
}

A tela de usuários é um StatelessWidget porque não precisa de um estado próprio. Ele se conecta à store e tem acesso a todos os dados do aplicativo, precisamos apenas mapear o estado em props, ainda não tenho certeza sobre esse termo, mas é o que estou usando, uma vez que o construtor do StoreConnector deve ter um parâmetro props que possa ser usado por meio de nosso componente e, como veremos, também mapearemos as ações para que possamos usá-las facilmente. Crie um users_screen.dart na pasta components/users.

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

import 'package:redux/redux.dart';

import 'package:flutter_with_redux/routes.dart';

import 'package:flutter_with_redux/models/app_state.dart';

import 'package:flutter_with_redux/models/user/user.dart';
import 'package:flutter_with_redux/models/user/user_state.dart';

import 'package:flutter_with_redux/actions/user_actions.dart';

class UsersScreen extends StatelessWidget {
  void handleInitialBuild(UsersScreenProps props) {
    props.getUsers();
  }

  @override
  Widget build(BuildContext context) {
    return StoreConnector<AppState, UsersScreenProps>(
      converter: (store) => mapStateToProps(store),
      onInitialBuild: (props) => this.handleInitialBuild(props),
      builder: (context, props) {
        List<User> data = props.listResponse.data;
        bool loading = props.listResponse.loading;

        Widget body;
        if (loading) {
          body = Center(
            child: CircularProgressIndicator(),
          );
        } else {
          body = ListView.separated(
            padding: const EdgeInsets.all(16.0),
            itemCount: data.length,
            separatorBuilder: (context, index) => Divider(),
            itemBuilder: (context, i) {
              User user = data[i];

              return ListTile(
                title: Text(
                  user.name,
                ),
                onTap: () {
                  props.getUserDetails(user.id);
                  Navigator.pushNamed(context, AppRoutes.userDetails);
                },
              );
            },
          );
        }

        return Scaffold(
          appBar: AppBar(
            title: Text('Users list'),
          ),
          body: body,
        );
      },
    );
  }
}

class UsersScreenProps {
  final Function getUsers;
  final Function getUserDetails;
  final ListUsersState listResponse;

  UsersScreenProps({
    this.getUsers,
    this.listResponse,
    this.getUserDetails,
  });
}

UsersScreenProps mapStateToProps(Store<AppState> store) {
  return UsersScreenProps(
    listResponse: store.state.user.list,
    getUsers: () => store.dispatch(getUsers()),
    getUserDetails: (int id) => store.dispatch(getUserDetails(id)),
  );
}

Finalmente…

A tela de detalhes do usuário é um StatelessWidget porque também não precisa de um estado próprio. Ele chama dinamicamente o middleware da API e despacha os estados, e a interface do usuário reflete isso perfeitamente. Crie um user_details_screen.dart na pasta components/users.

import 'package:flutter/material.dart';
import 'package:flutter_with_redux/models/user/user.dart';

import 'package:redux/redux.dart';

import 'package:flutter_redux/flutter_redux.dart';

import 'package:flutter_with_redux/models/app_state.dart';
import 'package:flutter_with_redux/models/user/user_state.dart';

class UserDetailsScreen extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return StoreConnector<AppState, UserDetailsScreenProps>(
      converter: (store) => mapStateToProps(store),
      builder: (context, props) {
        UserDetails data = props.detailsResponse.data;
        bool loading = props.detailsResponse.loading;

        TextStyle textStyle = TextStyle(
          height: 2,
          fontSize: 20,
        );

        Widget body;
        if (loading) {
          body = Center(
            child: CircularProgressIndicator(),
          );
        } else {
          body = Center(
            child: IntrinsicWidth(
              child: Column(
                mainAxisAlignment: MainAxisAlignment.center,
                crossAxisAlignment: CrossAxisAlignment.center,
                children: [
                  Text(data.name, style: textStyle),
                  Text(data.email, style: textStyle),
                  Text(data.website, style: textStyle),
                ],
              ),
            ),
          );
        }

        return Scaffold(
          appBar: AppBar(
            title: Text('User details'),
          ),
          body: body,
        );
      },
    );
  }
}

class UserDetailsScreenProps {
  final UserDetailsState detailsResponse;

  UserDetailsScreenProps({
    this.detailsResponse,
  });
}

UserDetailsScreenProps mapStateToProps(Store<AppState> store) {
  return UserDetailsScreenProps(
    detailsResponse: store.state.user.details,
  );
}

Hora de executar seu aplicativo e se divertir…

A maioria das APIs que consumimos em aplicativos é autenticada de alguma forma. E não queremos perguntar sempre as credenciais do usuário. Portanto, queremos armazenar essas credenciais para facilitar a nossa experiência e a experiência do usuário; no entanto isso traz alguns riscos, pois outros aplicativos podem ter acesso a esse arquivo específico, tornando-se um possível vetor de ataque. Existe uma biblioteca chamada flutter_secure_storage que resolve esse problema. Ele usa o Keychain nativo no IOS e o KeyStore no Android. Então, sim… Use-o se estiver armazenando credenciais do usuário.

Esqueça a ideia de que o Redux é um pacote do React! Use-o sempre que puder, facilitou minha vida e espero ter facilitado a sua também: D

Sei que o Flutter é uma nova tecnologia, mas é extremamente produtiva, fácil de trabalhar e acredito que terá um futuro brilhante. Fiquei surpreso com o Dart, é muito fácil aprender e divertido codificar, me lembra um pouco de javascript.

O projeto completo estará no meu GitHub. Você pode entrar em contato comigo no meu LinkedIn se precisar de ajuda, criticar ou apenas quiser falar sobre seus problemas.

Se você gostou desta leitura, conte-me nos comentários e lembre-se da coisa mais importante: gifs de gatos recebem palmas…