Construindo um App para Condomínios com Flutter 3.41: Arquitetura BLoC e GoRouter (Parte 3)

Este artigo irá demonstrar os novos passos para o projeto Condomínio Online, focando na implementação do BLoC em todas as telas, organização de rotas com GoRouter e a criação das áreas de Cadastro (Moradores e Convidados) e Histórico de Atividades. Tudo isso sob a estética moderna do Material 3.


🛠️ Passo 1: Configurando a Arquitetura (BLoC e GoRouter)

Para que o app seja sustentável, precisamos separar a lógica de negócio da interface. O BLoC cuidará dos estados (Carregando, Sucesso, Erro) e o GoRouter cuidará das URLs e navegação aninhada.

Adicione as dependências:

dependencies:
  flutter_bloc: ^8.1.5
  equatable: ^2.0.5
  go_router: ^14.2.0

Definindo as Rotas (app_router.dart)

final goRouter = GoRouter(
  initialLocation: '/auth',
  routes: [
    GoRoute(path: '/auth', builder: (context, state) => const LoginScreen()),
    GoRoute(path: '/register', builder: (context, state) => const RegisterScreen()),
    GoRoute(
      path: '/home',
      builder: (context, state) => const MainLayout(),
      routes: [
        GoRoute(path: 'resident-form', builder: (context, state) => const ResidentFormScreen()),
        GoRoute(path: 'guest-form', builder: (context, state) => const GuestFormScreen()),
      ],
    ),
  ],
);

🧠 Passo 2: O Sistema de Registro (Login e Cadastro) com BLoC

Agora, o fluxo de entrada não é apenas visual. O AuthBloc gerencia o processo de registro e autenticação.

Estado do AuthBloc:

abstract class AuthState extends Equatable {
  @override
  List<Object> get props => [];
}
class AuthInitial extends AuthState {}
class AuthLoading extends AuthState {}
class AuthAuthenticated extends AuthState {}

1. Definindo os Estados (auth_state.dart)

O estado representa o que a UI deve exibir em cada momento (carregamento, sucesso, erro ou logado).

import 'package:equatable/equatable.dart';

sealed class AuthState extends Equatable {
  const AuthState();
  
  @override
  List<Object?> get props => [];
}

final class AuthInitial extends AuthState {}

final class AuthLoading extends AuthState {}

final class AuthAuthenticated extends AuthState {
  final String userName;
  const AuthAuthenticated(this.userName);

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

final class AuthUnauthenticated extends AuthState {}

final class AuthError extends AuthState {
  final String message;
  const AuthError(this.message);

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

2. Definindo os Eventos (auth_event.dart)

Os eventos são as ações que o usuário dispara na tela.

import 'package:equatable/equatable.dart';

sealed class AuthEvent extends Equatable {
  const AuthEvent();

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

// Evento disparado pelo formulário de registro
final class RegisterSubmitted extends AuthEvent {
  final String nome;
  final String cpf;
  final String apartamento;

  const RegisterSubmitted({
    required this.nome,
    required this.cpf,
    required this.apartamento,
  });

