Construindo um App para Condomínios com Flutter 3.41: Agendamentos, Mural e IoT (Parte 4)

Este artigo demonstra a implementação de módulos avançados do projeto: Sistema de Agendamento de Áreas Comuns, Mural de Avisos Inteligente e a área de Destaques (IoT e Acessos). Utilizaremos o Flutter 3.41 para explorar novos widgets e a robustez das sealed classes do Dart para o gerenciamento de estado.


🏗️ Passo 1: Arquitetura de Destaques (Câmeras e Acionamentos)

A área de destaques na Home deve ser rápida e segura. Para “Acionamentos” (abrir portões), não podemos usar um botão simples (cliques acidentais são perigosos). Vamos criar um componente de Ação Segura e integrar a visualização de câmeras.

1.1 Widget de Ação Segura (Material 3)

Vamos criar um botão que exige “pressionar e segurar” com feedback visual tátil e animação.

Arquivo: lib/modules/home/widgets/secure_action_button.dart

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

class SecureActionButton extends StatefulWidget {
  final String label;
  final IconData icon;
  final VoidCallback onActionConfirmed;
  final Color? color;

  const SecureActionButton({
    super.key,
    required this.label,
    required this.icon,
    required this.onActionConfirmed,
    this.color,
  });

  @override
  State<SecureActionButton> createState() => _SecureActionButtonState();
}

class _SecureActionButtonState extends State<SecureActionButton> with SingleTickerProviderStateMixin {
  late AnimationController _controller;
  late Animation<double> _scaleAnimation;

  @override
  void initState() {
    super.initState();
    _controller = AnimationController(vsync: this, duration: const Duration(seconds: 2));
    _scaleAnimation = Tween<double>(begin: 1.0, end: 1.5).animate(_controller);

    _controller.addStatusListener((status) {
      if (status == AnimationStatus.completed) {
        HapticFeedback.heavyImpact(); // Feedback Tátil
        widget.onActionConfirmed();
        _controller.reset();
      }
    });
  }

  @override
  Widget build(BuildContext context) {
    final theme = Theme.of(context);
    final baseColor = widget.color ?? theme.colorScheme.primary;

    return GestureDetector(
      onLongPressStart: (_) => _controller.forward(),
      onLongPressEnd: (_) => _controller.reset(),
      child: AnimatedBuilder(
        animation: _controller,
        builder: (context, child) {
          return Container(
            width: 100,
            height: 100,
            decoration: BoxDecoration(
              color: baseColor.withOpacity(0.1 + (_controller.value * 0.2)),
              borderRadius: BorderRadius.circular(24), // M3 Radius
              border: Border.all(
                color: baseColor,
                width: 1 + (_controller.value * 4), // Borda engrossa ao segurar
              ),
            ),
            child: Column(
              mainAxisAlignment: MainAxisAlignment.center,
              children: [
                Transform.scale(
                  scale: _scaleAnimation.value,
                  child: Icon(widget.icon, size: 32, color: baseColor),
                ),
                const SizedBox(height: 8),
                Text(
                  widget.label,
                  style: theme.textTheme.labelMedium?.copyWith(
                    fontWeight: FontWeight.bold,
                    color: baseColor,
                  ),
                  textAlign: TextAlign.center,
                ),
              ],
            ),
          );
        },
      ),
    );
  }
}

1.2 Integração na Home

Na tela inicial, usamos esse componente para abrir portões e um card padrão para ver câmeras.

// Snippet da Home
Row(
  mainAxisAlignment: MainAxisAlignment.spaceEvenly,
  children: [
    SecureActionButton(
      label: 'Abrir Portão',
      icon: Icons.lock_open,
      color: Colors.red,
      onActionConfirmed: () {
        // Dispara evento BLoC: context.read<AccessBloc>().add(OpenGateEvent());
      },
    ),
    _buildQuickAction(
      context,
      icon: Icons.videocam,
      label: 'Câmeras',
      onTap: () => context.push('/cameras'),
    ),
    _buildQuickAction(
      context,
      icon: Icons.qr_code,
      label: 'Convite',
      onTap: () => context.push('/guest-form'),
    ),
  ],
)

1.3 Sistema de Convites (QR Code e Compartilhamento)

Para esta funcionalidade, o morador precisa gerar um código de acesso rápido para o visitante. Usaremos o pacote qr_flutter para renderizar o código e o share_plus para enviar via WhatsApp.

