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.builder que 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_plus com 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.

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