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:
-
Uniformidade: Todas as telas agora usam o mesmo
ThemeDatabaseado emcolorSchemeSeed. -
Estado Global: O
MultiBlocProviderno topo da árvore garante que o carrinho de compras (visto nos projetos anteriores) ou os dados do morador estejam acessíveis. -
Navegação: O uso do
context.go()em vez depush()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,RecordBloceHistoryBloc. -
Rotas: Gerenciadas centralmente no
app_router.dart. -
Identidade: Todos os componentes herdam as cores do
colorSchemeSeed. -
Performance: Uso de
consteBlocBuilderpara 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!