Adicione ao pubspec.yaml:

qr_flutter: ^4.1.0
share_plus: ^7.2.0

A. Lógica do Convite (InvitationBloc)

O BLoC será responsável por pegar os dados do visitante, criptografar (simulado aqui) e gerar uma string única para o QR Code.

Arquivo: lib/blocs/invitation/invitation_bloc.dart

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

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

final class GenerateQrCode extends InvitationEvent {
  final String guestName;
  final DateTime date;
  
  const GenerateQrCode(this.guestName, this.date);
}

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

final class InvitationInitial extends InvitationState {}
final class InvitationLoading extends InvitationState {}
final class InvitationReady extends InvitationState {
  final String qrData; // String criptografada para o Leitor da Portaria
  final String guestName;
  
  const InvitationReady(this.qrData, this.guestName);
  @override
  List<Object> get props => [qrData, guestName];
}

// --- BLOC ---
class InvitationBloc extends Bloc<InvitationEvent, InvitationState> {
  InvitationBloc() : super(InvitationInitial()) {
    on<GenerateQrCode>((event, emit) async {
      emit(InvitationLoading());
      
      // Simulação de delay de API e geração de hash seguro
      await Future.delayed(const Duration(seconds: 1));
      
      // Cria uma string única baseada no timestamp e nome
      final qrString = "ACCESS|${event.guestName}|${event.date.toIso8601String()}|SIG";
      
      emit(InvitationReady(qrString, event.guestName));
    });
  }
}

B. Interface de Geração (Modal Material 3)

Em vez de uma tela cheia, usaremos um showModalBottomSheet moderno, permitindo que o morador gere o convite sem sair totalmente da Home.

Arquivo: lib/modules/home/widgets/invitation_modal.dart

import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:qr_flutter/qr_flutter.dart';
import 'package:share_plus/share_plus.dart';
import '../../../blocs/invitation/invitation_bloc.dart';

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

  @override
  State<InvitationModal> createState() => _InvitationModalState();
}

class _InvitationModalState extends State<InvitationModal> {
  final _nameController = TextEditingController();

  @override
  Widget build(BuildContext context) {
    return BlocProvider(
      create: (context) => InvitationBloc(),
      child: Padding(
        // Ajuste para o teclado não cobrir o campo
        padding: EdgeInsets.only(
          bottom: MediaQuery.of(context).viewInsets.bottom,
          left: 24, 
          right: 24, 
          top: 24
        ),
        child: Column(
          mainAxisSize: MainAxisSize.min,
          crossAxisAlignment: CrossAxisAlignment.stretch,
          children: [
            // Handle do BottomSheet (detalhe visual M3)
            Center(
              child: Container(
                width: 32, 
                height: 4, 
                decoration: BoxDecoration(
                  color: Theme.of(context).colorScheme.outlineVariant,
                  borderRadius: BorderRadius.circular(2)
                ),
              ),
            ),
            const SizedBox(height: 24),
            
            Text(
              'Novo Convite', 
              style: Theme.of(context).textTheme.headlineSmall?.copyWith(fontWeight: FontWeight.bold)
            ),
            const SizedBox(height: 16),

            // Área Reativa
            BlocConsumer<InvitationBloc, InvitationState>(
              listener: (context, state) {
                // Poderia ter lógica de erro aqui
              },
              builder: (context, state) {
                // ESTADO 1: Exibe o QR Code Gerado
                if (state is InvitationReady) {
                  return Column(
                    children: [
                      Container(
                        padding: const EdgeInsets.all(16),
                        decoration: BoxDecoration(
                          color: Colors.white,
                          borderRadius: BorderRadius.circular(16),
                          border: Border.all(color: Colors.grey.shade200),
                        ),
                        child: QrImageView(
                          data: state.qrData,
                          version: QrVersions.auto,
                          size: 200.0,
                        ),
                      ),
                      const SizedBox(height: 16),
                      Text(
                        'Acesso liberado para ${state.guestName}',
                        style: Theme.of(context).textTheme.labelLarge,
                      ),
                      const SizedBox(height: 24),
                      Row(
                        children: [
                          Expanded(
                            child: OutlinedButton(
                              onPressed: () => Navigator.pop(context),
                              child: const Text('FECHAR'),
                            ),
                          ),
                          const SizedBox(width: 12),
                          Expanded(
                            child: FilledButton.icon(
                              onPressed: () {
                                Share.share('Olá! Aqui está seu QR Code de acesso ao condomínio: ${state.qrData}');
                              },
                              icon: const Icon(Icons.share),
                              label: const Text('COMPARTILHAR'),
                            ),
                          ),
                        ],
                      ),
                      const SizedBox(height: 24),
                    ],
                  );
                }

                // ESTADO 2: Carregando
                if (state is InvitationLoading) {
                  return const SizedBox(
                    height: 200, 
                    child: Center(child: CircularProgressIndicator())
                  );
                }

                // ESTADO 3 (Inicial): Formulário
                return Column(
                  crossAxisAlignment: CrossAxisAlignment.stretch,
                  children: [
                    TextField(
                      controller: _nameController,
                      decoration: const InputDecoration(
                        labelText: 'Nome do Visitante',
                        border: OutlineInputBorder(),
                        prefixIcon: Icon(Icons.person_add),
                      ),
                    ),
                    const SizedBox(height: 16),
                    FilledButton(
                      onPressed: () {
                        if (_nameController.text.isNotEmpty) {
                          context.read<InvitationBloc>().add(
                            GenerateQrCode(_nameController.text, DateTime.now())
                          );
                        }
                      },
                      style: FilledButton.styleFrom(padding: const EdgeInsets.symmetric(vertical: 16)),
                      child: const Text('GERAR QR CODE'),
                    ),
                    const SizedBox(height: 24),
                  ],
                );
              },
            ),
          ],
        ),
      ),
    );
  }
}