  @override
  List<Object?> get props => [nome, cpf, apartamento];
}

final class LogoutRequested extends AuthEvent {}

3. Implementando a Lógica (auth_bloc.dart)

Aqui é onde processamos os dados. No contexto de um condomínio, poderíamos integrar com uma API ou banco de dados local.

import 'package:flutter_bloc/flutter_bloc.dart';
import 'auth_event.dart';
import 'auth_state.dart';

class AuthBloc extends Bloc<AuthEvent, AuthState> {
  AuthBloc() : super(AuthInitial()) {
    
    // Mapeamento do evento de Registro
    on<RegisterSubmitted>((event, emit) async {
      emit(AuthLoading());

      try {
        // Simulação de uma chamada de rede ou salvamento no banco
        await Future.delayed(const Duration(seconds: 2));

        if (event.nome.length < 3) {
          emit(const AuthError("O nome deve ter pelo menos 3 caracteres."));
          return;
        }

        // Sucesso: Emitimos o estado de autenticado
        emit(AuthAuthenticated(event.nome));
        
      } catch (e) {
        emit(const AuthError("Ocorreu um erro inesperado ao registrar."));
      }
    });

    // Mapeamento do evento de Logout
    on<LogoutRequested>((event, emit) {
      emit(AuthUnauthenticated());
    });
  }
}

💡 Como utilizar no seu Projeto

Para que o context.read<AuthBloc>() funcione na sua RegisterScreen, você deve injetar o BLoC no topo da sua árvore de widgets (geralmente no main.dart ou no GoRouter).

Exemplo no main.dart:

void main() {
  runApp(
    BlocProvider(
      create: (context) => AuthBloc(),
      child: const CondoApp(),
    ),
  );
}

O BLoC de Autenticação gerencia o estado global de acesso. Abaixo, a implementação da tela de registro que se integra ao BLoC para criar novos usuários no sistema.

Tela de Registro (register_screen.dart):

import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import '../../blocs/auth/auth_bloc.dart';
import '../../blocs/auth/auth_event.dart';
import '../../blocs/auth/auth_state.dart';

class RegisterScreen extends StatefulWidget {
  const RegisterScreen({super.key});

  @override
  State<RegisterScreen> createState() => _RegisterScreenState();
}

class _RegisterScreenState extends State<RegisterScreen> {
  final _formKey = GlobalKey<FormState>();
  final _nomeController = TextEditingController();
  final _cpfController = TextEditingController();
  final _aptoController = TextEditingController();

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: const Text('Solicitar Acesso')),
      body: BlocConsumer<AuthBloc, AuthState>(
        listener: (context, state) {
          if (state is AuthAuthenticated) {
            ScaffoldMessenger.of(context).showSnackBar(
              const SnackBar(content: Text('Cadastro realizado com sucesso!')),
            );
            Navigator.of(context).pushReplacementNamed('/home');
          }
          if (state is AuthError) {
            ScaffoldMessenger.of(context).showSnackBar(
              SnackBar(content: Text(state.message), backgroundColor: Colors.red),
            );
          }
        },
        builder: (context, state) {
          return SingleChildScrollView(
            padding: const EdgeInsets.all(24.0),
            child: Form(
              key: _formKey,
              child: Column(
                children: [
                  const Icon(Icons.person_add_outlined, size: 64),
                  const SizedBox(height: 24),
                  TextFormField(
                    controller: _nomeController,
                    decoration: const InputDecoration(
                      labelText: 'Nome Completo',
                      prefixIcon: Icon(Icons.person),
                      border: OutlineInputBorder(),
                    ),
                    validator: (v) => v!.isEmpty ? 'Obrigatório' : null,
                  ),
                  const SizedBox(height: 16),
                  TextFormField(
                    controller: _cpfController,
                    decoration: const InputDecoration(
                      labelText: 'CPF',
                      prefixIcon: Icon(Icons.badge),
                      border: OutlineInputBorder(),
                    ),
                    keyboardType: TextInputType.number,
                  ),
                  const SizedBox(height: 16),
                  TextFormField(
                    controller: _aptoController,
                    decoration: const InputDecoration(
                      labelText: 'Apartamento / Bloco',
                      prefixIcon: Icon(Icons.home_work),
                      border: OutlineInputBorder(),
                    ),
                  ),
                  const SizedBox(height: 32),
                  SizedBox(
                    width: double.infinity,
                    child: FilledButton(
                      onPressed: state is AuthLoading ? null : () {
                        if (_formKey.currentState!.validate()) {
                          context.read<AuthBloc>().add(RegisterSubmitted(
                            nome: _nomeController.text,
                            cpf: _cpfController.text,
                            apartamento: _aptoController.text,
                          ));
                        }
                      },
                      child: state is AuthLoading 
                        ? const CircularProgressIndicator(color: Colors.white) 
                        : const Text('REGISTRAR'),
                    ),
                  ),
                ],
              ),
            ),
          );
        },
      ),
    );
  }
}

📝 Passo 3: Cadastro de Moradores e Convidados

Nesta etapa, criamos formulários detalhados. O Material 3 nos oferece campos de texto (TextField) com cantos arredondados e cores tonais que facilitam a leitura.

