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.yaml com 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 SplashScreen com controle de inicialização, a HomeScreen com o cabeçalho do usuário e o grid de 8 módulos operacionais, além de um Menu Drawer para 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.

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