Como chamar na Home:

Atualize o método _buildQuickAction do item de convite na tela inicial para abrir este modal:

_buildQuickAction(
  context,
  icon: Icons.qr_code_2, // Ícone moderno de QR
  label: 'Convite',
  onTap: () {
    showModalBottomSheet(
      context: context,
      isScrollControlled: true, // Permite que o modal cresça com o teclado
      useSafeArea: true,
      builder: (context) => const InvitationModal(),
    );
  },
),

 

📅 Passo 2: Sistema de Agendamento (Reserva de Áreas)

O agendamento é complexo: requer validação de datas, verificação de disponibilidade e seleção de horários.

2.1 BLoC de Agendamento (BookingBloc)

Usaremos sealed classes para garantir que a UI trate todos os estados possíveis.

Arquivo: lib/blocs/booking/booking_state.dart

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

final class BookingInitial extends BookingState {}
final class BookingLoading extends BookingState {}

final class BookingAvailabilityLoaded extends BookingState {
  final List<String> availableSlots; // Ex: ["10:00", "14:00"]
  final DateTime selectedDate;
  
  const BookingAvailabilityLoaded(this.availableSlots, this.selectedDate);
  @override
  List<Object?> get props => [availableSlots, selectedDate];
}

final class BookingSuccess extends BookingState {
  final String reservationCode;
  const BookingSuccess(this.reservationCode);
}

2.2 Tela de Agendamento (UI)

Utilizaremos o DatePicker nativo do Material 3 e Chips para seleção de horário.

Arquivo: lib/modules/booking/booking_screen.dart

import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import '../../blocs/booking/booking_bloc.dart';

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

  @override
  State<BookingScreen> createState() => _BookingScreenState();
}

class _BookingScreenState extends State<BookingScreen> {
  String? _selectedArea;
  String? _selectedSlot;
  