Tela de Cadastro de Convidados

O diferencial aqui é a geração de um estado que possa ser compartilhado com a portaria.

  • Campos: Nome, RG/CPF, Data da Visita, Placa do Veículo (opcional).

  • Lógica: O BLoC valida os campos e salva no histórico.

1. Definindo os Eventos e Estados (record_bloc_logic.dart)

import 'package:equatable/equatable.dart';
import 'package:flutter_bloc/flutter_bloc.dart';

// --- EVENTOS ---
sealed class RecordEvent extends Equatable {
  const RecordEvent();
  @override
  List<Object?> get props => [];
}

class SubmitResidentRecord extends RecordEvent {
  final String nome;
  final String bloco;
  final String unidade;
  const SubmitResidentRecord(this.nome, this.bloco, this.unidade);
}

class SubmitGuestRecord extends RecordEvent {
  final String nome;
  final String documento;
  final DateTime dataVisita;
  const SubmitGuestRecord(this.nome, this.documento, this.dataVisita);
}

// --- ESTADOS ---
sealed class RecordState extends Equatable {
  const RecordState();
  @override
  List<Object?> get props => [];
}

final class RecordInitial extends RecordState {}
final class RecordLoading extends RecordState {}
final class RecordSuccess extends RecordState {}
final class RecordError extends RecordState {
  final String message;
  const RecordError(this.message);
}

// --- BLOC ---
class RecordBloc extends Bloc<RecordEvent, RecordState> {
  RecordBloc() : super(RecordInitial()) {
    on<SubmitResidentRecord>((event, emit) async {
      emit(RecordLoading());
      await Future.delayed(const Duration(seconds: 2)); // Simulação
      emit(RecordSuccess());
    });

    on<SubmitGuestRecord>((event, emit) async {
      emit(RecordLoading());
      await Future.delayed(const Duration(seconds: 2)); // Simulação
      emit(RecordSuccess());
    });
  }
}

2. Tela de Cadastro Dupla Funcionalidade (registration_form_screen.dart)

Esta tela utiliza o Material 3 e alterna entre o formulário de Morador e Convidado usando um SegmentedButton.

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

class RegistrationFormScreen extends StatefulWidget {
  const RegistrationFormScreen({super.key});

  @override
  State<RegistrationFormScreen> createState() => _RegistrationFormScreenState();
}

class _RegistrationFormScreenState extends State<RegistrationFormScreen> {
  int _viewType = 0; // 0 para Morador, 1 para Convidado
  final _formKey = GlobalKey<FormState>();
  
