Construindo um App para Condomínios com Flutter 3.41: Gestão de Ocorrências e UX Avançada (Parte 5)
Neste artigo, implementaremos módulos de comunicação essenciais: Perdidos e Achados (visual e comunitário), Ocorrências e Manifestações (formal e processual) e revisaremos o Menu de Navegação transformando-o em um componente flutuante moderno.
Tudo isso utilizando Flutter 3.41, BLoC com Pattern Matching e os tokens visuais do Material 3.
🔐 Passo 1: Registro de Acesso (Sign Up)
Antes de reportar ocorrências ou ver perdidos e achados, o usuário precisa criar sua conta. Nesta tela, validamos se o CPF informado já consta na base de moradores (cadastrada na Parte 3) e permitimos a definição de e-mail e senha.
1.1 Lógica de Registro (SignUpBloc)
Usaremos sealed classes para garantir a segurança dos estados.
Arquivo: lib/blocs/auth/signup/signup_bloc.dart
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:equatable/equatable.dart';
// --- EVENTOS ---
sealed class SignUpEvent extends Equatable {
const SignUpEvent();
@override
List<Object> get props => [];
}
final class SubmitSignUp extends SignUpEvent {
final String cpf;
final String email;
final String password;
final String confirmPassword;
const SubmitSignUp({
required this.cpf,
required this.email,
required this.password,
required this.confirmPassword,
});
@override
List<Object> get props => [cpf, email, password, confirmPassword];
}
// --- ESTADOS ---
sealed class SignUpState extends Equatable {
const SignUpState();
@override
List<Object> get props => [];
}
final class SignUpInitial extends SignUpState {}
final class SignUpLoading extends SignUpState {}
final class SignUpSuccess extends SignUpState {}
final class SignUpFailure extends SignUpState {
final String error;
const SignUpFailure(this.error);
@override
List<Object> get props => [error];
}
// --- BLOC ---
class SignUpBloc extends Bloc<SignUpEvent, SignUpState> {
SignUpBloc() : super(SignUpInitial()) {
on<SubmitSignUp>((event, emit) async {
emit(SignUpLoading());
// 1. Validação Básica
if (event.password != event.confirmPassword) {
emit(const SignUpFailure("As senhas não conferem."));
return;
}
try {
// 2. Simulação de API (Verificar CPF e Criar Auth)
await Future.delayed(const Duration(seconds: 2));
// Sucesso: Conta criada
emit(SignUpSuccess());
} catch (e) {
emit(const SignUpFailure("Erro ao criar conta. Verifique os dados."));
}
});
}
}
1.2 Tela de Registro de Usuário (UserRegisterScreen)
Esta tela foca na conversão. Usamos ícones nos campos (prefixIcon) e um botão de ação primária bem destacado.
Arquivo: lib/modules/auth/user_register_screen.dart
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:go_router/go_router.dart';
import '../../blocs/auth/signup/signup_bloc.dart';
class UserRegisterScreen extends StatefulWidget {
const UserRegisterScreen({super.key});
@override
State<UserRegisterScreen> createState() => _UserRegisterScreenState();
}
class _UserRegisterScreenState extends State<UserRegisterScreen> {
final _formKey = GlobalKey<FormState>();
final _cpfController = TextEditingController();
final _emailController = TextEditingController();
final _passController = TextEditingController();
final _confirmPassController = TextEditingController();
bool _obscurePass = true;
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('Criar Minha Conta')),
body: BlocProvider(
create: (context) => SignUpBloc(),
child: BlocConsumer<SignUpBloc, SignUpState>(
listener: (context, state) {
if (state is SignUpSuccess) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Conta criada! Faça login para continuar.')),
);
context.go('/auth'); // Volta para o Login
}
if (state is SignUpFailure) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(state.error), backgroundColor: Theme.of(context).colorScheme.error),
);
}
},
builder: (context, state) {
return SingleChildScrollView(
padding: const EdgeInsets.all(24),
child: Form(
key: _formKey,
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Text(
'Primeiro Acesso',
style: Theme.of(context).textTheme.headlineSmall?.copyWith(fontWeight: FontWeight.bold),
),
const SizedBox(height: 8),
Text(
'Informe seu CPF para vincularmos sua unidade.',
style: Theme.of(context).textTheme.bodyMedium,
),
const SizedBox(height: 32),
// CPF
TextFormField(
controller: _cpfController,
keyboardType: TextInputType.number,
decoration: const InputDecoration(
labelText: 'CPF (apenas números)',
prefixIcon: Icon(Icons.badge_outlined),
border: OutlineInputBorder(),
),
validator: (v) => v!.length < 11 ? 'CPF inválido' : null,
),
const SizedBox(height: 16),
// Email
TextFormField(
controller: _emailController,
keyboardType: TextInputType.emailAddress,
decoration: const InputDecoration(
labelText: 'Seu melhor E-mail',
prefixIcon: Icon(Icons.email_outlined),
border: OutlineInputBorder(),
),
validator: (v) => !v!.contains('@') ? 'E-mail inválido' : null,
),
const SizedBox(height: 16),
// Senha
TextFormField(
controller: _passController,
obscureText: _obscurePass,
decoration: InputDecoration(
labelText: 'Senha',
prefixIcon: const Icon(Icons.lock_outline),
suffixIcon: IconButton(
icon: Icon(_obscurePass ? Icons.visibility : Icons.visibility_off),
onPressed: () => setState(() => _obscurePass = !_obscurePass),
),
border: const OutlineInputBorder(),
),
validator: (v) => v!.length < 6 ? 'Mínimo 6 caracteres' : null,
),
const SizedBox(height: 16),
// Confirmar Senha
TextFormField(
controller: _confirmPassController,
obscureText: true,
decoration: const InputDecoration(
labelText: 'Confirmar Senha',
prefixIcon: Icon(Icons.lock_reset),
border: OutlineInputBorder(),
),
validator: (v) => v != _passController.text ? 'Senhas não conferem' : null,
),
const SizedBox(height: 40),
// Botão de Ação
FilledButton(
onPressed: state is SignUpLoading ? null : () {
if (_formKey.currentState!.validate()) {
context.read<SignUpBloc>().add(SubmitSignUp(
cpf: _cpfController.text,
email: _emailController.text,
password: _passController.text,
confirmPassword: _confirmPassController.text,
));
}
},
style: FilledButton.styleFrom(padding: const EdgeInsets.symmetric(vertical: 16)),
child: state is SignUpLoading
? const SizedBox(height: 20, width: 20, child: CircularProgressIndicator(strokeWidth: 2))
: const Text('REGISTRAR'),
),
],
),
),
);
},
),
),
);
}
}
🎨 Passo 2: O Menu Flutuante (Custom Navigation UX)
O pedido foi específico: um “Menu Flutuante”. O NavigationBar padrão do Material 3 é fixo. Para criar um efeito flutuante elegante, precisamos sair do Scaffold.bottomNavigationBar e usar uma Stack.
Conceito: Um container suspenso na parte inferior da tela, com desfoque (Blur) e bordas arredondadas.
Arquivo: lib/modules/base/widgets/floating_nav_bar.dart
import 'dart:ui';
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
class FloatingNavBar extends StatelessWidget {
final int currentIndex;
final Function(int) onTap;
const FloatingNavBar({
super.key,
required this.currentIndex,
required this.onTap,
});
@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
return Align(
alignment: Alignment.bottomCenter,
child: Container(
margin: const EdgeInsets.all(24),
height: 70,
decoration: BoxDecoration(
color: colorScheme.surface.withOpacity(0.85), // Transparência para o Blur
borderRadius: BorderRadius.circular(35), // Pílula gigante
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.1),
blurRadius: 20,
offset: const Offset(0, 10),
),
],
border: Border.all(color: colorScheme.outlineVariant.withOpacity(0.5)),
),
child: ClipRRect(
borderRadius: BorderRadius.circular(35),
child: BackdropFilter(
filter: ImageFilter.blur(sigmaX: 10, sigmaY: 10), // Glassmorphism
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: [
_buildNavItem(context, 0, Icons.home_rounded, Icons.home_outlined, 'Home'),
_buildNavItem(context, 1, Icons.history_rounded, Icons.history_outlined, 'Histórico'),
_buildNavItem(context, 2, Icons.person_rounded, Icons.person_outline, 'Perfil'),
],
),
),
),
),
);
}
Widget _buildNavItem(BuildContext context, int index, IconData activeIcon, IconData inactiveIcon, String label) {
final isSelected = currentIndex == index;
final colorScheme = Theme.of(context).colorScheme;
return GestureDetector(
onTap: () => onTap(index),
behavior: HitTestBehavior.opaque,
child: AnimatedContainer(
duration: const Duration(milliseconds: 300),
curve: Curves.easeOutExpo,
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
decoration: isSelected
? BoxDecoration(
color: colorScheme.primaryContainer,
borderRadius: BorderRadius.circular(24),
)
: null,
child: Row(
children: [
Icon(
isSelected ? activeIcon : inactiveIcon,
color: isSelected ? colorScheme.onPrimaryContainer : colorScheme.onSurfaceVariant,
),
// Mostra o texto apenas se selecionado (Animação de expansão)
if (isSelected) ...[
const SizedBox(width: 8),
Text(
label,
style: TextStyle(
fontWeight: FontWeight.bold,
color: colorScheme.onPrimaryContainer,
),
),
]
],
),
),
);
}
}
Integração no Layout Principal (main_layout.dart):
Agora usamos um Stack para sobrepor o menu ao conteúdo.
@override
Widget build(BuildContext context) {
return Scaffold(
// extendBody permite que o conteúdo role por trás do menu
extendBody: true,
body: Stack(
children: [
// O Conteúdo (Home, Histórico, Perfil)
Positioned.fill(
child: widget.child, // Recebido via ShellRoute do GoRouter
),
// O Menu Flutuante
FloatingNavBar(
currentIndex: _calculateSelectedIndex(context),
onTap: (index) => _onItemTapped(index, context),
),
],
),
);
}
🔍 Passo 3: Perdidos e Achados (Visual & Grid)
Esta tela deve ser visual. O morador precisa ver a foto do item perdido. Usaremos o SliverGrid para performance e layout responsivo.
UI: lib/modules/lost_found/lost_found_screen.dart
class LostFoundScreen extends StatelessWidget {
const LostFoundScreen({super.key});
@override
Widget build(BuildContext context) {
// Mock de dados
final items = [
{'title': 'Chaves do Carro', 'status': 'lost', 'date': 'Hoje', 'image': null},
{'title': 'Guarda-chuva Preto', 'status': 'found', 'date': 'Ontem', 'image': null},
];
return Scaffold(
appBar: AppBar(title: const Text('Achados e Perdidos')),
floatingActionButton: FloatingActionButton.extended(
onPressed: () {},
label: const Text('Reportar Item'),
icon: const Icon(Icons.add_a_photo),
),
body: CustomScrollView(
slivers: [
// Filtros M3
SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.all(16),
child: SingleChildScrollView(
scrollDirection: Axis.horizontal,
child: Row(
children: [
FilterChip(label: const Text('Todos'), selected: true, onSelected: (_) {}),
const SizedBox(width: 8),
FilterChip(label: const Text('Perdidos'), selected: false, onSelected: (_) {}),
const SizedBox(width: 8),
FilterChip(label: const Text('Encontrados'), selected: false, onSelected: (_) {}),
],
),
),
),
),
// Grid
SliverPadding(
padding: const EdgeInsets.symmetric(horizontal: 16),
sliver: SliverGrid(
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 2,
mainAxisSpacing: 12,
crossAxisSpacing: 12,
childAspectRatio: 0.8,
),
delegate: SliverChildBuilderDelegate(
(context, index) {
final item = items[index];
final isLost = item['status'] == 'lost';
return Card(
clipBehavior: Clip.antiAlias,
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Expanded(
child: Container(
color: Theme.of(context).colorScheme.surfaceContainerHighest,
child: const Icon(Icons.image_not_supported, size: 40, color: Colors.grey),
),
),
Padding(
padding: const EdgeInsets.all(12),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Badge de Status
Container(
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
decoration: BoxDecoration(
color: isLost ? Colors.red.withOpacity(0.1) : Colors.green.withOpacity(0.1),
borderRadius: BorderRadius.circular(4),
),
child: Text(
isLost ? 'PERDIDO' : 'ENCONTRADO',
style: TextStyle(
fontSize: 10,
fontWeight: FontWeight.bold,
color: isLost ? Colors.red : Colors.green,
),
),
),
const SizedBox(height: 8),
Text(item['title'] as String, style: const TextStyle(fontWeight: FontWeight.bold)),
Text(item['date'] as String, style: Theme.of(context).textTheme.bodySmall),
],
),
)
],
),
);
},
childCount: items.length,
),
),
),
// Espaço para não ficar atrás do FAB e Menu
const SliverToBoxAdapter(child: SizedBox(height: 100)),
],
),
);
}
}
📢 Passo 4: Ocorrências e Manifestações (Ticket BLoC)
Aqui tratamos de processos formais. Uma “Ocorrência” (barulho, infração) e uma “Manifestação” (elogio, sugestão) têm fluxos de dados similares (título, descrição, tipo), então usaremos um BLoC Genérico para evitar duplicação de código.
4.1 Ticket Logic (TicketBloc)
// ticket_event.dart
sealed class TicketEvent extends Equatable { const TicketEvent(); }
class CreateTicket extends TicketEvent {
final String type; // 'occurrence' ou 'feedback'
final String title;
final String description;
final bool isAnonymous;
const CreateTicket({
required this.type,
required this.title,
required this.description,
this.isAnonymous = false,
});
@override
List<Object> get props => [type, title, description, isAnonymous];
}
// ticket_bloc.dart
class TicketBloc extends Bloc<TicketEvent, TicketState> {
TicketBloc() : super(TicketInitial()) {
on<CreateTicket>((event, emit) async {
emit(TicketLoading());
try {
await Future.delayed(const Duration(seconds: 2)); // API Call
// Lógica: Salvar no banco com flag de tipo
emit(TicketSuccess(message: event.type == 'occurrence'
? 'Ocorrência registrada. Protocolo #1234.'
: 'Obrigado pela sua manifestação!'));
} catch (e) {
emit(const TicketError('Erro ao enviar solicitação.'));
}
});
}
}
4.2 Tela de Formulário Unificado (TicketFormScreen)
Usaremos o novo DropdownMenu do Material 3.
class TicketFormScreen extends StatefulWidget {
final String ticketType; // 'occurrence' ou 'feedback'
const TicketFormScreen({super.key, required this.ticketType});
@override
State<TicketFormScreen> createState() => _TicketFormScreenState();
}
class _TicketFormScreenState extends State<TicketFormScreen> {
final _titleController = TextEditingController();
final _descController = TextEditingController();
bool _isAnonymous = false;
String? _category;
@override
Widget build(BuildContext context) {
final isOccurrence = widget.ticketType == 'occurrence';
final categories = isOccurrence
? ['Barulho', 'Estacionamento', 'Áreas Comuns', 'Segurança']
: ['Elogio', 'Reclamação', 'Sugestão', 'Dúvida'];
return Scaffold(
appBar: AppBar(
title: Text(isOccurrence ? 'Nova Ocorrência' : 'Nova Manifestação'),
),
body: BlocListener<TicketBloc, TicketState>(
listener: (context, state) {
if (state is TicketSuccess) {
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(state.message)));
Navigator.pop(context);
}
},
child: ListView(
padding: const EdgeInsets.all(24),
children: [
Text(
isOccurrence
? 'Relate o incidente para a administração.'
: 'Envie sua opinião para o síndico.',
style: Theme.of(context).textTheme.bodyLarge,
),
const SizedBox(height: 24),
// M3 Dropdown Menu
DropdownMenu<String>(
width: MediaQuery.of(context).size.width - 48, // Full width minus padding
label: const Text('Categoria'),
dropdownMenuEntries: categories.map((c) => DropdownMenuEntry(value: c, label: c)).toList(),
onSelected: (val) => _category = val,
),
const SizedBox(height: 16),
TextField(
controller: _titleController,
decoration: const InputDecoration(labelText: 'Assunto', border: OutlineInputBorder()),
),
const SizedBox(height: 16),
TextField(
controller: _descController,
maxLines: 5,
decoration: const InputDecoration(
labelText: 'Descrição detalhada',
border: OutlineInputBorder(),
alignLabelWithHint: true,
),
),
if (isOccurrence) ...[
const SizedBox(height: 16),
SwitchListTile(
title: const Text('Relato Anônimo'),
subtitle: const Text('Seus dados não serão exibidos ao notificado.'),
value: _isAnonymous,
onChanged: (val) => setState(() => _isAnonymous = val),
),
],
const SizedBox(height: 32),
BlocBuilder<TicketBloc, TicketState>(
builder: (context, state) {
return FilledButton(
onPressed: state is TicketLoading ? null : () {
// Validação e Submit
context.read<TicketBloc>().add(CreateTicket(
type: widget.ticketType,
title: _titleController.text,
description: _descController.text,
isAnonymous: _isAnonymous,
));
},
child: state is TicketLoading
? const CircularProgressIndicator()
: Text(isOccurrence ? 'REGISTRAR OCORRÊNCIA' : 'ENVIAR MANIFESTAÇÃO'),
);
},
),
],
),
),
);
}
}
🏁 Conclusão do Projeto
Chegamos ao fim da 5 parte. Você construiu um Super App para Condomínios utilizando as tecnologias mais modernas disponíveis no ecossistema Flutter.
Recapitulando a Arquitetura Final:
-
Framework: Flutter 3.41 com Dart moderno.
-
UI/UX: Design System Material 3 completo, com temas tonais, Glassmorphism no menu e componentes interativos (Secure Button).
-
Estado: BLoC (Business Logic Component) com
sealed classespara gerenciamento seguro de estado. -
Navegação: GoRouter com ShellRoutes para o menu persistente e rotas aninhadas.
-
Funcionalidades: Login, Cadastro, Convites QR, Agendamentos, Mural, Ocorrências, Achados e Perdidos e Histórico.