Construindo um App de Controle de Obras com Flutter 3.41: Multimídia, Autenticação e Sync Universal — Parte 3
Bem-vindo à terceira etapa do desenvolvimento do nosso aplicativo de Controle de Obras Pessoal. Nas partes anteriores, estruturamos a interface com Material 3 e estabelecemos as fundações do banco de dados local com Isar. Agora, vamos elevar substancialmente o nível técnico da nossa arquitetura.
Neste artigo, abordaremos a captura de evidências fotográficas no canteiro de obras, a construção de um serviço de Storage na nuvem altamente reaproveitável, a proteção do aplicativo com autenticação refinada e a expansão do nosso ecossistema offline-first para novos módulos operacionais.
1. Autenticação Refinada: Protegendo a Fronteira do App
Antes de gerar qualquer dado, precisamos saber quem o está gerando. Utilizaremos o Supabase Auth em conjunto com o Riverpod para criar um fluxo de autenticação seguro. A ideia é que o aplicativo escute ativamente o estado do usuário.
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:supabase_flutter/supabase_flutter.dart';
// Provider que expõe o estado reativo da autenticação
final authStateProvider = StreamProvider<AuthState>((ref) {
return Supabase.instance.client.auth.onAuthStateChange;
});
// Provider para obter o ID do Engenheiro/Supervisor logado instantaneamente
final currentUserIdProvider = Provider<String?>((ref) {
return Supabase.instance.client.auth.currentUser?.id;
});
Na SplashScreen ou AuthWrapper, você observa o authStateProvider. Se houver uma sessão válida, o usuário é direcionado para a HomeScreen; caso contrário, vai para a LoginScreen.
2. Atrelando Dados ao Supervisor Logado
Para garantir o rastreio das operações, todos os registros criados offline (no Isar) devem conter a “assinatura” de quem os criou. Atualizamos os Schemas do Isar para incluir o engineerId.
// Exemplo no Schema de Apontamento (appointment.dart)
@collection
class Appointment {
Id id = Isar.autoIncrement;
@Index(unique: true, replace: true)
String? remoteId;
@Index() // Indexado para facilitar buscas locais por usuário
late String engineerId; // ID do Supabase injetado no momento da criação local
late double realizedQuantity;
String? photoLocalPath;
bool isSynced = false;
// ... outras propriedades
}
3. Formulários Avançados: Integração com Câmera (image_picker)
No canteiro de obras, fotos são provas de medição. Vamos integrar o image_picker no formulário de Apontamentos para capturar a imagem e salvar seu caminho absoluto no armazenamento interno do dispositivo.
import 'dart:io';
import 'package:image_picker/image_picker.dart';
import 'package:path_provider/path_provider.dart';
class CameraService {
final ImagePicker _picker = ImagePicker();
Future<String?> takePhotoAndSaveLocally() async {
final XFile? photo = await _picker.pickImage(
source: ImageSource.camera,
imageQuality: 70, // Otimização para economizar banda 4G
);
if (photo == null) return null;
// Move a foto do cache temporário para o diretório de documentos do app
final directory = await getApplicationDocumentsDirectory();
final String fileName = '${DateTime.now().millisecondsSinceEpoch}.jpg';
final String localPath = '${directory.path}/$fileName';
await File(photo.path).copy(localPath);
return localPath; // Este caminho será salvo no banco Isar
}
}
4. Supabase Storage: Uma Classe Universal
Para evitar repetição de código, vamos criar um serviço de Storage limpo e injetável via Riverpod, desenhado para ser reaproveitado não apenas neste, mas em qualquer projeto futuro.
import 'dart:io';
import 'package:supabase_flutter/supabase_flutter.dart';
class SupabaseStorageService {
final SupabaseClient _client;
SupabaseStorageService(this._client);
/// Faz o upload de um arquivo e retorna a URL pública
Future<String> uploadFile({
required File file,
required String bucketName,
required String remotePath,
}) async {
try {
await _client.storage.from(bucketName).upload(
remotePath,
file,
fileOptions: const FileOptions(cacheControl: '3600', upsert: false),
);
return _client.storage.from(bucketName).getPublicUrl(remotePath);
} catch (e) {
throw Exception('Falha ao subir arquivo para o Storage: $e');
}
}
}
5. Módulo de Fotos: Orquestrando Upload e Banco de Dados
O motor de sincronização agora enfrenta um desafio: antes de enviar o registro de texto (JSON) do Apontamento para o Supabase PostgreSQL, ele precisa subir a foto (se existir) para o Storage e obter a URL.
// Trecho injetado no nosso SyncEngine (criado na Parte 2)
Future<void> _syncSingleAppointment(Appointment appt, SupabaseStorageService storage) async {
String? remotePhotoUrl;
// 1. Se existe foto local, sobe pro Storage primeiro
if (appt.photoLocalPath != null && appt.photoRemoteUrl == null) {
final file = File(appt.photoLocalPath!);
if (await file.exists()) {
remotePhotoUrl = await storage.uploadFile(
file: file,
bucketName: 'obras_evidencias',
remotePath: '${appt.engineerId}/${appt.id}.jpg',
);
// Atualiza o Isar com a URL remota provisória
appt.photoRemoteUrl = remotePhotoUrl;
}
}
// 2. Agora envia os dados pro PostgreSQL
final payload = {
'engineer_id': appt.engineerId,
'realized_quantity': appt.realizedQuantity,
'photo_url': appt.photoRemoteUrl,
};
// Executa o upsert e atualiza isSynced = true no Isar...
}
6. Tela de Equipamentos: CRUD Completo com GridView
Máquinas pesadas representam um alto custo. O módulo de equipamentos utiliza um GridView no estilo Material 3 para apresentar as máquinas e permitir CRUD (Create, Read, Update, Delete) totalmente offline.
-
Schema (Isar): Coleção
Equipment(Nome, Horímetro Inicial, Horímetro Atual, Status [Ativo/Manutenção],isSynced). -
UI:
GridView.builderque consome um stream de dados locais.
class EquipmentGridScreen extends ConsumerWidget {
const EquipmentGridScreen({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
// Escuta as mudanças no Isar em tempo real
final equipmentList = ref.watch(equipmentLocalStreamProvider);
return Scaffold(
appBar: AppBar(title: const Text('Equipamentos')),
floatingActionButton: FloatingActionButton(
onPressed: () => _showAddEquipmentModal(context),
child: const Icon(Icons.add),
),
body: equipmentList.when(
data: (equipments) => GridView.builder(
padding: const EdgeInsets.all(16),
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 2, mainAxisSpacing: 16, crossAxisSpacing: 16),
itemCount: equipments.length,
itemBuilder: (context, index) {
final eq = equipments[index];
return Card(
color: eq.status == 'Manutenção' ? Colors.red.shade50 : Colors.white,
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(Icons.precision_manufacturing, size: 40,
color: Theme.of(context).colorScheme.primary),
Text(eq.name, style: const TextStyle(fontWeight: FontWeight.bold)),
Text('Horímetro: ${eq.currentHorimeter}h'),
],
),
);
},
),
loading: () => const Center(child: CircularProgressIndicator()),
error: (e, st) => Center(child: Text('Erro: $e')),
),
);
}
}
7. Tela de Ciclo de Transporte
Semelhante aos equipamentos, o controle de frota (caminhões, escavadeiras) precisa registrar as viagens. O conceito é o mesmo: o usuário insere a placa, a origem, o destino e o volume de carga. A gravação é imediata no Isar (isSynced = false).
8. Sincronização Universal: A Prova de Falhas
Para garantir que a obra não pare por falta de internet, centralizamos nossa arquitetura de resiliência. Usando o connectivity_plus, criamos um listener global na inicialização do app.
import 'package:connectivity_plus/connectivity_plus.dart';
void setupGlobalSyncListener(WidgetRef ref) {
Connectivity().onConnectivityChanged.listen((List<ConnectivityResult> results) {
// Se conectou via Mobile ou WiFi
if (results.contains(ConnectivityResult.mobile) || results.contains(ConnectivityResult.wifi)) {
final syncEngine = ref.read(syncEngineProvider.notifier);
// Dispara a sincronização de todas as entidades de forma assíncrona
syncEngine.syncAppointments();
syncEngine.syncEquipments();
syncEngine.syncTransportCycles();
syncEngine.syncWorkforce();
}
});
}
Resumo
Neste capítulo, transformamos o nosso aplicativo em uma ferramenta robusta e pronta para as adversidades físicas do canteiro de obras:
-
Segurança Cimentada: Implementamos a autenticação do Supabase, garantindo que todo dado offline gerado no Isar carregue o ID do responsável, prevenindo corrupção de autoria no banco de dados central.
-
Gestão de Evidências: Criamos o fluxo de captura de imagens utilizando o
image_picker, salvando os arquivos no armazenamento interno para não sobrecarregar a memória do dispositivo durante períodos sem rede. -
Storage Elegante: Desenvolvemos a classe
SupabaseStorageService, uma ferramenta padronizada e modular pronta para ser reutilizada no upload de qualquer arquivo. -
Expansão de Módulos: Construímos as telas de Equipamentos e Ciclo de Transporte utilizando componentes Material 3 (
GridView), operando 100% no paradigma offline-first. -
O Motor Definitivo: Consolidamos a resiliência do sistema combinando o
connectivity_pluscom a lógica de background upload, orquestrando o envio complexo de fotos e textos apenas quando a internet volta, sem congelar a interface do usuário.