  // Controllers
  final _nomeController = TextEditingController();
  final _docController = TextEditingController(); // Bloco ou CPF
  final _infoExtraController = TextEditingController(); // Unidade ou Data

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: const Text('Cadastro Condomínio')),
      body: BlocListener<RecordBloc, RecordState>(
        listener: (context, state) {
          if (state is RecordSuccess) {
            ScaffoldMessenger.of(context).showSnackBar(
              const SnackBar(content: Text('Salvo com sucesso!')),
            );
            Navigator.pop(context);
          }
        },
        child: SingleChildScrollView(
          padding: const EdgeInsets.all(24),
          child: Form(
            key: _formKey,
            child: Column(
              crossAxisAlignment: CrossAxisAlignment.stretch,
              children: [
                // Seletor Material 3
                SegmentedButton<int>(
                  segments: const [
                    ButtonSegment(value: 0, label: Text('Morador'), icon: Icon(Icons.home)),
                    ButtonSegment(value: 1, label: Text('Convidado'), icon: Icon(Icons.person_add)),
                  ],
                  selected: {_viewType},
                  onSelectionChanged: (val) => setState(() => _viewType = val.first),
                ),
                const SizedBox(height: 32),
                
                // Campo Nome (Comum a ambos)
                TextFormField(
                  controller: _nomeController,
                  decoration: InputDecoration(
                    labelText: _viewType == 0 ? 'Nome do Morador' : 'Nome do Visitante',
                    border: const OutlineInputBorder(),
                    prefixIcon: const Icon(Icons.person),
                  ),
                  validator: (v) => v!.isEmpty ? 'Campo obrigatório' : null,
                ),
                const SizedBox(height: 16),

                // Campos Dinâmicos baseado na seleção
                if (_viewType == 0) ...[
                  TextFormField(
                    controller: _docController,
                    decoration: const InputDecoration(labelText: 'Bloco', border: OutlineInputBorder()),
                  ),
                  const SizedBox(height: 16),
                  TextFormField(
                    controller: _infoExtraController,
                    decoration: const InputDecoration(labelText: 'Unidade/Apto', border: OutlineInputBorder()),
                  ),
                ] else ...[
                  TextFormField(
                    controller: _docController,
                    decoration: const InputDecoration(labelText: 'CPF/RG do Convidado', border: OutlineInputBorder()),
                  ),
                  const SizedBox(height: 16),
                  // No caso de Convidado, poderíamos usar um DatePicker aqui
                  TextFormField(
                    controller: _infoExtraController,
                    readOnly: true,
                    decoration: const InputDecoration(
                      labelText: 'Data da Visita',
                      border: OutlineInputBorder(),
                      suffixIcon: Icon(Icons.calendar_today),
                    ),
                    onTap: () async {
                      final date = await showDatePicker(
                        context: context,
                        initialDate: DateTime.now(),
                        firstDate: DateTime.now(),
                        lastDate: DateTime.now().add(const Duration(days: 90)),
                      );
                      if (date != null) {
                        _infoExtraController.text = "${date.day}/${date.month}/${date.year}";
                      }
                    },
                  ),
                ],

                const SizedBox(height: 40),

                // Botão de Ação disparando o BLoC
                BlocBuilder<RecordBloc, RecordState>(
                  builder: (context, state) {
                    return FilledButton(
                      onPressed: state is RecordLoading ? null : () {
                        if (_formKey.currentState!.validate()) {
                          if (_viewType == 0) {
                            context.read<RecordBloc>().add(SubmitResidentRecord(
                              _nomeController.text, _docController.text, _infoExtraController.text
                            ));
                          } else {
                            context.read<RecordBloc>().add(SubmitGuestRecord(
                              _nomeController.text, _docController.text, DateTime.now()
                            ));
                          }
                        }
                      },
                      child: state is RecordLoading 
                        ? const SizedBox(height: 20, width: 20, child: CircularProgressIndicator(strokeWidth: 2))
                        : const Text('CONFIRMAR CADASTRO'),
                    );
                  },
                ),
              ],
            ),
          ),
        ),
      ),
    );
  }
}

🕒 Passo 4: Tela de Histórico de Atividades

A tela de Histórico é vital para a transparência do condomínio. Ela lista cronologicamente tudo o que aconteceu: entrada de convidados, entregas recebidas e reservas de áreas comuns.

Implementação com BlocBuilder:

BlocBuilder<HistoryBloc, HistoryState>(
  builder: (context, state) {
    if (state is HistoryLoading) return const CircularProgressIndicator();
    return ListView.builder(
      itemCount: state.logs.length,
      itemBuilder: (context, index) {
        final log = state.logs[index];
        return ListTile(
          leading: Icon(_getIcon(log.tipo)), // Icone dinâmico (Entrega, Visita, Alerta)
          title: Text(log.titulo),
          subtitle: Text(log.dataHora),
          trailing: const Icon(Icons.chevron_right),
        );
      },
    );
  },
)

Tela de Histórico (history_screen.dart):