  final List<String> _areas = ['Salão de Festas', 'Churrasqueira', 'Academia'];

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: const Text('Reservar Espaço')),
      body: BlocConsumer<BookingBloc, BookingState>(
        listener: (context, state) {
          if (state is BookingSuccess) {
            ScaffoldMessenger.of(context).showSnackBar(
              SnackBar(content: Text('Reserva confirmada! Código: ${state.reservationCode}')),
            );
            Navigator.pop(context);
          }
        },
        builder: (context, state) {
          return ListView(
            padding: const EdgeInsets.all(24),
            children: [
              // 1. Seleção de Área (Dropdown M3)
              DropdownButtonFormField<String>(
                decoration: const InputDecoration(
                  labelText: 'Área Comum',
                  border: OutlineInputBorder(),
                  prefixIcon: Icon(Icons.deck),
                ),
                value: _selectedArea,
                items: _areas.map((area) => DropdownMenuItem(value: area, child: Text(area))).toList(),
                onChanged: (val) => setState(() => _selectedArea = val),
              ),
              const SizedBox(height: 24),

              // 2. Seleção de Data
              FilledButton.tonalIcon(
                onPressed: () async {
                  final date = await showDatePicker(
                    context: context,
                    initialDate: DateTime.now(),
                    firstDate: DateTime.now(),
                    lastDate: DateTime.now().add(const Duration(days: 60)),
                  );
                  if (date != null && _selectedArea != null) {
                    context.read<BookingBloc>().add(CheckAvailability(_selectedArea!, date));
                  }
                },
                icon: const Icon(Icons.calendar_today),
                label: const Text('Selecionar Data'),
              ),
              
              const SizedBox(height: 24),

              // 3. Slots de Horário (Só aparece se data selecionada)
              if (state is BookingAvailabilityLoaded) ...[
                Text('Horários Disponíveis', style: Theme.of(context).textTheme.titleMedium),
                const SizedBox(height: 12),
                Wrap(
                  spacing: 8,
                  children: state.availableSlots.map((slot) {
                    return ChoiceChip(
                      label: Text(slot),
                      selected: _selectedSlot == slot,
                      onSelected: (selected) => setState(() => _selectedSlot = selected ? slot : null),
                    );
                  }).toList(),
                ),
              ] else if (state is BookingLoading) ...[
                const Center(child: CircularProgressIndicator()),
              ],

              const SizedBox(height: 40),

              // 4. Confirmar
              FilledButton(
                onPressed: (_selectedSlot != null) 
                  ? () => context.read<BookingBloc>().add(ConfirmBooking(_selectedSlot!)) 
                  : null,
                child: const Text('CONFIRMAR RESERVA'),
              ),
            ],
          );
        },
      ),
    );
  }
}

📌 Passo 3: Mural de Avisos (Feed de Notícias)

O Mural precisa diferenciar avisos urgentes (falta de água, manutenção) de comunicados gerais. Usaremos as variantes de Card do Material 3 para essa hierarquia visual.

Arquivo: lib/modules/notices/notice_board_screen.dart

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

  @override
  Widget build(BuildContext context) {
    // Mock de dados (Viria de um NoticeBloc)
    final notices = [
      {'title': 'Manutenção Elevador', 'date': 'Hoje', 'type': 'urgent', 'body': 'O elevador social estará parado das 14h às 16h.'},
      {'title': 'Feira Orgânica', 'date': 'Amanhã', 'type': 'info', 'body': 'Não perca nossa feira semanal no térreo.'},
    ];

    return Scaffold(
      appBar: AppBar(title: const Text('Mural Digital')),
      body: ListView.separated(
        padding: const EdgeInsets.all(16),
        itemCount: notices.length,
        separatorBuilder: (_, __) => const SizedBox(height: 12),
        itemBuilder: (context, index) {
          final notice = notices[index];
          final isUrgent = notice['type'] == 'urgent';

          return Card(
            // M3: Cores diferentes para urgência
            color: isUrgent 
                ? Theme.of(context).colorScheme.errorContainer 
                : Theme.of(context).colorScheme.surfaceContainerHighest,
            child: Padding(
              padding: const EdgeInsets.all(16.0),
              child: Column(
                crossAxisAlignment: CrossAxisAlignment.start,
                children: [
                  Row(
                    mainAxisAlignment: MainAxisAlignment.spaceBetween,
                    children: [
                      // Badge M3
                      Container(
                        padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
                        decoration: BoxDecoration(
                          color: isUrgent ? Colors.red : Colors.blue,
                          borderRadius: BorderRadius.circular(8),
                        ),
                        child: Text(
                          isUrgent ? 'URGENTE' : 'INFO',
                          style: const TextStyle(color: Colors.white, fontSize: 10, fontWeight: FontWeight.bold),
                        ),
                      ),
                      Text(notice['date']!, style: Theme.of(context).textTheme.bodySmall),
                    ],
                  ),
                  const SizedBox(height: 12),
                  Text(
                    notice['title']!,
                    style: Theme.of(context).textTheme.titleLarge?.copyWith(fontWeight: FontWeight.bold),
                  ),
                  const SizedBox(height: 8),
                  Text(notice['body']!),
                ],
              ),
            ),
          );
        },
      ),
    );
  }
}

 

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