Construindo um App de Controle de Obras com Flutter 3.41: Arquitetura de Dados, Isar e Sincronização — Parte 2
Bem-vindo à segunda etapa do nosso guia de desenvolvimento do aplicativo de Controle de Obras Pessoal. Na Parte 1, estabelecemos a fundação visual com Material 3 e estruturamos a navegação. Agora, vamos elevar o nível técnico mergulhando no coração do aplicativo: a persistência de dados offline-first com Isar Database, a modelagem de entidades e o motor de sincronização em background com o Supabase.
1. Atualização de Packages e Preparações
Para trabalharmos com a geração de código do Isar e garantirmos que nossa arquitetura reativa com Riverpod funcione perfeitamente com operações assíncronas em background, precisamos adicionar ferramentas de build ao pubspec.yaml.
dependencies:
flutter:
sdk: flutter
flutter_riverpod: ^2.5.1
isar: ^3.1.0
isar_flutter_libs: ^3.1.0
supabase_flutter: ^2.4.0
connectivity_plus: ^5.0.0 # Essencial para detectar quando há internet para o sync
dev_dependencies:
build_runner: ^2.4.8 # Necessário para gerar os Schemas do Isar
isar_generator: ^3.1.0
2. O Paradigma Offline-First no Canteiro de Obras
Em um canteiro de obras, a conexão com a internet é frequentemente intermitente. A arquitetura offline-first dita que todas as leituras e escritas do usuário são feitas no banco de dados local (Isar). O aplicativo nunca espera a rede para confirmar uma ação. Uma flag (como isSynced) marca os registros locais, e um serviço em background gerencia o envio para o Supabase quando a conexão é restabelecida.
3. Detalhando os Modelos e Coleções (Isar)
O Isar é um banco NoSQL orientado a objetos. Isso significa que não criamos tabelas relacionais clássicas, mas sim coleções de objetos interligados através de IsarLinks. Para nossa estratégia de sincronização funcionar, todo modelo precisa de um ID local (inteiro rápido do Isar), um ID remoto (UUID do Supabase) e dados de auditoria (updatedAt, isSynced).
4. O Schema de Tarefas (Task)
A entidade de Tarefa representa o plano macro da obra (ex: “Concretagem da Laje”).
import 'package:isar/isar.dart';
part 'task.g.dart'; // Arquivo gerado pelo build_runner
@collection
class Task {
Id id = Isar.autoIncrement; // ID local ultrarrápido
@Index(unique: true, replace: true)
String? remoteId; // UUID do Supabase
late String title;
late String code;
late double plannedQuantity;
late String unitMeasure; // M3, M2, UN, etc.
bool isSynced = false;
DateTime lastUpdated = DateTime.now();
// Link 1:N com os apontamentos diários dessa tarefa
@Backlink(to: 'task')
final appointments = IsarLinks<Appointment>();
}
5. O Schema de Apontamentos (Appointment)
O Apontamento é a execução diária de uma tarefa. É aqui que o usuário registra o volume produzido no dia e anexa evidências.
import 'package:isar/isar.dart';
import 'task.dart'; // Importa a entidade Task
part 'appointment.g.dart';
@collection
class Appointment {
Id id = Isar.autoIncrement;
@Index(unique: true, replace: true)
String? remoteId;
late double realizedQuantity;
String? notes;
String? photoLocalPath; // Caminho da foto no dispositivo antes do upload
String? photoRemoteUrl; // URL do bucket do Supabase após upload
late DateTime appointmentDate;
bool isSynced = false;
// Relação N:1 apontando para a Tarefa pai
final task = IsarLink<Task>();
}
6. O Schema de Mão de Obra (Workforce)
Para o controle de entrada e saída, ou apontamento de horas da equipe no canteiro.
import 'package:isar/isar.dart';
part 'workforce.g.dart';
@collection
class Workforce {
Id id = Isar.autoIncrement;
String? remoteId;
late String workerName;
late String role; // Pedreiro, Servente, Engenheiro
late DateTime checkIn;
DateTime? checkOut;
bool isSynced = false;
}
Após criar estes arquivos, executa-se no terminal: flutter pub run build_runner build.
7. Estratégia de Sincronização em Background (Sync Engine)
Com os Schemas prontos, precisamos de um serviço gerenciado pelo Riverpod que escute a conexão com a internet. Quando o connectivity_plus avisar que estamos online, ele busca no Isar tudo onde isSynced == false e faz o upsert no Supabase.
import 'package:flutter_riverpod/flutter_riverpod.dart';
// ... imports do Isar, Supabase e Connectivity
class SyncEngine extends StateNotifier<bool> {
final Isar isar;
final SupabaseClient supabase;
SyncEngine(this.isar, this.supabase) : super(false);
Future<void> syncUnsavedAppointments() async {
if (state) return; // Evita múltiplas instâncias de sync rodando juntas
state = true; // Status: Sync em andamento
try {
// 1. Busca apontamentos não sincronizados no Isar
final pending = await isar.appointments.filter().isSyncedEqualTo(false).findAll();
if (pending.isEmpty) return;
// 2. Transforma em JSON e envia pro Supabase
final List<Map<String, dynamic>> payload = pending.map((a) => {
'id': a.remoteId, // Pode ser null se for novo
'realized_quantity': a.realizedQuantity,
'appointment_date': a.appointmentDate.toIso8601String(),
// mapear tarefa relacionada...
}).toList();
final response = await supabase.from('appointments').upsert(payload).select();
// 3. Marca como sincronizado no banco local
await isar.writeTxn(() async {
for (var i = 0; i < pending.length; i++) {
pending[i].isSynced = true;
// Atualiza com o UUID real gerado pelo Supabase, caso fosse novo
pending[i].remoteId = response[i]['id'];
await isar.appointments.put(pending[i]);
}
});
} catch (e) {
// Log do erro (ignora no app para não travar a UI offline)
} finally {
state = false; // Status: Sync finalizado
}
}
}
8. Criação da Tela de Avanço Físico
Esta tela consolida os dados do banco local. Em vez do gráfico circular de tarefa única, usamos barras de progresso lineares (Material 3) para dar uma visão macro do projeto.
import 'package:flutter/material.dart';
class PhysicalProgressScreen extends StatelessWidget {
const PhysicalProgressScreen({super.key});
@override
Widget build(BuildContext context) {
// Em um cenário real, estes dados viriam de um StreamProvider do Isar
final progressData = [
{'fase': 'Fundações', 'progress': 1.0, 'color': Colors.green},
{'fase': 'Estrutura (Concreto)', 'progress': 0.65, 'color': Colors.blue},
{'fase': 'Alvenaria', 'progress': 0.30, 'color': Colors.orange},
{'fase': 'Acabamento', 'progress': 0.0, 'color': Colors.grey},
];
return Scaffold(
appBar: AppBar(title: const Text('Avanço Físico - Residencial Alpha')),
body: ListView.separated(
padding: const EdgeInsets.all(16),
itemCount: progressData.length,
separatorBuilder: (_, __) => const SizedBox(height: 16),
itemBuilder: (context, index) {
final item = progressData[index];
final progress = item['progress'] as double;
return Card(
elevation: 1,
child: Padding(
padding: const EdgeInsets.all(16.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(item['fase'] as String, style: Theme.of(context).textTheme.titleMedium),
Text('${(progress * 100).toInt()}%', style: const TextStyle(fontWeight: FontWeight.bold)),
],
),
const SizedBox(height: 12),
LinearProgressIndicator(
value: progress,
minHeight: 8,
borderRadius: BorderRadius.circular(4),
color: item['color'] as Color,
backgroundColor: Colors.grey.shade200,
),
],
),
),
);
},
),
);
}
}
9. Criação da Tela de Mão de Obra
A tela de gestão de recursos humanos no canteiro precisa focar em ações rápidas de “Entrada” e “Saída”.
class WorkforceScreen extends StatelessWidget {
const WorkforceScreen({super.key});
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('Controle de Mão de Obra')),
floatingActionButton: FloatingActionButton.extended(
onPressed: () {}, // Abrir modal para adicionar novo trabalhador
icon: const Icon(Icons.person_add),
label: const Text('Adicionar'),
),
body: ListView(
padding: const EdgeInsets.all(16),
children: [
_buildWorkerTile(context, 'João Silva', 'Pedreiro', true, '07:00'),
_buildWorkerTile(context, 'Marcos Souza', 'Servente', false, '--:--'),
],
),
);
}
Widget _buildWorkerTile(BuildContext context, String name, String role, bool isPresent, String time) {
return Card(
child: ListTile(
leading: CircleAvatar(
backgroundColor: isPresent ? Colors.green.shade100 : Colors.red.shade100,
child: Icon(Icons.engineering, color: isPresent ? Colors.green : Colors.red),
),
title: Text(name, style: const TextStyle(fontWeight: FontWeight.bold)),
subtitle: Text(role),
trailing: Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.end,
children: [
Text(isPresent ? 'Presente' : 'Ausente',
style: TextStyle(color: isPresent ? Colors.green : Colors.red, fontWeight: FontWeight.bold)),
Text('Check-in: $time', style: Theme.of(context).textTheme.labelSmall),
],
),
onTap: () {
// Lógica para registrar check-in/check-out localmente no Isar
},
),
);
}
}
Resumo e Próximos Passos
Nesta Parte 2, transformamos nosso aplicativo de uma interface estática para uma arquitetura de dados robusta:
-
Atualização de Dependências: Preparamos o ambiente para geração de código (
build_runner) e monitoramento de rede (connectivity_plus). -
Modelagem de Dados (Isar): Criamos as coleções NoSQL com
IsarLinkspara Tarefas, Apontamentos e Mão de Obra, preparando o terreno para gravações rápidas no dispositivo do usuário. -
Motor de Sincronização: Desenhamos a estratégia em background que varre o Isar em busca de registros não sincronizados (
isSynced == false) e faz o upsert no Supabase de forma invisível para o usuário. -
Telas Operacionais: Construímos as interfaces da Tela de Avanço Físico (usando indicadores lineares para visão macro) e da Tela de Mão de Obra (focada no controle de ponto diário do canteiro).