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:

  1. Framework: Flutter 3.41 com Dart moderno.

  2. UI/UX: Design System Material 3 completo, com temas tonais, Glassmorphism no menu e componentes interativos (Secure Button).

  3. Estado: BLoC (Business Logic Component) com sealed classes para gerenciamento seguro de estado.

  4. Navegação: GoRouter com ShellRoutes para o menu persistente e rotas aninhadas.

  5. Funcionalidades: Login, Cadastro, Convites QR, Agendamentos, Mural, Ocorrências, Achados e Perdidos e Histórico.

 

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