import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import '../../blocs/history/history_bloc.dart';
import '../../blocs/history/history_state.dart';

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

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: const Text('Atividades'),
        centerTitle: true,
      ),
      body: BlocBuilder<HistoryBloc, HistoryState>(
        builder: (context, state) {
          if (state is HistoryLoading) {
            return const Center(child: CircularProgressIndicator());
          }
          
          if (state is HistoryLoaded) {
            if (state.logs.isEmpty) {
              return const Center(child: Text('Nenhum registro encontrado.'));
            }

            return ListView.separated(
              itemCount: state.logs.length,
              separatorBuilder: (context, index) => const Divider(height: 1),
              itemBuilder: (context, index) {
                final log = state.logs[index];
                return ListTile(
                  leading: CircleAvatar(
                    backgroundColor: _getColor(log.type),
                    child: Icon(_getIcon(log.type), color: Colors.white, size: 20),
                  ),
                  title: Text(log.title, style: const TextStyle(fontWeight: FontWeight.bold)),
                  subtitle: Text(log.timestamp),
                  trailing: const Icon(Icons.chevron_right, size: 16),
                  onTap: () {
                    // Detalhes do log
                  },
                );
              },
            );
          }
          return const Center(child: Text('Erro ao carregar histórico.'));
        },
      ),
    );
  }

  // Lógica simples de UI para ícones baseada no tipo de log
  IconData _getIcon(String type) {
    switch (type) {
      case 'delivery': return Icons.local_shipping;
      case 'guest': return Icons.person;
      case 'alert': return Icons.warning;
      default: return Icons.info;
    }
  }

  Color _getColor(String type) {
    switch (type) {
      case 'delivery': return Colors.blue;
      case 'guest': return Colors.green;
      case 'alert': return Colors.orange;
      default: return Colors.grey;
    }
  }
}

1. Definindo os Estados (history_state.dart)

O estado define se o histórico está carregando, se foi carregado com dados ou se houve uma falha.

import 'package:equatable/equatable.dart';

// Modelo de dado para o item de histórico
class ActivityLog extends Equatable {
  final String id;
  final String title;
  final String timestamp;
  final String type; // 'delivery', 'guest', 'alert'

  const ActivityLog({
    required this.id,
    required this.title,
    required this.timestamp,
    required this.type,
  });

  @override
  List<Object?> get props => [id, title, timestamp, type];
}

sealed class HistoryState extends Equatable {
  const HistoryState();
  
  @override
  List<Object?> get props => [];
}

final class HistoryInitial extends HistoryState {}

final class HistoryLoading extends HistoryState {}

final class HistoryLoaded extends HistoryState {
  final List<ActivityLog> logs;
  const HistoryLoaded(this.logs);

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

final class HistoryError extends HistoryState {}

2. Definindo os Eventos (history_event.dart)

Nesta fase, o evento principal é a solicitação de carregamento dos dados.

import 'package:equatable/equatable.dart';

sealed class HistoryEvent extends Equatable {
  const HistoryEvent();

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

final class LoadHistoryStarted extends HistoryEvent {}

3. Implementando a Lógica (history_bloc.dart)

O BLoC processa a solicitação e emite a lista de atividades. Em um cenário real, aqui você buscaria dados do banco Isar ou de uma API.

import 'package:flutter_bloc/flutter_bloc.dart';
import 'history_event.dart';
import 'history_state.dart';

class HistoryBloc extends Bloc<HistoryEvent, HistoryState> {
  HistoryBloc() : super(HistoryInitial()) {
    
    on<LoadHistoryStarted>((event, emit) async {
      emit(HistoryLoading());

      try {
        // Simulação de carregamento de dados (ex: busca no banco de dados)
        await Future.delayed(const Duration(seconds: 1));

        final mockLogs = [
          const ActivityLog(
            id: '1',
            title: 'Entrega: Mercado Livre',
            timestamp: '24/02/2026 - 14:20',
            type: 'delivery',
          ),
          const ActivityLog(
            id: '2',
            title: 'Convidado: Carlos Silva',
            timestamp: '23/02/2026 - 19:00',
            type: 'guest',
          ),
          const ActivityLog(
            id: '3',
            title: 'Alerta: Manutenção de Elevador',
            timestamp: '22/02/2026 - 08:00',
            type: 'alert',
          ),
        ];

        emit(HistoryLoaded(mockLogs));
      } catch (e) {
        emit(HistoryError());
      }
    });
  }
}

🔧 Como integrar com a Tela

Para que a tela de histórico mostrada anteriormente comece a exibir dados assim que for aberta, você deve disparar o evento de carregamento no initState ou através do provedor:

No seu app_router.dart ou onde você chama a tela:

GoRoute(
  path: 'history',
  builder: (context, state) => BlocProvider(
    // Dispara o evento de carga assim que o BLoC é criado
    create: (context) => HistoryBloc()..add(LoadHistoryStarted()),
    child: const HistoryScreen(),
  ),
),

🔄 Passo 5: Atualização e Revisão Geral

Ao migrar para o Flutter 3.41, aproveitamos as melhorias de performance no motor de renderização. Nesta revisão geral:

