Construindo um App de Controle de Obras com Flutter 3.41: Arquitetura Híbrida com Isar e Supabase — Parte 1
Desenvolver aplicativos voltados para o setor de construção civil exige robustez, especialmente devido a ambientes de obra que frequentemente operam offline ou com baixa conectividade. Este projeto detalha a criação de um aplicativo de Controle de Obras Pessoal projetado para alta performance e escalabilidade.
A stack tecnológica escolhida eleva o nível arquitetural do projeto:
-
Flutter 3.41 & Material 3: Componentes de UI modernos, dinâmicos e com tipografia refinada.
-
Riverpod (flutter_riverpod): Injeção de dependências segura em tempo de compilação e gerenciamento de estado reativo.
-
Isar Database: Banco de dados local NoSQL ultrarrápido, garantindo a estratégia offline-first.
-
Supabase: Backend as a Service (BaaS) em PostgreSQL para autenticação e sincronização de dados em nuvem.
1. Estrutura de Dependências
Para iniciar, o arquivo pubspec.yaml deve conter as bibliotecas fundamentais que sustentarão a arquitetura. Utilizaremos o Riverpod para o estado e as SDKs oficiais do Isar e Supabase.
dependencies:
flutter:
sdk: flutter
flutter_riverpod: ^2.5.1
isar: ^3.1.0
isar_flutter_libs: ^3.1.0
supabase_flutter: ^2.4.0
intl: ^0.19.0
image_picker: ^1.1.0 # Para fotos no apontamento
2. Bootstrapping e Splash Screen Inicial
A SplashScreen não é apenas visual; é o momento de inicializar o banco local (Isar) e estabelecer a conexão com o Supabase antes de liberar o usuário para a aplicação. Utilizaremos um FutureProvider do Riverpod para gerenciar esse ciclo de vida.
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
final appInitializationProvider = FutureProvider<void>((ref) async {
// Inicialização do Supabase
await Supabase.initialize(
url: 'SUA_SUPABASE_URL',
anonKey: 'SUA_SUPABASE_ANON_KEY',
);
// Inicialização do Isar (Schema será definido nas próximas partes)
// await Isar.open([TaskSchema, UserSchema], directory: dir.path);
});
class SplashScreen extends ConsumerWidget {
const SplashScreen({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final initConfig = ref.watch(appInitializationProvider);
return Scaffold(
body: initConfig.when(
data: (_) => const HomeScreen(), // Navega para a Home
loading: () => const Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(Icons.construction, size: 80, color: Colors.blueGrey),
SizedBox(height: 24),
CircularProgressIndicator(),
],
),
),
error: (err, stack) => Center(child: Text('Erro de inicialização: $err')),
),
);
}
}
3. Layout da Home: Header do Usuário
A tela inicial (HomeScreen) adota o padrão Material 3, com um fundo levemente acinzentado para destacar os Cards. A parte superior exibe os dados do usuário logado, que idealmente seriam consumidos de um StateProvider do Riverpod alimentado pelo Supabase.
class UserHeaderBox extends StatelessWidget {
const UserHeaderBox({super.key});
@override
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.all(20),
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.primaryContainer,
borderRadius: const BorderRadius.vertical(bottom: Radius.circular(32)),
),
child: Row(
children: [
CircleAvatar(
radius: 30,
backgroundColor: Theme.of(context).colorScheme.primary,
child: const Icon(Icons.person, color: Colors.white, size: 30),
),
const SizedBox(width: 16),
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('Eng. Carlos Silva', style: Theme.of(context).textTheme.titleLarge),
Text('Obra: Residencial Alpha', style: Theme.of(context).textTheme.bodyMedium),
],
),
],
),
);
}
}
4. O Grid de Módulos (Os 8 Cards)
Para o acesso rápido às funções da obra, implementamos um GridView responsivo.
final List<Map<String, dynamic>> menuOptions = [
{'title': 'Avanço Físico', 'icon': Icons.trending_up},
{'title': 'Mão de Obra', 'icon': Icons.engineering},
{'title': 'Equipamentos', 'icon': Icons.precision_manufacturing},
{'title': 'Ciclo de Transporte', 'icon': Icons.local_shipping},
{'title': 'Pedido', 'icon': Icons.shopping_cart},
{'title': 'Pedido Extra', 'icon': Icons.add_shopping_cart},
{'title': 'Movimentações', 'icon': Icons.sync_alt},
{'title': 'Assistente de Obras', 'icon': Icons.support_agent},
];
class ModulesGrid extends StatelessWidget {
const ModulesGrid({super.key});
@override
Widget build(BuildContext context) {
return GridView.builder(
padding: const EdgeInsets.all(16),
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 2,
crossAxisSpacing: 16,
mainAxisSpacing: 16,
childAspectRatio: 1.2,
),
itemCount: menuOptions.length,
itemBuilder: (context, index) {
final item = menuOptions[index];
return Card(
elevation: 2,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
child: InkWell(
onTap: () {}, // Navegação para o respectivo módulo
borderRadius: BorderRadius.circular(16),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(item['icon'], size: 40, color: Theme.of(context).colorScheme.primary),
const SizedBox(height: 8),
Text(item['title'], textAlign: TextAlign.center,
style: const TextStyle(fontWeight: FontWeight.bold)),
],
),
),
);
},
);
}
}
5. Menu Drawer (Lateral)
O menu lateral fornece uma rota alternativa para as seções principais da obra e inclui a opção de troca de conta, limpando os estados globais do Riverpod.
class ProjectDrawer extends StatelessWidget {
const ProjectDrawer({super.key});
@override
Widget build(BuildContext context) {
return Drawer(
child: ListView(
padding: EdgeInsets.zero,
children: [
const DrawerHeader(
decoration: BoxDecoration(color: Colors.blueGrey),
child: Text('Navegação Rápida', style: TextStyle(color: Colors.white, fontSize: 24)),
),
ListTile(leading: const Icon(Icons.trending_up), title: const Text('Avanço Físico'), onTap: () {}),
ListTile(leading: const Icon(Icons.engineering), title: const Text('Mão de Obra'), onTap: () {}),
ListTile(leading: const Icon(Icons.precision_manufacturing), title: const Text('Equipamentos'), onTap: () {}),
ListTile(leading: const Icon(Icons.local_shipping), title: const Text('Ciclo de Transporte'), onTap: () {}),
const Divider(),
ListTile(
leading: const Icon(Icons.logout, color: Colors.red),
title: const Text('Trocar de Usuário', style: TextStyle(color: Colors.red)),
onTap: () {
// Lógica de SignOut do Supabase e ref.invalidate() no Riverpod
}
),
],
),
);
}
}
6. Tela de Tarefas de Projeto
Esta tela consolida as metas. A exigência técnica aqui é exibir visualmente o progresso através de um gráfico circular.
class TaskCard extends StatelessWidget {
final String title;
final String code;
final String date;
final double percentage;
const TaskCard({super.key, required this.title, required this.code, required this.date, required this.percentage});
@override
Widget build(BuildContext context) {
return Card(
margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
child: Padding(
padding: const EdgeInsets.all(16),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(title, style: Theme.of(context).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.bold)),
const SizedBox(height: 4),
Text('Cód: $code | Reg: $date', style: Theme.of(context).textTheme.bodySmall),
],
),
),
Stack(
alignment: Alignment.center,
children: [
CircularProgressIndicator(
value: percentage / 100,
backgroundColor: Colors.grey.shade300,
color: percentage == 100 ? Colors.green : Theme.of(context).colorScheme.primary,
),
Text('${percentage.toInt()}%', style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 12)),
],
)
],
),
),
);
}
}
7. Apontamento da Tarefa
O formulário de apontamento é a parte mais crítica para a integridade dos dados, unindo inputs de texto, cálculos reativos (Volume/Percentual) e captura de mídia.
class AppointmentScreen extends ConsumerStatefulWidget {
const AppointmentScreen({super.key});
@override
ConsumerState<AppointmentScreen> createState() => _AppointmentScreenState();
}
class _AppointmentScreenState extends ConsumerState<AppointmentScreen> {
final _qtyController = TextEditingController();
final _obsController = TextEditingController();
final double plannedQty = 500.0; // Exemplo de M3 planejado
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('Novo Apontamento')),
body: ListView(
padding: const EdgeInsets.all(16),
children: [
const Text('Tarefa: Concretagem da Laje Nível 2', style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold)),
const SizedBox(height: 16),
Text('Plano: $plannedQty M³'),
const SizedBox(height: 16),
TextFormField(
controller: _qtyController,
keyboardType: TextInputType.number,
decoration: const InputDecoration(labelText: 'Quantidade Realizada (M³)', border: OutlineInputBorder()),
onChanged: (val) => setState(() {}), // Dispara recálculo do percentual
),
const SizedBox(height: 16),
// Cálculo automático do percentual
Text('Percentual Atingido: ${(_calculatePercentage()).toStringAsFixed(1)}%'),
const SizedBox(height: 16),
ElevatedButton.icon(
icon: const Icon(Icons.camera_alt),
label: const Text('Capturar Foto da Obra'),
onPressed: () {
// Integração com image_picker
},
),
const SizedBox(height: 16),
TextFormField(
controller: _obsController,
maxLines: 3,
decoration: const InputDecoration(labelText: 'Observações (Opcional)', border: OutlineInputBorder()),
),
const SizedBox(height: 32),
FilledButton(
onPressed: () {
// Gravação offline via Isar e agendamento de sync para o Supabase
final now = DateTime.now();
// Salvar apontamento com a data e hora atual
},
child: const Text('Salvar Apontamento'),
)
],
),
);
}
double _calculatePercentage() {
final qty = double.tryParse(_qtyController.text) ?? 0.0;
if (plannedQty == 0) return 0.0;
return (qty / plannedQty) * 100;
}
}
Resumo e Próximos Passos
Chegamos ao fim da Parte 1 da construção do nosso aplicativo de Controle de Obras Pessoal. Nesta etapa, estabelecemos uma fundação sólida e moderna, focada na experiência do usuário e na organização do código:
-
Arquitetura Base: Configuramos o
pubspec.yamlcom as dependências essenciais (Riverpod, Isar, Supabase) que darão suporte ao nosso modelo offline-first. -
Interface Dinâmica: Adotamos o Flutter 3.41 com Material 3 para criar uma UI limpa e responsiva.
-
Navegação e Estrutura: Desenvolvemos a
SplashScreencom controle de inicialização, aHomeScreencom o cabeçalho do usuário e o grid de 8 módulos operacionais, além de umMenu Drawerpara navegação rápida. -
Interatividade e Apontamentos: Criamos os cartões de tarefas com indicadores visuais de progresso (gráficos circulares) e o formulário de apontamento com cálculo reativo de percentual e preparação para captura de fotos.