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']!),
],
),
),
);
},
),
);
}
}