  1. Uniformidade: Todas as telas agora usam o mesmo ThemeData baseado em colorSchemeSeed.

  2. Estado Global: O MultiBlocProvider no topo da árvore garante que o carrinho de compras (visto nos projetos anteriores) ou os dados do morador estejam acessíveis.

  3. Navegação: O uso do context.go() em vez de push() garante que a pilha de telas seja limpa corretamente após o login.

1. Uniformidade: Design System e Cores Tonais (Material 3)

No Material 3, não definimos apenas uma primaryColor. Usamos o ColorScheme para que o Flutter gere tons harmoniosos para superfícies, recipientes e erros.

Como era (Original):

// Definição manual e limitada
theme: ThemeData(
  primaryColor: Colors.indigo,
  accentColor: Colors.indigoAccent,
),

Como fica (Modificado para Flutter 3.41):

// Uso de Tonal Palettes e Material 3 nativo
theme: ThemeData(
  useMaterial3: true,
  colorScheme: ColorScheme.fromSeed(
    seedColor: Colors.indigo,
    brightness: Brightness.light,
    // O Flutter gera automaticamente onPrimary, surfaceVariant, etc.
  ),
  // Uniformidade em todos os campos de texto do app
  inputDecorationTheme: InputDecorationTheme(
    filled: true,
    fillColor: Colors.indigo.withOpacity(0.05),
    border: OutlineInputBorder(borderRadius: BorderRadius.circular(12)),
  ),
),

2. Estado Global: Provedores no Topo da Árvore

Para que os dados de Moradores e Histórico sejam acessíveis em qualquer tela (como mostrar o nome do morador no Perfil e no Histórico ao mesmo tempo), centralizamos os BLoCs.

Como era (Original):

// BLoC criado apenas dentro da tela (morre ao fechar a tela)
builder: (context, state) => BlocProvider(
  create: (context) => HistoryBloc(),
  child: const HistoryScreen(),
),

Como fica (Modificado – Injeção Global):

// No main.dart, envolvendo o MaterialApp
void main() {
  runApp(
    MultiBlocProvider(
      providers: [
        BlocProvider(create: (context) => AuthBloc()),
        BlocProvider(create: (context) => RecordBloc()),
        BlocProvider(create: (context) => HistoryBloc()..add(LoadHistoryStarted())),
      ],
      child: const CondoApp(),
    ),
  );
}

3. Navegação: GoRouter e Limpeza de Pilha

A navegação imperativa (Navigator.push) pode deixar rastros de telas antigas na memória. Com o GoRouter, usamos navegação declarativa, essencial para fluxos de Login/Logout.

Como era (Original):

// Empilha telas indefinidamente
onPressed: () {
  Navigator.of(context).push(
    MaterialPageRoute(builder: (context) => const MainLayout()),
  );
}

Como fica (Modificado – GoRouter):

// No LoginScreen, após sucesso no BLoC
onPressed: () {
  // .go() remove a tela de login da pilha e define a Home como nova raiz
  // Impede que o morador volte para o Login clicando no botão "voltar" do Android
  context.go('/home'); 
},

// Para formulários (Sub-rotas)
onPressed: () {
  context.push('/home/resident-form'); // Adiciona à pilha para permitir retorno
}

✅ Checklist de Finalização (Parte 3)

  • Lógica: Separada em AuthBloc, RecordBloc e HistoryBloc.

  • Rotas: Gerenciadas centralmente no app_router.dart.

  • Identidade: Todos os componentes herdam as cores do colorSchemeSeed.

  • Performance: Uso de const e BlocBuilder para reconstruções mínimas de tela.

Com estas modificações, o projeto Condomínio Online está pronto para ser expandido para módulos de câmeras em tempo real ou integração com APIs financeiras!

Please follow and like us:
error0
fb-share-icon
Tweet 20
fb-share-icon20